Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Drops, lag and other builds

A room keeps running for every player while one of them drops, lags or loads another build.

A player whose connection drops keeps her seat for a while, for every game, and a player who leaves gives it up at once. Her player entity stays in the room, with her character and everything else she plays, and its ConnectionTrait reads held with the tick her hold ends on. How long the room holds her depends on how the connection ended:

  • A close frame: 15 seconds. A browser sends one when the player reloads the page, closes the tab or navigates away, and the room cannot tell those apart, so a closed tab is held like a reload.
  • A broken connection: 30 seconds. A network that went sends nothing, and the room’s own end of a silent connection or one that fell behind sends nothing either. A phone that moves from Wi-Fi to cellular or rides through a tunnel needs the longer hold.
  • A leave: none. The platform’s app sends one when the player presses Leave or goes anywhere else in the app. The room takes her player out and frees her seat at once, and a page that does not send one, as a closed tab does not, is held as a drop. A game’s own quit button sends one with useRoom.getState().leave(), which closes the connection for good and resolves with whether the room released her: the room answers a leave by closing her socket with 1000, leftCloseCode, and a page that hears no answer within a second keeps her rejoin key for the hold.

A game sets one hold for both kinds of drop with rejoinHoldSeconds in defineRoom, up to 300 seconds, so a turn-based card game keeps a seat for minutes. The room takes each drop, rejoin and leave on its next tick, so a recording and a continue take it on the same step. It logs each dropped line with its holdSeconds, runs each plugin’s onPlayerDisconnect, and stands her character still where she was. The characters plugin puts the engine’s Disconnected trait on her character, which every page receives, so a game can leave her out of what she cannot answer, such as a vote or a monster’s chase.

The room’s welcome names her player entity and her character, and gives each page a rejoin key. The engine’s client keeps the key for its tab and sends it with each join: when the connection comes back, and when the page reloads in the same tab. A join that names the key of a player the room still holds takes that player back, with its seat, whatever she plays at that tick, and a new key, and the room runs each plugin’s onPlayerReconnect. In the platform’s app the seat comes back too: the app keeps the seat its tab holds, and a reload asks the backend for that seat again rather than a new one, so her room counts her once and a full room starts no second room. A page often comes back before the room sees its old connection close, as when a phone moves from Wi-Fi to cellular, so a join with the key of a player still connected takes her from that connection, which the room closes with 4005. A new tab has no key and joins as its own player, but a browser’s Duplicate Tab copies the key with the tab’s storage, so the copy takes the player and the first tab closes. A key the room does not know, used already, or past its hold, joins her as a new player, once her last save has stored, so the join loads it. In a room the platform hosts, that join asks the backend for the seat her spent ticket names, which closes it with 4007, and her page takes a new seat. A game that destroys her character leaves her player in the room: she plays on with no character until the game gives her one. A game removes a player with kick(step, player, { message }): the room runs each plugin’s onPlayerLeave, stores her save as the hooks left it, and closes her socket with 4013, kickedCloseCode, whose reason is the game’s sentence, and her page does not join again. Once the hold ends, each plugin’s onPlayerLeave runs and the room frees her seat. The one exception is a reload whose app asked for the seat back during the hold while its page was still loading: the room tells the backend how long it held her, the backend keeps that seat for the page, and the page joins on it as a new player.

The page’s side is a machine whose state useRoom’s status reads. It is connecting until the room’s welcome, joined from then, reconnecting once a socket that opened closes, whether or not the room welcomed it, and rejoining once the room holds her player no longer. A joined page that hears nothing from the room for 15 seconds, no delta and no answer to its ping, ends its socket and reconnects too, since a network that died without a word closes nothing. While no socket is open, it opens another after a gap that grows: 1, 1, 2, 4 and then 8 seconds. Each gap is up to half shorter at random, so the pages a restart dropped together do not all dial the room at once, and the gaps start over at each welcome. It is closed for good once the room refuses the first join, as a full room does, once a game kicks her with 4013, or once a try gives up. While it reconnects or rejoins, the page drops her input until the new room’s welcome places her.

A reconnect turns into a join as a new player, once for each drop, when the room holds her player no longer:

  • The room says so. It closes her reconnect with 4007, because her ticket was spent on her first join and her hold ended, or with 4004, because her place was filled. A first join that sends a key a page before it kept, and meets 4007, does the same.
  • The room stays out of reach for reconnectGiveUpSeconds, a minute by default, in the platform’s app, where the app can take her a seat in another room.

The page then forgets her key, takes a new seat, waits one gap so the room stores her last save, and joins without a key. In the platform’s app, the app asks the backend for the seat its tab holds, which comes back while it is still hers, or a new one, and hands it to the page over the channel. A development room seats her by her name, so the page joins the same room again. The room’s admission loads her save for that join, as for any join, so a player with a save never plays without it. A refusal or a give-up while it rejoins closes the connection for good. While the page reconnects or rejoins, the app keeps its frame, though the seat is freed meanwhile.

useRoom’s notice is what the player reads about her connection, as { kind, text }, and null while she plays. A game’s status line shows its text, and the loading screen shows the ended one, or a kick’s own sentence, for a game over before it lifts:

