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

Replay

A development build keeps each page’s play as a replay, with no code in the game: what its room sent it, what it sent the room, each frame’s length, the player’s input, the page’s own state each second, a log of what happened and a screenshot every two seconds where the view changed. Its dev room keeps a recording of its own beside it while a player is in it: every action the room took, from every player, and a checkpoint of its whole world as the first player comes in, as each minute begins, every ten seconds, and as the last one leaves. The two are kept together as a session, one for each run of the room, under one size budget for the whole game, 300 MB unless the game sets another: a long session shrinks from its oldest minute, and a marked moment stays until an agent releases it. When a player says “the monsters stopped hitting me just before I pressed F8”, an agent finds that moment in the replay’s log, looks at what the screen showed, continues the moment in a fresh room exactly as it was, fixes the code, and continues it again to prove the fix.

Two pages continue this one: Directing a moment and What a replay keeps.

The MCP’s replay tool runs spawnite replay list, log, shot, state, rerun and check, each command’s name as action and its options as fields, such as action: "rerun" with at: "m1-5", and replies as the command does, a shot’s image beside its line. replay names the page’s replay, latest where left out, and run a room run in its place.

A replay is not a video. What the room sent is the world, so playback draws it again on the game’s own page, from any angle the game’s code picks. What the room kept is the whole world, the state no page ever receives among it, so a continue is the room again, step for step.

The replay keeps three layers of what happened, and each tool plays one of them again:

Layer What it is Made by Played again by
Device input Keys, the mouse and the wheel The player Playback, into the page: controls, camera, HUD, prediction
Actions Moves, shots, walks, each at its step The page, from its input A continue, into the room: rules, AI, damage
Results Where everything stands, what it holds The room Nothing: reading the world is watching

In a dev build, the Escape menu reads “Replay is on · F8 marks this moment”, with a Mark this moment button, and Settings, Graphics has a Keep a replay switch, on until the player turns it off. F8 or the button marks the moment: a notice shows its number, m1, m2 and on, the log gets a line, and the page keeps a shot of the canvas at its own size, as the player saw it. Where marks already protect their share of the budget, the notice says the new one protects nothing. A production build has none of this: the replay’s code is a chunk only a development build loads, and a build test holds every game to it.

spawnite replay list names each session of the game, newest first: the room’s run and every page that played in it, or a page that played with no room, with the minutes each keeps, their marks and their bytes, then the saved clips, and how much of each budget they take:

Replays in .replays: 13.5 MB of the 15.0 MB budget; marks protect 9.7 MB of their 7.5 MB; saved clips 5.7 MB, a warning past 200.0 MB.
Sessions, newest first:
20260927-185312-diw4 the room's run of ./src/scenes/Holdfast.tsx, started 2026-09-28T00:53:12.458Z, minutes 2 to 3 3.5 MB being written
20260927-185317-25ny Iron Lantern kept 115.00 s to 194.09 s m1 68.39 s protects nothing 2.9 MB
20260927-183940-19mw the room's run of ./src/scenes/Holdfast.tsx, started 2026-09-28T00:39:41.009Z, minutes 3 to 6 10.0 MB
20260927-183945-hqxe Frost Keeper kept 175.26 s to 415.26 s m1 308.05 s, m2 314.03 s, m3 320.01 s, m4 326.00 s, m5 332.00 s, m6 338.00 s, m7 344.00 s, m8 350.01 s, m9 356.01 s, m10 362.00 s 9.2 MB
Saved clips:
minute-10 20260927-183945-hqxe of 20260927-183940-19mw, 475.26 s to 655.26 s 5.7 MB
Marks protect 9.7 MB of their 7.5 MB: a new mark protects nothing until one is released with `spawnite replay release`.

That list is Holdfast’s under a budget set to 15 MB: a 12-minute session whose ten marks in one minute protect minutes 3 to 6, trimmed to them by a later session, whose own mark came once marks filled their share. A command names a page by its name, the date and time it opened, such as 20260927-183945-hqxe, by the start of one, by its session’s name, by a saved clip’s name, or latest, the newest page. A moment is seconds into the page, a mark such as m1, or a mark and seconds, such as m1-10.

spawnite replay log latest prints the lines that say what happened, and counts the engine’s events:

