The development room
The development room, from @spawnite/room, is the server for one multiplayer room on your own machine, which plays a game exactly as the platform’s rooms do. It builds the game’s world headless, steps it 60 times a second, and streams it to every connected player as the engine’s replication protocol: a snapshot on join, then a delta at the game’s send rate, 60 times a second unless its room settings or SEND_RATE ask for 30. A player moves nothing but their own character, through the inputs and walks they send. The world stands the map’s rocks and trees, the scatter that blocks a walker, baked from the engine’s shapes table, so the room stops a character where her page does. The room bakes its navmesh at start from the same ground and rocks, so a walk finds the path her page finds. Multiplayer says how to run one while you build; this page is its reference.
The log lines
Section titled “The log lines”The room prints one JSON line when it starts, one per join and leave, one as a player’s connection drops (dropped) and as she comes back to her character (rejoined), and a heartbeat every 15 seconds:
{"kind":"heartbeat","room":"local","players":0,"steps":900,"step":900,"stepMilliseconds":{"mean":0.192,"max":25.595},"memoryMegabytes":89.6,"heapMegabytes":{"used":41.2,"afterCollection":36.8,"limit":4144},"stepShare":{"mean":0.012,"max":1.536},"slowSystems":[],"skippedSteps":0,"behindSeconds":0,"cpuShare":0.031,"stepMillisecondsPerPlayer":0.192,"systemFaults":{},"entities":31,"sentBytes":{"total":0,"max":0,"words":0},"sendMilliseconds":{"mean":0,"max":0},"clampedCoordinates":0,"refusedShots":{},"fellBehind":0,"inputs":{"starved":0,"late":0}}sentBytes.words is the part of total that each page’s own word took: her acknowledgement and her own traits, beside the delta every page shares. sendMilliseconds is one send’s time, from the dump to the last socket written, over the sends since the last heartbeat that had a player to send to: what a higher send rate costs the room. A room whose scene declares a timeline with parts also logs a heartbeat as each part ends, so no heartbeat’s steps span two parts. step is the steps the room has run since it started, so a tool lines a heartbeat up with the room’s timeline at whatever speed the room ran. sentBytes holds the bytes the room sent its players since the last heartbeat, as the payloads of its frames: over all of them as total, and the most to one as max.
memoryMegabytes is the process’s resident memory. heapMegabytes is its JavaScript heap: used now, afterCollection as the last full collection left it, which is what the room keeps from step to step, and limit, the most V8 lets it hold. A room whose kept heap climbs from heartbeat to heartbeat holds something it never lets go, and dies of an out-of-memory error when afterCollection reaches limit. The first time a full collection leaves the heap past three quarters of the limit, the room logs a heapNearLimit line with the same figures, since that death runs no handler of the room’s and logs nothing of its own:
{"kind":"heapNearLimit","room":"local","heapMegabytes":{"used":201.7,"afterCollection":196.3,"limit":259}}A room that keeps a recording, as ROOM_REPLAY sets, adds two fields. checkpointMilliseconds is the longest replay checkpoint it took since the last heartbeat, and 0 where it took none. The room counts a checkpoint in the step that took it, so stepMilliseconds includes it. ownStepMilliseconds has the same mean and max with each step’s checkpoint taken out, which reads as the game’s own step, as a room that records nothing runs it:
{"kind":"heartbeat","room":"local","players":1,"steps":60,"step":1200,"stepMilliseconds":{"mean":1.021,"max":31.5},"memoryMegabytes":221.4,"heapMegabytes":{"used":98.5,"afterCollection":84.1,"limit":4144},"sentBytes":{"total":5120,"max":5120,"words":540},"sendMilliseconds":{"mean":0.31,"max":1.2},"clampedCoordinates":0,"refusedShots":{},"fellBehind":0,"inputs":{"starved":0,"late":0},"checkpointMilliseconds":30.1,"ownStepMilliseconds":{"mean":0.519,"max":1.4}}clampedCoordinates counts the coordinates the stream held to the world limit, 10 km from the origin along each axis, since the last heartbeat: an entity past it that the game should keep inside. The first time an entity is held in a heartbeat’s window, the room also logs a clamped line naming it by its dump key:
{"kind":"clamped","room":"local","entity":"0/0/2/monster-0"}refusedShots counts the shots the room refused since the last heartbeat, by the name of the rule that refused each, such as {"origin":2,"cover":1}: armed, rate, origin and cover are the engine’s rules, weapon is a weapon the world does not hold, and any other name is the game’s own rule. A page runs its player ahead of the room, so a count that climbs in honest play says a rule is too tight for the game. The room also logs a shotRefused line for each, naming the shooter’s character, the weapon, the rule and its reason, up to four of each player’s between heartbeats:
{ "kind": "shotRefused", "room": "local", "player": "15", "weapon": "blaster", "rule": "origin", "reason": "Fired from 4.12 m from the middle of the shooter's body, past the 2.5 m allowed."}inputs counts, over every player since the last heartbeat, the ticks the room stepped a character without an input of hers as starved, and her ticks it dropped for coming after it stepped them as late. A player who joins and has sent nothing yet counts as starved too. Two counts that climb in honest play say her pages run too short a lead for the room’s links.
A page may send two input messages for each of the room’s ticks on average, and 120, inputMessageBurst, at once, counted on the room’s ticks so a page keeps up with a room a tool runs fast. The room drops each input message past that and keeps her connection. It reads a frame that begins {"type":"input", as the engine’s page and the bot write one, as an input before it parses it, so a flood costs it no parse, and counts any other input once parsed. The first drop in each heartbeat’s window logs an inputFlooded line naming her character:
{"kind":"inputFlooded","room":"local","player":"15"}commands says what the room’s commands came to since the last heartbeat: admitted, accepted, refused by each reason, admission’s refusals among them, receiptsConfirmed, the results pages confirmed, and receiptsRetired, those dropped with a lineage that retired, beside what it holds now, receiptsHeld and lineages, one for each page joined now or gone for less than six minutes, and longestWaitSeconds, the longest a command waited between its admission and its result, or the oldest still waiting. A reason that climbs in honest play says a rule, a rate or a deadline is too tight for the game. A hosted room sends the same figures to the platform with each heartbeat.
fellBehind counts the players whose connection the room ended since the last heartbeat because they fell behind. A page that reads the room’s sends slower than the room sends them leaves the rest waiting in the room’s memory. Once more than startRoom’s maxQueuedBytes, 1 MB by default, has waited for one page for 30 seconds running, queueOverSeconds, the room ends her connection. It logs a fellBehind line with her player, her name and the bytes that waited, then a dropped line, and keeps her character for her key as for any drop:
{"kind":"fellBehind","room":"local","player":"15","name":"Ada","queuedBytes":1000412}The room sends every connection a WebSocket ping frame every 5 seconds, socketPingSeconds, which a browser and a ws client answer on their own. It ends a connection it has not heard from, by a pong or a message, for 15 seconds, silenceSeconds, logs a silent line with her player and her name, then a dropped line, and keeps her player for her key as for any drop. It ends a connection that sends no join within 10 seconds, joinSeconds, and holds at most two connections that have not joined for each of its seats, unjoinedPerSeat, ending the oldest past that. A connection counts as a player for emptySeconds only once a join seats her. While a tool holds the room’s clock, the room sends no deltas, so at each ping it also tells every page its pace, as a pong, and a page that waits on the room’s word hears it. The room reads these times on the wall’s clock, whatever a tool does to its own. A continue’s stand-in sockets are none of these connections.
{"kind":"silent","room":"local","player":"15","name":"Ada"}Settings
Section titled “Settings”The room reads the following environment variables. spawnite play start --room-env NAME=VALUE sets one for a run:
| Variable | What it sets | Default |
|---|---|---|
HOST |
The interface the room listens on. The default keeps it off the network; 0.0.0.0 opens it to every interface, such as for a phone on the same network. |
127.0.0.1 |
MAX_PLAYERS |
The most players the room holds at once, 2 to 50, over the scene’s room settings. A join beyond it closes with code 4004, but a join the platform’s seats confirmed is admitted whatever the count. |
the scene’s, else 20 |
SEND_RATE |
The room’s sends a second, 60 or 30, over the scene’s room settings: each send carries the world’s changes and each player’s word on her own character, and the welcome names the rate. |
the scene’s, else 60 |
PORT |
The port the room serves WebSocket on. | 8787 |
ROOM_HEARTBEAT_SECONDS |
Seconds between heartbeats. The cli sets 1 for a room spawnite play profile starts, so it reads the room’s steps second by second, and one over the speed for a run of spawnite play room, so each heartbeat covers a second of play. |
15 |
ROOM_EDITS |
1 applies a client’s devtools edit of any entity’s prop, a number clamped to its declared range; 0 ignores every edit. Off unless set, whatever the HOST: a proxy or a tunnel on the same machine makes a public room look like loopback. 1 also turns on the engine’s dev warnings, such as a walker’s CharacterCapsuleTrait cut to fit. The arena’s spawnite.room sets 1. |
0 |
ROOM_INVULNERABLE |
1 makes the character of every player that joins InvulnerableTrait, so the damage the engine deals leaves her be, for spawnite play profile --invulnerable; 0 leaves them as the game has them. |
0 |
ROOM_DIRECTOR |
1 lets a tool hold the room’s clock over HTTP at /__director on the room’s port, as spawnite play does: GET reads it, and a POST of { "action": "pause" }, { "action": "resume", "speed": 2 } or { "action": "step", "steps": 60 } holds it, runs it again at a speed over 0 and up to 16, or runs that many steps at once, up to 3600. Each answers { "paused", "speed", "step" }. A POST of { "action": "set", "subject": "[controlled]", "traits": { "transform": { "position": [1, 0, 2] } } } writes each trait, by the name a dump files it under and in the shape a dump writes it, onto every entity the subject names, a dump key or a selector, between two steps, as spawnite play set does: a walker whose transform it writes stands there at once. It answers the clock and the entities it wrote, or 400 naming a subject no entity answers to or a trait no module registers. A recording room records the write, and a continue takes it again on its step. Held, the room runs no step and its time stands still while its players stay joined; running, its time goes at the speed. Each player’s budgets, of shots, walks, bag verbs and game messages, fill on the room’s time, so what she sends a held room past her budget is refused rather than landing all at once as it runs again. It answers a tool on this machine alone: a request from another host, or one a page from another site sends, is refused with 403. Off unless set, whatever the HOST, as ROOM_EDITS is. |
0 |
ROOM_REPLAY |
1 keeps a recording under .replays/<run>/rooms/<run> in the working directory while a player is in the room, as a session the dev server files the pages that play in it beside: every action it takes, logged with the step it took it after, and a checkpoint of its whole world every 600 steps, so spawnite replay rerun, state and check continue any moment of it exactly. The cli sets 1 on each game’s room it starts, unless the game’s spawnite.room, --room-env or the shell sets 0; a production room never does. |
0 |
ROOM_CONTINUE |
A plan file the cli writes: the room serves nobody, takes MAX_PLAYERS, SEND_RATE, ROOM_EDITS, ROOM_INVULNERABLE and its rejoin hold from the recorded run’s room.json rather than its own environment, writes the plan’s checkpoint back, takes the recorded actions again as fast as it steps, writes its report where the plan says, and exits. ROOM_GAME names the scene. |
none |
ROOM_SEED |
The seed the world’s random draws start from, a whole number from 0, so two rooms on one seed draw alike, as spawnite play room --seed sets it. |
a fresh one each start |
ROOM_WATCH |
0 keeps the room from starting afresh when a file its scene loaded changes, as spawnite play room sets it, so an edit never ends a measured run; 1 restarts it. |
1 |
ROOM_SAVES |
0 keeps no save, so every player starts afresh, as every room the cli starts for spawnite play, its playtests and its profiles sets it unless --room-env ROOM_SAVES=1 asks for saves; 1 keeps each player’s save of a game that declares one in .spawnite/saves in the working directory, the game’s folder, as Saves says. |
1 |
ROOM_START_SAVE |
A save file, by its path, that each player starts from in place of her own, once: what she stores after is kept for the room’s session, so a rejoin keeps her progress, and nothing is written to the file. spawnite play start --save sets it for a room game. |
none |
ROOM_HEAP_SNAPSHOT |
When the room writes a heap snapshot, which Chrome’s DevTools Memory panel loads, and logs a heapSnapshot line with its path: exit as it stops, before it closes, or a share of the heap limit over 0 and under 1, such as 0.6, the first time a full collection leaves the heap past it. One snapshot a start. Writing it holds the room for as long as the write takes, 25 s for a 616 MB heap on a desktop. spawnite play room --heap-snapshot sets it. |
none |
ROOM_HEAP_SNAPSHOT_FOLDER |
The folder ROOM_HEAP_SNAPSHOT writes into, made where it is missing. spawnite play room sets the cli’s heap-snapshots folder in the system’s temp folder. |
the system’s temp folder |
ROOM_ID |
The id the room reports under. | local |
ROOM_GAME |
The game’s scene, read against the working directory, a source file, such as a .tsx, which the room loads through the game’s own Vite, whose config it loads with Vite’s runner config loader so that node --watch on the room sees no temporary file, or a server half’s .mjs module, which spawnite build --server writes and Node imports with no Vite. The room mounts the component the file exports under its name headless on the room’s world. On a source file it restarts on the scene afresh when a file the scene loaded changes, logging a restarted line that names the file, unless ROOM_WATCH is 0; a half never changes. The file may also export the game’s plugins, a bot that the room’s bots play by, and a timeline, whose measures the room reads after every step and logs as they change. Each player’s character spawns as the scene’s <CharacterSpawn> says: at its position, or round it where another character stands there, with its facing, health, stats, movement, body and the weapons she holds. The room refuses a file that exports movement: the <CharacterSpawn> sets the movement of every player’s character. |
the room’s own demo scene |
The started line names the seconds between its heartbeats, as "heartbeatSeconds":15, and, for a room that records, its run, as "run":"20260927-174326-l4hl", which spawnite replay check --run takes. For a scene whose game’s Vite config loads the engine’s plugin, it names the game’s build, as "build":"a1b2c3d4e5f6", which each welcome names and the room compares with each join’s, as the conversation below says. For a scene that exports a timeline, it names the timeline’s shape, as "timeline":{"measures":{"wave":"gauge","kills":"counter"},"parts":["wave"],"over":true}, and the room logs a timeline line before its first step with every measure’s value, at step 0, then one after each step where a value changed, with the ones that changed and, where the run’s end changed, over:
{"kind":"timeline","room":"local","step":0,"values":{"wave":0,"kills":0},"over":false}{"kind":"timeline","room":"local","step":1206,"values":{"wave":1}}Measuring runs says how spawnite play room reads them. A process that starts the room with an IPC channel, as the cli does, stops it by sending "stop": the room closes its recording and exits, where Windows would end it on a signal without running its handler.
The started line also names each stage of the room’s start in whole milliseconds, so a slow start says where its time went, as "stageMilliseconds":{"loaded":320,"imported":2385,"mounted":394,"navMeshBaked":108,"voiceOpened":8,"listening":5} for Holdfast’s scene through its Vite. The stages run in that order: loaded, from the process’s start to the room’s, Node and the room’s own modules; imported, opening the scene’s source, which starts a game’s Vite for a source file, and importing its module and the engine’s mount; mounted, the scene mounted headless, its navmesh bakes aside; navMeshBaked, every ground’s navmesh, the ones the scene’s World bakes as it mounts and the ones the room bakes after; voiceOpened, the media server a hosted room starts, near 0 here; and listening, its port opened. The room’s own demo scene names no imported and no mounted.
What the room guarantees
Section titled “What the room guarantees”A room promises the following whatever its game’s code does.
A system’s throw costs that system its tick, never the room. A room the platform hosts, which the hosted room’s process starts with startRoom’s deployed, catches a throw from any of the world’s systems at the system and runs the systems after it. No environment variable sets deployed, so a development room never becomes one. The system runs again next tick, as a Roblox, Unity or Godot script that errored does. Each throw logs a systemFaulted line, once for each of the system’s messages between heartbeats and at most four, faultLinesPerSystem, so a system that throws every tick writes four lines a heartbeat at most:
{"kind":"systemFaulted","room":"local","system":"waves.spawn","tick":5120,"streak":1,"message":"Cannot read properties of undefined (reading 'x')","stack":"TypeError: ..."}streak counts the ticks in a row the system threw. The heartbeat’s systemFaults counts every throw since the last heartbeat by system, such as {"waves.spawn":900}: a count equal to steps says the system fails every tick. A development room and a headless world catch nothing, so a creator’s agent sees the throw at once.
A room that has to close stores every player’s save first. A throw outside the systems closes any room: the step’s own work, the send, the saves, a timeline’s read, the heartbeat, or one the room’s process hands to Room.fail, as the hosted room does with every uncaught throw and rejection. A system’s throw in a development room closes it too. The room logs a failed line with the steps it ran, the message and the stack. It stores every player’s save as a stop does, within stopSaveSeconds, and closes each page with 4012, the engine’s roomFailedCloseCode. The reason is JSON such as {"saved":true}, which says whether her save landed. It then frees its seats with the platform and calls onFailure, which exits the process with 1. Her page takes a new seat at once where it can, which loads that save, and otherwise joins the same address again as a new player.
A room that cannot hold sixty steps a second says so, and never spirals. Each turn of the room’s loop runs the steps the time since the last turn buys, at most maximumCatchUpSteps, 6, as a page’s loop caps a hitch at maximumFrameSeconds. It drops the time past them, so its world runs slower than the wall and each page re-paces off its pongs. It never runs more steps to catch up, so a slow step cannot make the next turn slower. The heartbeat reports the cost:
stepShareis the step’s time as a share of a tick’s 16.7 ms, its mean and worst. Past 1, the room cannot keep up.slowSystemsnames each system whose mean took more thanslowSystemShare, a tenth of a tick, with its mean and worst shares, slowest first.skippedStepsis the steps’ worth of time the room dropped, andbehindSecondsis the seconds in which it dropped at least a step’s time.cpuShareis the process’s CPU time over the wall’s, as a share of one core, its collections included.stepMillisecondsPerPlayeris the step’s mean over the players seated, counting at least one, so an empty room reads its whole cost.
After behindLogSeconds, 5 seconds in a row behind, the room logs a behind line with the seconds, the steps skipped and the slow systems of the last second, then a caughtUp line once a second passes with nothing dropped. The room lowers no rate and sheds no player: it reports, and the platform judges.
A view that keeps what it adds is named. A room mounts its game’s scene headless and draws no frame, so a view that adds objects on an event and clears them only on a frame keeps every one. The heartbeat counts the world’s entities and, for a mounted scene, its sceneObjects. Once the objects grow at sceneGrowthHeartbeats, 4 heartbeats in a row, while the entities do not, the room logs one sceneGrowing line. The line names the parent whose children grew most, by the names or types from the scene down to it:
{"kind":"sceneGrowing","room":"local","heartbeats":4,"sceneObjects":5210,"entities":48,"parent":{"path":"Group > tracers","children":5184}}A game whose scene file exports save, the declaration defineSave made in src/save.ts and the one <Game save> takes, saves each player in the room by checkpoints, as the saves page says: every player’s record read at one tick, each that changed uploaded, and all committed at once through the room’s store, so what the game moved between two players is in both saves or in neither. One checkpoint is on its way at a time, and the room starts 12 a minute at most. It takes one every 60 seconds while any record changed, every 2 minutes while it holds one player, within 5 seconds of a game’s requestSave(world), at once for an ask, a takeover or the last player’s leave, and as the room stops. The room loads her save as she joins, restores it onto her player after her join hooks, and onto each saved entity a ready hook registers, her character among them, so a saved value wins over a spawn default, and captures a saved entity into it as the game destroys it; then, once every onPlayerReady has run, reads her record once as a trial and measures it, as JSON and gzipped, as the game’s version reads it after its migration steps: a trial that fails or a record past the size limit refuses the join and leaves the other players as they were. Her leave is read in the tick she leaves, after her leave hooks and before her player and her saved entities go, and the checkpoint that holds it frees her seat. A record that cannot be read twice in a row, a record past the platform’s ceiling, a commit the store fences or calls invalid, and a checkpoint 45 seconds old with no commit each end the room: every page closes with 4012 and saved: false, and each player goes back to the last checkpoint. A record past the size limit is committed with the room’s, then its player is removed by her ordinary leave, whose commit holds her out for 10 minutes. Her player’s PlayerSaveTrait reads saved, unsaved, saving or refused, and each failure logs a saveFailed line.
A development room keeps each save as a JSON file in .spawnite/saves, by the name the page sends, and commits it through the same checkpoints, so a restart or a rejoin under the same name restores her progress. A commit writes every player’s file it names, or none: it writes each to a draft, puts the list of drafts in place as .commit.json, then moves each over its file, and a stop or a move that fails leaves the list, which the room finishes before it loads or commits again. Until then each load fails and says why, so no load reads one player’s file beside another’s from the commit before. A second page under a name already playing is refused with 4007, since both would write one file. The file is the name, its characters but letters, digits, _ and - as _, then a hash of the whole name, as Ada-3f2a9c1e0b7d4a65.json, so Ada and ada keep apart on a file system blind to case. ROOM_SAVES=0 keeps none. A room that records, ROOM_REPLAY=1, keeps with each join the record it restored, so a continue restores the same with no store. A save the room cannot load closes her join with 4009 and writes nothing over it, and a save a newer version of the game wrote closes it with 4010; each reason is JSON such as {"key":"game","retry":false}, and the room logs a saveFailed line with what failed. A save that declares the world’s scope, or names levels, loot or random, stops the room as it starts: a room’s world is shared, so one player’s save must not restore it.
A room the platform runs keeps each save on the platform instead.
dist/bots.js plays a room with bots and no browser, as spawnite play room runs it: each a player that joins over the room’s WebSocket, sends the input for the ticks to the room’s next send at each of its sends, 20 a second of the room’s time, naming the room’s next tick two sends ahead, turns to the nearest living entity with health, or keeps to the one she chose where the scene’s bot names a skill, walks to it round walls along the room’s navmesh or strafes beside it, or walks to the place the scene’s bot names, fires at it the first weapon her character holds of those the bot names, and sends the game commands its play sends. It loads the scene ROOM_GAME names as the room does, a source file through the game’s own Vite and a server half’s .mjs through Node, for its bot and for the traits its modules register, plays the room at ROOM_URL with BOT_COUNT bots, 1 by default, for BOT_SECONDS, 60 by default, and prints each bot’s report as a JSON line of kind bot. BOT_SEED starts each bot’s own random stream, which the scene bot’s skill draws her aim’s stray from, mixed with which bot she is, and spawnite play room sets it to the run’s seed. BOT_SKILL sets numbers over that skill as JSON, or none for a perfect aim. With BOT_JOIN_FILE set, they load the scene at once and join once that file exists, as spawnite play start --bots has them join after its page. BOT_LATENCY, BOT_JITTER and BOT_STALL play each bot over a slow link, as spawnite play room --latency, --jitter and --stall set them: a round trip and a jitter in milliseconds, and a stall as <milliseconds>/<every seconds>, such as 500/10. Each message she sends and each the room sends her waits half the round trip, in order. A page in development plays over the same link from its address, as Multiplayer says. Multiplayer says what a scene’s bot holds.
The conversation
Section titled “The conversation”A client opens a WebSocket and sends a join with the player’s name and protocolVersion, the engine’s version of these messages. A join that names another, or none, closes with 4006, whose reason names both, as {"room":"protocol 6","page":"protocol 5"}, before the room asks for her seat or records the join; the engine’s page reads it as the room’s verdict and does not join again. The room takes the join on its next tick: it makes her player entity, runs each plugin’s onPlayerJoin, restores her save onto her player and runs each onPlayerReady, where the characters plugin spawns her character unless the game set characters({ autoSpawn: false }), and restores her character’s record over it. A hook that throws refuses the join: a development room throws, and a deployed one destroys what her join spawned, frees her seat and closes her with 4014, joinRefusedCloseCode, whose reason names the plugin. At its next send the room answers a welcome carrying its own protocolVersion, her player entity’s id as player, her character’s id as character where she has one, the whole world, whether it takes devtools edits, composition, the step its world composed, which a page of another composes refuses, tick, the room’s tick the snapshot stands after: its steps counted from 0, the last one the snapshot holds the result of, predicted, the traits her acknowledgements carry, and rewound, her values of them exact at the snapshot, and sendRate, the room’s sends a second, which her page draws others by. A page that meets a welcome of another version closes the socket before it takes it. The room sends binary frames, the engine’s codec, to a join that asks for "wire": "binary-7", as the engine’s client does. A binary frame holds one message or several one after another, each spelling its own strings: at each send, a page’s frame holds the delta every page shares and then her own word on her character, her acknowledgement and, to a page that reads owned messages, her own traits, so the two leave in one write. JSON text holds one message a frame. Any other join gets the same messages as JSON text, to read as they arrive: one that names no wire or null, "json", or a codec version this room does not speak. A join whose name holds half of a character, a lone UTF-16 surrogate, closes with 4000. The welcome also carries rejoin, a key for this player: a later join that sends it as its own rejoin gets the same player back, with whatever she plays at that tick, while the room holds her, as the close below says. Each welcome gives a new key, made from a secret the room keeps and the count of keys before it, so no page guesses another’s and a continue makes the ones the room made. A key the room does not keep joins her as a new player. A join may carry build, the page’s build, which the engine’s Vite plugin reads from the game’s code and the engine’s as it builds the page. A room on a game’s scene reads its own the same way, from the code it runs, and names it in each welcome as build. The room admits a join that names another build and logs a buildMismatch line with both, as {"kind":"buildMismatch","room":"arena","build":"a1b2c3d4e5f6","page":"0f9e8d7c6b5a"}. A join that names none, or null, logs nothing, as from a dev server’s page or a bot. So does every join to a room with no build, such as the demo room, whose welcome names none. The client sends JSON text, but for its input messages, which go as bytes in the same codec where its join asked for the binary wire; the room reads either for any message. Every frame carries the world at the stream’s precision: positions and speeds to the centimetre, rotations to a thousandth of each component, and a position past 10 km from the origin along an axis held at 10 km. Every delta after that builds on that snapshot. Beside what changed, a delta carries events: every event the room’s world held since the last send, in the order they landed, each as { entity, name, value }, the entity by the dump’s key and the value as the dump writes it, with an entity it names by its key too. A delta with none leaves the field out, and no hash covers it. A delta’s hash and a welcome snapshot’s are the hash of the world they leave her with, where the room hashes its sends: while it records, for a continue and a page replay to match its sends by, and in a room startRoom starts with hashSends. Elsewhere, as in every room the platform hosts, they are 0, since the hash stringifies the whole world at each send and nothing in a page’s play reads it. A client lands each event on its copy of the entity and takes it off as the next delta lands. The client then sends an input message after each frame’s steps, carrying the input of each tick the frame stepped: at 60 frames a second, one tick a message. Its tick names the room’s tick its first input is meant for, any whole tick from 0, and its own fields are that input: intent, steering, heading, and strafe, pitch and jump where they are set, and the game’s held inputs: buttons, a bit for each button the welcome’s composition lists that she holds, left out for none, and axes, one whole step for each of its axes, 0 its rest, below 0 under it and above 0 over it, 254 steps across the axis’s range split by each side’s span, left out where all rest. Its next lists the input of each tick after it in the same shape, 0 to 11 of them, empty for a frame of one tick, so a message holds 1 to 12 ticks, maximumInputTicks. A message with more ticks, no next, a tick that is not a whole number from 0, a bit past the game’s buttons, or axes of another count or past their steps, closes the connection with 4000. The room writes the held inputs a game declares seenByAll into her seenInput trait, which every page holds. The room writes each tick’s input into her character as it steps the tick, whatever a game system wrote there since the last tick, as her page writes it. The room files each tick’s input at the tick it names and steps every player’s character once a tick, never waiting for one and never stepping one twice. A tick it holds her input for, it steps her on it. A tick it holds none for, it steps her on her last input without the jump for 15 ticks, inputRepeatTicks, then on a still input that keeps her heading and stops the path she walks. It drops a tick’s input whose tick it has stepped already and makes the newest such input what it repeats. Such an input’s jump runs once, on the room’s next tick, where it comes up to 17 ticks late, the engine’s lateEdgeTicks, for a tick newer than every one of hers the room has stepped or heard late, so a copy neither repeats a jump nor adds one; a jump later than that is dropped with its tick. It drops a tick’s input more than 60 ticks ahead, inputAheadTicks, and a second copy of a tick it holds, so the first copy wins. It sends each delta every tick at 60 sends a second and every 2 ticks at 30, the engine’s readSendTicks(sendRate), and after it an ack to each client naming, as sequence, the ticks it has stepped, whatever it stepped her on: one more than the tick it last stepped, and the number her page gives its move for that tick, so the client can check where the room has her against where the same tick left her on its end. The acknowledgement is her own, apart from the delta every client shares, and belongs to the same send: on the binary wire it follows the delta in the same frame, and in JSON it comes as the next frame. To a join that carries "baseline": true, as the engine’s page sends, each acknowledgement is its whole word on her character at that tick: owned, what changed of the traits it sends her alone, her inventory, her equipment and her cooldowns, as a delta writes one entity’s traits, and rewound, her transform, velocity, turn and jump after the room stepped that tick, every number exact rather than rounded as the stream rounds. Each is left out where nothing changed since the last acknowledgement that carried it. grants names each predicted trait a rule set with grantPredicted since the last acknowledgement that carried any, so her page counts the correction it brings as the grant; it is left out for none. To any other join, such as a bot’s, the acknowledgement carries the number alone, and after it a client whose character’s own traits changed since it was last told gets an owned message with what changed. No other client’s stream, and no hash, carries them. A checkpoint keeps, for each player, whether she asked and what her last acknowledgement carried. A room run records its shape, the engine’s recordingFormat, which a change to these messages raises, and a continue of a run of another shape refuses at its start. A verb on her own bag or worn items, move, split, use, equip, unequip or drop, is one of the inventory plugin’s commands, as below. The room runs it on her character, and the next acknowledgement or owned message carries what it made. An edit message carries one prop of one entity’s trait, by the dump’s names, as the devtools edit it. Where ROOM_EDITS allows, the room writes it, and the write streams to every client with the rest of the world. A number is clamped to its prop’s declared range and refused where the prop declares none, but for the World’s day clock, dayClock, which declares no ranges: its hour wraps into the day, so 42 is 18, and its realSecondsPerCycle is refused where the clock cannot run on it, as <World dayLengthSeconds> refuses one. A boolean is gated by declaration the same way: it is refused where the trait’s behaviour declares no ranges, as Jump’s grounded is. A well-formed edit the room refuses, on a refusing room or of an entity, trait or prop it does not hold, is ignored, and the connection stays open. Her click-to-walk is the characters.commands.walk predicted command, and its stop characters.commands.stop, sent in her commands frame like any other. A walk the room’s navmesh refuses, as the engine’s walkTo refuses one, is refused with the navmesh’s reason and leaves the walk she was on, and the room logs a walkRefused line naming her character and the refusal, such as {"kind":"walkRefused","room":"local","character":"15","reason":"destination-off-mesh"}, within the allowance of four between heartbeats that her refused shots share. A commands frame carries her page’s commands, as Commands declares them, and confirm, the request ids of the results she has received, which the room then forgets. Each command names its handle as plugin.key, her request id, counted from 1 in her lineage, her player’s session, the tick her page stood on, the payload, and, where the handle acts through an entity, that entity and its control serial. The room admits each once by its request id, so a resend runs nothing twice, and settles each with one result: accepted, with the value its returns declares, or refused with a reason, the engine’s CommandRefusal or one the command declares. A command the room cannot admit, of a name no listed plugin declares, of a payload of another shape, or past its rate, is refused at once and never reaches a system. The rest wait for the next step, where the one system that answers each reads it with the player who sent it. A predicted command, one declared predicted: true, also carries offset, how far into its tick she sent it, in thousandths. The room runs it on the tick it names, or on its next tick where it comes up to the command’s lateTicks late, and that result names the tick that ran it as ranTick; it refuses one later than that late, and one 60 or more ticks ahead malformed. Just ahead of each delta, a causes message tells her alone which of its events her commands caused, each { id, requestId, effect }: the event’s number in the stream, her request id, and the effect’s key where the event names one, so no other page learns who acted. The results ride her acknowledgements, at most 64 in one, maximumAckResults, and a results message carries the rest before the room closes her socket. Her join names her page’s lineage, and her welcome says what the room still holds of it, so a rejoin hears every result of her hold and resends what never reached the room. While a round’s restart mounts the room’s scene again, the room steps no more and holds every message but a ping and voice, a join, a leave and a close among them, and takes each in the order it came once the scene has mounted, before its next step; a pong meanwhile carries rate 0. A checkpoint keeps a late jump waiting for the next tick. A shot names a weapon the world registered, the room’s step the page drew as it fired, an origin and a unit direction. It may carry id, the page’s own whole number for it, and hits, the keys of the targets each pellet hit on the page’s screen. The room runs the shot’s rules, the engine’s and the game’s, as the weapon page lists them, then judges it: against where each target stood at the step, held to the player’s latency as the room measures it from her shots and at most a second back, or by the hits the page claims where the game trusts the page. A refused shot that carries an id is answered with shotRefused, naming the id, the rule and its reason; a shot with none is answered with nothing. The room keeps each player’s latency and last shots on her character, as the engine’s LatencyTrait and FiredShotsTrait, and its last second of sends as the world’s RewindTrait, so a checkpoint keeps them and a continue judges each shot as the room did. A ping carries the client’s clock, and the room answers it at once with a pong that sends that time back with the room’s step, to a thousandth of a step, so the client can estimate the room’s step and the round trip. The pong also carries what her input met since the last pong, which her page paces its lead by: early, the fewest ticks ahead of its first tick that one of her input messages came, negative for one that came late, and earlyTick, that message’s first tick, both left out where none came; late, her ticks it dropped for coming after it stepped them; and starved, the ticks it stepped her on without an input of hers, each left out for none. It also carries refusedShotOrigins, the player’s own shots refused for their origin since she joined, left out for none, and rate, the room’s steps for each step of the wall’s time where a tool holds its clock at other than real time: 0 held, 4 at speed 4, and left out at 1, so a page names the step it saw at any speed. The engine’s client pings once a second from its join, and at once as each welcome lands. As a tool pauses, resumes or steps the room’s clock, the room also sends every page that has pinged a pong at once, as the answer to a ping sent then: the time the page’s last ping carried, plus the wall’s time since it landed. The page takes the new pace from it as from any answer, rather than up to a second later at its next ping. A leave says the player left on purpose: on its next tick the room runs each plugin’s onPlayerLeave, takes her player out, frees her seat with the platform, and closes her socket with 1000. On any other close, the room holds her player on its next tick, runs each plugin’s onPlayerDisconnect, and stands her character still, while her player’s ConnectionTrait reads Held on every page, for a join with her key to take back, which runs each onPlayerReconnect: 15 seconds, closedHoldSeconds, for a socket closed with a close frame, as a browser closes one on a reload, a closed tab or a navigation, and 30 seconds, brokenHoldSeconds, for one that ended with no close frame, as a network that went or the room’s own end of a silent or slow connection does. A game’s rejoinHoldSeconds in defineRoom sets one hold for both, up to 300 seconds. A key that names a player who has left joins as a new player once her last save has stored, so the join loads it; in a hosted room her spent ticket is refused with 4007 and her page takes a new seat. A game that destroys her character leaves her player connected, with no character until the game gives her one. A game’s kick takes her out after the step that asked, stores her save and closes her with 4013, kickedCloseCode, whose reason is the game’s sentence, and her page does not join again. The dropped line names the hold as holdSeconds. The room counts the hold in its steps, so a continue lets her go on the same step: a recorded close notes broken where no close frame came, and a checkpoint keeps each held player, her key, the secret and the accounts that left. After the hold, the room runs each plugin’s onPlayerLeave, takes her player out and frees her seat with the platform, telling it how long it held her, so a seat her reload asked for again during the hold stays for her late page. startRoom’s rejoinSeconds sets one hold for every drop in place of the two, for a test or a tool, and 0 takes her player out as her connection closes.
A development room runs no voice, which the platform runs for a published game. It answers a page’s voice open with unavailable, and passes every other voice signal by. The page then says, in its Audio settings, that voice runs when the game plays on the platform, and a game’s voice hooks read as a room where nobody speaks. A game whose scene’s room sets voice: false has no voice in any room: its welcome carries voiceOff: "game", its page asks for none, and the room starts no media server and passes every voice signal by. While an authoritative system has voice off with setVoice, a welcome carries voiceOff: "phase", and each change goes to every welcomed page at the room’s next send as a voice set signal, { "type": "voice", "signal": "set", "enabled": false }, while the room’s voice pauses every microphone. A checkpoint keeps the phase on the world.
The room runs text chat beside the world, on its own clock, as text-chat messages each with a kind. After a page’s welcome the room sends it opened: whether chat is on, off or unavailable, the channels’ names, the level she reads at, and up to 30 recent messages of channels every player reads, each delivery rule run again for her. A page sends send with its own requestId, a channel and the text, report with a message’s id and a reason, block with a player’s key, and level with the filter level she chose; a message of the wrong fields closes her socket with 4000, and a text past 200 UTF-16 units, as sent or once cleaned, is refused too-long with the socket left open. A page may send 30 text chat frames at once, refilled at 10 a second; past them the room answers a message or a report rate-limited, drops a level or a block, and logs a textChatFlooded line once a heartbeat, keeping her socket open. The room checks her right, the length and her credit, a burst of 5 refilled at one message every 2 seconds and kept by account across a reconnect, as the message lands; then, as each tick ends, it cleans, checks the channel and the repeat rule, filters, and delivers a message to each other player who may read it, by blocks, the channel and the platform’s age groups, at the stricter of her level and the sender’s loosest, within 1 ms of work a tick in turn by sender. It answers sent with the message as she reads it, or with a refusal, the engine’s TextChatRefusal. A room holds each message 10 minutes for reports, and files a report with the platform, retrying while it runs; a development room logs its id and reason as a textChatReported line instead. A game whose scene’s room sets textChat: false opens chat off, refuses every message chat-off and drops the game’s own. A deployed room drops a game’s message that cleaning lengthens past 200 units and logs a textChatGameMessageDropped line; a development room throws. A recording keeps no chat message, and a scene reload holds none back. The room’s check of a player’s text, which a command’s playerText() field reads, refuses an author who may not chat not-allowed and a field sent past its perMinute too-soon, chat on or off. The heartbeat line counts text chat under textChat: messages sent, refusals by reason, masked spans by category, reports and those unsent, the queue’s peak and the slowest message.
The room closes a connection whose frame is not a client message with code 4000, an edit whose value is not a finite number or a boolean, such as a string or null, among them, an input whose next is not a list of 0 to 11 ticks, each with a two-number intent, a steering flag and a finite heading, one whose tick is not a whole number from 0, a commands frame that is not a list of well-formed commands with confirmations, and one that holds more than 64 of her commands with no result, maximumPendingCommands. It closes one that sends an input, an edit or a ping before joining with 4001, one that joins twice with 4002, one whose player a later join took back with her key while this connection was still open with 4005, one that sent leave with 1000, so the page it belonged to stops, one whose build is not 1 to 64 letters, digits, dots, dashes and underscores with 4000, and one that joins a full room with 4004, whose reason is JSON such as {"players":4,"maxPlayers":4}: the players it holds, each whose character it keeps among them, and the most it may. A join with the key of a character the room keeps is never refused as full. A frame the room fails to handle closes its connection with 4500 and leaves the room running. A frame over 32 KiB, the engine’s maximumFrameBytes, closes it with 1009 before the room reads it; the engine’s page reads a 1009 as its own frame too large rather than the room gone. The engine’s armed rule refuses a shot from a character with the engine’s DisarmedTrait trait, as it refuses a downed character’s. A connection more than maxQueuedBytes behind the room’s sends for 30 seconds ends with no close frame, since a close frame would wait behind the bytes its page is not reading, so the page sees 1006 and rejoins. So does a connection the room has not heard from for 15 seconds, and one that sends no join within 10. A room that stops closes every connection with 1001, WebSocket’s going-away code. A room that stops on an error closes each with 4012, roomFailedCloseCode, after storing every player’s save, and her page takes a new seat at once, as the room’s guarantees say. A room whose process is killed, as Node’s watch mode does on Windows, drops them, and the client sees 1006. On either code, and when a page opens before its room is listening, the engine’s client tries the room again after 1, 1, 2, 4 and then every 8 seconds, each gap up to half shorter at random, until it answers, then joins it again with its rejoin key. It stops after a minute with no socket open. A reconnect that the room closes with 4007 or 4004 joins once more without its key, on a new seat where the platform hosts the room, as a new player whose save the room loads. A joined page that hears nothing from the room for 15 seconds ends its socket and does the same. The messages are in the engine’s replication protocol.