kind text
connecting “Joining the game…”
reconnecting “Connection lost. Reconnecting…”
rejoining “Rejoining the game…”
failed “Couldn’t reconnect. Play again.”
ended “Your game has ended.”

The room’s connectionText gives a game its own words for any of them, and reconnectGiveUpSeconds its own give-up time:

<Game
room={{
url,
playerName,
reconnectGiveUpSeconds: 90,
connectionText: { reconnecting: "The fire dims. Holding your place…" },
}}
/>

Roblox draws its own “Disconnected” prompt with Reconnect and Leave buttons. Unity’s Netcode, Photon, Unreal and Godot fire a callback and leave the screen to the game. Photon’s PlayerTtl and Unreal’s InactivePlayerStateLifeSpan give back the old slot inside a hold, and after it the game’s saved data restores the player who joins fresh. Here the page does both, because a creator with no code cannot write either, and the words are plain ones a player knows from other games.

The room ends a connection it has not heard from for 15 seconds, and drops her as for any other drop. It sends each connection a WebSocket ping every 5 seconds, and a pong or any message counts as hearing from her. The browser answers the ping itself, without the page’s code, so a tab in the background stays connected and keeps playing while the browser slows its timers. A phone whose browser the system suspends, as iOS does, stops answering and drops. The room logs a silent line with the player. The page pings the room each second from the moment its join goes, so the room hears from it while a large welcome is still arriving.

The room also drops a connection that falls too far behind. A page that reads the room’s sends slower than the room sends them, such as on a phone with a weak signal, leaves those sends waiting in the room’s memory. Once more than 1 MB has waited for one page for 30 seconds running, the room ends her connection and keeps her character as for any other drop. The 30 seconds let a welcome larger than the bound drain on a slow link, so only a page that has stopped reading reaches the end. Her page rejoins with its key and gets the whole world again in its welcome. A full Holdfast room sends each page about 9 KB a second, and 22 KB in its heaviest second of a fight, so the bound holds about 100 seconds of an average room’s sends. The room logs a fellBehind line with the player and the bytes that waited, and her page reads the drop as RoomDrop.Gone, so the game’s reconnecting line covers it. To set another bound, pass maxQueuedBytes to startRoom.

useRoom’s lastDrop says why the last socket closed, so a game’s line for reconnecting tells the player the truth. It is RoomDrop.Gone for the room stopping, restarting or falling out of reach, and RoomDrop.MessageTooLarge for the close code 1009, messageTooLargeCloseCode, which the room’s socket sends on a frame past maximumFrameBytes, such as from a page of another build. A 1009 is the player’s own frame, not a network drop: the page warns in the console that the frame was too large, and reconnects as for any drop.

Photon keeps a dropped player in the room for its PlayerTTL and lets her rejoin with her token, and Unreal keeps a dropped player’s state for InactivePlayerStateLifeSpan to hand back on her return. Roblox, Unity’s Netcode and Godot drop the character with the connection unless the game keeps it. Here the room keeps it, because a creator with no code cannot write a rejoin, and a friend whose phone loses its network for ten seconds should not lose her run. Colyseus, Photon and Nakama each free a player at once only on an explicit leave from the client, Colyseus’s close code 4000 and Photon’s LeaveRoom(becomeInactive: false), and count a held player’s seat as taken. Their holds run from Ably’s 15 seconds after a page unloads and Photon’s advice of at least 12 to Colyseus’s example of 30 and Unity Lobby’s 60. The room follows them: a leave frees the seat, and the two holds sit in the same range. Every engine that ships its own transport ends a connection that goes silent: Photon and Unity Relay after 10 seconds, Unity Transport after 30, Unreal after 60, Godot’s ENet after 5 to 30 and Colyseus after about 6 to 9. The room’s 15 seconds sit among them, and it measures them by the WebSocket pings a browser answers on its own, so a background tab is not mistaken for a dead one. For a connection that falls behind, Unreal closes one whose reliable buffer overflows, Unity’s transport disconnects a peer whose send queue is full, and Roblox drops a client that falls too far behind, each beside its silence timeout. Unreal and Unity let the developer set the bound, so the room takes maxQueuedBytes.

When the room itself stops on an error, every player keeps her progress. A throw in one of your systems does not stop a room the platform hosts: the room logs it and runs the rest of the step, as When a system throws says. A throw in the room’s own work does stop it. The room first stores every player’s save, then closes each page with 4012, roomFailedCloseCode. Her page takes a new seat at once, and the new room loads that save.

A page that falls behind the room catches up in one update. The room sends every page a delta at its send rate, 30 or 60 times a second, and the page folds each delta into the one that waits as it lands. Its next frame applies the merged delta once, so a page never replays a backlog, and a game sees one change where the room made hundreds. The merged delta leaves the world as applying each delta in turn would: its dump is the same.

