Build Multiplayer Pong¶
This tutorial builds a complete two-player online Pong: one player hosts, a friend joins, and
each controls a paddle on their own machine. It is the best place to start with the net
API – read the Multiplayer concepts page first if you have not.
Rather than handing you the finished script, each step explains one idea and shows only the lines that carry it; you write the rest. If you get stuck or want to check your work, the complete code is one click away.
What you will build¶
A menu where the player chooses to host or join a session
Two paddles, each controlled by its own player, shared through
net.stateA ball simulated by the host and replicated to the other player
A score, point announcements via
net.emit, and a win conditionClean handling of the opponent leaving
No sprites or map are needed – the whole game is drawn with fill_rect() and
rect(), so you can go straight to the Code Editor.
Step 1: A game with four states¶
A multiplayer game cannot jump straight into gameplay: the session has to be created first, and that involves a platform dialog the player can cancel. So the game is a small state machine. Keep the current state in a global and dispatch on it every frame:
state = "menu" -- "menu" | "waiting" | "playing" | "over"
function _update()
if state == "menu" then
update_menu()
elseif state == "waiting" then
update_waiting()
elseif state == "playing" then
update_playing()
elseif state == "over" then
update_over()
end
end
Alongside state, declare two more globals you will need throughout: is_host (are we
the one who created the session?) and side ("left" or "right" – which paddle is
ours). Write an _init() that resets all three and prints the menu instructions.
You will also want the usual clamp(v, lo, hi) helper, and a handful of constants: the
screen size (320 x 180), paddle dimensions and speed, ball size, and a winning score.
Pick palette colors for each side – the examples below use 12 (blue) for the left paddle
and 8 (red) for the right.
Note
There is no on-canvas text API, so menus and announcements go to the output panel with
print().
Step 2: Hosting and joining¶
net.host and net.join open a platform dialog and return immediately; your callback
fires only if the session is actually created or joined. That asymmetry drives the whole
menu design. Two rules to encode:
Switch state before calling. A repeat
net.hoston the next frame while the dialog is still open is silently ignored – a no-op, not an error (an error is raised only if a session is already active). Moving to"waiting"first keeps the game’s state clear instead of firing the call unconditionally every frame.Cancel means nothing fires. The only signal for “the player cancelled” is the absence of your callback, so give
"waiting"its own inputs – otherwise a cancel strands the player there. Let them re-open host/join, or press a key to return to the menu.
function update_menu()
if key_pressed("h") then
state = "waiting"
is_host = true
net.host({ max_players = 2, title = "Pong" }, on_connected)
elseif key_pressed("j") then
state = "waiting"
is_host = false
net.join(on_connected)
end
end
Write update_waiting() yourself: m returns to "menu" and reprints the instructions,
while h/j re-open the host/join dialog. Calling net.host/net.join again from
here is safe – a cancelled attempt fully resets the net state. Without these inputs, a cancel
would strand the player in "waiting".
Try it
Add a placeholder on_connected that just prints something, plus empty
update_playing / update_over, and run the game. H should open the host dialog
(note how the capacity is fixed at 2 – the game decided that, not the player). Cancel it –
pressing H/J re-opens the dialog, or M takes you back to the menu.
Step 3: The host sets the table¶
on_connected runs once, on success, for both roles – use is_host to split the work.
Following the ownership convention from Multiplayer, the host creates every piece of
shared state the game will ever read, so nobody else has to wonder whether a key exists:
if is_host then
net.state.pads = { left = (H - PAD_H) / 2, right = (H - PAD_H) / 2 }
net.state.score = { left = 0, right = 0 }
net.state.playing = false
reset_ball(1)
net.on("peer.joined", function(playerId) net.state.playing = true end)
net.on("peer.left", function(playerId) net.state.playing = false end)
end
net.state.playing is the referee’s whistle: the ball only moves while an opponent is
connected, and the host flips it from the peer.joined / peer.left events.
Complete the function for both roles: derive side from is_host, switch state to
"playing", and subscribe to "ended" (the session dies when the host leaves – go to
"over" and tell the player). Then write reset_ball(direction): assign
net.state.ball a fresh table with a centered x, y and a dx, dy velocity moving
toward direction. Assigning a whole table replaces the subtree in one go – exactly what
a reset wants.
Step 4: Your paddle, their paddle¶
Each player writes only its own paddle key and merely reads the other one – that is the entire synchronization model, no messages needed. Two things matter in the code:
function update_paddle()
local pads = net.state.pads
if not pads then
return -- state not replicated yet
end
local y = pads[side]
-- move y with ArrowUp / ArrowDown, then:
pads[side] = clamp(y, 0, H - PAD_H)
end
The nil guard is not paranoia: the joiner’s first frames can run before the host’s
state has replicated to it, and indexing a branch that does not exist yet would crash the
game. Guard every read of a shared branch this way.
Now make it visible. In _draw(), clear the screen and (in the "playing" state) draw
both paddles from net.state.pads – same nil guard – and the ball from
net.state.ball, but only while net.state.playing is true and nobody has won. Draw
everything from net.state, never from local variables: that is what guarantees both
screens show the same game.
Try it
Host in one browser window, join from another (invite code). You should see both paddles on both screens, each window controlling its own – and the ball sitting frozen in the center, because nothing moves it yet.
Step 5: The host simulates the ball¶
Only the host runs ball physics; the other player just draws the replicated result. One
referee means the two screens can never disagree about a bounce. Note what the first line
gives you – net.state.ball is a live view, so writing ball.x goes straight into
shared state:
function update_ball()
if not net.state.playing or net.state.winner then
return
end
local ball = net.state.ball
ball.x = ball.x + ball.dx
ball.y = ball.y + ball.dy
-- bounces and scoring go here
end
Fill in the physics – it is classic Pong:
Walls: when
ball.yleaves0 .. H - BALL_SIZE, negatedy.Paddles: when the ball moves left (
dx < 0), reaches the left paddle’s x-plane, and overlaps it vertically, negatedx; mirror the test for the right side.Goals: when the ball fully exits on the left, the right side scores (and vice versa).
Scoring is where the host talks to the other player. The sender of an event never receives
it, hence the local print() next to the emit:
function score_point(scorer, serve_direction)
net.state.score[scorer] = net.state.score[scorer] + 1
net.emit("point", scorer)
print("Point for the " .. scorer .. " side!")
if net.state.score[scorer] >= WIN_SCORE then
net.state.winner = scorer
else
reset_ball(serve_direction)
end
end
On the receiving side, subscribe once in on_connected:
net.on("event:point", function(from, scorer) ... end). Finally, wire it all into
update_playing(): everyone updates their paddle; only the host calls
update_ball().
Step 6: Winning and leaving¶
The host decides the winner by writing net.state.winner; everyone else just watches for
it. At the end of update_playing(), when net.state.winner is set, switch to
"over" and announce the result. In update_over(), let m clean up and restart:
function update_over()
if key_pressed("m") then
net.leave()
_init()
end
end
net.leave() is safe here even when the session already ended (after an "ended"
event it simply does nothing), so one exit path covers both “we won” and “the host left”.
To finish the presentation, extend _draw() for the other states: fill the screen with
the winner’s color in "over", and draw something for the menu (the complete code shows
two idle paddles and a dotted center line). A score display needs no text: draw one small
square per point in each side’s color along the top edge.
Try it
Play a full match to 5. Then close the host’s window mid-rally: the joiner should get the
“host closed the session” message and land back in the menu via M. That path –
session dies, "ended" fires, player recovers – is one your game should never leave
untested.
How it all fits together¶
Host machine Joiner machine
-------------------------- --------------------------
net.host{max_players = 2} ------> net.join() (invite code)
owns: ball, score, playing owns: pads.right only
writes pads.left reads ball, score, pads.left
simulates the ball ------> draws the replicated ball
net.emit("point", ...) ------> net.on("event:point", ...)
sets net.state.winner ------> sees winner, shows "over"
Complete code¶
Compare your build against the full documented script.
Extending the example¶
Rematch – on the “over” screen, let the host reset the score and ball instead of leaving.
Faster rallies – increase
ball.dxslightly on each paddle bounce.Spin – adjust
ball.dybased on where the ball hits the paddle.Sound – call
play_music()onevent:pointif your project has music slots.