20260927-060206-sgqv, holdfast, Ash Warden, started 2026-09-27T12:02:06.877Z:
3.95 s room joined
5.44 s long frame 1352 ms under the loading screen
7.49 s long frame 1837 ms
8.50 s 2 corrections of her character, up to 0.12 m
11.68 s marked m1
Engine events, counted: load ×37, scene ×1, spawn ×9, loading screen lifted ×1, shot ×124, destroy ×4.
The shot at m1, 11.69 s, 1920 by 1080: `spawnite replay shot 20260927-060206-sgqv --at m1`.

With --around m1, the 20 s before a moment and the 5 s after it, or --from and --to, it prints every line in the window. The lines are the engine’s events, the ones Finding hitches reads, a game’s own performance.mark calls, frames over 50 ms, the console’s errors and warnings, the room’s joins and drops, the room’s corrections of the player’s character, the marks, where the page sat idle, and where the page’s clock fell behind the wall. The log also reads the room’s changes from the stream, each a room: line: what came in and went, such as room: spawn monster 17, who joined and left, and each hit on her character, such as room: her character took 5 damage, 65 left. Without a window it shows the joins and leaves, and counts the rest. A window’s log ends by summing its frames: how many, the seconds of the page’s clock they took, and the seconds of the game’s time their steps ran, which part where a tool stepped the game or ran it at another speed, as 292 frames in the window: 2.96 s of the page's clock, 3.00 s of the game's time. A continue’s log is its room’s changes alone.

spawnite replay shot latest --at m1-2 writes the screenshot the page kept nearest the moment and names the file: an 854 by 480 WebP every 2 s, about 550 of an agent’s image tokens, and one at each mark at the canvas’s own size, 1920 by 1080 on a full HD screen. A shot comes out at most 1280 px wide, never wider than it was kept, and the command says the whole size it shrank from; --width full writes it whole, and --width 640, or any width, writes it that wide at most, at its own shape. An image costs about its width times its height over 750, so 640 by 360 is about 300 tokens, 1280 by 720 about 1,230 and a full HD mark about 2,760, where the model does not scale it down first:

The shot at 36.93 s, at m1, 0.01 s from the moment asked, 1280 by 720 of its 1920 by 1080: /tmp/spawnite/replay/20260927-175414-moe8-36934-m1-1280w.webp. `--width full` writes it whole.

It shows the canvas alone, not the page’s own panels, so a HUD drawn in the page’s DOM, as Holdfast’s is, is not in it; --render shoots the whole page, at most 1280 px wide the same way. Without --out, shots and exports go to spawnite/replay in the system’s temp folder, which keeps the newest 20.

The page leaves a rolling shot out when it barely differs from the last one it kept, so a character standing still, or a menu open over a quiet scene, keeps one shot every 10 s rather than five: the first of each ten seconds is always kept, so every ten seconds have one. The encoding worker compares the two on a grid of 32 by 18 cells, each cell’s mean brightness, and keeps the shot where 16 cells or more moved by more than 5 % of the range from black to white. On Holdfast, shots 2 s apart differed in 1 to 12 cells while her character stood still and in 37 or more while she fought. A skipped moment reads from the kept shot before it, which showed the same view; the command says how far that shot is from the moment asked.

spawnite replay play latest --from m1-3 --to m1+2 --every 1 plays the window back on the game’s dev page, headless, and shoots the whole page at each end and each second. spawnite replay shot latest --at m1 --render plays back to one moment and shoots it. The page opens on its state at the last second before the window, then runs each recorded frame with the room’s messages and the player’s input that came before it, on a clock that reads the recording’s time. What the page computes is the page’s code as it stands, so a fix to the camera shows in the shot. The command prints how far the player’s character strayed from where the recording had her, which on Holdfast is 0.000 m, what the page drew before it stood on its keyframe, and what the page logged as it played:

Played 20260927-060206-sgqv to 13.68 s in 553 recorded frames on the game's dev page, with the code as it stands now.
The dev server answered in 0.6 s; the page stood on its keyframe 8.3 s after it opened.
Before it stood on its keyframe, the page drew 0 frames of the loading world that the loading screen did not cover, and showed a frame the recording never had for 10 ms. It drew the keyframe on 2 frames of no time until what it mounted had loaded; the first waited on 0 shader programs.
Her character stood at most 0.000 m from where the recording's keyframes had her.

With --json, the numbers on the second line are in status.opening, counted from the page’s last open on a keyframe, a seek’s included:

  • staleFrames: frames of the loading world that the loading screen did not cover.
  • staleMilliseconds: the time from the loading screen’s lift to the end of the keyframe’s first frame, while the page showed a frame the recording never had.
  • keyframeFrames: frames of no time drawn on the keyframe until what it mounted had loaded.
  • keyframePrograms: shader programs the first of those frames waited on: those it compiled, and those still linking as it began. A nonzero count means the loading frames left out a material the keyframe mounted.