A hidden tab stays connected, and nothing of the game runs while it is hidden:

  • While hidden. The browser runs no frames, so the page applies nothing: no trait changes, no event lands, and no game code reacts unseen. What waits is one delta the size of the world, however long the tab stays hidden.
  • On return. The first frame applies the merged delta. Every entity stands where the room has it now, with no glide from where it stood before the tab hid. The events and shots that landed while the tab was hidden are dropped, so a game plays no burst of sounds or damage numbers. The frame steps the world for at most a frame’s longest time, 0.1 seconds, as any long frame does.
  • A page that lags while shown. A stalled frame or a network burst leaves several deltas waiting, and the next frame applies them as one. While a second of sends or less waits, as many as the room’s send rate that its welcome named, the page keeps their events and shots in the order they came, so each hit still plays and draws its marker. The streamed entities move on from where they are drawn toward where the room has them, as after any delta. Past that second, the page drops the events and shots as stale and stands the entities where the room has them, as on a hidden tab’s return.

The acknowledgement the room sent right after the last delta a frame applies alone checks the player’s own character against the room, so her state is never compared with an older send’s: a delta that lands after an acknowledgement drops it, and the next acknowledgement takes its place. What the room sends her alone, her bag, what she wears and her cooldowns, and the names of the predicted traits the room granted her, merge from every acknowledgement that waited, the latest word on each standing. A welcome that arrives while deltas wait replaces them, and the deltas after it wait behind it. A page that the browser freezes, as a phone may, runs no code at all: the room drops it as it drops any page that stops answering, and the page rejoins with a fresh welcome.

Unity’s Netcode, Unreal and Roblox send each client the latest value of what changed, so a client that fell behind takes the newest state in one step. The Quake and Source engines send each client the difference from the last state it confirmed, and Photon’s tick-based engines resync a client that falls too far behind from a snapshot. Every one of them ends with the client taking the latest state at once. The room here sends one shared stream to every page, so the page does the merging, and the room and what it sends stay the same.

A player whose character the room keeps still holds her seat, so she counts toward the room’s maxPlayers, and she comes back even to a room that is otherwise full. On the platform, the backend hands out a room’s seats, and a room admits every player whose seat the backend confirmed, whatever its own count says. A player who leaves frees her seat at once, so in a full room the next player takes it rather than start another room. The room refuses a join past its players with the close code 4004, whose reason says how full it is. The engine publishes that reason as full on useRoom, so a game says “This room is full (4 of 4)” rather than that no room answered.

A room on the platform ends itself a minute after its last player leaves. Only a join that seats a player restarts that minute, so a socket that never joins does not keep an empty room running. The room ends a socket that sends no join within 10 seconds, and holds at most two such sockets for each seat, ending the oldest past that. The development room runs until it is stopped.

A page and a room should run the same game on the same engine, or they may misread each other’s messages and state. So a built page names its build as it joins, and the room names its own in its welcome. A room of another build admits the page and logs a buildMismatch line with both builds. The page plays on. The engine publishes both builds as buildMismatch on useRoom, as {"room":"a1b2c3d4e5f6","page":"0f9e8d7c6b5a"}, and every game shows a small note in the bottom-left corner that says “The room runs another build of the game” and names both, until the player dismisses it. A player who sees odd play knows why, and the fix is a rebuild of the room’s machine. spawnite play join serves the game’s build and names both builds for each page a room of another build admits.

The room admits the page rather than refusing it because the build changes on almost every merge, most often through an engine change that cannot affect a connection, and a room’s machine is rebuilt by hand: a refusal would lock players out of a fresh page within the hour. Refusal returns with published versions, since the app then loads the page at its room’s own version.

What the room and the page say to each other has a version of its own, protocolVersion, raised by any change to the messages. The join names it, and so does the welcome. A room refuses a join that names another, or none, with the close code 4006, whose reason names both as {"room":"protocol 6","page":"protocol 5"}. A page that meets a welcome of another closes the socket before it takes the welcome, with the same reason. Either way the page shows both on its loading screen, as two builds, and does not try that room again. Every page built since 4006 existed reads it this way, so a page and a room of different versions never play together, and neither misreads the other’s messages. A dev page and a bot name the version too. A recording of a room or a page keeps its shape, recordingFormat, which a change to the messages raises too, and a continue or a replay of a recording of another shape refuses at its start.

The build is a hash of the code both run, which the engine’s Vite plugin reads from the tree: the game’s src folder and its package.json, the engine’s code and its package.json, and the src folder and package.json of each workspace package either runs with, such as the schema and the ui. Two copies of one tree read the same build, whatever their line endings, and a change to either’s code reads another. The room reads it through the game’s own Vite config as it starts, and again at each restart on a changed file. A page from the dev server names no build, since the server swaps its modules while it runs, and neither does a bot or a page from before the check: a room logs nothing for a join that names none, and a room whose game’s config loads no plugin names no build. The build is the page’s word, so it catches a page and a room built apart and proves nothing about the page, which the room trusts for nothing else either, as the trust boundary says.

Every engine compares versions as a client connects: a protocol number in Minecraft and Rust, a build checksum in Unreal, the engine and place versions on Roblox. Here a version number would not do, since the repository’s games are version 0 on every merge, so the check compares a hash of the code itself. Those engines refuse a client of another version, because each client downloads the version its server runs; this platform warns until published versions give a page the same.