My work

Portfolio

Screenshots, models and code from my Roblox game, Apocrypha

Apocrypha is a free-roam game where every planet is a floating island a few thousand studs across, all built from parts. Everything here is straight out of Roblox Studio. Click any picture to see it bigger.

The world

The planets you can travel to, and the towns on them.

New builds

Newer areas added to Apocrypha, kept in the same blocky style as the rest of the map.

Models

Some of the buildings on their own, so you can see them properly.

Scripts

Some of the systems in Apocrypha, written in Luau. Each one has a short piece of the actual code.

NPC daily life

NPCs in town follow the in-game clock. They go to work, eat at the cantina, hang out or pick flowers in the evening and go home to sleep. The cantina keeper takes orders and serves real menu items, NPCs talk to each other, and the ones who drink too much start slurring. The code below is the slurring, plus the shuffle that stops NPCs repeating the same lines.

  • About 8,000 lines
  • Server
  • Pathfinding

From NPCLife/Engine

-- slurred, once they've had a fewlocal function slur(text, level)	if level < DRUNK then return text end	local chance = level >= WASTED and 0.75 or 0.4	text = text:gsub("s([^h])", function(after)		if math.random() < chance then			return "sh" .. after		end	end)	if math.random() < (level >= WASTED and 0.5 or 0.25) and not text:find("*hic*", 1, true) then		text = text .. " *hic*"	end	return textend-- a shuffled deck: everything comes up once before anything comes up again (and never twice in a row)local Deck = {}Deck.__index = Deckfunction Deck.new(list)	return setmetatable({list = list or {}, order = {}}, Deck)endfunction Deck:deal()	if #self.order == 0 then		for i = 1, #self.list do			self.order[i] = i		end		for i = #self.order, 2, -1 do			local j = math.random(i)			self.order[i], self.order[j] = self.order[j], self.order[i]		end		if #self.order > 1 and self.order[#self.order] == self.last then			self.order[1], self.order[#self.order] = self.order[#self.order], self.order[1]		end	end	local index = table.remove(self.order)	self.last = index	return self.list[index] or ""end

Player shops

Players can buy a market stall, name it, stock it from their inventory and set their own prices. Each stall saves on its own and stays in sync across servers. Rent is due every week, and sales go into a till, so sellers still get paid while they’re offline. The code below handles a purchase, including the refund if someone else bought the item first.

  • About 1,240 lines
  • Server + client
  • DataStores

From WorldServices/PlayerShops

function actions.purchase(plr, id, itemId)	local r = booths[id].record	if not r or not r.owner then return {ok = false, msg = "This stall is empty."} end	if r.owner == plr.UserId then return {ok = false, msg = "That's your own stall."} end	if owedWeeks(r) > 0 then return {ok = false, msg = "This shop is closed right now."} end	local listed	for _, it in ipairs(r.items or {}) do		if it.id == itemId then listed = it end	end	if not listed then return {ok = false, msg = "That item was just sold."} end	local c = credits(plr)	if not c or c.Value < listed.price then return {ok = false, msg = "You don't have enough credits."} end	c.Value -= listed.price	local removed	local ok, record = updateBooth(id, function(old)		removed = nil		if not old or old.owner ~= r.owner or owedWeeks(old) > 0 then return nil end		for i, it in ipairs(old.items or {}) do			if it.id == itemId and it.price == listed.price then				removed = table.remove(old.items, i)				old.till = (old.till or 0) + it.price				return old			end		end		return nil	end)	if not ok or not record or not removed then		c.Value += listed.price		if ok then reload(id) end		return {ok = false, msg = "That item was just sold or changed. You weren't charged."}	end	setRecord(id, record, true)	if not giveItem(plr, removed) then		warn("[PlayerShops] couldn't hand over " .. removed.kind .. " " .. removed.name .. " to " .. plr.Name)	end	return {ok = true, msg = "Bought " .. removed.name .. ".", view = browseView(id)}end

Crash-site tutorial

The first few minutes for a new character: look around, get to the wreck, sprint, jump, search a supply crate, follow a trail out of the valley and talk to Timothy in town. Progress saves, so if you rejoin you pick up where you left off. The code below is the supply crate. The server only pays out once per character, and only if you’re actually standing next to it.

  • About 760 lines
  • Server + client

From WorldServices/CrashTutorial

-- the supply crate: once per character, only during the tutorial, only within reachlocal busy = {}local function onSearch(player)	if busy[player] then		return	end	busy[player] = true	local folder = playerFolderOf(player)	local list = folder and readList(folder)	local points = sitePoints()	local root = rootOf(player)	if folder and list and points and points.crate and root and (root.Position - points.crate).Magnitude <= CRATE_REACH then		local paid = 0		if not table.find(list, "CrashSupplies") then			paid = SUPPLY_CREDITS			local credits = folder:FindFirstChild("Credits")			if credits then				credits.Value += paid			end			addFlag(folder, list, "CrashSupplies")		end		notify:FireClient(player, "searched", paid)	end	task.delay(1, function()		busy[player] = nil	end)end