A person opens the same moment in a browser at the dev server’s address with ?replay=<name>&at=<seconds>, and it plays at the display’s pace; &session=<session>, or &session=saved/<clip>, reads it from that session or saved clip, where the page is in more than one. Before it plays, the page draws the keyframe’s world on frames of no time until every model it began loading has landed. The first of those frames compiles the shaders of anything the keyframe added that the loading frames had not drawn, and the page shows its last loading frame until it ends. The loading screen stays up until a frame compiles nothing new, so on Holdfast that frame compiles none: the page draws one frame of the loading world after the screen lifts, and the keyframe shows about 20 ms after that frame.

spawnite replay shot --render takes the same camera flags as a held session’s screenshot, and spawnite replay play --camera <json> shoots each moment from one camera; --no-hud on either shoots the player’s camera without the page’s panels. Their other cameras, and --no-hud, leave out the screen overlays the player’s view draws in the canvas, as a held session’s shots do, below, and --overlays keeps them.

spawnite replay rerun latest --at m1-5 --seconds 8 continues the moment in a fresh room of the game, with the code as it stands:

  1. The command finds the room’s run the page played in, and the step the room had run when it sent the last delta the page had at the moment, by the hash the delta carries.
  2. A room of the game starts, serving nobody, on the settings the run’s room.json says its room started with, whatever the continuing room’s own environment says: whether it spared every player’s character, the most players it held, whether it took devtools edits, and how long it kept a dropped player’s character. It writes back the room’s checkpoint from before that step: every trait of every entity, the server-only ones among them, each entity under koota’s own number, the order every query walks, the physics world as Rapier snapshots it, the navmesh polygon each chaser stood on, the world’s random stream, and each player’s queued moves and credits.
  3. The room takes every action it recorded from the checkpoint on, from every player, after the same step as it took it then, at the room’s time then, as fast as it steps: Holdfast’s 8 s take about 0.3 s. Koota defers taking a destroyed entity out of each query until a query next runs, so the room, recording or continuing, commits those removals as each step and each action ends: a mounted scene’s React render, which queries the world whenever the room’s thread is free, then finds none to commit, and every query walks its entities in the order the continue gives them.
  4. The command keeps what the room sent her as a replay of its own, <name>-r1, on the first one’s clock, and prints the two side by side.

With the code unchanged the room comes back exactly, and the command says so from the room’s own record, not from a tolerance. A room that records hashes its world at each send, puts the hash on the delta, and logs each one, with a hash of the events the delta carried beside it: every send of the continue must hash as the room’s did, its world and its events alike. Where the continue passes a later checkpoint, it sets its whole world beside that checkpoint: every trait of every entity, and the physics world byte for byte as Rapier snapshots it, which holds state no trait shows. It then names the first change in the world that parts the two, and where her character stood and what the world held, second by second:

Continued 20260927-131554-dfbf from 20.00 s to 27.46 s as 20260927-131554-dfbf-r3: a fresh room of the game with the code as it stands now, from the room's checkpoint at step 1200, with the 226 actions it recorded from there taken again, in 0.3 s.
Exact: every one of the 177 sends hashed as the room's own did.
Every change in the world, each spawn, removal, join and hit on her, came in the same order in both.
Second by second, step for step, the continue against the recording:
20.46 s her character where she was; her health 85; monster ×4, 120 health
21.46 s her character where she was; her health 75; monster ×5, 150 health

With a fix in the code, the same command shows what the fix changes from that moment: the send where the stream first parts, the entity and trait each later checkpoint holds otherwise, and the first spawn, removal or hit that differs. spawnite replay play plays the continue back on the game’s page, and spawnite replay log reads its changes. The page times of a recording bunch where its page ran slowly, so the comparison sets the two side by side by the room’s step, which each delta’s hash gives.

A continue needs the room’s recording, which each room the cli starts for a game keeps while a player is in it, unless the game’s spawnite.room, --room-env or the shell sets ROOM_REPLAY=0. A room that keeps its players’ saves records, with each join, the record it loaded for her and her id, or that she had none, and never a pass: a continue restores that record onto her character, with no store, so a session of saved players comes back exactly from her first step. A page played against a room with none, or a moment whose checkpoint the trim deleted, is refused with the reason. A game that plays alone has no room, and nothing to continue.

