Multiplayer
A multiplayer game runs in a room: one running copy of the game that players join together. The room runs the game’s world on a server the platform hosts, decides every outcome, and streams the world to each player’s client, the game running in that player’s browser. You do not write a separate server application. The engine networks its own movement, weapons and items, and a mechanic of the game’s own picks how it is networked, as Networking shows. A room holds 20 players unless the game sets another number, from 2 to 50. Rooms run near Montreal. A single-player game is played alone, with no room, and keeps its saves and progress all the same.
- Commands and what each client sees: what a player asks the room for, and who receives each trait.
- Drops, lag and other builds: a player who drops, rejoins or runs another build.
- Bots in a room: players with no person behind them, to test a room.
- Networking: how to network a mechanic of the game’s own.
- The trust boundary: what a room decides, stated as rules.
Played alone or in a room
Section titled “Played alone or in a room”A game is played alone or in a room, never both, and its package.json says which: a game that names spawnite.room always plays in a room, and a game that names none is played alone. The template you create the game from writes it: spawnite create --room, or the MCP’s new_game with room: true, gives a new game a room, and Templates says which templates offer one.
Give a game a room when people play it together: they share one world, the room decides every outcome, and they talk over voice. Leave it alone when one person plays, against the game or its bots. A game played alone runs in the player’s browser and nowhere else, and costs the platform no machine time. It keeps:
- the player’s saves, which the platform keeps for every account and guest, as Saves says;
- her coins, in the wallet, which her save carries;
- everything the engine runs on a page: the character, physics, bots of the game’s own and the devtools.
It has no voice and no chat, which run in a room.
A room’s cap, which the game sets, is 2 to 50 players, since a game for one player is played alone. A room still runs from its first player. defineRoom({ maxPlayers: 1 }) is a type error, and a game played alone that exports defineRoom is refused by the game’s lint rule room/no-define-room-alone, and by spawnite build and spawnite publish, each naming the fix. The platform checks again on its own: the upload refuses a version that is played alone and names a room’s scene or settings, or that runs a room for one player, and no room ever starts for a game played alone. A version published before the rule that breaks it is withdrawn: it leaves the store, its link loads nothing and says why, and spawnite versions list and the creator site say why and what to change. A new version that passes lists the game again. Nothing is deleted.
To move a game into a room later, add the spawnite.room key that Run a room while you build shows and install @spawnite/room as a dev dependency. A player’s save stays with the game either way, but a room writes a room game’s save, and keeps less than a page does. spawnite build refuses a persisted store and the other parts a room cannot keep, as Saves in a room says, so move that state into a plugin’s save first. To move a room game to played alone, delete the key, the dev dependency and its defineRoom export.
What a multiplayer game gets
Section titled “What a multiplayer game gets”- A room that runs the game. The room steps the game’s own scene 60 times a second, decides every outcome, and sends each player 60 updates a second, or 30 where the game asks. Choose a send rate gives the bytes and the CPU it measured.
- Prediction. Each player’s client runs their own character ahead of the room, so a key moves it at once, and the room corrects it where they differ, as The player’s own character comes first says.
- Several players in one run, for your agent.
spawnite play start --pages 2opens a second player’s page beside the first, and--bots <n>adds bots.spawnite play joinjoins several headless players to a room and says what each saw, andspawnite play roomplays the room with bots and no browser.
Play with friends in one room
Section titled “Play with friends in one room”To play with just your friends, publish the game unlisted and share the keyed link. An unlisted game stays off the store, only people with the link can join it, and it runs one room at a time, so everyone with the link plays in that room until it is full. It needs nothing more. A friend who opens the link while the room is full is refused with “The game’s room is full. Try again in a minute.”, and can try again to join once a seat is free.
In a public game, Play seats each player in the game’s fullest open room that still has a seat. Friends who press Play close together usually share a room, but nothing holds a seat for one of them, and a player cannot pick which open room Play seats them in. A player who wants to play apart from strangers opens a private room from the game’s page for the people they invite, and shares its link. A private room runs while two or more players are in it. Once it is empty it closes, and its link stays.
What runs where
Section titled “What runs where”The room runs the same simulation as the client: the scene’s own file, headless, stepped 60 times a second. Each step is a tick, and the room and every client number the ticks alike. It sends the world to every client at the game’s send rate, 60 times a second unless the game asks for 30: a snapshot when a player joins, and after that an update with what changed.
A player’s client does three things:
- It sends the player’s input: the input of each tick, where the player clicked to walk, each shot, and the game’s own commands. It never sends where the player’s character is.
- It runs the player’s own character ahead of the room, so a key press moves the character at once, even on a phone far from the room. This is called prediction.
- It draws every other player and every other entity from what the room sends, smoothed between updates. A dynamic physics prop is drawn where the room’s updates put it too, but the client also simulates its own body for it, which can drift from the room’s, so the room alone decides what the prop stops.
The room runs each input under the game’s rules and decides every entity, the player’s own character included. With each update, it tells the client where the player’s character stands and what its predicted traits hold. Where the client has the character elsewhere, the client takes the room’s values, steps the character again from there, and blends it to the result, as What a correction does explains.
A behaviour declares where it runs, as Entity describes. A client in a room skips each behaviour that runs in the room, such as Pickup, Health and Chase, and draws what the room did instead. Authority lists which end steers each kind of entity.
Limits
Section titled “Limits”- Players. A room holds 20 players unless the game’s
maxPlayerssets another number, 50 at most, a dropped player’s kept character among them. A join past it is refused, and told how full the room is. - Updates. 60 a second unless the game’s
sendRatesets 30. - A dropped player. The character stays 15 seconds after a close frame, as from a reload or a closed tab, and 30 seconds after a broken connection, for the player to come back to, unless the game’s
rejoinHoldSecondssets another hold. A leave frees it at once. - Seats across games. An account holds a seat in three games at once, and a guest in two. A seat whose room has not opened is freed three minutes after it was taken.
- A silent connection. The room drops a connection it has not heard from for 15 seconds, and the player rejoins. The client reconnects once it has heard nothing from the room for 15 seconds.
- A client that falls behind. Once more than 1 MB of updates has waited for one client for 30 seconds running, the room drops that client’s connection at its next update, and the player rejoins.
- A socket that never joins. The room ends it after 10 seconds, and holds at most two for each seat.
- Input. One input for each of the room’s ticks, 1 to 12 ticks in a message, which names its first tick, sent as bytes on the binary wire. A game declares at most 32 held inputs across its plugins. A client may send 120 messages at once, then two for each of the room’s ticks; the room drops the rest and keeps the connection. Where a player’s input for a tick has not come, the room steps the character on the last input for 15 ticks, a quarter of a second, then stands it still, and drops an input that comes after its tick.
- Walks. Eight walks at once, then five a second.
- Item verbs. Eight of each verb at once, then four a second.
- Commands. Each command’s own
perSecondpast itsburst, a predicted command’s included. At most 64 of a player’s commands wait for a result at once; a client past that breaks the protocol, and the room closes its connection. - Command size. A command’s payload holds at most 16 KiB, and the value it returns 1 KiB. A world throws as it is made for a command whose fields could pass either, and the room’s socket takes no frame past 32 KiB.
Run a room while you build
Section titled “Run a room while you build”A game with a room names it under spawnite.room in its package.json, and passes the room to <Game>. You don’t write either by hand in a new game. spawnite create --room, or the MCP’s new_game with room: true, writes the key, pointed at the template’s scene, and installs the room, and every template that can play in a room already passes room to <Game>. A template that plays alone refuses --room. For the example template, it writes the following:
{ "spawnite": { "room": { "scene": "./src/scenes/Run.tsx" } }}- The key itself says the game plays in a room, which is what the tools below start. A template that plays alone unless it is created with
--room, such as the example, offers its room underspawnite.template.room, which only the registry reads, andspawnite create --roomwrites it as the game’sspawnite.room.spawnite listand the MCP’slist_templatessay which templates play in a room, which play alone, and which can do either. sceneis the scene the room runs. The room loads that file alone, so the file re-exports what the room reads from the game, such asexport { plugins } from "../game"andexport { save } from "../save".envis optional and holds the room’s environment while you build, such as"PORT": "8788". A room keeps the room’s half of a replay while you build, with no setting, whichspawnite replay rerun,replay checkandreplay stateread.
A creator uses the following settings of a development room’s environment, each set in env, or for one run with --room-env NAME=VALUE:
PORT: the port the room listens on.ROOM_REPLAY=0: keep none of the room’s half of a replay, as a measured run that should not pay for it does.ROOM_EDITS=1: apply the devtools’ edits from a client.ROOM_INVULNERABLE=1: give every player’s character theInvulnerableTraittrait, which Health spares.ROOM_SAVES=1: keep players’ saves in a room the CLI starts, as Saves in a room says.ROOM_HEARTBEAT_SECONDS: the seconds between two heartbeats, which hitches reads.MAX_PLAYERSandSEND_RATE: the room’smaxPlayersandsendRate, over the game’s, as Set the room’s players and send rate says.
The client joins the room through <Game>’s room prop, a RoomOptions from @spawnite/engine with the room’s address as url and the player’s name as playerName. The example game reads both from the page’s address. Where the address names no room, it passes none, and the game plays alone:
/** The room `?room=` names, as `spawnite play start` opens a game created * with `--room`; none, so the game plays alone, where it names none. */function readRoomOptions(search: string): RoomOptions | undefined { const query = new URLSearchParams(search); const url = query.get("room"); if (url === null) return undefined; return { url, playerName: query.get("name") ?? "Player" };}The game’s App reads the options once, as it mounts, and passes them to <Game>:
export function App() { // Game reads its room and its renderer once, when it mounts. const [room] = useState(() => readRoomOptions(window.location.search)); const [renderer] = useState(() => readRenderer(window.location.search));
return ( <Game name="example" start="lobby" room={room} gl={renderer} plugins={plugins} save={save} >A room the platform’s backend runs seats a client only with a seat ticket, the pass the backend issues when it gives a player a seat in that room. In the platform’s app, Play takes the seat and hands the client the ticket and the room’s address, and the engine joins that room in place of the one the prop names, under the player’s name in the app. Outside the app, as on a game’s dev server, the client plays the room the prop names. A room with no backend, such as the one a game runs while you build, seats any client without a ticket.
Once created with --room, the example’s room runs its Run scene. Every player in the room plays the room’s one round, which, as every room’s round does, waits for the first player to open its scene, here by pressing Play. The lobby stays each client’s own menu.
spawnite play start starts the game’s room on a free port beside its dev server, and passes the room’s address in the page’s room parameter, so the page it opens plays in a fresh room. It holds the room with its page, so the room’s clock steps only on command, and --bots <n> adds bots that play until the session stops, so an agent pauses a live fight, steps it and shoots it from any camera, as Directing a moment says. spawnite play join joins players’ clients to the game’s room, or to any room by its address, and says what each saw.
The room restarts on its scene when a file the scene loaded changes, and logs a restarted line that names the file. It loads the game’s Vite config with Vite’s runner config loader, which writes no temporary file, so Node’s watch sees no change while the room starts. A config that loads under vite but not under vite --configLoader runner, such as a CommonJS vite.config.cjs, fails the room’s start. spawnite play room runs its room unwatched, so an edit never ends a measured run.
The player’s own character comes first
Section titled “The player’s own character comes first”The engine follows one rule for the player’s own character: the client shows at once, at the frame rate, whatever it can know, and the room’s update corrects it afterwards. The client runs the character’s movement, jump, turn, cooldowns and resources ahead of the room, with the same rules the room runs, and the room’s result confirms or corrects them.
The client steps the character 60 times a second, each step on that step’s own input. After each frame’s steps, it sends the room one message that carries the input of each tick the frame stepped: at 60 frames a second, one tick a message. Each end writes each tick’s input into the character’s InputTrait as it steps that tick. A game system that writes the character’s input therefore sees its own write only until the next tick, in the room and on the client alike.
The client shows the following at once:
- The character’s turn, while the camera’s lock or first person holds, and the look’s pitch in the character’s spine and arm, every frame.
- Cooldowns counting down, and mana, stamina and every other resource but health regenerating, between the room’s updates. A cooldown or a cost that only a rule in the room starts, such as an item’s use, reaches the client with the room’s next update, which corrects the client from that tick.
- Movement, the jump and the walk to a clicked point, stepped 60 times a second. A key press moves the character on the next step, at most 17 ms after it, and reaches the room with that step’s input as the frame ends.
- An ability’s cast, on the frame of the press: its cost off the bar, its cast bar, clip and charge, the character’s turn toward its target, its refusal line, and at its release the spent cost and the cooldowns sweeping. Abilities names the casts that wait for the room: one that walks the character into range first, and one of an ability registered
predicted: false. - A weapon’s shot: its line, muzzle flash, sound and camera kick, held to the weapon’s rate on the client. An instant weapon’s hit marker shows on the click, and the client takes it back if the room refuses the shot.
- What the character holds, its head turn, its footsteps and the camera.
The room alone decides the following, and the client shows each when the room’s update arrives:
- Damage, health, being downed, death and respawn.
- An ability’s effect: its projectile, its impact, its strikes, the damage numbers and the slow it leaves.
- A projectile weapon’s projectile.
- The bag, what the character wears and what it equips.
- The wallet, the score, the timers and the round.
A game’s own mechanic chooses the same way. Networking explains the choices, with examples. It covers the room clock and the lead, which is how many ticks the client runs ahead of the room, predicted traits and predicted commands, corrections, and the checks a development client runs on a predicted mechanic.
Fair play, built in
Section titled “Fair play, built in”The room checks what each player does; it does not hide what a player could see, since every client receives every entity in the room, as the end of this section says.
The room runs every player’s character from that player’s input, so a client changed on the player’s own device gets from the room only what the room’s rules allow. In a room the platform hosts, with the engine’s default movement and weapon rules, the room checks the following for every game, with no code of the game’s own. A development room started with ROOM_EDITS=1 also applies the devtools’ edits from any client, to any entity, so it trusts its clients more than a hosted room does.
The checks:
- Input. The room steps each character once a tick, on the input named for that tick, at the speed the game gives it and into the walls of the room’s own world. A client that has the character somewhere else is corrected.
- Shots. The room judges each shot itself. It rewinds the targets to where the shooter’s screen showed them, by the shooter’s measured latency, held within 200 ms of it and at most 1 s back. It refuses a shot from a downed or disarmed character, from a weapon the character does not hold, faster than the weapon’s rate allows, from more than 2.5 m from the middle of the character’s body in the room, or from behind cover.
- Outcomes. Only the room writes the wallet, the bag, the score, the timers and the round. A client reads them, and the room ignores a client’s write to them.
- Commands. The room refuses a command whose payload does not match its declared shape, that arrives faster than its rate, or that acts through an entity the player does not control, and runs a resent command once.
- Predicted commands. The room runs each predicted command once, on the tick it names, or on its next tick where the command comes up to its
lateTickslate, 17 ticks by default, never earlier, and within the command’s rate. It answers a second copy with the first result, refuses one stamped more than a second aheadmalformed, and refuses one past itslateTickslate. A client that holds a command back delays its own action and gains nothing: the wait earns the command no extra rate.
For example, a modified client sends input at twice the rate, to run its character at twice the speed. The room steps the character once a tick on the input named for that tick, so the extra input names ticks the room already holds, or ticks more than a second ahead, and the room drops it. The character runs at the normal speed however fast the client sends. A client that holds its input back gains no time either. The room steps the character anyway, on the last input it has for a quarter of a second and then standing still, and drops the held input when it comes. The modified client, which ran its character elsewhere, takes the room’s position once the room’s update arrives, and its character snaps back to where every other player sees it.
Each check a shot meets is a named rule that a game loosens, turns off or adds its own to, for the whole game or for one weapon. The rules a shot meets lists each rule, what it can cost an honest player, and how to change it. The room judges a shot where the shooter’s screen showed its target, and the shots a network stall of up to 0.65 s held back all count. A refused shot is counted by its rule in the room’s heartbeat, the line of counts the room logs every 15 seconds, and in its log, and the shooter’s client takes its hit marker back. In a game where nobody gains by cheating, judge: ShotJudge.Page lets each client decide what its own shots hit, and the room still runs the rules. It goes in registerShotSettings(world, { judge: ShotJudge.Page }) for the whole game, or in one weapon’s own judge setting, which lies over the game’s.
The room does not check everything:
- A game’s own command is checked only by the game. The system that reads it decides what the player may have, such as whether they can afford an item or stand close enough to open a door, from the room’s own world, as Send the room a command says. A command that carries a price, a score or a position asks the room to take the client’s word for it.
- Every client receives every entity in the room, wherever it stands, and every trait not declared
ownerOnlyorserverOnly. The room does not hold back what a player cannot see, so a modified client can draw a player hidden behind a wall. Keep a secret, such as another player’s hand of cards, in a trait only its owner or the room receives, as Choose who a trait reaches shows.
Set the room’s players and send rate
Section titled “Set the room’s players and send rate”A game declares how many players one room holds, how often the room sends them updates, and how long it holds a dropped player, with defineRoom. Export it from src/game.ts, and re-export it from the room’s scene file beside the plugins, as export { plugins, room } from "../game", since the room loads the scene’s file alone:
import { defineRoom } from "@spawnite/engine/core";
// A party game played on phones: eight players, and the world sent 30// times a second rather than the default 60, which halves what each phone// downloads. Exported from src/game.ts and re-exported from the room's// scene file, as export { plugins, room } from "../game".export const room = defineRoom({ maxPlayers: 8, sendRate: 30 });The room reads four settings, each optional:
maxPlayers: the most players one room holds, a whole number from 2 to 50, and 20 when left out. A game for one player is played alone, with no room. A dropped player’s kept character counts. A game for more players runs several rooms. Holdfast declares 4.sendRate: the room’s updates a second, 60 or 30, and 60 when left out. Each update carries the world’s changes and each player’s own character as the room has it. The simulation steps 60 times a second in every game, whatever the send rate.rejoinHoldSeconds: how long the room holds a dropped player’s seat and character for them to come back, above 0 and up to 300 seconds. Left out, it is 15 seconds after a closed page and 30 after a broken connection, as When a player drops says.voice: whether players talk, and on when left out.falseturns voice off for the game, as Turning voice off says.
The types refuse any other maxPlayers or sendRate in your editor. spawnite build writes them and voice off into the bundle’s manifest, and fails where src/game.ts exports room and the room’s scene file does not, and where it exports room in a game played alone. spawnite build and the room as it starts check all three settings against the same rules, and a room with settings it cannot read does not start and says why. The platform checks maxPlayers and sendRate again at publish and each time it starts a room, so a room never runs on a value outside the range. While you build, --room-env MAX_PLAYERS=2 or --room-env SEND_RATE=30 runs the game’s room on another value with no edit.
Choose a send rate
Section titled “Choose a send rate”60 is the default, and a room may run at 60 at any size. At 60, each player sees the others sooner. The room’s next update leaves at most one tick after it steps, against two at 30, and the client draws the other players one update interval behind the last update: 17 ms at 60, and 33 ms at 30, the slower rate. A fast shooter or a fighting game, where a hit lands on what a player saw a few milliseconds ago, gains the most from it.
Consider 30 for a very large room, which runs lighter at 30, or for a game people play on mobile data, where each player downloads about half as much.
The cost of 60 is bandwidth. Each update carries what changed, and in a game where everyone moves, everyone’s position changes at every update. So each client receives about twice the bytes at 60 as at 30. A client receives every other player’s changes, so its bytes also grow with the room’s size. The following table shows the bytes a second the room sent each client in a light game, the Arena with every bot moving and firing, measured on the platform’s room machine on 2026-10-03, before TCP and WebSocket overhead:
| Players in the room | 30 updates a second | 60 updates a second |
|---|---|---|
| 12 | 11.5 kB/s | 20.6 kB/s |
| 36 | 31.7 kB/s | 56.4 kB/s |
| 50, estimated | about 44 kB/s | about 77 kB/s |
| Each player added, at 12 to 36 | 0.84 kB/s | 1.49 kB/s |
A client’s own reading agrees. In a 9-player Arena room played through spawnite play on a development machine on 2026-10-03, over a 30 ms link, the client received about 15 kB/s at 30 updates a second and 30 kB/s at 60 updates a second. It drew the other players about 58 ms behind the room’s last update at 30, and 43 ms behind it at 60, the faster rate. Its lead on the room was the same at either rate, about 6 ticks over a 30 ms link and 12 over a 150 ms one. The room corrected the player’s character a handful of times a minute at most at either rate. spawnite play state names the room’s send rate and that draw delay on its Room: line.
The 50-player row adds 14 players’ worth to the 36-player row. At 60 in a 50-player room, each player downloads about 77 kB/s, about 280 MB in an hour of play, against about 44 kB/s and 160 MB an hour at 30 (estimates). That is little on a home connection, and a large share of a phone’s data plan. A game with more on screen, such as Holdfast’s dozens of monsters, sends more. A room at 60 used 41% of its one core at 36 players, against 34% at 30, about a fifth more. Hosting is free, so the room’s traffic costs a game nothing at either rate.
Each room runs in a container of its own, with one CPU core and 512 MB of memory, and how many players it holds depends on how much the game does each step. The light Arena used 41% of its core at 36 players and 60 updates a second. 50 players at 60 updates fits that core for a game as light as the Arena (an estimate from the 36-player run).
A heavier game holds fewer. Holdfast’s room, with dozens of monsters, held 24 players at 60 steps a second, but its late fights used 79 to 87% of the core, past the 70% at which players’ inputs began to arrive late. That was measured on the platform’s room machine on 2026-10-03, while the room sent 20 updates a second. A game now sends 30 or 60 updates a second, the only rates sendRate allows, and the Arena’s run above shows the core’s share growing with the rate, so a game as heavy as Holdfast likely holds fewer than 24 at either (an estimate). spawnite play room plays your own game’s room with up to 12 bots and reports its step time, memory and bytes for each wave, so you can measure your game before you set maxPlayers.
Play it on a slow connection
Section titled “Play it on a slow connection”A mechanic that feels right on your machine can break for a player: your room answers in a millisecond, and theirs in 150. In development, the page plays its room over a slow link you choose, so you see what the player sees before they do:
spawnite play start --latency 150 --jitter 20 --stall 500/10The link takes the following settings:
--latencyis the round trip in milliseconds, up to 10,000. Each message to the room, and each message back, waits half of it.--jitteris how much each message’s trip varies, in milliseconds, up to 10,000. Most trips land within it of half the round trip, and now and then one runs two or three times it.--stall <milliseconds>/<every seconds>freezes the link now and then, as a phone’s handover or a busy Wi-Fi does. With500/10, nothing gets through either way for half a second every ten seconds, then everything held arrives at once. A stall must be shorter than the time between two stalls.
Messages keep their order and none is lost, because the room’s link is a WebSocket, which delivers every message in order. A lost packet reaches the game as a late message, which the jitter and the stall cover, so the link drops nothing. The engine’s room connection holds the messages itself, so the round trip the client measures, the clock it keeps with the room and every correction run on the slow link. A browser’s own network throttling does not reach a WebSocket’s messages. Voice travels on a connection of its own, which the link does not slow.
The same settings work from every tool:
- A dev page reads
?latency=150&jitter=20&stall=500/10, whichplay startwrites for you. - In the devtools, the Performance panel shows the room’s round trip. Beside it, a row of connections sets the link while you play: Direct, Good Wi-Fi (40 ms, jitter 5 ms), Phone, 4G (120 ms, jitter 25 ms, a 400 ms stall every 30 s) and Bad hotel (250 ms, jitter 60 ms, a 1 s stall every 15 s). Custom shows three boxes, the round trip, the jitter and the stall, for any link you type, and holds a link the address set. The choice lasts until the page reloads.
- The MCP’s
start_playtesttakeslatency,jitterandstall, and starts a headless page on them. A playtest on the creator’s own browser tab plays over the link the creator set in that tab’s devtools. spawnite play profile --devtakes the flags for its page, andspawnite play roomfor each bot. Onplay roomthe times are the game’s, so a run at--speed 4holds each message a quarter as long on the wall clock. The bots thatplay start --botsandplay profile --botsadd play directly.
spawnite play state names the client’s lead on its Room: line: the ticks its clock runs ahead of the room’s, as the room’s answers estimate it, after the round trip. The same line names the bytes a second the client sent the room beside those it received, and the link, such as on a slow link of 150 ms, jitter 20 ms, stall 500 ms every 10 s, and a --until condition reads the link as room.link. The round trip reads at or above the latency: a message waits behind the one before it, and a stall holds the pings too.
A built game carries none of it. The link’s code compiles out of a production build, and a published page ignores the address’s settings, so play profile needs --dev beside the flags.