create-siltrun 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/index.mjs +98 -0
  2. package/package.json +18 -0
  3. package/templates/mass-grid/AGENTS.md +106 -0
  4. package/templates/mass-grid/README.md +59 -0
  5. package/templates/mass-grid/_gitignore +4 -0
  6. package/templates/mass-grid/index.html +22 -0
  7. package/templates/mass-grid/package.json +31 -0
  8. package/templates/mass-grid/room.test.ts +214 -0
  9. package/templates/mass-grid/room.ts +530 -0
  10. package/templates/mass-grid/skills/authoritative-tick.md +116 -0
  11. package/templates/mass-grid/skills/compact-snapshots.md +110 -0
  12. package/templates/mass-grid/skills/demo-room-lifecycle.md +145 -0
  13. package/templates/mass-grid/skills/genre-lane-mapping.md +88 -0
  14. package/templates/mass-grid/skills/territory-capture.md +115 -0
  15. package/templates/mass-grid/src/Game.tsx +174 -0
  16. package/templates/mass-grid/src/main.tsx +9 -0
  17. package/templates/mass-grid/tsconfig.json +15 -0
  18. package/templates/mass-grid/vite.config.ts +9 -0
  19. package/templates/minimal/README.md +39 -0
  20. package/templates/minimal/_gitignore +4 -0
  21. package/templates/minimal/index.html +15 -0
  22. package/templates/minimal/package.json +30 -0
  23. package/templates/minimal/room.ts +30 -0
  24. package/templates/minimal/src/Game.tsx +71 -0
  25. package/templates/minimal/src/main.tsx +9 -0
  26. package/templates/minimal/tsconfig.json +15 -0
  27. package/templates/minimal/vite.config.ts +7 -0
  28. package/templates/tower-defense/AGENTS.md +132 -0
  29. package/templates/tower-defense/README.md +53 -0
  30. package/templates/tower-defense/_gitignore +4 -0
  31. package/templates/tower-defense/index.html +15 -0
  32. package/templates/tower-defense/package.json +31 -0
  33. package/templates/tower-defense/room.test.ts +159 -0
  34. package/templates/tower-defense/room.ts +321 -0
  35. package/templates/tower-defense/skills/authoritative-tick.md +96 -0
  36. package/templates/tower-defense/skills/demo-room-lifecycle.md +145 -0
  37. package/templates/tower-defense/skills/state-budget.md +79 -0
  38. package/templates/tower-defense/skills/two-lanes.md +66 -0
  39. package/templates/tower-defense/skills/waves-and-timing.md +79 -0
  40. package/templates/tower-defense/src/Game.tsx +227 -0
  41. package/templates/tower-defense/src/main.tsx +9 -0
  42. package/templates/tower-defense/tsconfig.json +15 -0
  43. package/templates/tower-defense/vite.config.ts +7 -0
  44. package/templates/turn-based-grid/AGENTS.md +183 -0
  45. package/templates/turn-based-grid/README.md +72 -0
  46. package/templates/turn-based-grid/_gitignore +4 -0
  47. package/templates/turn-based-grid/index.html +15 -0
  48. package/templates/turn-based-grid/package.json +30 -0
  49. package/templates/turn-based-grid/room.ts +309 -0
  50. package/templates/turn-based-grid/skills/play.md +97 -0
  51. package/templates/turn-based-grid/src/App.tsx +141 -0
  52. package/templates/turn-based-grid/src/GraphicsView.tsx +148 -0
  53. package/templates/turn-based-grid/src/brains.ts +112 -0
  54. package/templates/turn-based-grid/src/main.tsx +8 -0
  55. package/templates/turn-based-grid/src/stubAgent.ts +82 -0
  56. package/templates/turn-based-grid/src/substrate/ascii.ts +87 -0
  57. package/templates/turn-based-grid/src/substrate/map.ts +104 -0
  58. package/templates/turn-based-grid/src/substrate/types.ts +59 -0
  59. package/templates/turn-based-grid/src/view.ts +94 -0
  60. package/templates/turn-based-grid/tsconfig.json +15 -0
  61. package/templates/turn-based-grid/vite.config.ts +7 -0
