diff --git a/.gitignore b/.gitignore
index dbfce05..5493f2e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,7 @@
# The control server binary, if you build it outside Docker
lab/control/control
+
+# A recording from `make record`, under its default name. A file somebody
+# meant to keep gets a name of its own.
+lab/lab.jsonl
diff --git a/lab/Makefile b/lab/Makefile
index c698840..9df74be 100644
--- a/lab/Makefile
+++ b/lab/Makefile
@@ -19,7 +19,7 @@ URL ?= http://localhost:8099
.DEFAULT_GOAL := help
.PHONY: help up attack down reset logs shell status audit check ui hooks \
- day-one typical weak hardened
+ record day-one typical weak hardened
help:
@printf 'The lab — real containers, real WireGuard, real packets.\n\n'
@@ -27,6 +27,7 @@ help:
@printf ' make attack add evil-box (never starts on its own)\n'
@printf ' make status tailscale status from the laptop\n'
@printf ' make audit run the eleven checks and print the score\n'
+ @printf ' make record write the wire to lab.jsonl — watch it back in replay.html\n'
@printf ' make check licences, links, go vet, go test, gofmt, and the UI JavaScript\n'
@printf ' make ui parse-check ui/ and assets/, run the board rules\n'
@printf ' make hooks install the pre-push hook that runs make check\n'
@@ -87,6 +88,42 @@ M ?= lab-vps
shell:
$(COMPOSE) exec $(M) bash
+# A recording of the wire, with no browser in it.
+#
+# The wire is SSE, so what comes down it is an `event:` line, a `data:` line
+# and a blank one. `sed -n 's/^data: //p'` keeps the middle one, and what is
+# left is one JSON object per line — a `.jsonl` file, which is what
+# lab/control/ui/replay.html reads. The recording is complete on its own: the
+# first frame carries the machines, the attacks and the themes, so playing it
+# back needs no lab and no server.
+#
+# make record until you press Ctrl-C
+# make record FOR=90 ninety seconds, then stop by itself
+# make record FILE=stolen-key.jsonl
+#
+# Ctrl-C is the ordinary ending and it can cut the last line in half. The
+# player counts that as one skipped line and plays the rest, which is why
+# there is no cleanup step here.
+#
+# It records what the wire carries by default, which is everything except the
+# two sources that cost a process inside a container. For a capture, ask for
+# it in the URL — the parameters are documented at the top of
+# lab/control/feed.go, and a recording's URL is the honest record of what was
+# being watched:
+#
+# make record STREAM='/api/stream/hud?capture=evil-box'
+FILE ?= lab.jsonl
+FOR ?=
+STREAM ?= /api/stream/hud
+record:
+ @printf 'Recording %s from %s%s.\n' '$(FILE)' '$(URL)' '$(STREAM)'
+ @if [ -n '$(FOR)' ]; then printf 'Stopping by itself after %s seconds.\n\n' '$(FOR)'; \
+ else printf 'Drive the lab, then press Ctrl-C.\n\n'; fi
+ @curl -sN $(if $(FOR),--max-time $(FOR),) '$(URL)$(STREAM)' \
+ | sed -n 's/^data: //p' > '$(FILE)' || true
+ @printf '%s frames in %s.\n' "$$(wc -l < '$(FILE)' | tr -d ' ')" '$(FILE)'
+ @printf 'Watch it: open %s/replay.html and drop the file on the page.\n' '$(URL)'
+
audit:
@curl -fsS $(URL)/api/audit | \
python3 -c 'import json,sys; r=json.load(sys.stdin); \
diff --git a/lab/README.md b/lab/README.md
index 3792143..547412b 100644
--- a/lab/README.md
+++ b/lab/README.md
@@ -1119,6 +1119,7 @@ make up # build and start, then wait for the three machines to join
make attack # add evil-box — it never starts on its own
make status # tailscale status and netcheck, from the laptop
make audit # the eleven checks, scored, from the command line
+make record # write the wire to lab.jsonl, to watch back in replay.html
make check # Go and JavaScript — the one target that runs with the lab down
make ui # just the JavaScript half: parse-check ui/, run the board rules
make hooks # install the pre-push hook that runs make check for you
@@ -1256,7 +1257,8 @@ lab/
├── checks/ the JavaScript half of `make check` — no npm, no packages
│ ├── syntax.mjs node --check over every file the browser loads
│ ├── reading.test.mjs the board rules, asserted without a browser
-│ └── setpieces.test.mjs that hud/ links, and that no action lost its shot
+│ ├── setpieces.test.mjs that hud/ links, and that no action lost its shot
+│ └── replay.test.mjs that a recording plays back as the thing recorded
├── config/headscale/ coordination server configuration, commented
├── images/
│ ├── node/ a lab machine: tailscaled, tailcat, sshd, ufw, mosh
@@ -1278,8 +1280,12 @@ lab/
├── *_test.go fixtures captured from a live lab; no containers needed
└── ui/ the same vocabulary as chapter 14
├── index.html the shell: both drawings, the two tab strips, the guide
+ ├── wire.js what the wire carries — the only copy of that list
├── feed.js one EventSource, shared by everything that reads the lab
├── app.js a classic script, the same shape as assets/sandbox.js
+ ├── replay.html the player: a recording, with no lab behind it
+ ├── replay.js both halves — record the wire, and play a file back
+ ├── replay-app.js the player's page: a frame becomes a drawing here
└── hud/
├── three.module.js three.js r166, MIT, inside the binary
├── scene.js the board: five gates, three planes, the packet
@@ -1326,7 +1332,7 @@ assume:
| type | carries | when |
|---|---|---|
-| `hello` | the type list, what was subscribed, the server's clock | once, at open |
+| `hello` | the type list, what was subscribed, the server's clock, and `page` | once, at open |
| `state` | the whole `State` snapshot | when it changed |
| `status` | `tailscale status`, as text | when it changed |
| `netcheck` | `tailscale netcheck`, as text | when it changed |
@@ -1356,13 +1362,16 @@ well as returned. The board still draws from the reply, so the feed is not in
the drawing path and cannot break it; what the feed adds is that something
which *did not press the button* can see what the button did. Which is the
whole reason it exists: a captured attack is a recording of this wire, and
-`data:` lines are `.jsonl` already.
+`data:` lines are `.jsonl` already. [Record and replay](#record-and-replay) is
+what that turned into.
-```bash
-# One attack, as a file. Play it back later with no Docker at all.
-curl -sN 'http://127.0.0.1:8099/api/stream/hud?capture=evil-box' \
- | sed -n 's/^data: //p' > attack.jsonl
-```
+The opening `hello` also carries `page` — the payload `/api/meta` serves, the
+theme store and the widget registry, the three things the page is made of. That
+looks like duplication and is the opposite: it is what makes a recording
+complete. The thing that plays one back has no server to ask, so a file
+carrying only events could not say which machines to cut doorways for, which
+actions have a set-piece, or where the cards go. The first line has to be enough
+on its own.
A reader that stops reading loses events rather than holding the lab up —
`/api/action` publishes from inside the request, so a send that blocked would be
@@ -1499,6 +1508,90 @@ fails if a selector reaches wider — the same test, and the same reason, as the
one holding a theme to the nine `--board-*` properties. Below 1000px the cards
stop floating and stack at the foot of the page, which is what the rails do.
+### Record and replay
+
+One wire was worth building for two readers. The first is a widget nobody has
+written yet. The second is a file:
+
+```bash
+make record # until you press Ctrl-C
+make record FOR=90 # ninety seconds, then it stops by itself
+make record FILE=stolen-key.jsonl STREAM='/api/stream/hud?capture=evil-box'
+```
+
+That writes one JSON object per line — the frames from the wire, with the SSE
+framing stripped. To watch one, open **[`/replay.html`](control/ui/replay.html)**
+and drop the file on the page.
+
+```
+http://localhost:8099/replay.html
+```
+
+The live page has a **record** button in the bar, which is the other half of
+the same thing and usually the one you want. It opens no connection and starts
+no process in any container: the frames are already arriving, and it writes
+down the ones that do. So a recording is exactly what the page saw — including
+the capture you had switched on, and not including the one you had not. It
+opens with the board you were looking at rather than with three seconds of an
+empty one, because the snapshots the page is already drawn from go in first.
+Pressing stop hands you the file.
+
+**The player needs an HTTP origin and does not need a lab.** Nothing on that
+page asks the control server anything — the recording carries the machines, the
+attacks, the themes and the widget registry in its first line, which is what
+`page` in the `hello` is for. So this works with every container stopped, and
+with Docker not installed:
+
+```bash
+cd lab/control/ui && python3 -m http.server 8000
+# then http://localhost:8000/replay.html
+```
+
+It cannot be a double-click on the file, and that is the one limit worth
+knowing: `
+
diff --git a/lab/control/ui/replay-app.js b/lab/control/ui/replay-app.js
new file mode 100644
index 0000000..d9715da
--- /dev/null
+++ b/lab/control/ui/replay-app.js
@@ -0,0 +1,599 @@
+/* SPDX-License-Identifier: GPL-3.0-or-later
+ Copyright (C) 2026 Hashem Aldhaheri */
+
+/* ============================================================
+ The player's page.
+
+ app.js is the control surface: it posts to the API, reads the lab back, and
+ plays the shot the reply describes. This is the same drawing with all of
+ that taken out. Nothing here posts anything, because there is nothing to
+ post to — the recording already happened, and this file's whole job is to
+ turn a file of frames into the board somebody would have been looking at.
+
+ Which means the mapping from a frame to a drawing is the interesting part,
+ and it is deliberately the same mapping app.js makes:
+
+ state the configuration, drawn continuously -> board.setState
+ verdict a Result from /api/action -> the set-piece for its id
+ a Result from /api/set or /api/preset -> the verdict line only
+ flow a Result from /api/probe -> board.probe
+ packet one line of tcpdump -> whatever asked to watch
+
+ `set` and `preset` draw no shot on purpose, and that is not a simplification.
+ On the live page a switch flip moves the board by changing the lab and
+ letting the next state snapshot redraw it — the board never renders a switch
+ as an event. A player that played a shot for every flip would be a player
+ showing something the reader never saw.
+
+ Everything here survives a page with no WebGL, a recording with no page
+ payload in it, and a file that is half a recording. What it will not survive
+ is being opened from `file://`, and nothing can: `
+
+
+
+