A round’s restart mounts the room’s scene again, which gives the scene’s entities numbers a fresh room’s mount does not, so a continue starts from the last checkpoint taken before the room’s first restart and takes each restart again, as the room took it. A continue from a later checkpoint is refused with the reason.

A continue from any checkpoint taken before the room’s first restart steps as the room stepped from it. Rapier’s snapshot leaves out one piece of its state, the queue its broad phase reshapes its tree by a little each step, and a restore starts that queue empty. So the recording room, as it takes each checkpoint, writes its own physics world back from the snapshot it just took, as a continue does, and a continue that passes a later checkpoint does the same. Without it, the room’s tree and the continue’s grow apart some ten seconds on, and a later cast that meets two colliders at the same distance takes them in the other order.

A checkpoint holds every trait, so a continue parts from the room only where the game keeps state outside the world: a count in a module’s variable, a system’s own closure, the wall clock, or Math.random in a system. State a continue can carry says where each goes instead. spawnite replay check latest proves it for a game: it continues each room run the replay played in from its first checkpoint to its last action, sets every send and every checkpoint beside the room’s, and exits 1 where one parts. --run <session> checks the room run of a session or a saved clip, by its name.

On Holdfast it passes:

Room run 20260927-131551-k8d4 of ./src/scenes/Holdfast.tsx: continued from step 213 to 4357, 69.07 s of play, in 0.7 s.
Exact: 1381 sends and 7 checkpoints, each as the room had it. A continue of any moment in it comes back as it was.

A system that counted its steps in a variable of its module and wrote the count into the world’s CoinScatter fails it, and the check names the trait:

Room run 20260927-132201-p1qz of ./src/scenes/Holdfast.tsx: continued from step 4022 to 5552, 25.50 s of play, in 0.7 s.
At the checkpoint at step 4200, the first that parts: entity 0 CoinScatter.seed: 4200 in the room, 178 in the continue.
State the checkpoint cannot see parts a continue from the room: a module's variable a system writes, a system's own closure, the wall clock, or Math.random in a system. Keep it in a trait, and draw from random(world).

That advice follows a trait the game owns. Where every difference is the engine’s own state, the check points at the engine instead: the physics world as Rapier snapshots it, blobs.physics, which no game’s code writes directly, a trait the engine registers or exports, such as transform, or the state of the engine’s own checkpoint codec; a codec a game registers for its own trait, and the bytes it keeps, are the game’s. So a codec’s blob takes a name of its own: a checkpoint refuses one named as another codec’s blob, or as one the engine keeps, such as physics, naming both codecs. The likely cause is then in what the engine’s checkpoint keeps or a restore writes back, and the check says so. With --json, each such difference carries engine: true. An entity alive in one world and absent in the other names no owner, so a checkpoint that holds one keeps the game’s advice.

A room played with no browser records the same way. spawnite play room plays the game’s own room with bots and names the run the room recorded, and check --run takes it:

$ pnpm spawnite play room --project holdfast --seconds 40
The room recorded run 20260927-174326-l4hl: spawnite replay state --run 20260927-174326-l4hl --at <m:ss> prints its world at a moment of play, and spawnite replay check --run 20260927-174326-l4hl checks that a continue of it comes back exactly.
$ pnpm spawnite replay check --project holdfast --run 20260927-174326-l4hl
Room run 20260927-174326-l4hl of ./src/scenes/Holdfast.tsx: continued from step 177 to 2559, 39.70 s of play, in 0.9 s.
Exact: 794 sends and 5 checkpoints, each as the room had it. A continue of any moment in it comes back as it was.

log, state and export take --run the same way, in place of a page’s replay. With no page to name a moment, a moment of a run is its seconds of play, the room’s steps over 60, or minutes and seconds as the run’s table prints them, such as 14:25: spawnite replay state --run <run> --at 14:25 --trait monster continues the checkpoint before that moment of play to it and prints the world, spawnite replay log --run <run> prints who joined and left and what each player said, such as player 1 said ready, at its seconds of play, with the moves, shots and sends counted, and spawnite replay export --run <run> --around 14:25 writes the run’s actions and its checkpoints from the one before the window.

A run played with --invulnerable or another --room-env, by the room bots or by a profile, checks as any other does. The run’s room.json keeps each setting that changes what the room does with an action: invulnerable: true where it spared every player’s character, maxPlayers, sendRate, allowEdits, and rejoinSeconds where the room held every drop for one length; a room on the platform’s two holds records none, and each recorded close notes broken where no close frame came. The continue takes each from there, whatever its own environment says, so a run played with MAX_PLAYERS=2 refuses the third join again, even where the game’s room settings name 4.

