Build a Coin Rush¶
Up to four players race to collect coins scattered on the screen – first to ten wins. When
two players touch the same coin on the same frame, only one may get the point: this tutorial
is all about net.lock() (settling races fairly) and net.queue() (feeding respawn
work to the host). Do the Build Multiplayer Pong tutorial first – this one builds on its
session menu and moves faster over everything the two games share.
As in the Pong tutorial, each step teaches an idea and shows only the lines that carry it; the complete code is there to compare against when you are done.
What you will build¶
A host/join menu for up to four players
One colored square per player, moved with the arrow keys
Coins that only one player can collect, no matter how simultaneous the grab – each coin carries a lock in
net.state(net.lock)A respawn work queue in
net.state, processed by the host (net.queue)Per-player scores and a first-to-ten win
Everything is drawn with fill_rect() and rect() – no sprites needed.
Step 2: The host provisions every player¶
In Pong each player wrote its own paddle from the start. With four players and
host-assigned colors it is cleaner to flip the pattern: the host creates each player’s
entry (spawn position, color, score 0) – for itself at session start, and for the
others from peer.joined. A joiner simply waits until its entry appears, then starts
moving it. Mid-game joins need no special code at all: the late joiner receives the full
current state, and the host’s peer.joined gives them a square like everyone else.
The provisioning function is where the net.state empty-branch rule bites, so this one is
worth copying exactly:
-- A net.state branch only exists once it holds a value, so the first
-- player entry must create the branch itself
if net.state.players then
net.state.players[playerId] = entry
else
net.state.players = { [playerId] = entry }
end
Build provision_player(playerId) around it: construct entry with a random position,
score = 0, and col = COLORS[next_color], advancing next_color with wrap-around
(next_color % #COLORS + 1).
The host’s part of on_connected then reads like a checklist: provision yourself, create
the coins, create the respawn queue, subscribe to peer.joined (provision them) and
peer.left (delete net.state.players[playerId]). For the coins, build a plain local
table of COIN_COUNT entries – each with a random position, taken = false, and its
own lock = net.lock() – and assign it to net.state.coins in one go. A new_coin()
helper that returns one such entry keeps this tidy and gets reused on respawn:
function new_coin()
return {
x = math.random(8, W - 8 - COIN_SIZE),
y = math.random(8, H - 8 - COIN_SIZE),
taken = false,
lock = net.lock(), -- each coin guards itself; see Step 4
}
end
Create the respawn queue in the same place, with net.state.respawns = net.queue() – a
queue lives in net.state just like the coins do. Both roles subscribe to "ended" and
switch to "playing", as in Pong.
Note
Collected coins will be marked taken rather than deleted. Keeping all
COIN_COUNT entries alive means the coins branch always exists and every index
stays valid – one less nil case everywhere else in the game. Each coin’s lock sits
right beside the taken flag it protects.
Step 3: Moving your square¶
Movement is Pong’s paddle logic on two axes, applied to your own entry. The only new element is finding that entry – it belongs to the host until it has been provisioned:
function my_player()
local players = net.state.players
if not players then
return nil
end
return players[net.id()]
end
Write update_movement(): get my_player(), return if it is nil (not provisioned
yet – the multi-player version of Pong’s replication-lag guard), then move me.x /
me.y with the arrow keys and clamp both to the screen.
For drawing, iterate the players. pairs over a net.state branch yields string
keys, so convert before comparing ids:
for id, p in pairs(net.state.players or {}) do
fill_rect(p.col, p.x, p.y, PLAYER_SIZE, PLAYER_SIZE)
if tonumber(id) == net.id() then
rect(7, p.x - 2, p.y - 2, PLAYER_SIZE + 4, PLAYER_SIZE + 4) -- highlight yourself
end
end
Draw the untaken coins the same way (skip entries with taken set), and later add a score
display: a row of small squares per player, in that player’s color.
Try it
Host plus one or two joiners: every window should show every square moving live, each with a white ring around its own. Join a third window after moving around a bit – the newcomer sees everyone in the right place. That is the state snapshot at work.
Step 4: Collecting coins with a lock¶
Here is the race this game exists for: two players overlap coin 3 on the same frame and
both try to take it. Both read taken == false, both would mark it, both would score. The
coin’s own lock – net.state.coins[i].lock, created in Step 2 – settles it: requests are
granted one peer at a time, so whatever runs inside acquire runs exclusively:
function try_collect(i)
if claiming[i] then
return -- we already have a pending request for this coin
end
claiming[i] = true
net.state.coins[i].lock.acquire(function(release)
claiming[i] = false
local coin = net.state.coins[i]
if not coin.taken then -- still there: it is ours
coin.taken = true
local me = my_player()
me.score = me.score + 1
net.state.respawns.push(i) -- Step 5
if me.score >= WIN_SCORE then
net.state.winner = net.id()
end
end
release()
end)
end
Every line of ceremony here is the lesson:
The
claimingguard stops_updatefrom piling up a new lock request every frame while you stand on a coin.The
takenre-check insideacquireis the whole point of the lock: the world may have changed between asking for the lock and being granted it. The loser of the race reaches this line and finds the coin already gone.release()runs on every path – a lock that is never released blocks that coin for the rest of the session.
Drive it from an update_collect() that loops over the coins and calls try_collect(i)
for any untaken coin overlapping you. For the overlap test, use the AABB helper from
Current Limitations with the player and coin sizes.
Step 5: The host respawns coins from a queue¶
Collectors push the coin’s index onto the net.state.respawns queue (already done in
Step 4); the host pops one every couple of seconds and refreshes that coin. A queue fits
perfectly: pushes from all players line up in order, each index is delivered to exactly one
popper, and popping an empty queue just hands the callback nil:
net.state.respawns.pop(function(i)
if i and not net.state.winner then
net.state.coins[i] = new_coin() -- new_coin() gives it a fresh lock too
end
end)
Wrap this in update_respawns(), gated by respawn_timer counting up to
RESPAWN_DELAY. Only the host calls it – add it to update_playing() inside an
is_host branch, next to update_movement() and update_collect() which everyone
runs. Close the loop like in Pong: when net.state.winner appears, announce it and switch
to "over", where m calls net.leave() and restarts.
Try it – prove the lock works
Park two players on the same coin. Exactly one score goes up, every time. Then remove the
lock – call the body of try_collect directly – and repeat: sooner or later both
players score off the same coin. That double-collect is the race the lock removes.
How it all fits together¶
Any player Host
------------------------------ --------------------------------
moves own players.<id> provisions players on peer.joined
sees a coin, wants it cleans them up on peer.left
|
v
net.state.coins[i].lock ----> grants requests one at a time
winner: taken = true,
score + 1,
net.state.respawns.push(i) --> pops one index every 2 s,
loser: already taken, respawns that coin
does nothing
Complete code¶
Compare your build against the full documented script.
Extending the example¶
Bonus coins – store a
valueon each coin and add it to the score.Sudden death – host shortens
RESPAWN_DELAYas scores climb.Announcements –
net.emit("stolen", i)when you snatch a coin someone was standing on.Round timer – host counts frames down in
net.state.time_left; highest score wins at zero.