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,66 @@
1
+ # Two lanes — datagram vs reliable
2
+
3
+ Read this before sending anything from the client. Silt gives you **two lanes** over one
4
+ connection, with opposite trade-offs. Choosing the wrong one is the most common design mistake.
5
+
6
+ ## The client API
7
+
8
+ `useRoom` gives you `send`:
9
+
10
+ ```ts
11
+ const { state, send, status } = useRoom<State>("http://localhost:4000", { id });
12
+
13
+ send({ x, y }); // DATAGRAM lane — droppable, latest-wins, unordered
14
+ send({ type: "place", slot, kind }, // RELIABLE lane — ordered, guaranteed, discrete
15
+ { reliable: true });
16
+ ```
17
+
18
+ That single `{ reliable: true }` flag is the whole choice. It changes which lane the data rides
19
+ and how it arrives in `tick`.
20
+
21
+ ## The two lanes
22
+
23
+ | | **Datagram** (default) | **Reliable** (`{ reliable: true }`) |
24
+ |---|---|---|
25
+ | Delivery | best-effort — **may be dropped** | guaranteed, in order |
26
+ | Rate | high-frequency firehose (~20Hz+) | discrete, occasional |
27
+ | Semantics | **latest-wins** — only the newest matters | every one matters, exactly once |
28
+ | Arrives in `tick` as | `{ kind: "input", from, data }` — one latest per peer per tick | `{ kind: "event", from, data }` — each, in arrival order |
29
+ | Use for | continuous, self-correcting signals | state-changing actions |
30
+
31
+ ## The rule
32
+
33
+ - **Continuous state that overwrites itself → datagram.** A cursor, a position, a heading, an
34
+ aim direction. If the newest value makes the previous one irrelevant, dropping one costs
35
+ nothing — the next arrives in ~50ms. Sending these reliably would flood the ordered lane and
36
+ add latency for no benefit.
37
+ - **Discrete actions that must land exactly once → reliable.** Place a tower, ready up, cast a
38
+ spell, end a turn, buy an item. Dropping or reordering these corrupts the game. This is where
39
+ `{ reliable: true }` earns its keep.
40
+
41
+ ## In this template
42
+
43
+ Both lanes are used for a real reason — that's the teaching point:
44
+
45
+ - **Teammate cursors → datagram.** `Game.tsx` calls `send({ x, y })` on pointer-move (~pointer
46
+ rate). In `room.ts`, the top of `tick` reads `kind: "input"` and stores `state.cursors[from]`.
47
+ Latest-wins: a dropped cursor frame is invisible. Losing one is fine; the next paints over it.
48
+ - **Ready-up + tower placement → reliable.** `Game.tsx` sends `{ type: "ready" }` and
49
+ `{ type: "place", slot, kind }` with `{ reliable: true }`. These are handled as
50
+ `kind: "event"` — the shell reads `ready`, `stepGame` reads `place`. A dropped "place" would
51
+ mean gold spent with no tower (or vice versa) — unacceptable, so it rides the guaranteed lane.
52
+
53
+ ## Gotchas
54
+
55
+ - **`kind: "input"` is latest-wins per peer per tick** — if a peer sends five cursor updates
56
+ between ticks, `tick` sees only the last. Don't put anything you must not lose on this lane.
57
+ - **Reliable events are unbounded arbitrary data** (`data: unknown`) — you type/validate them
58
+ yourself in `tick`. This template guards with a `type` discriminator (`"ready"` / `"place"`)
59
+ and range-checks the payload before acting. Always validate — it's untyped intent from a
60
+ client (see `skills/authoritative-tick.md`).
61
+ - **Server-originated events**: `ctx.emit(ev)` (from `authoritative-tick`) pushes a reliable
62
+ event out after the tick, appearing to clients as an event from `"@server"`. Use it to
63
+ announce discrete outcomes (a wave cleared, a boss spawned) without bloating broadcast state.
64
+ - **`status`** from `useRoom` is `"connecting" | "connected" | "reconnecting" | "failed" |
65
+ "closed"`. Render an explicit failed/joining state (this template does) instead of an eternal
66
+ spinner.
@@ -0,0 +1,79 @@
1
+ # Timing in a deterministic realm — waves, spawns, cooldowns
2
+
3
+ Read this before doing anything time-based: spawning on a schedule, cooldowns, delays,
4
+ countdowns, "every N seconds". The key shift: **there is no `setTimeout`.** You measure time by
5
+ counting ticks.
6
+
7
+ ## Time = ticks
8
+
9
+ `tick` runs at a fixed 60Hz. You don't get wall-clock time inside it (it's forbidden — see
10
+ `skills/authoritative-tick.md`). Instead:
11
+
12
+ - `ctx.tick` — integer count from 0. **This is the clock.** 60 ticks = 1 second.
13
+ - `ctx.dt` — always `1/60`. Multiply per-tick rates by nothing (dt is already baked into "per
14
+ tick"); use it only when converting a per-second quantity.
15
+ - `ctx.time` — `ctx.tick / 60`, seconds. Derived convenience.
16
+
17
+ Two idioms cover almost everything:
18
+
19
+ **A. Countdown counters** — store a number of ticks remaining, decrement each tick, act at 0:
20
+
21
+ ```ts
22
+ if (state.spawnCd <= 0) {
23
+ spawnACreep(state);
24
+ state.spawnCd = 24 + Math.floor(ctx.random() * 12); // reset with jitter
25
+ } else {
26
+ state.spawnCd -= 1;
27
+ }
28
+ ```
29
+
30
+ **B. Absolute deadlines** — stamp `ctx.tick` when something starts, compare later:
31
+
32
+ ```ts
33
+ shell.phaseSince = ctx.tick; // when the phase began
34
+ if (ctx.tick - shell.phaseSince >= gameoverTicks) resetPhase(); // elapsed check
35
+ ```
36
+
37
+ Use (A) for repeating intervals (spawns, cooldowns), (B) for one-shot delays (the gameover
38
+ hold, a warmup). Both are deterministic and replay identically.
39
+
40
+ ## How this template does waves
41
+
42
+ The wave system in `stepGame` is entirely counter-driven:
43
+
44
+ - `beginWave(state, n)` sets `state.spawnLeft = 3 + n * 2` — how many creeps this wave still owes.
45
+ - Each tick, if `spawnLeft > 0` and the `spawnCd` counter hits 0, spawn one creep, decrement
46
+ `spawnLeft`, and reset `spawnCd` to `24 + random jitter` ticks (~0.4–0.6s between spawns).
47
+ - A wave ends when `spawnLeft === 0` **and** `creeps.length === 0` (all spawned and cleared).
48
+ Then `beginWave(n+1)`, or — past the last wave — set `shell.over = true` (a win).
49
+ - Tower cooldowns are the same idiom per-tower: a tower fires, sets `c` (cooldown ticks), and
50
+ counts it down each tick before it can fire again.
51
+
52
+ Nothing here references wall time. Change the pace by changing the counts (`WAVES`, the
53
+ `3 + n*2` formula, the `24` spawn gap, `CREEP_SPEED`) — never by reaching for a timer.
54
+
55
+ ## Deterministic randomness
56
+
57
+ Jitter, crit chance, spawn variety — all must use `ctx.random()`, never `Math.random()`. It's a
58
+ deterministic hash of `(seed, tick, drawIndex)`: reproducible across replays, but still well-
59
+ distributed. This template uses it for the spawn gap so creeps don't arrive on a robotic beat.
60
+
61
+ `Math.floor`, `Math.hypot`, `Math.min/max` etc. are pure and **fine** — only the *sources of
62
+ nondeterminism* (random, clock) are banned. (This template calls `Math.floor(ctx.random()*12)`
63
+ — pure math wrapping a deterministic draw.)
64
+
65
+ ## Empty rooms pause — a subtlety
66
+
67
+ The room only ticks while ≥1 peer is present; an empty room pauses at its last tick and resumes
68
+ on the next join. So `ctx.tick` measures **occupied** time, not real elapsed time. Don't build
69
+ mechanics that assume ticks keep advancing while nobody's watching (e.g. "regenerate 1 gold/sec
70
+ even when empty" won't run when empty). For this always-warm demo it doesn't matter; for a game
71
+ with offline progression, keep the source of truth outside the room (Silt has no durable
72
+ cross-restart persistence yet — see the silt `docs/BOUNDARIES.md`).
73
+
74
+ ## Verify
75
+
76
+ Because timing is just counters over `ctx.tick`, you test it deterministically: drive N ticks
77
+ with a scripted `ctx` and assert the state (creeps spawned, wave advanced, cooldown elapsed).
78
+ `room.test.ts` drives thousands of ticks to force a full game to its loss, and asserts the
79
+ determinism of a fixed script — copy that pattern for your own timing changes.
@@ -0,0 +1,227 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useRoom } from "@siltrun/react";
3
+ import { createStage, createCamera, type StageHandle } from "@siltrun/stage";
4
+ import { tapBoard } from "@siltrun/stage/input";
5
+ import { Graphics } from "pixi.js";
6
+ // Types are erased at build; PATH/SLOTS/KINDS/creepPos are shared GEOMETRY — the client
7
+ // derives rendering from the very same waypoints the server owns (one source of truth).
8
+ import { PATH, SLOTS, KINDS, WAVES, creepPos, type State } from "../room.ts";
9
+
10
+ // Register palette (ink-on-bone, Silt #7E837A the one accent). Tokens, not invented art —
11
+ // one set of CSS strings serves both worlds: Pixi 8 accepts them, and so does the DOM.
12
+ const INK = "#0B0B0D", BONE = "#EAE7DE", SAND = "#CFC9BC", SILT = "#7E837A", RUST = "#8a3b2e";
13
+ const BOARD = "#141417", HAIR = "#2a2a2e";
14
+ const A = 480; // arena size in world units (arena-space is 0..1 → ×A); the camera fits it
15
+
16
+ // A stable identity for this browser tab.
17
+ const PLAYER_ID =
18
+ sessionStorage.getItem("td-id") ??
19
+ (() => { const id = crypto.randomUUID().slice(0, 8); sessionStorage.setItem("td-id", id); return id; })();
20
+
21
+ // Local UI: which tower kind the next tap will place. Kept out of authoritative state.
22
+ let SELECTED_KIND = 0;
23
+
24
+ export function Game() {
25
+ const { state, send, status, error } = useRoom<State>("http://localhost:4000", { id: PLAYER_ID });
26
+
27
+ // React owns the DOM HUD; the stage redraws the arena each frame from this ref.
28
+ const stateRef = useRef(state);
29
+ stateRef.current = state;
30
+ const hostRef = useRef<HTMLDivElement>(null);
31
+
32
+ // Machine-readable proof hook — the two-window test reads this, not pixels.
33
+ useEffect(() => {
34
+ (window as unknown as { __silt: unknown }).__silt = {
35
+ id: PLAYER_ID, status,
36
+ phase: state?.shell.phase, round: state?.shell.round,
37
+ gold: state?.gold, lives: state?.lives, wave: state?.wave,
38
+ creeps: state?.creeps.length, towers: state?.towers.length,
39
+ seats: state?.shell.seats,
40
+ mySeat: state?.shell.seats.findIndex((s) => s.id === PLAYER_ID),
41
+ };
42
+ });
43
+
44
+ useEffect(() => {
45
+ let stage: StageHandle | undefined;
46
+ let cancelled = false;
47
+ // createStage is awaited INSIDE the effect — never top-level (see @siltrun/stage README).
48
+ createStage(hostRef.current!, { background: INK }).then((s) => {
49
+ if (cancelled) return s.dispose();
50
+ stage = s;
51
+
52
+ // Fit the whole arena on any screen, refit on rotate/resize.
53
+ const cam = createCamera(s);
54
+ const fit = () => cam.fitRect({ x: 0, y: 0, w: A, h: A }, { pad: 16 });
55
+ fit();
56
+ s.onResize(fit);
57
+
58
+ // THE core verb, phone-first: tap a build slot to place the selected tower.
59
+ // Taps map through the camera into arena space; the server still validates.
60
+ tapBoard(s.app.canvas, {
61
+ map: (sx, sy) => { const w = cam.toWorld(sx, sy); return { x: w.x / A, y: w.y / A }; },
62
+ onTap: (p) => {
63
+ const st = stateRef.current;
64
+ if (!st || st.shell.phase !== "playing") return;
65
+ let slot = -1, best = 0.08; // tap must land within 0.08 arena units of a slot
66
+ SLOTS.forEach(([x, y], i) => {
67
+ const d = Math.hypot(p.x - x, p.y - y);
68
+ if (d < best) { best = d; slot = i; }
69
+ });
70
+ if (slot >= 0 && !st.towers.some((t) => t.s === slot))
71
+ send({ type: "place", slot, kind: SELECTED_KIND }, { reliable: true });
72
+ },
73
+ });
74
+
75
+ // Teammate presence: my cursor rides the datagram lane (latest-wins, droppable).
76
+ const onMove = (e: PointerEvent) => {
77
+ const r = s.app.canvas.getBoundingClientRect();
78
+ const w = cam.toWorld(e.clientX - r.left, e.clientY - r.top);
79
+ send({ x: w.x / A, y: w.y / A });
80
+ };
81
+ s.app.canvas.addEventListener("pointermove", onMove);
82
+
83
+ // Redraw the arena from authoritative state every frame (the Silt idiom).
84
+ const gfx = s.world.addChild(new Graphics());
85
+ s.app.ticker.add((tk) => {
86
+ cam.update(tk.deltaMS / 1000);
87
+ gfx.clear();
88
+ const st = stateRef.current;
89
+ // board ground + frame
90
+ gfx.rect(0, 0, A, A).fill(BOARD).stroke({ color: HAIR, width: 1 });
91
+ // creep path — the sand track the server marches creeps along
92
+ const [px0, py0] = PATH[0]!;
93
+ gfx.moveTo(px0 * A, py0 * A);
94
+ for (const [x, y] of PATH.slice(1)) gfx.lineTo(x * A, y * A);
95
+ gfx.stroke({ color: SAND, alpha: 0.35, width: 16, join: "round", cap: "round" });
96
+ if (!st) return;
97
+ // build slots — square markers (register shape language); tap to place
98
+ SLOTS.forEach(([x, y], slot) => {
99
+ const t = st.towers.find((tw) => tw.s === slot);
100
+ const cx = x * A, cy = y * A;
101
+ if (!t) gfx.rect(cx - 9, cy - 9, 18, 18).stroke({ color: SILT, alpha: 0.5, width: 1 });
102
+ else if (t.k === 0) gfx.rect(cx - 8, cy - 8, 16, 16).stroke({ color: BONE, width: 2 }); // arrow: open
103
+ else gfx.rect(cx - 8, cy - 8, 16, 16).fill(BONE); // cannon: filled
104
+ });
105
+ // creeps — position DERIVED from (waypoint idx + progress), never stored
106
+ for (const c of st.creeps) {
107
+ const [x, y] = creepPos(c);
108
+ const cx = x * A, cy = y * A;
109
+ gfx.circle(cx, cy, 7).fill(SILT);
110
+ gfx.rect(cx - 8, cy - 13, 16, 2.5).fill(INK);
111
+ gfx.rect(cx - 8, cy - 13, 16 * Math.max(0, Math.min(1, c.h / (8 + st.wave * 4))), 2.5).fill(BONE);
112
+ }
113
+ // teammate cursors — the datagram lane, latest-wins
114
+ for (const [id, cur] of Object.entries(st.cursors)) {
115
+ if (id === PLAYER_ID) continue;
116
+ const cx = cur.x * A, cy = cur.y * A;
117
+ gfx.moveTo(cx - 6, cy).lineTo(cx + 6, cy).moveTo(cx, cy - 6).lineTo(cx, cy + 6)
118
+ .stroke({ color: SAND, width: 1 });
119
+ }
120
+ });
121
+ });
122
+ return () => { cancelled = true; stage?.dispose(); }; // dispose reaps canvas + its listeners
123
+ }, [send]);
124
+
125
+ if (status === "failed") return <Note>connection failed: {String(error)} — is `siltrun dev` running?</Note>;
126
+ if (!state) return <Note>{status === "reconnecting" ? "reconnecting…" : "joining the room…"}</Note>;
127
+
128
+ const { shell, gold, lives, wave } = state;
129
+ const mySeat = shell.seats.findIndex((s) => s.id === PLAYER_ID);
130
+
131
+ // ── DOM chrome (the HUD convention): top bar, phase overlay, bottom bar ──
132
+ return (
133
+ <main style={{ position: "fixed", inset: 0, fontFamily: "Geist, system-ui, sans-serif" }}>
134
+ <div ref={hostRef} style={{ position: "absolute", inset: 0 }} />
135
+ <Bar at="top">
136
+ <SeatStrip seats={shell.seats} me={PLAYER_ID} />
137
+ <span style={{ color: SILT, textTransform: "uppercase", letterSpacing: "0.1em", fontSize: 11 }}>
138
+ {shell.phase} · game {shell.round}
139
+ </span>
140
+ </Bar>
141
+
142
+ {shell.phase !== "playing" && (
143
+ <div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", pointerEvents: "none", background: "rgba(11,11,13,0.55)" }}>
144
+ <div style={{ textAlign: "center" }}>
145
+ <div style={{ color: BONE, fontSize: 26, letterSpacing: "0.14em" }}>
146
+ {shell.phase === "gathering" ? "READY UP" : lives <= 0 ? "OVERRUN" : "HELD THE LINE"}
147
+ </div>
148
+ <div style={{ color: SILT, fontSize: 12, letterSpacing: "0.1em", marginTop: 8 }}>
149
+ {shell.phase === "gathering" ? "two players to begin" : `reached wave ${wave}`}
150
+ </div>
151
+ </div>
152
+ </div>
153
+ )}
154
+
155
+ {shell.phase === "gathering" ? (
156
+ <Bar at="bottom">
157
+ <span style={{ color: SAND, fontSize: 12 }}>
158
+ {mySeat < 0 ? "no free seat — you'll play next round" : shell.seats[mySeat]!.ready ? "ready — waiting for a teammate" : "ready up to start (needs 2)"}
159
+ </span>
160
+ <button disabled={mySeat < 0 || shell.seats[mySeat]?.ready}
161
+ onClick={() => send({ type: "ready" }, { reliable: true })}
162
+ style={btn(mySeat >= 0 && !shell.seats[mySeat]?.ready)}>Ready</button>
163
+ </Bar>
164
+ ) : shell.phase === "playing" ? (
165
+ <Bar at="bottom">
166
+ <Stat label="gold" value={gold} />
167
+ <Stat label="lives" value={lives} tone={lives <= 5 ? RUST : BONE} />
168
+ <Stat label="wave" value={`${wave}/${WAVES}`} />
169
+ <KindPicker />
170
+ </Bar>
171
+ ) : (
172
+ <Bar at="bottom"><span style={{ color: SAND, fontSize: 12 }}>a new game starts in a moment…</span></Bar>
173
+ )}
174
+ </main>
175
+ );
176
+ }
177
+
178
+ function SeatStrip({ seats, me }: { seats: State["shell"]["seats"]; me: string }) {
179
+ return (
180
+ <span style={{ display: "inline-flex", gap: 6 }}>
181
+ {seats.map((s, i) => {
182
+ const mine = s.id === me;
183
+ const bg = s.abandoned ? "transparent" : s.id ? (s.ready ? SILT : SAND) : "transparent";
184
+ return (
185
+ <span key={i} title={s.id ?? "empty"} style={{
186
+ width: 26, height: 16, border: `1px solid ${s.id ? SILT : HAIR}`, background: bg,
187
+ display: "grid", placeItems: "center", fontSize: 9, color: s.ready ? INK : SAND,
188
+ opacity: s.abandoned ? 0.5 : 1,
189
+ }}>{mine ? "you" : s.abandoned ? "—" : s.id ? "P" : ""}</span>
190
+ );
191
+ })}
192
+ </span>
193
+ );
194
+ }
195
+
196
+ function KindPicker() {
197
+ // kind 0 = arrow (open square), kind 1 = cannon (filled square) — mono glyphs, no art.
198
+ return (
199
+ <span style={{ display: "inline-flex", gap: 6 }}>
200
+ {KINDS.map((k, i) => (
201
+ <button key={i} onClick={() => { SELECTED_KIND = i; }} style={{ ...btn(true), fontSize: 11, padding: "3px 8px" }}>
202
+ {i === 0 ? "arrow" : "cannon"} · {k[0]}g
203
+ </button>
204
+ ))}
205
+ </span>
206
+ );
207
+ }
208
+
209
+ const Bar = ({ at, children }: { at: "top" | "bottom"; children: React.ReactNode }) => (
210
+ <div style={{
211
+ position: "absolute", left: 0, right: 0, [at]: 0, display: "flex", alignItems: "center",
212
+ justifyContent: "space-between", gap: 12, padding: at === "top"
213
+ ? "calc(10px + env(safe-area-inset-top, 0px)) 12px 10px"
214
+ : "10px 12px calc(10px + env(safe-area-inset-bottom, 0px))",
215
+ }}>{children}</div>
216
+ );
217
+ const Stat = ({ label, value, tone = BONE }: { label: string; value: React.ReactNode; tone?: string }) => (
218
+ <span style={{ fontSize: 12, color: SAND }}>{label} <b style={{ color: tone, fontVariantNumeric: "tabular-nums" }}>{value}</b></span>
219
+ );
220
+ const Note = ({ children }: { children: React.ReactNode }) => (
221
+ <p style={{ color: SAND, fontSize: 13, fontFamily: "system-ui", padding: 24 }}>{children}</p>
222
+ );
223
+ const btn = (on: boolean): React.CSSProperties => ({
224
+ background: on ? BONE : "transparent", color: on ? INK : SAND, border: `1px solid ${on ? BONE : HAIR}`,
225
+ borderRadius: 0, padding: "5px 14px", fontSize: 12, letterSpacing: "0.06em", textTransform: "uppercase",
226
+ cursor: on ? "pointer" : "default", fontFamily: "Geist, system-ui, sans-serif",
227
+ });
@@ -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", "vite.config.ts"]
15
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: { port: 5173 },
7
+ });
@@ -0,0 +1,183 @@
1
+ # AGENTS.md — how to play this world as an agent
2
+
3
+ This is a **turn-based grid** built on [Silt](https://silt.run): an authoritative,
4
+ deterministic world that runs server-side. Humans *watch*; **agents play**. This file is
5
+ your complete briefing. If you are an agent about to play, read it top to bottom — it is
6
+ everything you need, and it is *sufficient* (a brain that reads only what's described here
7
+ can play competently; see `skills/play.md`).
8
+
9
+ > The game itself (collect pellets on a grid) is deliberately thin — it exists to
10
+ > demonstrate the **substrate**, not to be deep. What's worth learning here is the
11
+ > perception → decision → action loop against a live deterministic clock. Richer games
12
+ > ride this same substrate.
13
+
14
+ ---
15
+
16
+ ## 1. The world
17
+
18
+ A 12×12 grid. Some cells are walls; the rest are floor. Pellets appear on floor cells;
19
+ move onto a pellet to collect it and score. Every player is one piece, one cell, and moves
20
+ one cell per turn. That's it.
21
+
22
+ You never see the server's internal state object. You see a **rendered ASCII grid** — the
23
+ same projection a human sees rendered as graphics. This ASCII *is your sensorium*:
24
+
25
+ ```
26
+ ############
27
+ #A...*.....#
28
+ #..#....#..#
29
+ #....**....#
30
+ #....##....#
31
+ #....##....#
32
+ #........B.#
33
+ #..#....#..#
34
+ #.......*..#
35
+ #....*.....#
36
+ #.....*....#
37
+ ############
38
+
39
+ phase: playing round: 3 beat: 7
40
+ beat resolves in: 118 ticks (~2.0s) pellets left: 6
41
+ you are: A pos: (1,1) score: 4 missed: 0 buffered this beat: —
42
+ others: B@(9,6)
43
+ ```
44
+
45
+ ### Symbol table
46
+
47
+ | Glyph | Meaning |
48
+ |------|---------|
49
+ | `#` | wall — impassable |
50
+ | `.` | floor — walkable |
51
+ | `*` | pellet — move onto it to score |
52
+ | `A`–`Z` | a player piece. **Your own letter is named in the `you are:` line.** |
53
+
54
+ The status lines below the grid tell you who you are, your score, how many beats you've
55
+ missed, what you have buffered this turn, and — critically — **how long until the beat
56
+ resolves**.
57
+
58
+ ### Reading coordinates and directions — the exact convention
59
+
60
+ Both are needed to move correctly; neither is optional to know:
61
+
62
+ - **`pos: (x, y)` is `(column, row)`**, zero-indexed, **origin top-left**, with **y
63
+ increasing downward**. The grid is exactly the width and height of the ASCII block (12×12
64
+ here, walls included). In the sample, `A` is at `(1,1)` — column 1, row 1 (the top-left
65
+ interior cell). The **first** grid line under the status is the top row; you can trust the
66
+ rendered layout, not just the `pos:` numbers.
67
+ - **Moves are grid-relative to what you see**: `up` = toward the top of the grid (row − 1),
68
+ `down` = row + 1, `left` = column − 1, `right` = column + 1. `up` really is visually up.
69
+
70
+ ---
71
+
72
+ ## 2. The tick / beat model — the one thing to internalize
73
+
74
+ The world runs on a **constant clock** that never stops while anyone is present (60 steps
75
+ per second). You do **not** act on that clock directly. Turns are quantized into **beats**
76
+ — roughly every **4 seconds**. Here is the whole model:
77
+
78
+ 1. **You buffer one action per beat.** Send your move any time during the ~4s beat window.
79
+ 2. **Latest-wins.** If you send again before the beat resolves, your newer action replaces
80
+ the older one. You may change your mind until the beat lands.
81
+ 3. **The beat resolves all buffered actions simultaneously.** At the boundary, every
82
+ player's buffered move applies at once, from one shared snapshot.
83
+ 4. **Conflicts are symmetric.** Two players moving into the same cell? *Both bounce* (stay
84
+ put). Move into a cell someone is holding? *You bounce.* Move into a wall? *You bounce.*
85
+ There is no turn order and no initiative — contention just means nobody wins that cell.
86
+ 5. **If you don't act in time, you idle that beat.** Were you still thinking when the beat
87
+ landed? Then you had nothing buffered, your `missed` count ticks up, and your piece
88
+ stays put. **Being too slow is a real, visible failure** — and it's part of the game.
89
+
90
+ ### What this means for you, the agent
91
+
92
+ - **You have a deadline every beat.** The `beat resolves in: N ticks (~Ns)` line is your
93
+ clock. If your think-time exceeds it, you miss. Budget accordingly — a fast, rough
94
+ decision that lands beats a perfect decision that arrives late.
95
+ - **Your perception is stale by up to one beat.** You decide from the grid as it was when
96
+ you read it; by the time your action resolves, others have moved. Plan for it — don't
97
+ assume the board is frozen while you think.
98
+ - **You cannot be starved by another slow agent.** The clock never waits for anyone. A
99
+ frozen opponent just misses its beats; the world rolls on.
100
+
101
+ That's the design. A constant clock that never pauses + buffered actions + a hard per-beat
102
+ deadline is what lets variable-latency agents share one live, watchable world.
103
+
104
+ ---
105
+
106
+ ## 3. The action API
107
+
108
+ You act by sending messages on the **reliable, ordered lane** (in Silt client terms:
109
+ `send(msg, { reliable: true })`). **Turns must use the reliable lane, never the droppable
110
+ datagram lane** — a dropped turn is a silently missed beat you didn't choose.
111
+
112
+ | Message | Effect |
113
+ |---|---|
114
+ | `{ "t": "sit" }` | Take a seat — become a player and spawn your piece. Send this first. |
115
+ | `{ "t": "act", "move": "up" \| "down" \| "left" \| "right" \| "stay" }` | Buffer your move for the current beat (latest-wins). `stay` is a real, deliberate action — it is **not** a miss. |
116
+ | `{ "t": "emote", "glyph": "<one of the vocab>" }` | Broadcast a coarse signal (see §4). Applies immediately; does not consume your turn. |
117
+ | `{ "t": "stand" }` | Leave your seat — become a spectator, despawn your piece. |
118
+
119
+ The loop, in words: **`sit` once → each beat, read the ASCII, decide a move, `act` before
120
+ the deadline → repeat.**
121
+
122
+ ---
123
+
124
+ ## 4. The emote channel — coarse comms
125
+
126
+ You can't send prose. You can flash **one glyph** that every other agent (and the
127
+ spectators) see next to your piece for a couple of beats, then it fades. Emotes are how
128
+ agents signal intent in a world with no chat. The vocabulary is tiny and the meanings are
129
+ **convention, not enforced** — the substrate only guarantees your glyph is delivered,
130
+ shown, and decays.
131
+
132
+ | Glyph | Suggested meaning |
133
+ |------|---------|
134
+ | `!` | alert / danger here |
135
+ | `?` | confused / need help |
136
+ | `+` | friendly / let's cooperate |
137
+ | `-` | back off / hostile |
138
+ | `^` | good / agreed |
139
+ | `x` | no / taunt |
140
+
141
+ Emotes are exempt from beat-buffering: they apply the moment they arrive (so they keep the
142
+ world lively between the slower turn beats). Rate-limited to roughly one per beat.
143
+
144
+ ---
145
+
146
+ ## 5. Strategy hints
147
+
148
+ - **Path to the nearest pellet.** Parse the grid, find `*` cells, breadth-first search over
149
+ floor toward the closest one, take the first step. (A worked example is in
150
+ `skills/play.md`.)
151
+ - **Act early in the beat, revise if you learn more.** Latest-wins means an early rough move
152
+ is free insurance against missing; refine it if you have time.
153
+ - **Expect contention.** If another piece wants the same pellet, you may both bounce. A
154
+ `stay` or a sidestep sometimes beats a doomed push.
155
+ - **Don't over-think.** The deadline is real. A good-enough move on time always beats a
156
+ perfect move that misses the beat.
157
+
158
+ ---
159
+
160
+ ## 6. Honest limits
161
+
162
+ - **Presence-gated clock.** The world clock is constant *while at least one peer is
163
+ present*; an empty room pauses until someone joins. During play this never bites — there's
164
+ always at least you.
165
+ - **Stale-by-one-beat perception**, as above. Baked into the model, not a bug.
166
+ - **Use a unique, stable identity.** Your `id` is how the world knows you. It must be
167
+ unique per agent: if two live connections claim the same id, the server treats the newer
168
+ as a reconnection and *supersedes* the old one in place — so two genuinely-concurrent
169
+ agents sharing an id will thrash (each keeps kicking the other off) and neither plays.
170
+ One agent, one durable id. (This is a real multi-agent-deploy requirement — it bites the
171
+ moment you run more than one agent, so pick unique ids up front.)
172
+ - **This template proves the interface with scripted stub agents.** Connecting a *real*
173
+ LLM agent over MCP is a separate piece of work — the substrate here is exactly the surface
174
+ such a connection targets, verified by stubs that perceive only this ASCII and play well.
175
+
176
+ ---
177
+
178
+ ## Files
179
+
180
+ - `skills/play.md` — a hands-on recipe: how to turn this briefing into a working brain.
181
+ - `room.ts` — the authoritative contract (the rules above, as deterministic code). Read it
182
+ if you want ground truth; you don't need to in order to play.
183
+ - `src/substrate/` — the shared kernel: the map, the ASCII projection, the types.
@@ -0,0 +1,72 @@
1
+ # turn-based grid — an agent-native game on Silt
2
+
3
+ A genre starter for **agent-first games**: a deterministic, authoritative world that *agents*
4
+ play and *humans* watch. This template is the substrate — an action API for agents, dual
5
+ rendering (a graphics view for people + an ASCII view for agents, both projections of one
6
+ state), an emote channel, and the agent-paced beat model — proven with scripted stub agents.
7
+
8
+ The game itself (collect pellets on a 12×12 grid) is deliberately thin. What's worth learning
9
+ is the **substrate**: how a variable-latency agent perceives, decides, and acts against a
10
+ constant server clock. Richer games ride the same shape.
11
+
12
+ ## Run
13
+
14
+ ```bash
15
+ npm install
16
+ npm run dev
17
+ ```
18
+
19
+ This boots both halves:
20
+
21
+ - **room** — `siltrun dev room.ts`: your authoritative contract at 60Hz (room-info on
22
+ `http://localhost:4000`, WebTransport on `:4433`). A determinism doctor re-checks it on
23
+ every save.
24
+ - **web** — vite on [http://localhost:5173](http://localhost:5173): the human spectator view.
25
+
26
+ Then launch some agents by naming them in the URL — they join and play, you watch:
27
+
28
+ ```
29
+ http://localhost:5173/?agents=greedy,slow,chatty
30
+ ```
31
+
32
+ `greedy` plays well, `slow` is deliberately too slow (watch it stutter and rack up missed
33
+ beats), `chatty` flashes emotes. They connect as real, independent clients — each perceiving
34
+ **only** the ASCII grid, exactly as a real agent would.
35
+
36
+ ## The one thing to understand — the beat model
37
+
38
+ The world clock never stops. Turns are quantized into **beats** (~4s). An agent buffers one
39
+ action per beat on the reliable lane (latest-wins); at the beat boundary every buffered action
40
+ resolves at once, with symmetric conflict rules. **An agent still thinking when the beat lands
41
+ simply misses it** — the too-slow failure is visible, and it's the point. Read
42
+ [`AGENTS.md`](./AGENTS.md) for the full model; it's the real deliverable here.
43
+
44
+ ## Edit
45
+
46
+ - `room.ts` — the authority: the beat engine, movement/collision resolution, pellets, rounds.
47
+ Hot-reloads on save (determinism re-checked each time).
48
+ - `src/App.tsx` — the spectator: the graphics view + the agent ASCII view, side by side.
49
+ - `src/GraphicsView.tsx` — the human render on `@siltrun/stage` (PixiJS): `createStage` +
50
+ `cam.fitRect(board)` — a pure spectator surface, so that IS the whole ceremony (gliding
51
+ pieces, emote bubbles, missed-beat stutter, beat-progress bar; phone-viewable by default).
52
+ - `src/substrate/` — the shared kernel both sides project from: `types.ts`, `map.ts`
53
+ (geometry + the tick/beat constants — tune `BEAT_TICKS` for tempo), `ascii.ts` (the
54
+ agent-facing projection + the symbol table).
55
+ - `src/brains.ts` — the scripted stub agents. Swap in your own brain here.
56
+
57
+ ## Learn to write an agent
58
+
59
+ - [`AGENTS.md`](./AGENTS.md) — the complete agent briefing: world model, symbol table, the
60
+ beat model, the action API, the emote vocabulary, strategy, and honest limits.
61
+ - [`skills/play.md`](./skills/play.md) — a hands-on recipe: turn the briefing into a working
62
+ brain (parse the ASCII → BFS to the nearest pellet → act before the deadline).
63
+
64
+ ## Determinism
65
+
66
+ The contract is a pure function of `(state, inputs, ctx)`, so the whole beat engine —
67
+ buffering, simultaneous resolution, missed beats, scoring, rounds — is deterministic and
68
+ replayable. `siltrun dev` runs a **determinism doctor** on every save that replays your contract
69
+ and fails loudly if it drifts (uses wall-clock time, unseeded randomness, etc.). Watch for the
70
+ `determinism ✔ ok` line in the room output — that's your correctness guard. Because the
71
+ contract is pure, you can also unit-test it directly by calling `tick()` with scripted input
72
+ batches; no transport or browser needed.
@@ -0,0 +1,4 @@
1
+ node_modules/
2
+ dist/
3
+ *.log
4
+ .DS_Store