Guide 1 Docs 2 Reference 3

Guide 2 of 5

Coin rush

Turn a pack into a game: gold coins lie on a round arena, players have 60 seconds to walk onto as many as they can, a sidebar keeps score, and each player’s best score is saved. You will split the pack into modules, keep state on players, and run rounds that repeat forever.

What you build

  • A round grass arena with a fence on its rim.
  • Five gold pressure plates, the coins, at random places on the floor. Walk onto one to take it; a new one appears somewhere else.
  • A boss bar that counts down the round, and a sidebar with every player’s score.
  • At the end: the winner’s name on screen, fireworks, and a message to each player who beat their best.

Read Your first pack first: this guide uses what it explains.

1. The pack

Three Luau files this time. main.luau runs the game, and it loads two modules: arena.luau for the ground, coins.luau for the coins.

# citadel.yml
name: coins
entry: main.luau
motd: Coin rush
spawn_point: [0.5, 65.0, 0.5]
gamemode: 2
mode: lite
world: script

2. The arena

A module is a file that returns a table. require("arena") runs arena.luau once and hands back what it returned, so every file that requires it shares one arena.

-- arena.luau
world.on_generate(function(chunk)
    for x = chunk.min.x, chunk.max.x do
        for z = chunk.min.z, chunk.max.z do
            local d = math.sqrt(x * x + z * z)
            if d <= arena.RADIUS then
                chunk:set(vec3(x, 64, z), "grass_block")
            elseif d <= arena.RADIUS + 1.5 then
                chunk:set(vec3(x, 64, z), "stone_bricks")
                chunk:set(vec3(x, 65, z), "oak_fence")
            end
        end
    end
end)

math.sqrt(x * x + z * z) is the distance from the middle. Inside the radius is grass; a ring just outside it gets a stone floor and a fence. Fences join their neighbours on their own: write "oak_fence", and the ring closes.

The coins need random places on the floor, away from the fence:

-- arena.luau
-- A random block on the floor, two blocks in from the fence.
function arena.spot(): vector
    local r = arena.RADIUS - 2
    while true do
        local x, z = math.random(-r, r), math.random(-r, r)
        if x * x + z * z <= r * r then
            return vec3(x, 65, z)
        end
    end
end

It picks a random square around the middle, and tries again until the spot is inside the circle.

3. The coins

A coin is two things: a gold pressure plate that players see, and a zone that notices a player walking onto it.

-- coins.luau
function coins.place(on_take: (player: any) -> ())
    -- Pick again when that spot already holds a coin.
    local at = arena.spot()
    while world.get_block(at) ~= "air" do
        at = arena.spot()
    end
    world.set_block(at, "light_weighted_pressure_plate")
    local coin = { at = at }
    -- A zone notices the player who walks onto the plate.
    coin.zone = world.zone{
        from = at,
        to = at,
        on_enter = function(player)
            coins.remove(coin)
            fx.particle("totem_of_undying", at + vec3(0.5, 0.5, 0.5), { count = 12, spread = 0.4, speed = 0.3 })
            sound.play("entity.experience_orb.pickup", { at = at, pitch = 1.2 })
            on_take(player)
        end,
    }
    table.insert(placed, coin)
end
  • A spot that already has a coin is not "air", so place picks again.
  • world.zone is a box of blocks. Here it is one block, and on_enter runs when a player’s feet enter it.
  • Taking a coin removes it, plays a sound and a burst of particles at it, and calls on_take: the game decides what a coin is worth, not the coin.

Removing a coin puts the floor back and forgets the zone:

-- coins.luau
function coins.remove(coin)
    coin.zone:destroy()
    world.set_block(coin.at, "air")
    table.remove(placed, table.find(placed, coin) :: number)
end

function coins.clear()
    while #placed > 0 do
        coins.remove(placed[1])
    end
end

4. Scores

Each player carries their own score in player.data, a table that lives as long as the player is connected. The sidebar shows everyone’s:

-- main.luau
-- The sidebar lists every player, most coins first.
local function show_scores()
    local list = players.list()
    table.sort(list, function(a, b)
        return a.data.coins > b.data.coins
    end)
    local lines = {}
    for _, p in list do
        table.insert(lines, `{p.name}  {p.data.coins}`)
    end
    ui.sidebar{ title = "COIN RUSH", lines = lines }