The check runs the same continue as rerun, in a fresh process, so a module’s state starts as it does there. A game’s lint warns of the common cases as they are written: Math.random and a module’s variable written in a function that takes the world. Factorio finds its own desyncs the same way, by loading a save and setting what it steps beside what the game stepped.

spawnite replay state latest --at m1 prints the room’s whole world at the moment as JSON: every trait of every entity, filed by the key a dump files it under, with the room’s own, such as Holdfast’s monsters’ ClawsTrait cooldowns, among them. --entity <key...> and --trait <name...> narrow it. The command continues the checkpoint before the moment to the moment, so it runs the game’s room, and prints the world as the room held it:

spawnite replay state latest --at 22 --trait ClawsTrait on a Holdfast replay, the monsters’ cooldowns the stream never carries:

{
"id": "20260927-131554-dfbf",
"at": 22000,
"step": 1398,
"entities": {
"17": { "Claws": { "cooldown": 0.2666666666666652 } },
"18": { "Claws": { "cooldown": 0.7833333333333327 } },
"19": { "Claws": { "cooldown": 0 } }
}
}

A number keeps its full precision. A value JSON cannot hold is tagged with its type, such as { "$": "Vector3", "value": [1, 0, 2] }. A trait no module exports and nothing registers is filed by koota’s own number, such as #75, and the checkpoint keeps its fields, so a continue under changed code refuses it rather than writing it into another trait.

spawnite replay export latest --around m1 --out replay-m1 writes a replay, or a window of it, as files any tool reads, and names each:

File What it holds
replay.json How it began, its marks, and what it continued.
log.jsonl Its log and the room’s changes, a line each: the time, the words, and the event’s own fields.
records.jsonl Every record, decoded, a line each: room for a frame the room sent, sent, frame, input, keyframe.
shots/ Its screenshots.
rooms/<run>/room.json How the room’s run began, and the settings a continue of it takes: whether it spared every character, and more.
rooms/<run>/actions.jsonl Every action the room took, a line each.
rooms/<run>/checkpoint-<step>.json The room’s whole world and its own state at the step.
rooms/<run>/physics-<step>.bin Rapier’s snapshot of the physics world then, which World.restoreSnapshot reads.

A script reads the same records itself: the engine’s ./replay export is the decoder. readReplayRecords(folder, id, window) yields each record decoded, readRoomActions a run’s actions, readRoomCheckpoint a checkpoint with its blobs, and readWorldLines the room’s changes. Each takes the game’s .replays folder and a replay’s or a run’s name:

import { readReplayRecords, readRoomActions } from "@spawnite/engine/replay";
for (const record of readReplayRecords(".replays", "20260927-131554-dfbf"))
if (record.kind === "sent") console.log(record.at, record.message);

A mark is a tag, not a copy. It records its time, its name and one shot at the canvas’s size, and protects its window, the two minutes before it to the thirty seconds after, from the trim: every minute of the session the window touches stays, the page’s and the room’s. Marks that overlap protect the same minutes once, so ten presses of F8 in one minute protect one window of four or five minutes and copy nothing.

The minutes marks protect may fill half the budget, 150 MB unless the game sets another share. Past it, a new mark still tags the moment and keeps its shot, but protects nothing: the page’s notice says so, the dev server’s log warns, spawnite play profile --dev prints the warning among its notes, and spawnite replay list and spawnite replay log name the mark. An agent that investigates a moment names the page and the mark in its issue, and releases the mark when the issue closes:

Terminal window
spawnite replay release 20260927-183945-hqxe m1 m2
spawnite replay release 20260927-183945-hqxe

With no mark named, release lets every mark of the page go.

A moment an agent wants to keep for good, past any release and any trim, it saves. save copies the whole minutes a window touches into .replays/saved/<name>, with the room’s checkpoints from the minute before to the minute after and the world as the first minute begins, so the clip plays back and continues after its session is gone:

Terminal window
spawnite replay save latest --around m1 --name boss-fight
spawnite replay save latest --from 1180 --to 1260 --name minute-20

--around saves the window a mark protects. A saved clip is named like a session, so spawnite replay play boss-fight, rerun boss-fight and log boss-fight read it. The trim never reads the saved clips; the tools warn once they pass 200 MB together, and spawnite replay remove saved/boss-fight removes one. remove also removes a session whole, by its name.