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,321 @@
1
+ // room.ts — co-op tower defense. This file runs SERVER-SIDE at 60Hz inside a
2
+ // deterministic realm: browsers submit intent, this tick() decides truth, and every
3
+ // client receives the full authoritative state each tick. Edit it while the dev loop
4
+ // is running — it hot-reloads (a determinism doctor re-checks each reload).
5
+ //
6
+ // Two things live here, deliberately separated:
7
+ // 1. THE DEMO-ROOM LIFECYCLE SHELL — genre-agnostic. One always-warm persistent room:
8
+ // drop-in join, take over an abandoned player or start fresh, ready-up → rolling
9
+ // rounds → play until you lose → a new game auto-starts. No lobby. Other genre
10
+ // templates COPY this block verbatim. Normative spec: skills/demo-room-lifecycle.md.
11
+ // 2. THE TOWER-DEFENSE GENRE — everything below the shell. Replace this to build a
12
+ // different game on the same lifecycle.
13
+ import type { Room, Input, Ctx } from "@siltrun/room";
14
+
15
+ // ════════════════════════════════════════════════════════════════════════════════════
16
+ // ══ DEMO-ROOM LIFECYCLE SHELL — genre-agnostic. Copy verbatim. ══
17
+ // ══ Normative spec + invariants: skills/demo-room-lifecycle.md ══
18
+ // ════════════════════════════════════════════════════════════════════════════════════
19
+
20
+ export type Phase = "gathering" | "playing" | "gameover";
21
+
22
+ /** A fixed seat at the table. `id` is the peer holding it (null = empty). A seat whose
23
+ * peer left MID-GAME is kept (`abandoned`) so a newcomer can take it over — its game
24
+ * data (towers, etc.) keeps running. The original can also reclaim it by rejoining. */
25
+ export type Seat = { id: string | null; ready: boolean; abandoned: boolean };
26
+
27
+ /** Shell-owned meta. Namespaced under one key so the genre state stays cleanly separable
28
+ * and this whole block copies without collision. */
29
+ export type Shell = {
30
+ phase: Phase;
31
+ seats: Seat[];
32
+ round: number; // increments each game start
33
+ phaseSince: number; // ctx.tick when the current phase began — drives countdowns
34
+ over: boolean; // the GENRE sets this true to end a game (playing → gameover)
35
+ };
36
+
37
+ export type ShellOpts = {
38
+ seats: number; // fixed table size
39
+ minPlayers: number; // ready seats required to start a game
40
+ gameoverTicks: number; // how long the gameover screen holds before auto-reset
41
+ };
42
+
43
+ export function initShell(o: ShellOpts): Shell {
44
+ return {
45
+ phase: "gathering",
46
+ seats: Array.from({ length: o.seats }, () => ({ id: null, ready: false, abandoned: false })),
47
+ round: 0,
48
+ phaseSince: 0,
49
+ over: false,
50
+ };
51
+ }
52
+
53
+ /** Seat a peer: resume your own seat (reconnect), else claim an empty seat, else TAKE
54
+ * OVER an abandoned seat (adopt its in-progress game data). No seat available →
55
+ * spectator (returns -1); a mid-game spectator waits for the next gathering. */
56
+ function seatFor(shell: Shell, id: string): number {
57
+ const own = shell.seats.findIndex((s) => s.id === id);
58
+ if (own >= 0) { shell.seats[own].abandoned = false; return own; } // reconnect
59
+ const empty = shell.seats.findIndex((s) => s.id === null);
60
+ if (empty >= 0) { shell.seats[empty] = { id, ready: false, abandoned: false }; return empty; }
61
+ const aband = shell.seats.findIndex((s) => s.abandoned);
62
+ if (aband >= 0) { shell.seats[aband].id = id; shell.seats[aband].abandoned = false; return aband; } // takeover
63
+ return -1; // spectator
64
+ }
65
+
66
+ /** Runs FIRST in tick(). Applies membership (join/leave) + `ready` events, then opens a
67
+ * round when enough seats are ready. Returns "start" on the tick a game begins (the genre
68
+ * seeds a fresh game on that signal); the caller passes back which seats went live. */
69
+ export function openRound(shell: Shell, inputs: Input[], ctx: Ctx, o: ShellOpts): "start" | null {
70
+ for (const ev of inputs) {
71
+ if (ev.kind === "join") {
72
+ seatFor(shell, ev.id);
73
+ } else if (ev.kind === "leave") {
74
+ const i = shell.seats.findIndex((s) => s.id === ev.id);
75
+ if (i < 0) continue;
76
+ if (shell.phase === "playing") shell.seats[i].abandoned = true; // keep data, allow takeover
77
+ else shell.seats[i] = { id: null, ready: false, abandoned: false }; // no game to preserve → free it
78
+ } else if (ev.kind === "event" && isReady(ev.data)) {
79
+ const i = shell.seats.findIndex((s) => s.id === ev.from);
80
+ if (i >= 0 && shell.phase === "gathering") shell.seats[i].ready = true;
81
+ }
82
+ }
83
+ if (shell.phase === "gathering") {
84
+ const readyCount = shell.seats.filter((s) => s.id && s.ready && !s.abandoned).length;
85
+ if (readyCount >= o.minPlayers) {
86
+ shell.phase = "playing";
87
+ shell.round += 1;
88
+ shell.phaseSince = ctx.tick;
89
+ shell.over = false;
90
+ for (const s of shell.seats) s.ready = false;
91
+ return "start";
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /** Runs LAST in tick(). Advances playing→gameover (when the genre set `over`) and
98
+ * gameover→gathering (after the countdown, freeing abandoned seats). Returns "reset" on
99
+ * the tick a fresh gathering begins (the genre clears its game state on that signal). */
100
+ export function closeRound(shell: Shell, ctx: Ctx, o: ShellOpts): "reset" | null {
101
+ if (shell.phase === "playing" && shell.over) {
102
+ shell.phase = "gameover";
103
+ shell.phaseSince = ctx.tick;
104
+ return null;
105
+ }
106
+ if (shell.phase === "gameover" && ctx.tick - shell.phaseSince >= o.gameoverTicks) {
107
+ shell.phase = "gathering";
108
+ shell.phaseSince = ctx.tick;
109
+ shell.over = false;
110
+ for (const s of shell.seats) {
111
+ if (s.abandoned) { s.id = null; s.abandoned = false; } // its player is gone
112
+ s.ready = false;
113
+ }
114
+ return "reset";
115
+ }
116
+ return null;
117
+ }
118
+
119
+ function isReady(data: unknown): boolean {
120
+ return !!data && typeof data === "object" && (data as { type?: unknown }).type === "ready";
121
+ }
122
+
123
+ // ════════════════════════════════════════════════════════════════════════════════════
124
+ // ══ END DEMO-ROOM LIFECYCLE SHELL ══
125
+ // ════════════════════════════════════════════════════════════════════════════════════
126
+
127
+ // ─────────────────────────────────────────────────────────────────────────────────────
128
+ // TOWER-DEFENSE GENRE — replace everything below to build a different game.
129
+ //
130
+ // Design notes (each mechanic is here to TEACH a Silt substrate concept):
131
+ // • Waves spawn on a ctx.tick schedule with ctx.random jitter → the determinism realm
132
+ // • Creeps walk a fixed server-side path; clients interpolate → server-owns-truth
133
+ // • Fixed build slots; place via a reliable event, server valid.→ reliable lane + intent
134
+ // • Teammate cursors ride the datagram lane (kind:"input") → latest-wins presence
135
+ // • Shared gold + shared lives; a leak costs a life; 0 = loss → co-op shared state
136
+ //
137
+ // STATE BUDGET (see skills/state-budget.md): the whole State is broadcast every tick in
138
+ // ONE ~1200B datagram. Short keys on the arrays that scale (creeps/towers) are a
139
+ // deliberate budget choice — measured by room.test.ts. Positions are DERIVED client-side
140
+ // from (waypoint index + progress), never stored.
141
+ // ─────────────────────────────────────────────────────────────────────────────────────
142
+
143
+ // The creeps' path as grid waypoints (0..1 arena space). Rendering derives pixel positions.
144
+ const PATH: ReadonlyArray<readonly [number, number]> = [
145
+ [0.0, 0.2], [0.8, 0.2], [0.8, 0.5], [0.2, 0.5], [0.2, 0.8], [1.0, 0.8],
146
+ ];
147
+ // Fixed build slots (0..1 arena space) — the only places a tower may go.
148
+ const SLOTS: ReadonlyArray<readonly [number, number]> = [
149
+ [0.5, 0.12], [0.9, 0.35], [0.5, 0.42], [0.1, 0.42], [0.35, 0.62], [0.5, 0.9], [0.85, 0.65], [0.1, 0.72],
150
+ ];
151
+ // Tower kinds: [cost, range(arena units), damage, cooldown(ticks)].
152
+ const KINDS: ReadonlyArray<readonly [number, number, number, number]> = [
153
+ [50, 0.18, 3, 30], // 0: arrow — cheap, steady
154
+ [90, 0.14, 8, 55], // 1: cannon — pricier, hits hard
155
+ ];
156
+ const CAPS = { creeps: 14, towers: SLOTS.length }; // hard ceilings — keep state under MTU
157
+ const WAVES = 5;
158
+ const CREEP_SPEED = 0.006; // arena units / tick along the path
159
+ const START_GOLD = 120;
160
+ const START_LIVES = 20;
161
+
162
+ type Creep = { i: number; w: number; t: number; h: number }; // id, waypoint idx, seg progress 0..1, hp
163
+ type Tower = { s: number; k: number; c: number }; // slot idx, kind, cooldown-remaining
164
+ type Cursor = { x: number; y: number };
165
+
166
+ export type State = {
167
+ shell: Shell;
168
+ gold: number;
169
+ lives: number;
170
+ wave: number; // 0 = not started; 1..WAVES active
171
+ creeps: Creep[];
172
+ towers: Tower[];
173
+ next: number; // next creep id
174
+ spawnLeft: number; // creeps still to spawn this wave
175
+ spawnCd: number; // ticks until next spawn
176
+ cursors: Record<string, Cursor>; // teammate pointers — datagram lane, latest-wins
177
+ };
178
+
179
+ /** The datagram-lane intent: a teammate's live cursor. */
180
+ export type Cmd = { x: number; y: number };
181
+ /** A reliable-lane event: place a tower, or ready-up. */
182
+ type Place = { type: "place"; slot: number; kind: number };
183
+
184
+ const SHELL_OPTS: ShellOpts = { seats: 2, minPlayers: 2, gameoverTicks: 180 };
185
+
186
+ function freshState(): State {
187
+ return {
188
+ shell: initShell(SHELL_OPTS),
189
+ gold: START_GOLD, lives: START_LIVES, wave: 0,
190
+ creeps: [], towers: [], next: 0, spawnLeft: 0, spawnCd: 0, cursors: {},
191
+ };
192
+ }
193
+
194
+ function startGame(state: State): void {
195
+ state.gold = START_GOLD;
196
+ state.lives = START_LIVES;
197
+ state.wave = 0;
198
+ state.creeps = [];
199
+ state.towers = [];
200
+ state.next = 0;
201
+ state.spawnLeft = 0;
202
+ state.spawnCd = 0;
203
+ beginWave(state, 1);
204
+ }
205
+
206
+ function clearGame(state: State): void {
207
+ state.wave = 0;
208
+ state.creeps = [];
209
+ state.towers = [];
210
+ state.spawnLeft = 0;
211
+ state.spawnCd = 0;
212
+ }
213
+
214
+ function beginWave(state: State, wave: number): void {
215
+ state.wave = wave;
216
+ state.spawnLeft = 3 + wave * 2; // more creeps each wave
217
+ state.spawnCd = 0;
218
+ }
219
+
220
+ function dist(ax: number, ay: number, bx: number, by: number): number {
221
+ return Math.hypot(ax - bx, ay - by);
222
+ }
223
+
224
+ /** Derive a creep's arena position from (waypoint idx + progress). Pure — clients run the
225
+ * same math to render, so positions never need to live in state. */
226
+ function creepPos(c: Creep): [number, number] {
227
+ const a = PATH[c.w], b = PATH[Math.min(c.w + 1, PATH.length - 1)];
228
+ return [a[0] + (b[0] - a[0]) * c.t, a[1] + (b[1] - a[1]) * c.t];
229
+ }
230
+
231
+ /** One tick of the tower-defense game. Only runs while shell.phase === "playing". */
232
+ function stepGame(state: State, inputs: Input<Cmd>[], ctx: Ctx): void {
233
+ // 1. reliable events: tower placement (server validates — occupied? affordable?)
234
+ for (const ev of inputs) {
235
+ if (ev.kind !== "event") continue;
236
+ const d = ev.data as Partial<Place>;
237
+ if (d?.type !== "place") continue;
238
+ const slot = d.slot ?? -1, kind = d.kind ?? -1;
239
+ if (slot < 0 || slot >= SLOTS.length || kind < 0 || kind >= KINDS.length) continue; // bad input
240
+ if (state.towers.some((t) => t.s === slot)) continue; // slot occupied
241
+ if (state.towers.length >= CAPS.towers) continue; // ceiling
242
+ const cost = KINDS[kind][0];
243
+ if (state.gold < cost) continue; // can't afford
244
+ state.gold -= cost;
245
+ state.towers.push({ s: slot, k: kind, c: 0 }); // truth: the server placed it
246
+ }
247
+
248
+ // 2. spawn this wave's creeps on a tick schedule with deterministic jitter
249
+ if (state.spawnLeft > 0) {
250
+ if (state.spawnCd <= 0) {
251
+ if (state.creeps.length < CAPS.creeps) {
252
+ const hp = 8 + state.wave * 4;
253
+ state.creeps.push({ i: state.next++, w: 0, t: 0, h: hp });
254
+ state.spawnLeft -= 1;
255
+ state.spawnCd = 24 + Math.floor(ctx.random() * 12); // jitter — ctx.random, never Math.random
256
+ }
257
+ } else {
258
+ state.spawnCd -= 1;
259
+ }
260
+ }
261
+
262
+ // 3. towers fire (nearest creep in range)
263
+ for (const tw of state.towers) {
264
+ if (tw.c > 0) { tw.c -= 1; continue; }
265
+ const [tx, ty] = SLOTS[tw.s];
266
+ const [, range, dmg, cd] = KINDS[tw.k];
267
+ let best = -1, bestD = Infinity;
268
+ for (let j = 0; j < state.creeps.length; j++) {
269
+ const [cx, cy] = creepPos(state.creeps[j]);
270
+ const dd = dist(tx, ty, cx, cy);
271
+ if (dd <= range && dd < bestD) { bestD = dd; best = j; }
272
+ }
273
+ if (best >= 0) { state.creeps[best].h -= dmg; tw.c = cd; }
274
+ }
275
+
276
+ // 4. move creeps along the path; remove the dead (bounty) and leaks (a life)
277
+ const survivors: Creep[] = [];
278
+ for (const c of state.creeps) {
279
+ if (c.h <= 0) { state.gold += 5; continue; } // killed → bounty
280
+ c.t += CREEP_SPEED / segLen(c.w);
281
+ while (c.t >= 1 && c.w < PATH.length - 1) { c.t -= 1; c.w += 1; }
282
+ if (c.w >= PATH.length - 1 && c.t >= 1) { state.lives -= 1; continue; } // leaked the exit
283
+ survivors.push(c);
284
+ }
285
+ state.creeps = survivors;
286
+
287
+ // 5. wave / loss progression
288
+ if (state.spawnLeft === 0 && state.creeps.length === 0) {
289
+ if (state.wave < WAVES) beginWave(state, state.wave + 1);
290
+ else state.shell.over = true; // cleared all waves — the demo ends in a win, then resets
291
+ }
292
+ if (state.lives <= 0) state.shell.over = true; // loss → the shell rolls a new game
293
+ }
294
+
295
+ function segLen(w: number): number {
296
+ const a = PATH[w], b = PATH[Math.min(w + 1, PATH.length - 1)];
297
+ return Math.max(1e-3, Math.hypot(b[0] - a[0], b[1] - a[1]));
298
+ }
299
+
300
+ export default {
301
+ tick(state = freshState(), inputs, ctx) {
302
+ // datagram lane: teammate cursors (latest-wins). Only keep cursors for live seats.
303
+ for (const ev of inputs) {
304
+ if (ev.kind === "input") state.cursors[ev.from] = { x: ev.data.x, y: ev.data.y };
305
+ }
306
+
307
+ // ── the lifecycle shell drives the room ──
308
+ if (openRound(state.shell, inputs, ctx, SHELL_OPTS) === "start") startGame(state);
309
+ if (state.shell.phase === "playing") stepGame(state, inputs, ctx);
310
+ if (closeRound(state.shell, ctx, SHELL_OPTS) === "reset") clearGame(state);
311
+
312
+ // prune cursors for peers no longer seated (keeps state small + correct)
313
+ const live = new Set(state.shell.seats.map((s) => s.id).filter(Boolean));
314
+ for (const id in state.cursors) if (!live.has(id)) delete state.cursors[id];
315
+
316
+ return state;
317
+ },
318
+ } satisfies Room<State, Cmd>;
319
+
320
+ // Exposed for the derive/verify tooling (room.test.ts) — not part of the wire contract.
321
+ export { PATH, SLOTS, KINDS, CAPS, WAVES, freshState, creepPos };
@@ -0,0 +1,96 @@
1
+ # The authoritative tick — how server truth works
2
+
3
+ Read this before changing any server logic in `room.ts`. It is the core Silt model; every
4
+ other skill assumes it.
5
+
6
+ ## The contract
7
+
8
+ `room.ts` default-exports an object with one required method:
9
+
10
+ ```ts
11
+ export default {
12
+ tick(state, inputs, ctx) { /* mutate state, return it */ return state; },
13
+ } satisfies Room<State, Cmd>;
14
+ ```
15
+
16
+ - Runs **server-side**, **60 times per second**, while ≥1 peer is present (an empty room
17
+ pauses — see `skills/waves-and-timing.md`).
18
+ - Receives a **fresh clone of the canonical state** each tick. Mutate it freely and return it.
19
+ **Never stash a reference across ticks** — next tick gets a new clone; a stashed reference is
20
+ a determinism bug waiting to happen.
21
+ - Returns the new state, which the server **broadcasts in full to every client**.
22
+ - Optional `init()` runs once when the room is created and returns the starting state. This
23
+ template uses a default parameter instead (`tick(state = freshState(), …)`) — either works.
24
+ If neither seeds it, the first tick receives `state === undefined`.
25
+
26
+ ## Intent in, truth out
27
+
28
+ Clients cannot write state. They call `send(...)` (see `skills/two-lanes.md`), which arrives in
29
+ `tick` as an entry in the `inputs` batch. Your `tick` is the ONLY place truth is decided.
30
+
31
+ The pattern to internalize — **validate intent, then commit**. From this template's `stepGame`,
32
+ tower placement:
33
+
34
+ ```ts
35
+ if (state.towers.some((t) => t.s === slot)) continue; // reject: slot occupied
36
+ if (state.gold < cost) continue; // reject: can't afford
37
+ state.gold -= cost; // commit: now it's truth
38
+ state.towers.push({ s: slot, k: kind, c: 0 });
39
+ ```
40
+
41
+ A malicious client can *ask* to place ten free towers; the server prices and validates every
42
+ one. This is the whole security model — never trust intent, always decide in `tick`.
43
+
44
+ ## The input batch
45
+
46
+ `inputs` is an ordered `Input<Cmd>[]`. Each entry is one of:
47
+
48
+ | `kind` | fields | meaning |
49
+ |---|---|---|
50
+ | `"join"` | `id` | a peer joined |
51
+ | `"leave"` | `id`, `reason: "left" \| "timeout"` | a peer left (clean bye vs dropped) |
52
+ | `"input"` | `from`, `data: Cmd` | a peer's latest **datagram** intent (droppable, latest-wins) — here, a cursor |
53
+ | `"event"` | `from`, `data: unknown` | a peer's **reliable** event (ordered) — here, `ready`/`place` |
54
+
55
+ Ordering within a tick is deterministic: joins, then leaves (by id), then reliable events
56
+ (arrival order), then each present peer's single latest `input`. Membership rides the same
57
+ batch — there are no join/leave callbacks; you handle them by looping `inputs`. This template
58
+ loops it twice: the shell reads join/leave/`ready`; `stepGame` reads `place`; the top of `tick`
59
+ reads cursor `input`s. That's fine — iterate as many times as clarity wants.
60
+
61
+ ## The determinism realm — the rule that bites first
62
+
63
+ `tick` runs in a sandbox where **non-deterministic APIs are removed**. A determinism doctor
64
+ replays your contract on every hot-reload and **rejects** it if two runs of the same inputs
65
+ diverge. Inside `tick` you must NOT use:
66
+
67
+ - `Date.now()`, `performance.now()`, `new Date()` — use `ctx.time` / `ctx.tick`.
68
+ - `Math.random()`, `crypto.getRandomValues` — use `ctx.random()`.
69
+ - `fetch`, timers (`setTimeout`/`setInterval`), or any I/O.
70
+
71
+ Why: the server must be able to replay ticks identically (crash recovery, verification). The
72
+ `ctx` gives you deterministic substitutes:
73
+
74
+ ```ts
75
+ ctx.tick // integer tick count from 0 — THE clock. Count ticks to measure time.
76
+ ctx.dt // fixed 1/60. Never wall time.
77
+ ctx.time // ctx.tick * ctx.dt, seconds. Derived.
78
+ ctx.random() // deterministic hash(seed, tick, drawIndex) → [0,1). Reproducible.
79
+ ctx.emit(ev) // queue a reliable event to broadcast AFTER this tick (from "@server").
80
+ ```
81
+
82
+ This template uses `ctx.random()` for spawn jitter and `ctx.tick` for phase countdowns. If the
83
+ doctor rejects a change, you reached for a forbidden API — find it and route through `ctx`.
84
+
85
+ ## Failure is safe
86
+
87
+ Throwing inside `tick` **skips that tick** — the last good state holds, the room survives. One
88
+ bad input can't kill the room. Still, prefer explicit validation (like the placement checks
89
+ above) over relying on throws.
90
+
91
+ ## Verify
92
+
93
+ `tick` is a pure function of `(state, inputs, ctx)`, so test it with no browser: build inputs,
94
+ call `tick`, assert the returned state. See `room.test.ts` — including a determinism test that
95
+ runs the same script twice and asserts identical output. That test is how you *prove* your
96
+ change stayed deterministic, beyond the doctor's reload check.
@@ -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,79 @@
1
+ # The state budget — the ceiling that shapes everything
2
+
3
+ Read this before adding anything to `State`. It is the one hard constraint that most shapes how
4
+ you design a Silt game, and the reason tower defense — a genre that wants many entities on
5
+ screen — is a good 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.
12
+
13
+ A QUIC datagram gives you roughly **~1200 bytes of usable payload**. At ~40 bytes of JSON per
14
+ small entity, that's a hard ceiling of about **28–30 small entities** in state before the
15
+ datagram no longer fits. **A state that outgrows the datagram is silently undeliverable on that
16
+ lane** — there is no automatic fallback. So the budget is not a soft guideline; exceed it and
17
+ the game breaks quietly.
18
+
19
+ (Full limits doc: the silt repo's `docs/BOUNDARIES.md` §1.)
20
+
21
+ ## The three disciplines
22
+
23
+ **1. Store only what the server must own to decide truth.** Positions, health, ownership, whose
24
+ turn — the minimum. Everything else is derived.
25
+
26
+ **2. Derive rendering client-side — don't store it.** This is the biggest saver. This template
27
+ never stores creep pixel positions. It stores a creep as `{ i, w, t, h }` — id, **waypoint
28
+ index**, **progress along that segment** (0..1), and hp — and both server and client compute the
29
+ actual position from the shared `PATH` via `creepPos()`. Animation, interpolation, particle
30
+ effects, glow, health-bar layout — all live in `Game.tsx`, none in `State`. A creep costs ~4
31
+ numbers on the wire instead of a fat object.
32
+
33
+ **3. Short keys on the arrays that scale.** Object keys are literal bytes in the JSON, repeated
34
+ per entity per tick. The types that appear in bounded-but-growing arrays use terse keys:
35
+
36
+ ```ts
37
+ type Creep = { i: number; w: number; t: number; h: number }; // NOT {id,waypoint,progress,hp}
38
+ type Tower = { s: number; k: number; c: number }; // slot, kind, cooldown
39
+ ```
40
+
41
+ Meta fields that appear **once** (`gold`, `lives`, `wave`) stay readable — one occurrence, the
42
+ bytes don't multiply. Only shorten what repeats.
43
+
44
+ ## Hard caps
45
+
46
+ The template caps the arrays that could otherwise blow the budget:
47
+
48
+ ```ts
49
+ const CAPS = { creeps: 14, towers: SLOTS.length /* 8 */ };
50
+ ```
51
+
52
+ Spawns refuse to exceed `CAPS.creeps`; placement refuses past the fixed slots. With 2 seats + 8
53
+ towers + 14 creeps + meta + 2 cursors, the **worst-case serialized state is ~931 bytes** —
54
+ comfortably under the ~1100B budget. When you add fields or raise caps, you spend against that
55
+ headroom.
56
+
57
+ ## The budget is a test, not a hope
58
+
59
+ `room.test.ts` builds a **maxed** state (full seats, every slot towered, creep array saturated,
60
+ cursors set) and asserts:
61
+
62
+ ```ts
63
+ expect(Buffer.byteLength(JSON.stringify(s), "utf8")).toBeLessThan(1100);
64
+ ```
65
+
66
+ **When you add anything to `State`, this test is your guardrail.** Add a field to `Creep`, raise
67
+ `CAPS.creeps`, add a third seat — re-run `bun test room.test.ts` and watch the printed
68
+ `worst-case state = NNNB`. If it approaches 1100, you must claw bytes back: shorten a key, drop a
69
+ stored value you can derive, lower a cap. Don't ship a state you haven't measured at its worst.
70
+
71
+ ## If you genuinely need more than the budget allows
72
+
73
+ - **Hidden information** (fog of war, secret hands) can't be solved by "just don't render it" —
74
+ every client receives the full state and can read the wire. Handle secrets client-side (e.g.
75
+ commit-reveal). See `docs/BOUNDARIES.md` §2.
76
+ - **More entities than fit** — you're past what alpha Silt broadcasts. Options: reduce entity
77
+ count (the design move), or represent many things as a few (e.g. a wave as a count + a
78
+ spawn-schedule rather than N individual pre-spawned creeps). Per-tick diffs + keyframes are the
79
+ designed v1 path but are **not built** — don't design assuming them.