end

-- One coin taken: count it, and put a new one down.
local function take(player)
    player.data.coins += 1
    show_scores()
    coins.place(take)
end

take counts the coin and places a new one, so there are always five on the floor. It passes itself to coins.place: the new coin calls take again when someone walks onto it.

When a player joins, their score starts at 0, and storage tells them their best:

-- main.luau
players.on_join(function(player)
    player.data.coins = 0
    local best = storage.get("best." .. player.uuid) or 0
    player:message(`Walk onto the gold plates! Your best is {best}.`, "gold")
    show_scores()
end)

storage keeps values across restarts. The key uses player.uuid, not the name: a player can change their name, never their id.

5. A round

One round sends everyone to the middle, puts five coins down, and counts down sixty seconds on a boss bar:

-- main.luau
local bar = ui.bossbar{ title = "Coin rush", color = "yellow", progress = 1 }

local function round()
    for _, p in players.list() do
        p.data.coins = 0
        p:teleport(arena.CENTER)
    end
    show_scores()
    for _ = 1, ON_FIELD do
        coins.place(take)
    end
    ui.title{ title = "GO!", color = "gold", stay = 1 }

    for left = ROUND, 1, -1 do
        bar.title = `Coin rush · {left} s`
        bar.progress = left / ROUND
        task.wait(1)
    end
    coins.clear()
    local function coins_text(n: number): string
        return if n == 1 then "1 coin" else `{n} coins`
    end
    local winner, most = nil, -1
    for _, p in players.list() do
        local key = "best." .. p.uuid
        if p.data.coins > (storage.get(key) or 0) then
            storage.set(key, p.data.coins)
            p:message(`New best: {coins_text(p.data.coins)}!`, "gold")
        end
        if p.data.coins > most then
            winner, most = p, p.data.coins
        end
    end
    if winner then
        ui.title{ title = `{winner.name} wins`, subtitle = coins_text(most), color = "gold" }
        fx.particle("firework", arena.CENTER + vec3(0, 2, 0), { count = 60, spread = 2, speed = 0.1 })
    end
end

task.wait(1) inside the loop pauses the round, not the server: coins are taken, sounds play and other players join while it waits.

At the end the round finds the winner and saves each best score:

-- main.luau
local function coins_text(n: number): string
    return if n == 1 then "1 coin" else `{n} coins`
end
local winner, most = nil, -1
for _, p in players.list() do
    local key = "best." .. p.uuid
    if p.data.coins > (storage.get(key) or 0) then
        storage.set(key, p.data.coins)
        p:message(`New best: {coins_text(p.data.coins)}!`, "gold")
    end
    if p.data.coins > most then
        winner, most = p, p.data.coins
    end
end
if winner then
    ui.title{ title = `{winner.name} wins`, subtitle = coins_text(most), color = "gold" }
    fx.particle("firework", arena.CENTER + vec3(0, 2, 0), { count = 60, spread = 2, speed = 0.1 })
end

6. Rounds forever

-- main.luau
-- Rounds run forever: a round, a five-second break, the next round.
task.spawn(function()
    while true do
        if players.count() == 0 then
            task.wait(1)
        else
            round()
            task.wait(5)
        end
    end
end)

task.spawn runs the loop as its own script, so main.luau finishes loading while the rounds go on. With nobody online it waits; otherwise it plays a round, rests five seconds, and plays the next.

The whole pack

-- main.luau
--!strict
-- Coin rush: sixty seconds to pick up as many coins as you can.

local arena = require("arena")
local coins = require("coins")

local ROUND = 60 -- seconds
local ON_FIELD = 5 -- coins on the floor at once

-- The sidebar lists every player, most coins first.
local function show_scores()
    local list = players.list()
    table.sort(list, function(a, b)
        return a.data.coins > b.data.coins
    end)
    local lines = {}
    for _, p in list do
        table.insert(lines, `{p.name}  {p.data.coins}`)
    end
    ui.sidebar{ title = "COIN RUSH", lines = lines }
end

-- One coin taken: count it, and put a new one down.
local function take(player)
    player.data.coins += 1
    show_scores()
    coins.place(take)
end

players.on_join(function(player)
    player.data.coins = 0
    local best = storage.get("best." .. player.uuid) or 0
    player:message(`Walk onto the gold plates! Your best is {best}.`, "gold")
    show_scores()
end)

