Holo archive · in-engine work

Portfolio

Apocrypha · a Roblox open-world game set in a galaxy of outlaws

Apocrypha is a free-roam game made of floating planet islands, each a few thousand studs across and built entirely from parts. Below are in-engine shots from Roblox Studio, close-ups of single models, and excerpts from the game's scripts. Click any picture to see it full size.

The world

The planets players travel between, and the places they live in.

New builds

New areas and models built for Apocrypha. Each one is made to match the blocky style of the planets around it.

Models

Single models from the new builds, each shown on its own on a plain floor.

Scripts

Game systems written in Luau for Apocrypha. Each one comes with a short excerpt from the real source, with its line numbers.

NPC daily life

Town residents live by the game clock. They work, eat dinner at the cantina, stroll or pick flowers in the evening and sleep in their own beds. The cantina keeper takes orders and serves real items from the game's menu, residents hold whole conversations with each other, and the ones who drink start to slur. The excerpt is the slurring, and the shuffled "deck" that stops lines from repeating.

  • About 8,000 lines
  • Server
  • Pathfinding

Excerpt 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 buy a market stall, name it, stock it from their inventory and set prices. Each stall is saved on its own and kept in sync between servers. Rent is due weekly, and sales go into a till, so sellers get paid even while they're offline. The excerpt is a purchase: the stall record is updated in one step, and the buyer gets a refund if someone else bought the item first.

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

Excerpt 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

A guided first few minutes for new characters: look around, reach the wreck, sprint, jump, search a supply crate, follow a trail out of the valley, and talk to Timothy in town. Progress is saved on the character, so a rejoin picks up where it left off. The excerpt is the supply crate, which the server pays out once per character, and only to a player standing next to it.

  • About 760 lines
  • Server + client

Excerpt 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 volcano and lava effects

Effects for the lava planet: bubbles that swell and pop with sound, a rumble that grows louder near lava, falling ash during storms, and eruptions with screen shake, a blast, lava bombs and a drifting smoke column. The server only says when an eruption starts. Each player's game then plays it on a timeline, so a player who arrives halfway through sees the rest of it, without replaying the parts that already happened.

  • About 680 lines
  • Client + server

Excerpt 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 lays out a rim of rocks, rock clusters, stepped dunes, sand plates and drifts on a 10-stud grid. Every piece is a copy of a real desert part, so the new ground matches the old, and nothing new overlaps what was already there. The same seed always gives the same map. The excerpt places the rock clusters.

  • 574 lines
  • Studio build script

Excerpt 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