Mustafar lava effects

All the lava planet effects: bubbles that pop, a rumble that gets louder near lava, ash during storms, and eruptions with screen shake, lava bombs and a smoke column. The server just says when an eruption starts, and each player’s game plays it out, so if you show up halfway through you still see the rest. The code below is the eruption timeline.

  • About 680 lines
  • Client + server

From MustafarFX

local function once(key, at, t, fn)   -- run fn when the timeline passes `at`, unless we joined well after it	local e = eruption	if not e.done[key] and t >= at then		e.done[key] = true		if e.joinedAt <= at + 3 then fn() end	endendlocal function stepEruption(dt, loudness)	local e = eruption	local t = os.clock() - e.t0	local power = e.power	-- shake: builds for 2 s, goes wild for 7, dies away over 15	local env	if t < 2 then env = 0.35 * (t / 2)	elseif t < 9 then env = 1	elseif t < 24 then env = (1 - (t - 9) / 15) ^ 2	else env = 0 end	local scale = player:GetAttribute("MustafarShakeScale")	shakeAmp = env * power * loudness * ((typeof(scale) == "number") and math.clamp(scale, 0, 2) or 1)	-- sounds	once("quake", 0, t, function() quakeSound.Volume = 1.2 * loudness; quakeSound:Play() end)	once("blast", 2, t, function()		blastSound.Volume = 2.2 * loudness		blastSound:Play()		boomSound.Volume = 2.5 * loudness		boomSound:Play()	end)	once("debris", 6, t, function() debrisSound.Volume = 0.9 * loudness; debrisSound:Play() end)	once("boom2", 9.5, t, function() boomSound.Volume = 1.6 * loudness; boomSound:Play() end)	if t > 22 and quakeSound.IsPlaying then quakeSound.Volume = math.max(quakeSound.Volume - dt * 0.25, 0) end	-- the blast cloud, lava bombs and the smoke column	once("ring", 2, t, function()		for n = 1, 14 do			local a = n / 14 * math.pi * 2			spawnPuff(crater, Vector3.new(math.cos(a) * 45, 70, math.sin(a) * 45), 180, 560, 26, 0)		end	end)	once("bombs1", 2, t, function() launchBombs(16, power) end)	once("bombs2", 5, t, function() launchBombs(8, power) end)	once("bombs3", 9, t, function() launchBombs(8, power * 0.9) end)	once("bombs4", 14, t, function() launchBombs(6, power * 0.8) end)

Map generator

The build script behind the Badlands south plain. It places rocks, dunes and sand on a 10-stud grid, copying real desert parts so the new ground matches the old, and it never overlaps anything that’s already there. The same seed gives the same map every time. The code below places the rock clusters.

  • 574 lines
  • Studio build script

From Tatooine_SouthFill_Generator

-- 2. rock clusters insidesetStatus("building rock clusters")local clusters, tries = {}, 0while #clusters < 34 and tries < 8000 do	tries += 1	local ix, iz = rng:NextInteger(0, NX - 1), rng:NextInteger(0, NZ - 1)	local i = idx(ix, iz)	if buffer.readu8(bare, i) == 1 and buffer.readu8(nearB, i) == 0 and buffer.readu8(rockCover, i) == 0 and dv(ix, iz) >= 26 then		local x, z = centre(ix, iz)		if townDist(x, z) > 170 and corrDist(x, z) > 50 then			local spaced = true			for _, c in ipairs(clusters) do if (c[1] - x) ^ 2 + (c[2] - z) ^ 2 < 330 ^ 2 then spaced = false break end end			if spaced then clusters[#clusters + 1] = {x, z} end		end	end	if tries % 300 == 0 then breathe() endendlocal rockList = {}for _, c in ipairs(clusters) do	local yaw0 = yawPick()	local W, D, H = rng:NextNumber(70, 160), rng:NextNumber(60, 130), rng:NextNumber(80, 250)	local ux, uz = math.cos(math.rad(yaw0)), math.sin(math.rad(yaw0))	local core = placeRock(F.Rock, "Rock", c[1], c[2], W, D, H, ux, uz, nearB, false, 120, 20)	if core then		counts.rock += 1 rockList[#rockList + 1] = core		for _ = 1, rng:NextInteger(2, 6) do			local a = rng:NextNumber(0, 2 * math.pi)			local w, d = rng:NextNumber(40, 120), rng:NextNumber(40, 110)			local r = (math.max(W, D) / 2 + math.max(w, d) / 2) * rng:NextNumber(0.45, 0.9)			local sux, suz = rot(ux, uz, rng:NextNumber(-8, 8))			local s = placeRock(F.Rock, "Rock", c[1] + math.cos(a) * r, c[2] + math.sin(a) * r, w, d, H * rng:NextNumber(0.2, 0.75), sux, suz, nearB, false, 120, 20)			if s then counts.rock += 1 rockList[#rockList + 1] = s end		end	end	breathe()end