local bar = ui.bossbar{ title = "Coin rush", color = "yellow", progress = 1 }

local function round()
    for _, p in players.list() do
        p.data.coins = 0
        p:teleport(arena.CENTER)
    end
    show_scores()
    for _ = 1, ON_FIELD do
        coins.place(take)
    end
    ui.title{ title = "GO!", color = "gold", stay = 1 }

    for left = ROUND, 1, -1 do
        bar.title = `Coin rush · {left} s`
        bar.progress = left / ROUND
        task.wait(1)
    end
    coins.clear()
    local function coins_text(n: number): string
        return if n == 1 then "1 coin" else `{n} coins`
    end
    local winner, most = nil, -1
    for _, p in players.list() do
        local key = "best." .. p.uuid
        if p.data.coins > (storage.get(key) or 0) then
            storage.set(key, p.data.coins)
            p:message(`New best: {coins_text(p.data.coins)}!`, "gold")
        end
        if p.data.coins > most then
            winner, most = p, p.data.coins
        end
    end
    if winner then
        ui.title{ title = `{winner.name} wins`, subtitle = coins_text(most), color = "gold" }
        fx.particle("firework", arena.CENTER + vec3(0, 2, 0), { count = 60, spread = 2, speed = 0.1 })
    end
end

-- Rounds run forever: a round, a five-second break, the next round.
task.spawn(function()
    while true do
        if players.count() == 0 then
            task.wait(1)
        else
            round()
            task.wait(5)
        end
    end
end)
-- coins.luau
--!strict
-- Coins: gold pressure plates on the floor. Walk onto one to take it.

local arena = require("arena")

local coins = {}
local placed: { any } = {}

function coins.place(on_take: (player: any) -> ())
    -- Pick again when that spot already holds a coin.
    local at = arena.spot()
    while world.get_block(at) ~= "air" do
        at = arena.spot()
    end
    world.set_block(at, "light_weighted_pressure_plate")
    local coin = { at = at }
    -- A zone notices the player who walks onto the plate.
    coin.zone = world.zone{
        from = at,
        to = at,
        on_enter = function(player)
            coins.remove(coin)
            fx.particle("totem_of_undying", at + vec3(0.5, 0.5, 0.5), { count = 12, spread = 0.4, speed = 0.3 })
            sound.play("entity.experience_orb.pickup", { at = at, pitch = 1.2 })
            on_take(player)
        end,
    }
    table.insert(placed, coin)
end

function coins.remove(coin)
    coin.zone:destroy()
    world.set_block(coin.at, "air")
    table.remove(placed, table.find(placed, coin) :: number)
end

function coins.clear()
    while #placed > 0 do
        coins.remove(placed[1])
    end
end

return coins
-- arena.luau
--!strict
-- The arena: a round grass floor with a fence on its rim.

local arena = {}
arena.RADIUS = 12
arena.CENTER = vec3(0.5, 65, 0.5)

world.on_generate(function(chunk)
    for x = chunk.min.x, chunk.max.x do
        for z = chunk.min.z, chunk.max.z do
            local d = math.sqrt(x * x + z * z)
            if d <= arena.RADIUS then
                chunk:set(vec3(x, 64, z), "grass_block")
            elseif d <= arena.RADIUS + 1.5 then
                chunk:set(vec3(x, 64, z), "stone_bricks")
                chunk:set(vec3(x, 65, z), "oak_fence")
            end
        end
    end
end)

-- A random block on the floor, two blocks in from the fence.
function arena.spot(): vector
    local r = arena.RADIUS - 2
    while true do
        local x, z = math.random(-r, r), math.random(-r, r)
        if x * x + z * z <= r * r then
            return vec3(x, 65, z)
        end
    end
end

return arena

Try this next

  • Make one coin in ten a diamond plate worth five coins: pick the block in coins.place, and pass the value to on_take.
  • Show the three best scores of all time on the sidebar under the live scores, with storage.keys("best.").
  • Give the winner a golden title in chat for the next round with player.data.

Next, Parkour, a room each gives every player a world of their own.

Next guideParkour, a room eachGive every player a world of their own. /play builds a new parkour course in a room that belongs to one player, with a timer, checkpoints, a floor of air that catches a fall, and a…→