Make it multiplayer
Make it multiplayer
The platformer, played by two machines at once — and everything that has to change for that to work.
some coding · about 50 minutes · 12 steps
Follow this along inside the editor — the 🎓 Learn tab has the same steps and ticks each one off as your project starts to match it.
The platformer you built, played by two machines at once — one hosting the truth, the other seeing it a beat later — and everything that has to change for that to work. Which is less than you'd think: a component on the things that move, one line to host, and two scripts that learn to say "the server decides".
You'll test all of it on one desk. The editor can run a real client against a real server inside one process, over a link whose latency and packet loss you set with a slider — so every bug that only shows up at 200 ms is one you find now, alone, rather than in front of the first person who plays it with you.
What you should already know
The Build a 3D platformer tutorial, or its starter template open in front of you. This picks up exactly where that ends.
The plan
- Decide what is shared, and mark it.
- Host a session from the game script.
- Join a ghost client, and make the link bad on purpose.
- Put the score and the coins under the server's control.
- A door you ask the server to open.
- Make your own character predicted, and feel the difference.
1. Start from the platformer
Open the finished platformer: create a new project with the platformer
template (in the Hub, or floptle --new <dir> --template platformer), or use the
one you built in that tutorial.
Press Play once to make sure it runs: walk, jump onto the Lift, collect a
coin. Everything below assumes the nodes that tutorial made — Ground,
Player, Camera, Platform, Lift, Game, some Coins and a Goal — and
that Player is tagged player.
Nothing about the level itself changes. Multiplayer in this engine is something you add to a game, not something you rebuild it around.
Done when: a node called Player is in the scene.
2. What a session is
One machine is the host. It runs the real game — the one where the coin was or wasn't collected, where the lift is right now — and that is the truth. Everyone else is a client: it draws what the host tells it, about a tenth of a second late, and when a client wants something to happen it asks. That's the whole model, and the reason it's the default is that it makes cheating a matter of asking the server nicely.
What gets shared
Only what you mark. A node with a 🌐 Networked component exists on every machine and the host keeps them agreeing; a node without one is local, which is the right answer for the camera, the UI, particles, and scenery that never changes.
The rule of thumb for what to sync on a networked node:
- sync transform for things physics moves — the player, the lift.
- only vars (transform off) for things a script animates from a fact — a
door that eases open because
openis true, a coin that hides becausetakenis true. The script runs on every machine; the fact is what travels. - nothing for the camera. Every machine has its own.
Which scripts run where
In a session every script runs on every machine — with one exception: a node
whose transform the server owns is driven entirely by the server's snapshots
on a client, so its scripts don't run there. Everything else does, and
net.isServer() is how a line of code says "only the one with the truth does
this". You'll write that line four times in this tutorial.
3. Mark what moves
Select Player. In the Inspector, ➕ Add Component → 🌐 Networked. Leave
mode on Server authority and sync transform ticked, and tick
sync physics too — a rigidbody's velocity lets a client extrapolate between
snapshots instead of stepping, and you'll want it on for the last step.
Do the same for Platform and Lift, minus sync physics: they're kinematic, so
the transform alone says everything.
sync animator covers the Animation Controller on this node only. A rig whose
animator sits on a Model child is not covered, and an animator a script drives
from synced wants it off. The capsule has none; leave it.
Leave Ground, Camera, the coins and the Goal alone for now. The ground
never moves. The camera is yours — the other machine has its own, following
its own player. The coins are the next lesson.
Why the default is the right default
Server authority means the host simulates this node and clients receive it. It costs nothing from your scripts — the movement code you already wrote just runs on the host — and it's cheat-proof, because nothing a client sends about its own position is believed. Start every node here. Change it only for the one node the local player controls, and only when the input lag becomes the thing you hate about your game.
Done when: Player has a Networked component.
4. Host from the game script
Replace platformerGame.lua with the version below. Three things changed.
net.host
net.host{ maxPlayers = params.maxPlayers }
No port, no relay — that is the in-editor harness, and it's what the next
step uses. Later, net.host{ relay = "…" } is the one-line difference between
this and playing across the internet.
The respawn is the server's
if net.isServer() and player.y < params.fallY then
On a client, Player is a copy driven by the server's snapshots. Teleporting it
from the client's own update would be overwritten a moment later — and it
would mean two machines disagreeing about where the player is, which is the
one thing a session exists to prevent. Facts about where things are are
decided in one place.
The HUD says who you are
net.role() reads "server", "client" or "offline". In a session this
script runs on every machine, and while you're learning, a line on screen that
says which one you're looking at is worth more than any amount of reasoning.
scripts/platformerGame.lua
-- The script that knows about the GAME rather than about a node: the score,
-- falling off the world, whether you have won — and now, the session.
--
-- Put it on an Empty node. Everything else reaches it with
-- findScript("platformerGame") and calls the functions below.
defaults = {
--@desc Fall below this height and you are put back at the start.
--@units m
fallY = -20,
--@desc Where the start is.
--@units m
spawnY = 3.0,
--@desc How many can be in the session, you included.
--@range 1 16
maxPlayers = 4,
}
local score = 0
local won = false
local player
function start(node)
player = find("Player")
score = 0
won = false
-- Become the host. No port and no relay means the in-editor harness:
-- press 🌐 in the toolbar to join a ghost client. A relay address here
-- is the only difference between this and playing across the internet.
net.host{ maxPlayers = params.maxPlayers }
end
-- Called by coin.lua. Not `local`, so a script handle can reach it.
function collect()
score = score + 1
end
-- Called by goal.lua.
function reach()
won = true
end
function update(node, dt)
-- Only the host decides where the player IS. On a client the Player is a
-- copy driven by the server's snapshots, and moving it here would be
-- overwritten a moment later anyway.
if net.isServer() and player and player.valid and player.y < params.fallY then
player.pos = vec3(0, params.spawnY, 0)
player.vel = vec3(0, 0, 0)
end
if not camera.exists() then return end
local w, h = camera.screenSize()
draw.text(24, 24, "Coins: " .. score, 24, 1, 0.85, 0.35)
-- Which machine this is. In a session every script runs everywhere, and
-- this is the line that tells you which "everywhere" you are looking at.
local who = net.role()
if net.isServer() then who = who .. " · " .. #net.peers() .. " client(s)" end
draw.text(24, 56, who, 18, 0.7, 0.9, 1)
if won then
draw.text(w * 0.5, h * 0.5, "You made it!", 44, 1, 1, 1, 1, "center")
end
end
Done when: platformerGame.lua hosts a session.
5. Join a ghost, then ruin the connection
Press Play. The HUD reads server · 0 client(s) — your script is hosting.
Click the 🌐 button in the toolbar. The panel says hosting · 0 ghost clients; click ➕ Join a local ghost client. (With nothing hosting yet, the panel offers ⏵ Host + join a local client instead — your script is already hosting, so you see the join button alone.) A real client has joined your real server, inside this one process, over a simulated link.
Cyan spheres appear: one per networked node, each drawn where the client believes that node is. Walk around. Yours trails a beat behind you. The lift's ghost rides the lift. Nothing else has a ghost — because nothing else is networked, which is exactly the point of the previous step.
Now make it bad
In the same panel, drag latency (ticks) up to 12 — about 200 ms round trip, a bad day between two countries — and packet loss up to a tenth. Watch your ghost fall further behind and start to stutter. That is what the other player sees of you, and it is what every decision in the next steps is about.
Do this early and often. A bug that only appears at 200 ms is one you want to find on your own desk, with the slider in your hand, rather than in a voice call with someone saying "it's weird on my end".
Done when: you've pressed Play.
6. The game itself is networked
Right now the score is a local in the Game script. On the client, the same
script runs with its own score, which stays at zero forever — and someone who
joins after you've collected six coins sees nothing about them.
The score isn't a fact about a coin, or about the player. It's a fact about the
game. Those live on the Game node, and they have to travel.
Select Game and ➕ Add Component → 🌐 Networked. Then untick sync
transform: nothing about this node moves, and a networked node that syncs
only vars runs its script on every machine — which is what lets each machine's
HUD read the numbers.
That's the whole step. The script that uses it is next.
Done when: Game has a Networked component.
7. Move the score into synced
Replace platformerGame.lua again, with the version below.
replicated and synced
replicated = { score = 0, won = false }
A top-level replicated table declares which of this script's values travel,
with their starting values. You then read and write them through synced:
synced.score, synced.won. Numbers, booleans, strings and small tables are
all fine.
Only the server writes
if net.isServer() then synced.score = synced.score + 1 end
Only the host's writes replicate. A client writing to synced gets a warning in
the Console and is overwritten by the next snapshot — so the guard isn't
defensive, it's the rule made visible. collect() runs on whichever machine's
coin trigger fired; only the server's call counts.
Late joiners
Everything in synced is part of the join handshake. Stop, press Play, collect
three coins, then join the ghost: its copy of the script starts at score = 3. You didn't write anything for that. That is what "the server has the
truth" buys you.
scripts/platformerGame.lua
-- The script that knows about the GAME rather than about a node: the score,
-- falling off the world, whether you have won — and the session.
--
-- Put it on an Empty node WITH a Networked component (sync transform off:
-- nothing here moves, only facts). Everything else reaches it with
-- findScript("platformerGame") and calls the functions below.
defaults = {
--@desc Fall below this height and you are put back at the start.
--@units m
fallY = -20,
--@desc Where the start is.
--@units m
spawnY = 3.0,
--@desc How many can be in the session, you included.
--@range 1 16
maxPlayers = 4,
}
-- Facts about the GAME live in synced, not in locals: every machine reads
-- the same numbers, and someone who joins ten minutes in gets the current
-- ones in the handshake.
replicated = { score = 0, won = false }
local player
function start(node)
player = find("Player")
net.host{ maxPlayers = params.maxPlayers }
end
-- Called by coin.lua. Only the server's writes to synced replicate — a
-- client's would be overwritten, with a warning — so the guard is the rule
-- "the server decides", written down.
function collect()
if net.isServer() then synced.score = synced.score + 1 end
end
-- Called by goal.lua.
function reach()
if net.isServer() then synced.won = true end
end
function update(node, dt)
if net.isServer() and player and player.valid and player.y < params.fallY then
player.pos = vec3(0, params.spawnY, 0)
player.vel = vec3(0, 0, 0)
end
if not camera.exists() then return end
local w, h = camera.screenSize()
draw.text(24, 24, "Coins: " .. synced.score, 24, 1, 0.85, 0.35)
local who = net.role()
if net.isServer() then who = who .. " · " .. #net.peers() .. " client(s)" end
draw.text(24, 56, who, 18, 0.7, 0.9, 1)
if synced.won then
draw.text(w * 0.5, h * 0.5, "You made it!", 44, 1, 1, 1, 1, "center")
end
end
Done when: platformerGame.lua declares the score as a synced value.
8. Coins everyone agrees on
The coin still calls node:destroy() on whichever machine's trigger fired. The
host takes a coin; the client's copy of it is still floating there, because
nobody told it otherwise — and nothing ever could tell a machine that joins
later about a node that no longer exists.
Select every Coin (click the first, shift-click the last) and ➕ Add
Component → 🌐 Networked, then untick sync transform on them. A coin never
moves; what has to be shared is whether it's been taken, and that's one flag.
Then replace coin.lua with the version below.
Hidden, not destroyed
node.visible = not synced.taken
A destroyed node has no way to say it's gone. A synced flag does — it is
carried to every machine now, and to anyone who joins later. The trigger fires
on every machine the player's body passes through it on; the server's is the
one that counts, and everyone else hears about it through synced. Notice
that this is the same shape as the score — a fact, decided in one place,
read everywhere.
Press Play, join a ghost, and collect a coin. It vanishes; the score goes up once, not twice.
scripts/coin.lua
-- A pickup everyone agrees on. The node needs a collider with `trigger`
-- ticked, and a Networked component with sync transform OFF: a coin never
-- moves, so its transform has nothing to say — what has to be shared is
-- whether it has been taken, and that is one synced flag.
replicated = { taken = false }
function onTriggerEnter(node, other, hit)
if synced.taken then return end
if not other:hasTag("player") then return end
-- The trigger fires on every machine the body passes through it on. Only
-- the server's answer counts; everyone else hears about it through synced.
if not net.isServer() then return end
local game = findScript("platformerGame")
if game then game.collect() end
synced.taken = true
end
function update(node, dt)
-- Hidden rather than destroyed: a destroyed node can't tell someone who
-- joins later that it's gone. A synced flag can.
node.visible = not synced.taken
end
Done when: coin.lua marks the coin taken through synced.
9. A door you ask the server to open
Everything so far has been the server noticing things. Now a client wants something.
Add a Cube, name it Door, scale it to about 3, 4, 0.5, and stand it across
a gap in your level. Rigidbody, mode Kinematic — it's solid, and the
script moves it. 🌐 Networked, with sync transform unticked: the script
animates this node from one fact, on every machine.
Attach netDoor.lua. Press Play, walk up to the door and press E
(Interact — a named action every project starts with).
What an RPC is
net.rpc("use")
That's a request, from whichever machine the player is on, to the server:
"I'd like the door toggled". It arrives as onRpc.use(args, sender) on the
server, sender is the peer that asked (verified — a client can't claim to be
someone else), and the server decides. What it decides goes into synced.open,
and every machine — including the one that asked — eases the door from that.
Turn latency up to 12 ticks and press E again. There's a beat between the key and the door moving: the round trip. That's honest, and it's the same for everyone — nobody's door opens early because they're the host. Fine for a door. Wrong for your own legs, which is the last step.
scripts/netDoor.lua
-- A door the server opens when a player asks. Attach to a solid node — a
-- Rigidbody in KINEMATIC mode — with a Networked component, sync transform
-- OFF: this script moves the door on every machine from one synced flag.
defaults = {
--@desc How far the door lifts when open.
--@range 0 10 --@units m
lift = 3.5,
--@desc How close the player has to be to use it.
--@range 0 10 --@units m
reach = 3.0,
}
replicated = { open = false }
local player
local restY
function start(node)
player = find("Player")
restY = node.y
end
-- An RPC is an INTENT: "I'd like the door toggled". The server decides, and
-- the decision reaches everyone — including this machine — through synced.
onRpc = {}
function onRpc.use(args, sender)
if net.isServer() then synced.open = not synced.open end
end
function update(node, dt)
if player and player.valid and input.justPressed("Interact")
and distance(player, node) < params.reach then
net.rpc("use")
end
-- Cosmetic, and on every machine: ease toward what the server says.
local target = restY + (synced.open and params.lift or 0)
node.y = ease(node.y, target, 6, dt)
end
Done when: Door runs netDoor.lua.
10. Make your own character predicted
With latency at 12 ticks, walk. Your character responds a fifth of a second after you press the key, because under Server authority your input goes to the server and the result comes back. For a door that's fine. For the one node you are steering, it's the thing that makes an online game feel broken.
Select Player → 🌐 Networked → mode → Predicted (owner). (sync physics
is already on, from step 3 — prediction needs the velocity.)
Press Play, open 🌐, and this time click 🎮 Test as remote player (predicted). Your play world becomes a client, predicting against a hidden authoritative server; the orange ghost is the server's truth. Drag latency to 12. Your character stays glued to your keys while the ghost lags behind — and when the two disagree, you're smoothly corrected toward it.
Watch the reconciles line in the panel. Corrections near 0% is healthy. A
rate that climbs means the client is doing something in fixedUpdate the server
can't reproduce.
Why it just worked
You didn't touch the movement script. Two rules from the platformer tutorial —
gameplay in fixedUpdate, and named actions, never raw keys — turn out
to have been the prediction contract all along. The server re-runs your exact
inputs through the exact same code at the exact same rate, so it gets the same
answer. A script that polled input.pressed("space") would read neutral on a
predicted node and simply never jump, with no error anywhere — which is why the
Input settings list every such call site.
11. Let a friend join, from their machine
Everything so far ran on your desk. This is the step that makes it a multiplayer game: a body for your friend, a camera that follows it, and a relay between you.
A body for your friend
The scene has one Player, and it is yours. The scene's Predicted nodes are the
player slots, in node order — #1 the host, #2 the first joiner, #3 the next. A
friend who joins a one-slot scene spectates, and their Console says so.
Select Player, duplicate it (Ctrl+D), name the copy Player2, and move it
a couple of metres along X so the two don't spawn inside each other. The copy
keeps its Networked component, mode Predicted (owner) — that is the whole
slot.
The other way, for a game where players come and go, is to spawn one avatar
per joiner: net.spawn("Player", { owner = peer }) in a playerJoined
handler, from a prefab. Per-player avatars in docs/scripting/networking.md
shows it. Two slots is enough here.
Each camera follows its own player
Both machines run the same scene, so your friend's camera would follow your
Player by name. In platformerCamera.lua, pick the player this machine
steers instead:
-- The player this machine steers: yours on the host, theirs on a friend's.
-- Offline every node is yours, so the wired reference stands.
local function mine()
if net.role() == "offline" then return params.target end
for _, p in ipairs(findTagged("player")) do
if net.isMine(p) then return p end
end
return params.target
end
function lateUpdate(node, dt)
local target = mine()
if not target or not target.valid then return end
local want = target.pos + vec3(0, params.height, params.distance)
node.pos = ease(node.pos, want, params.smoothing, dt)
node:lookAt(target.pos)
end
And the respawn in platformerGame.lua catches both players:
if net.isServer() then
for _, p in ipairs(findTagged("player")) do
if p.valid and p.y < params.fallY then
p.pos = vec3(0, params.spawnY, 0)
p.vel = vec3(0, 0, 0)
end
end
end
A relay between you
You need a relay — a rendezvous both machines can reach, so neither of you
forwards a port. You can run your own (cargo run -p floptle-relay on any box
you both can reach), or use Floptle Cloud, whose free plan seats a small
session — fopull.com/cloud says how many — and is what the rest of this step
uses.
- Register the game at fopull.com/cloud, under a name. You get a game key.
- Paste the key in: ⚙ Settings ⏵ Networked ⏵ Game key. That writes it into your project, so every build carries it.
- Host on it — from a key, not from
start. Both of you run the same script, so anet.hostinstartwould have your friend hosting a lobby of their own before they could join yours (the engine refuses the join: already HOSTING). InplatformerGame.lua, take the call out ofstartand put it behind a key at the top ofupdate:
-- H hosts. Your friend never presses it: they join with the code instead.
if net.role() == "offline" and input.pressed("h") then
net.host{ relay = "cloud", maxPlayers = params.maxPlayers }
end
- Read them the code.
net.lobbyCode()gives you six characters; the first one names the region, so the code is the whole address. Your friend types it into 🌐 → code → ⏵ Join by code (the relay field readscloud), or your game calls:
net.join("cloud://" .. code)
Their capsule drops in beside yours and walks when they press their keys — on your screen from the server's word, on theirs predicted, exactly as step 10 felt on your desk. That is the whole difference between a game you can test and a game somebody else can play.
Without a key, net.host{ relay = "cloud" } says so and points at the page
— it does not fail as a connection error, and it never needs the network to tell
you. Hosting on your own machine or your own relay needs no key at all.
If more people turn up than your plan seats, nobody playing is disconnected:
the next arrival is asked to try again shortly, and you are told once, with the
number and where to raise it. net.notice() hands you that sentence if you want
it on your own lobby screen.
You are done when your friend's Player2 moves on your screen, and your
Player on theirs.
Done when: Player2 has a Networked component.
12. Where to go next
You have a networked platformer, tested at 200 ms and 10% loss without leaving your desk. The things you'd reach for next, in the order you'll want them:
- A lobby screen that handles a wrong code.
net.joindoesn't block; wait onnet.joinState()and show its reason. Mistyping the code is the most common thing that will ever go wrong in an online session. - One avatar per player. Instead of authored slots, spawn one for each
joiner with
net.spawnonplayerJoined— see Per-player avatars indocs/scripting/networking.md. - Hits that respect latency.
net.rewindjudges a swing against the world the attacker was actually seeing. - Rollback, if you're making a fighting game — and only then.
docs/multiplayer.md is the long version of all of it, in that order.