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