@@ -0,0 +1,110 @@
1
+ # Compact snapshots — the state IS the wire
2
+
3
+ Read this before adding anything to `State`. It is the one hard constraint that most shapes how
4
+ you design a mass-grid game, and the reason this genre — which wants a *crowd* on one board — is
5
+ the sharp stress test of the substrate.
6
+
7
+ ## The ceiling
8
+
9
+ The server broadcasts the **entire authoritative state** to every client **every tick** (60Hz),
10
+ as a single WebTransport **datagram**. There is no diffing, no delta compression, no interest
11
+ management — the whole `State`, serialized to JSON, every tick. A QUIC datagram gives roughly
12
+ **~1200 bytes of usable payload**. **A state that outgrows the datagram is silently
13
+ undeliverable on that lane.** There is no automatic fallback and no error — the datagram just
14
+ doesn't arrive.
15
+
16
+ So there is no separate "encoding" step you could add later. **The object `tick()` returns *is*
17
+ the wire.** You engineer the *state* to be compact, or you don't fit.
18
+
19
+ ## The discipline: pack the state, unpack it in `tick`
20
+
21
+ The move that makes this genre fit is the same one it uses for the grid, applied to everything
22
+ that scales: **store a compact wire form in `State`; decode it to readable working structures at
23
+ the top of `tick`; re-encode at the end.** `tick` reads like normal game code; the wire stays
24
+ tight.
25
+
26
+ ```ts
27
+ tick(state, inputs, ctx) {
28
+ const grid = decodeGrid(state.g, state.w, state.h); // RLE string → Int16Array
29
+ const players = readPlayers(state); // flat arrays → readable objects
30
+ // …run the sim over `grid` and `players` as ordinary structures…
31
+ state.g = encodeGrid(grid); // pack back to the wire form
32
+ writePlayers(state, players);
33
+ return state;
34
+ }
35
+ ```
36
+
37
+ Three things ride compact:
38
+
39
+ **1. The grid → a run-length string (`state.g`).** Territory is blobby by construction (claims
40
+ are solid regions), so long runs of one owner compress to a few characters. A 20×20 board of
41
+ mostly-solid regions is a few hundred bytes, not 400 cells.
42
+
43
+ **2. Players → flat, slot-indexed arrays (`state.pf` + `state.pt`).** This is the single biggest
44
+ lesson. The naive shape — `players: Record<peerId, {x,y,d,…}>` — is the trap: per-player object
45
+ keys and peer-id strings are literal bytes repeated on the wire *every tick*, and they blow the
46
+ datagram at ~8 players. Instead players ride as a flat scalar array (`pf` = `[alive,x,y,d,nd,rs]`
47
+ per seat slot) plus trails (`pt`). No keys, no ids on the wire — the seat *index* is the
48
+ identity. `readPlayers`/`writePlayers` convert to/from readable `Player` objects for the sim.
49
+
50
+ **3. Trails → turn-point polylines (`pt`), and capped.** A trail is stored as its *corners*, not
51
+ its cells — a long straight trail is one point pair, not twenty. And the number of corners is
52
+ capped (`MAX_TRAIL_TURNS`): over-extend and you die. That cap is what makes the worst case
53
+ *bounded* — without it, trails are unbounded and a few zig-zagging players alone blow the budget.
54
+
55
+ ## The measured budget — numbers, not hope
56
+
57
+ These are **measured** (the budget test + the probes behind it), not guessed:
58
+
59
+ | component | cost | note |
60
+ |---|---|---|
61
+ | lifecycle shell | ~55 B **per seat** | fixed — you can't shrink the copied block; fewer seats is the only lever |
62
+ | grid RLE | ~hundreds of B, **data-dependent** | grows with cells AND fragmentation (jagged borders → more runs) |
63
+ | players (flat) | ~30 B/player + trail | vs ~60 B/player as objects — the flat encoding roughly halves it |
64
+ | per-peer input ack | **0 B here** | dropped because moves ride the reliable lane (see `skills/genre-lane-mapping.md`) |
65
+
66
+ That is why this template ships **6 seats on a 20×20 grid with a 6-turn trail cap** — measured
67
+ worst case ~**1099 B**, fitting the ~1200 B datagram with headroom. It is *not* an arbitrary
68
+ choice:
69
+
70
+ - **8 seats does not fit** once players trail (~1300–1400 B). The verbatim shell's per-seat tax
71
+ plus trails simply exceed a JSON datagram. Reaching 8+ concurrent needs a different wire (see
72
+ below), not a tuning tweak.
73
+ - **28×28 did not fit** under realistic jagged play. 20×20 is where 6 seats became robust.
74
+
75
+ **`SEATS × GRID × TRAIL-LENGTH` is a budget decision, not a taste one.** When you raise any of
76
+ them, you spend against ~1200 B — re-run `bun test room.test.ts` and read the guard.
77
+
78
+ ## The honest ceiling — and why it degrades gracefully
79
+
80
+ Row-major RLE has a worst case it **cannot** survive: **vertically-striped territory**, where
81
+ every column boundary is a run on every row. There the grid alone can exceed a datagram, at *any*
82
+ seat or grid size — the `room.test.ts` CHARACTERIZATION test pins this so nobody assumes JSON
83
+ snapshots reach 100 players. **They do not.** This is the substrate's honest limit, not a bug in
84
+ the template.
85
+
86
+ It degrades **gracefully, not fatally**: the state datagram is *latest-wins and droppable*, so a
87
+ transiently-oversized tick is simply dropped and the client recovers on the next in-budget tick —
88
+ a brief stutter, not a freeze. Blobby play (what claiming actually produces) stays well inside
89
+ budget; pathological striping is rare and self-heals as the board un-stripes.
90
+
91
+ ## If you genuinely need more than the budget allows
92
+
93
+ - **Reaching a true crowd (50–100 players)** needs what alpha Silt does not yet do: **binary
94
+ and/or delta snapshots** (send only changed cells + periodic keyframes, in a packed binary
95
+ frame instead of JSON). That is the designed path to the genre's headline promise — but it is
96
+ **not built**. Don't design assuming it; design for the JSON-snapshot budget you have.
97
+ - **More players on the current wire** — the levers are exactly the three in the table: fewer
98
+ seats, smaller grid, tighter trail cap. Spend them deliberately.
99
+ - **Hidden information** (fog of war) can't be solved by "just don't render it" — every client
100
+ receives the full state and can read the wire. Handle secrets with a commit-reveal scheme, not
101
+ by omitting from render.
102
+
103
+ ## The budget is a test, not a hope
104
+
105
+ `room.test.ts` builds the fattest state the config allows (full table, every seat trailing at
106
+ the cap, over a jagged realistic board) and asserts it stays under the margin. **When you add
107
+ anything to `State`, that guard is your guardrail.** Add a `Player` field, raise a cap, add a
108
+ seat — re-run `bun test room.test.ts`. If it trips, claw bytes back before you ship: shorten the
109
+ encoding, derive instead of store, or lower a cap. Never ship a state you haven't measured at its
110
+ worst.
@@ -0,0 +1,145 @@
1
+ # The demo-room lifecycle shell
2
+
3
+ **This is the always-warm, tap-and-play room model — genre-agnostic.** It is the block
4
+ between the `══ DEMO-ROOM LIFECYCLE SHELL ══` markers in `room.ts`. Copy that block
5
+ verbatim into any genre template; wire your game to the three signals it emits. This
6
+ document is its **normative spec**: the state, the transitions, and the invariants an
7
+ extender must preserve.
8
+
9
+ ## The idea
10
+
11
+ One constant, persistent room. No lobby, no matchmaking, no "create game" button. A player
12
+ opens the page and is immediately *in* — they drop into a seat, ready up, and play. When a
13
+ game ends, a new one starts on its own. The room is always warm because the Silt room runs
14
+ whenever ≥1 peer is present and pauses when empty (BOUNDARIES §6).
15
+
16
+ Three moves define it:
17
+
18
+ - **Drop-in join** — claim an empty seat the instant you arrive.
19
+ - **Take over an abandoned player, or start fresh** — if a teammate left mid-game, the next
20
+ arrival inherits their in-progress position; the game never pauses for a missing player.
21
+ - **Ready-up → rolling rounds → play until you lose → a new game starts** — the loop runs
22
+ forever with no operator.
23
+
24
+ ## State
25
+
26
+ The shell owns one namespaced key, `shell`, so your genre state stays cleanly separable:
27
+
28
+ ```ts
29
+ type Phase = "gathering" | "playing" | "gameover";
30
+ type Seat = { id: string | null; ready: boolean; abandoned: boolean };
31
+ type Shell = {
32
+ phase: Phase;
33
+ seats: Seat[]; // fixed length = opts.seats; index is the stable seat number
34
+ round: number; // increments each time a game starts
35
+ phaseSince: number; // ctx.tick when the current phase began — drives countdowns
36
+ over: boolean; // YOUR GENRE sets this true to end a game (see "The over flag")
37
+ };
38
+ ```
39
+
40
+ A **seat** is a fixed slot at the table, addressed by index (`seats[0]`, `seats[1]`, …).
41
+ `id` is the peer holding it, or `null` if empty. `abandoned` marks a seat whose peer left
42
+ *mid-game*: the seat and its game data are kept so a newcomer can take it over.
43
+
44
+ ## Phases
45
+
46
+ ```
47
+ ┌─────────────────────────────────────────────────────────────┐
48
+ ▼ │
49
+ gathering ──(≥ minPlayers ready)──▶ playing ──(over)──▶ gameover ──┘
50
+ (after gameoverTicks)
51
+ ```
52
+
53
+ - **gathering** — seated players ready up. When `minPlayers` seats are occupied *and* ready,
54
+ a game starts.
55
+ - **playing** — the genre runs. Rolling rounds/waves happen entirely inside your game code;
56
+ the shell does not know or care what a "round" is. It only waits for you to set `over`.
57
+ - **gameover** — a brief hold (`gameoverTicks`) so players see the result, then the room
58
+ auto-resets to `gathering` and the loop repeats.
59
+
60
+ ## Seat assignment — the takeover rule
61
+
62
+ On a `join`, the shell seats the peer by this priority (`seatFor` in the shell):
63
+
64
+ 1. **Reconnect** — if the peer already holds a seat (same `id`), resume it and clear
65
+ `abandoned`. (Silt's stable-id reconnect means a dropped player who returns keeps its
66
+ seat — see WIRE "Identity & reconnect".)
67
+ 2. **Empty seat** — claim the first `id === null` seat.
68
+ 3. **Takeover** — else adopt the first `abandoned` seat: set its `id` to the newcomer, clear
69
+ `abandoned`. The seat's genre data (towers, score, …) carries over untouched.
70
+ 4. **Spectator** — if every seat is held by a live player, the joiner gets no seat
71
+ (`seatFor` returns −1). A mid-game spectator waits; the next `gathering` frees seats and
72
+ they can ready up then.
73
+
74
+ On a `leave`:
75
+
76
+ - **During `playing`** → mark the seat `abandoned` (keep `id` and all game data). The game
77
+ continues; the seat is now takeover-eligible.
78
+ - **During `gathering`/`gameover`** → free the seat entirely (`id: null`), because there is
79
+ no in-progress game to preserve.
80
+
81
+ On reset (`gameover → gathering`), any still-`abandoned` seat is freed — its player is gone.
82
+
83
+ ## The three signals — how your genre wires in
84
+
85
+ The shell exposes exactly two functions and one flag. Your `tick` composes them around your
86
+ game step:
87
+
88
+ ```ts
89
+ tick(state = freshState(), inputs, ctx) {
90
+ // (optional) datagram-lane intent you handle yourself, e.g. cursors
91
+
92
+ if (openRound(state.shell, inputs, ctx, SHELL_OPTS) === "start") startGame(state); // ① seed a fresh game
93
+ if (state.shell.phase === "playing") stepGame(state, inputs, ctx); // ② run your game; set shell.over on loss/win
94
+ if (closeRound(state.shell, ctx, SHELL_OPTS) === "reset") clearGame(state); // ③ tear the game down
95
+
96
+ return state;
97
+ }
98
+ ```
99
+
100
+ - **`openRound(...) === "start"`** — fired on the single tick a game begins. Seed a fresh
101
+ game: reset resources, spawn the first wave, clear the board. Runs FIRST so membership and
102
+ the start transition are settled before your game steps.
103
+ - **`shell.over`** — YOUR game sets this `true` when the game ends (you lost, you won, time
104
+ ran out — the shell doesn't judge). It is the ONLY bridge from genre → shell. `closeRound`
105
+ reads it.
106
+ - **`closeRound(...) === "reset"`** — fired on the single tick a fresh `gathering` begins
107
+ (after the gameover hold). Clear your game state back to idle. Runs LAST.
108
+
109
+ **Order is load-bearing:** `openRound` before your step (so a just-started game steps this
110
+ same tick), `closeRound` after (so an `over` set during the step is seen this same tick).
111
+
112
+ ## `SHELL_OPTS`
113
+
114
+ ```ts
115
+ const SHELL_OPTS = { seats: 2, minPlayers: 2, gameoverTicks: 180 };
116
+ ```
117
+
118
+ - `seats` — table size (fixed). This template ships 2 (the smallest co-op).
119
+ - `minPlayers` — ready seats required to start. Keep ≤ `seats`.
120
+ - `gameoverTicks` — how long `gameover` holds before reset. 180 ticks = 3s at 60Hz.
121
+
122
+ ## Invariants an extender MUST preserve
123
+
124
+ Break these and the room stops being a safe, always-warm demo:
125
+
126
+ 1. **`shell` is the only shell-owned state.** Keep it namespaced; don't scatter phase/seat
127
+ fields into your genre state.
128
+ 2. **Only the genre writes `shell.over`; only the shell writes `shell.phase`.** One flag in,
129
+ one machine out. Don't set `phase` from game code.
130
+ 3. **Seats are fixed-length and index-stable.** Never `push`/`splice` `seats`; mutate slots
131
+ in place. Clients and game data address players by seat index.
132
+ 4. **A mid-game leave abandons, never deletes.** Deleting a seat's data mid-game desyncs any
133
+ client rendering it and forfeits the takeover promise.
134
+ 5. **State stays under the datagram MTU (~1100B).** The shell is ~150B of that budget; the
135
+ rest is your genre's. See `state-budget.md`. Fixed seats + short keys keep it bounded.
136
+ 6. **`tick` stays deterministic.** No wall clock / `Math.random` / `fetch` inside the shell
137
+ or your step — use `ctx` (BOUNDARIES §5). `phaseSince` uses `ctx.tick`, never time.
138
+
139
+ ## Verifying the shell
140
+
141
+ The shell is a pure function of `(state, inputs, ctx)`, so it is tested without a browser —
142
+ see `room.test.ts`: scripted input sequences assert seat claim, takeover of an abandoned
143
+ seat, reconnect-resume, the ready-up gate, `gathering → playing → gameover → gathering`, and
144
+ a loss rolling into a fresh game. Run `bun test room.test.ts`. When you change the shell, the
145
+ membership and phase-transition tests are your safety net — keep them green.
@@ -0,0 +1,88 @@
1
+ # Genre → lane mapping — why moves ride the reliable lane
2
+
3
+ Read this before sending anything from the client. Silt gives you **two lanes** over one
4
+ connection, with opposite trade-offs. Most templates put continuous intent on the datagram
5
+ lane. **This genre deliberately does the opposite for moves** — and understanding why is the
6
+ lesson.
7
+
8
+ ## The client API
9
+
10
+ `useRoom` gives you `send`:
11
+
12
+ ```ts
13
+ const { state, send } = useRoom<State>(url, { id });
14
+
15
+ send({ turn: 2 }, { reliable: true }); // RELIABLE lane — ordered, guaranteed, discrete
16
+ send({ x, y }); // DATAGRAM lane — droppable, latest-wins (NOT used here)
17
+ ```
18
+
19
+ That `{ reliable: true }` flag is the whole choice. It changes which lane the data rides and how
20
+ it arrives in `tick`.
21
+
22
+ ## The two lanes
23
+
24
+ | | **Datagram** (default) | **Reliable** (`{ reliable: true }`) |
25
+ |---|---|---|
26
+ | Delivery | best-effort — **may be dropped** | guaranteed, in order |
27
+ | Rate | high-frequency firehose (~20Hz+) | discrete, occasional |
28
+ | Semantics | **latest-wins** — only the newest matters | every one matters, exactly once |
29
+ | Arrives in `tick` as | `{ kind: "input", from, data }` — one latest per peer per tick | `{ kind: "event", from, data }` — each, in arrival order |
30
+
31
+ ## The genre → lane rule
32
+
33
+ **The right lane is a property of the genre, not a default.**
34
+
35
+ - **Fast-continuous games → datagram.** A cursor, a ship heading, an aim vector — values that
36
+ overwrite themselves many times a second. Dropping one costs nothing; the next arrives in
37
+ ~50ms. (The `minimal` / tower-defense templates use this lane for cursors.)
38
+ - **Slow-discrete grid games → reliable.** In this genre a move is a *turn*: one discrete
39
+ decision every ~150ms that must not be dropped or reordered — **a lost turn is a death**
40
+ (you sail off a cliff or into a trap you meant to avoid). So turns ride the reliable lane,
41
+ where each one lands exactly once, in order.
42
+
43
+ This template's `Game.tsx` sends every steering key as `send({ turn }, { reliable: true })`,
44
+ and `ready`-up the same way. In `room.ts`, both arrive as `kind: "event"` — the shell reads
45
+ `ready`, the genre reads `turn`.
46
+
47
+ ## The bonus: it keeps the datagram lean
48
+
49
+ There's a second, quieter reason the reliable lane fits here. When a client sends intent on the
50
+ **datagram** lane, the server attaches a small per-peer acknowledgement map (last-processed
51
+ input, for client-side prediction/reconciliation) to **every** broadcast state datagram. A
52
+ territory game does no client-side prediction — it just renders the authoritative grid — so that
53
+ ack map would be pure dead weight on a datagram budget this genre already pushes hard (see
54
+ `skills/compact-snapshots.md`). Because moves ride the *reliable* lane instead, that map stays
55
+ empty and is dropped from the wire entirely. The lane choice that is *correct for the genre* is
56
+ also the one that *reclaims budget*. That is not a coincidence you should fight.
57
+
58
+ ## The prediction amendment (DIG-763, ratified 2026-07-21)
59
+
60
+ The rule above has one ratified exception: **a room that opts into client-side prediction
61
+ puts its steering on the INPUT (datagram) lane — even discrete steering.** Prediction's
62
+ reconciliation ack (`appliedSeq`) acks *only* input-envelope seqs; reliable events carry no
63
+ seq and can never reconcile. So the doctrine is:
64
+
65
+ > **Predicted intent rides the input lane as HELD intent; the event lane is for discrete,
66
+ > non-predicted acts** (fire, ready-up, callsign — things that must apply exactly once and
67
+ > must NOT be optimistically replayed).
68
+
69
+ "A lost turn is a death" was the pre-prediction rationale — under prediction the held intent
70
+ (`{ d }`, the desired direction) is re-sent every pump tick (~60Hz), so a dropped datagram is
71
+ a non-event: the next pump re-carries it, latest-wins recovers, and your own head already
72
+ turned locally the frame you pressed. The buffered-`nd` semantics are unchanged — latest-wins
73
+ held direction is exactly what the `nd` buffer already reduces turns to.
74
+
75
+ A room that does NOT predict keeps the original rule (and the lean-datagram bonus below).
76
+ See `packages/PREDICT.md` for the full opt-in.
77
+
78
+ ## Gotchas
79
+
80
+ - **Reliable events are unbounded arbitrary data** (`data: unknown`) — you type/validate them
81
+ yourself in `tick`. This template guards with a `type` discriminator for `ready` and a range
82
+ check for `turn` before acting. Always validate — it's untyped intent from a client.
83
+ - **`status`** from `useRoom` is `"connecting" | "connected" | "reconnecting" | "failed" |
84
+ "closed"`. Render an explicit failed/joining state (this template does) instead of an eternal
85
+ spinner.
86
+ - **Adding a fast-continuous signal later?** (say, an aim reticle for a power-up) — *that* one
87
+ belongs on the datagram lane. The rule is per-signal: discrete-and-must-land → reliable;
88
+ continuous-and-self-correcting → datagram. Don't put everything on one lane by habit.
@@ -0,0 +1,115 @@
1
+ # Territory capture — the genre mechanics
2
+
3
+ Read this before changing the game itself: trails, claiming, collisions, respawn, and the round
4
+ timer. This is the code *below* the shell in `room.ts` — the part you replace to build a
5
+ different game. Everything here assumes `skills/authoritative-tick.md` (the tick model) and
6
+ `skills/compact-snapshots.md` (why state is stored the way it is).
7
+
8
+ ## The loop, in one paragraph
9
+
10
+ Each player owns a solid patch of the grid (their **territory**). While your head sits on your
11
+ own territory you're safe. Leave it and you draw a **trail** across neutral/enemy ground; loop
12
+ back to touch your own territory again and everything your trail enclosed is **claimed**
13
+ (flood-fill). While your trail is out, it's exposed: if any head — yours or a rival's — steps on
14
+ it, the trail's owner is **cut** (dies, releases their land, respawns). When the round timer
15
+ sounds, most territory wins and a fresh round rolls. That's the whole genre.
16
+
17
+ ## The sim step (`simStep`)
18
+
19
+ The sim advances one grid-step every `STEP_TICKS` ticks (see `authoritative-tick.md` → *slow sim
20
+ inside a fast clock*). One step is four ordered phases — **order matters**:
21
+
22
+ 1. **Move.** Apply each alive player's buffered turn (`nd`), reject 180° reversals, advance the
23
+ head one cell. Running off the board edge is death. Leaving your own territory starts/extends
24
+ a trail; a **turn-point is recorded only when the heading changed this tick**, so the stored
25
+ polyline's segments are always axis-aligned (a straight run is one segment). Overrunning the
26
+ trail-turn cap is over-extension death.
27
+ 2. **Collisions.** Build the set of occupied trail cells (each trailing player's trail, excluding
28
+ its own live head). Any head sitting on a trail cell → that **trail's owner** is cut. Two
29
+ heads on one cell → both die (head-on).
30
+ 3. **Claims.** An alive player whose head re-entered its **own** territory with a live trail
31
+ closes the loop → `claim` fills everything enclosed, and the trail clears.
32
+ 4. **Respawns.** A dead player whose respawn tick has arrived (and whose seat is still occupied)
33
+ gets a fresh 3×3 blob at an empty spot.
34
+
35
+ ## Claiming — the flood-fill (`claim`)
36
+
37
+ Closing a loop calls `claim(grid, w, h, owner, trailCells)`:
38
+
39
+ 1. The trail cells become owned.
40
+ 2. Flood-fill from the grid **border** through every non-owned cell.
41
+ 3. Any cell the border flood **couldn't reach** is enclosed → claimed (this captures both empty
42
+ pockets and enemy cells the loop wrapped around).
43
+
44
+ It's `O(cells)`, but only runs on the rare tick a loop actually closes, so it's cheap. The
45
+ `room.test.ts` claim tests pin both the capture (a fully-enclosed pocket, including an enemy
46
+ cell) and the no-over-claim case (a loop with a gap to the outside claims nothing new).
47
+
48
+ ## Trails are turn-points, and capped
49
+
50
+ A trail is stored as its **corners** (`Player.t = [x0,y0,x1,y1,…]`), not its cells — see
51
+ `compact-snapshots.md` for why (wire budget). `trailCells()` expands corners + the live head
52
+ back into cell indices when the sim needs them (collision, claim). **Invariant: consecutive
53
+ points must be axis-aligned** — the move phase guarantees this by only recording a corner when
54
+ the heading changed. (If you ever change trail recording and break that invariant, `trailCells`
55
+ would loop forever; there's a defensive cap that throws instead. Keep the invariant.)
56
+
57
+ `MAX_TRAIL_TURNS` caps the corners: over-extend and you die. This is both a genre mechanic
58
+ (don't stay exposed too long) and the thing that bounds the trail's wire cost. Don't remove the
59
+ cap without re-reading `compact-snapshots.md` — uncapped trails can blow the datagram.
60
+
61
+ ## Death, release, respawn
62
+
63
+ Any death (wall, cut, head-on, over-extension) does the same three things: mark the player dead
64
+ (`a = 0`), **release** its territory (its grid cells → 0), clear its trail, and set a respawn
65
+ tick (`ctx.tick + RESPAWN_TICKS`). No elimination — respawn keeps agency alive at scale, which is
66
+ why territory-capture (not instant-death light-cycles) is the genre that stays fun with a crowd.
67
+
68
+ ## How the genre wires to the shell
69
+
70
+ The shell owns the room lifecycle; the genre plugs into its three signals (full spec:
71
+ `skills/demo-room-lifecycle.md`):
72
+
73
+ ```ts
74
+ if (openRound(...) === "start") startGame(state, players, grid, ctx.random); // seed fresh board + spawns
75
+ if (state.shell.phase === "playing") { /* absorb turns, simStep on step ticks, run round timer */ }
76
+ if (closeRound(...) === "reset") clearGame(state, players, grid); // wipe the board
77
+ ```
78
+
79
+ The **round timer** is this genre's win condition. When `ctx.tick - shell.phaseSince >=
80
+ ROUND_TICKS`, the genre picks the most-territory slot (the `mostTerritory` helper) into
81
+ `state.win` and sets `shell.over = true` — the shell then shows the winner (`gameover`) and rolls
82
+ a fresh round. `shell.over` is the only bridge from genre to shell; the genre never touches
83
+ `shell.phase` directly.
84
+
85
+ ### Adding another win condition (e.g. a capture threshold)
86
+
87
+ If you add a second way to end the round — say "first to 40% of the board wins" — three things
88
+ are easy to get subtly wrong:
89
+
90
+ - **Define your denominator.** "The board" is **`state.w * state.h`** total cells (400 on the
91
+ default 20×20), not "claimed cells" — neutral ground counts. `countCells(grid, slot + 1)` is a
92
+ slot's cell count; compare it against `w * h * fraction`.
93
+ - **Read territory AFTER `simStep`, and know a dying player's land is already gone.** The
94
+ win-check runs on the post-step grid, and `simStep`'s death path calls `release()` (zeroes the
95
+ dead player's cells) *within* the same step. So a player who dominates *and* dies in one step
96
+ has no territory left to win with by the time you check — which is correct, but surprising.
97
+ - **Win-checks run every tick, not only on step boundaries.** Note the round-timer check sits
98
+ *outside* the `if (ctx.tick % STEP_TICKS === 0)` gate — it's evaluated every 60Hz tick so the
99
+ round can end the instant the condition is met. Put your new check alongside it, same cadence.
100
+
101
+ Mid-game seating is reconciled by `syncPlayers`: a fresh arrival / a takeover of an emptied slot
102
+ gets a new player; a seat that emptied loses its player and land. An **abandoned** seat (its peer
103
+ left mid-game) keeps its player, frozen, so a newcomer can take over the territory — the shell's
104
+ takeover promise, made concrete.
105
+
106
+ ## Ideas for extending (each is a small, contained change)
107
+
108
+ - **Kill feed / claim announcements** — `ctx.emit({ type: "cut", by, victim })` on a kill; the
109
+ client shows a toast. Reliable, off the broadcast state (doesn't cost budget).
110
+ - **Power-ups** — a cell that grants a temporary effect. Store the pickup cells compactly; if you
111
+ add a continuous aim signal, that one goes on the *datagram* lane (`genre-lane-mapping.md`).
112
+ - **Bots** — spawn seats controlled by server-side logic in `tick` (deterministic via `ctx`),
113
+ so the room is lively before humans arrive.
114
+ - **Bigger boards / more players** — this is a *budget* change, not a free one. Read
115
+ `compact-snapshots.md` first and watch the guard test.
@@ -0,0 +1,174 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useRoom } from "@siltrun/react";
3
+ import { createStage, createCamera, type StageHandle } from "@siltrun/stage";
4
+ import { dpad } from "@siltrun/stage/input";
5
+ import { Graphics } from "pixi.js";
6
+ // Type-only import of the room's State (erased at build), PLUS the actual wire codec and
7
+ // board constants — the client decodes the SAME compact grid/player forms the server
8
+ // encoded, and frames the SAME board size. One codec, one source of truth.
9
+ import { decodeGrid, readPlayers, trailCells, W, H, type State } from "../room.ts";
10
+
11
+ // A stable, SHORT player id for this browser tab. Short ids keep the shell's seat map
12
+ // (which rides the state datagram) lean — see the budget note in room.ts.
13
+ const PLAYER_ID =
14
+ sessionStorage.getItem("mg-id") ??
15
+ (() => {
16
+ const id = Math.random().toString(36).slice(2, 5); // 3 chars
17
+ sessionStorage.setItem("mg-id", id);
18
+ return id;
19
+ })();
20
+
21
+ // Player colours — the Silt register's warm family (ink ground, hue-restrained), NOT a
22
+ // neon-gamer rainbow. silt · amber · copper · rust · sand · bone.
23
+ const COLORS = [0x7e837a, 0xc98a3d, 0xb07050, 0xa65a2e, 0xcfc9bc, 0xeae7de];
24
+ const COLORS_CSS = ["#7e837a", "#c98a3d", "#b07050", "#a65a2e", "#cfc9bc", "#eae7de"];
25
+ const INK = 0x0b0b0d, BONE = 0xeae7de, HAIR = 0x26262a;
26
+ const CELL = 20; // world units per grid cell (arbitrary — the camera fits the board)
27
+
28
+ // The dev loop serves room-info on :4000 by default; override for a custom --info-port or a
29
+ // deployed room (VITE_ROOM_URL).
30
+ const ROOM_URL = (import.meta as { env?: Record<string, string> }).env?.VITE_ROOM_URL ?? "http://localhost:4000";
31
+
32
+ export function Game() {
33
+ const { state, send, status, error } = useRoom<State>(ROOM_URL, { id: PLAYER_ID });
34
+
35
+ // React owns the DOM HUD below; the stage redraws the board each frame from this ref.
36
+ const stateRef = useRef(state);
37
+ stateRef.current = state;
38
+ const hostRef = useRef<HTMLDivElement>(null);
39
+
40
+ useEffect(() => {
41
+ let stage: StageHandle | undefined;
42
+ let pad: { dispose(): void } | undefined;
43
+ let cancelled = false;
44
+ // createStage is awaited INSIDE the effect — never top-level (see @siltrun/stage README).
45
+ createStage(hostRef.current!, { background: INK }).then((s) => {
46
+ if (cancelled) return s.dispose();
47
+ stage = s;
48
+
49
+ // God view: fit the whole board on any screen, refit on rotate/resize.
50
+ const cam = createCamera(s);
51
+ const fit = () => cam.fitRect({ x: 0, y: 0, w: W * CELL, h: H * CELL }, { pad: 16 });
52
+ fit();
53
+ s.onResize(fit);
54
+
55
+ // Steer on the RELIABLE lane — a slow grid game wants ordered, no-drop turns.
56
+ // dpad = arrows/WASD AND swipe, from birth; its dir indices (0=up 1=right 2=down
57
+ // 3=left) are the same convention the sim uses, so intent passes straight through.
58
+ pad = dpad(s.app.canvas, {
59
+ mode: "turn",
60
+ onDir: (dir) => send({ turn: dir }, { reliable: true }),
61
+ });
62
+
63
+ // Redraw everything from authoritative state, every frame (the Silt idiom).
64
+ const gfx = s.world.addChild(new Graphics());
65
+ s.app.ticker.add((tk) => {
66
+ cam.update(tk.deltaMS / 1000);
67
+ gfx.clear();
68
+ const st = stateRef.current;
69
+ if (!st) return;
70
+
71
+ // 1. claimed territory — the RLE grid, decoded
72
+ const grid = decodeGrid(st.g, st.w, st.h);
73
+ for (let i = 0; i < grid.length; i++) {
74
+ const owner = grid[i]!;
75
+ if (owner === 0) continue;
76
+ gfx.rect((i % st.w) * CELL, ((i / st.w) | 0) * CELL, CELL, CELL)
77
+ .fill({ color: COLORS[(owner - 1) % COLORS.length]!, alpha: 0.32 });
78
+ }
79
+
80
+ // 2. live trails + heads
81
+ const players = readPlayers(st);
82
+ const mySlot = st.shell.seats.findIndex((seat) => seat.id === PLAYER_ID);
83
+ players.forEach((pl, slot) => {
84
+ if (!pl || !pl.a) return;
85
+ const color = COLORS[slot % COLORS.length]!;
86
+ for (const c of trailCells(pl.t, pl.x, pl.y, st.w)) {
87
+ gfx.rect((c % st.w) * CELL, ((c / st.w) | 0) * CELL, CELL, CELL)
88
+ .fill({ color, alpha: 0.7 });
89
+ }
90
+ gfx.rect(pl.x * CELL, pl.y * CELL, CELL, CELL).fill(color); // head
91
+ if (slot === mySlot) {
92
+ gfx.rect(pl.x * CELL + 1, pl.y * CELL + 1, CELL - 2, CELL - 2)
93
+ .stroke({ color: BONE, width: 2 });
94
+ }
95
+ });
96
+
97
+ // 3. faint grid lines + board frame
98
+ for (let x = 0; x <= st.w; x++) gfx.moveTo(x * CELL, 0).lineTo(x * CELL, st.h * CELL);
99
+ for (let y = 0; y <= st.h; y++) gfx.moveTo(0, y * CELL).lineTo(st.w * CELL, y * CELL);
100
+ gfx.stroke({ color: BONE, width: 1, alpha: 0.06 });
101
+ gfx.rect(0, 0, st.w * CELL, st.h * CELL).stroke({ color: HAIR, width: 1 });
102
+ });
103
+ });
104
+ return () => {
105
+ cancelled = true;
106
+ pad?.dispose(); // dpad holds window key listeners — the stage can't remove those
107
+ stage?.dispose();
108
+ };
109
+ }, [send]);
110
+
111
+ // ── DOM HUD (the convention: chrome in DOM, world on the stage) ──
112
+ const mySlot = state?.shell.seats.findIndex((s) => s.id === PLAYER_ID) ?? -1;
113
+ const hud = (() => {
114
+ if (status === "failed") return <span style={{ color: "#a65a2e" }}>connection failed: {String(error)} — is the dev loop running?</span>;
115
+ if (!state) return <span>{status === "reconnecting" ? "reconnecting…" : "joining…"}</span>;
116
+ const { phase, seats } = state.shell;
117
+ const win = state.win; // NOTE: win rides top-level State, not the shell
118
+ const seated = mySlot >= 0;
119
+ const iAmReady = seated && seats[mySlot]!.ready;
120
+ if (phase === "gathering") {
121
+ const readyCount = seats.filter((s) => s.id && s.ready).length;
122
+ const playerCount = seats.filter((s) => s.id).length;
123
+ return (
124
+ <>
125
+ <span>{seated ? `${readyCount}/${playerCount} ready` : "room full — spectating"}</span>
126
+ {seated && (
127
+ <button onClick={() => send({ type: "ready" }, { reliable: true })} disabled={iAmReady}
128
+ style={{
129
+ fontFamily: "inherit", fontSize: 12, padding: "5px 14px", cursor: iAmReady ? "default" : "pointer",
130
+ background: iAmReady ? "#1c1c1f" : "#eae7de", color: iAmReady ? "#7e837a" : "#0b0b0d",
131
+ border: "1px solid #3a3a3d", borderRadius: 0, letterSpacing: "0.06em", textTransform: "uppercase",
132
+ }}>
133
+ {iAmReady ? "ready ✓" : "ready up"}
134
+ </button>
135
+ )}
136
+ </>
137
+ );
138
+ }
139
+ if (phase === "playing") {
140
+ const grid = decodeGrid(state.g, state.w, state.h);
141
+ const total = state.w * state.h;
142
+ return seats
143
+ .map((s, slot) => ({ slot, id: s.id, cells: countOwner(grid, slot + 1) }))
144
+ .filter((s) => s.id)
145
+ .sort((a, b) => b.cells - a.cells)
146
+ .map((s) => (
147
+ <span key={s.slot} style={{ color: COLORS_CSS[s.slot % COLORS_CSS.length], fontWeight: s.slot === mySlot ? 700 : 400, fontVariantNumeric: "tabular-nums" }}>
148
+ {Math.round((s.cells / total) * 100)}%
149
+ </span>
150
+ ));
151
+ }
152
+ return (
153
+ <span style={{ color: win >= 0 ? COLORS_CSS[win % COLORS_CSS.length] : "#7e837a", letterSpacing: "0.06em" }}>
154
+ {win === mySlot ? "you win the round" : win >= 0 ? `player ${win + 1} wins the round` : "round over"} — next game starting…
155
+ </span>
156
+ );
157
+ })();
158
+
159
+ // Static chrome (title, steering hint) lives in index.html; only the DYNAMIC strip is React's.
160
+ return (
161
+ <main style={{ position: "fixed", inset: 0, fontFamily: "ui-sans-serif, system-ui, sans-serif" }}>
162
+ <div ref={hostRef} style={{ position: "absolute", inset: 0 }} />
163
+ <div style={{ position: "absolute", left: 0, right: 0, bottom: "calc(30px + env(safe-area-inset-bottom, 0px))", display: "flex", justifyContent: "center", alignItems: "center", gap: 12, fontSize: 12, minHeight: 30, color: "#7e837a" }}>
164
+ {hud}
165
+ </div>
166
+ </main>
167
+ );
168
+ }
169
+
170
+ function countOwner(grid: Int16Array, owner: number): number {
171
+ let n = 0;
172
+ for (let i = 0; i < grid.length; i++) if (grid[i] === owner) n++;
173
+ return n;
174
+ }
@@ -0,0 +1,9 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { Game } from "./Game.tsx";
4
+
5
+ createRoot(document.getElementById("root")!).render(
6
+ <StrictMode>
7
+ <Game />
8
+ </StrictMode>,
9
+ );
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "allowImportingTsExtensions": true,
10
+ "isolatedModules": true,
11
+ "skipLibCheck": true,
12
+ "noEmit": true
13
+ },
14
+ "include": ["src", "room.ts", "room.test.ts", "vite.config.ts"]
15
+ }