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.
- package/index.mjs +98 -0
- package/package.json +18 -0
- package/templates/mass-grid/AGENTS.md +106 -0
- package/templates/mass-grid/README.md +59 -0
- package/templates/mass-grid/_gitignore +4 -0
- package/templates/mass-grid/index.html +22 -0
- package/templates/mass-grid/package.json +31 -0
- package/templates/mass-grid/room.test.ts +214 -0
- package/templates/mass-grid/room.ts +530 -0
- package/templates/mass-grid/skills/authoritative-tick.md +116 -0
- package/templates/mass-grid/skills/compact-snapshots.md +110 -0
- package/templates/mass-grid/skills/demo-room-lifecycle.md +145 -0
- package/templates/mass-grid/skills/genre-lane-mapping.md +88 -0
- package/templates/mass-grid/skills/territory-capture.md +115 -0
- package/templates/mass-grid/src/Game.tsx +174 -0
- package/templates/mass-grid/src/main.tsx +9 -0
- package/templates/mass-grid/tsconfig.json +15 -0
- package/templates/mass-grid/vite.config.ts +9 -0
- package/templates/minimal/README.md +39 -0
- package/templates/minimal/_gitignore +4 -0
- package/templates/minimal/index.html +15 -0
- package/templates/minimal/package.json +30 -0
- package/templates/minimal/room.ts +30 -0
- package/templates/minimal/src/Game.tsx +71 -0
- package/templates/minimal/src/main.tsx +9 -0
- package/templates/minimal/tsconfig.json +15 -0
- package/templates/minimal/vite.config.ts +7 -0
- package/templates/tower-defense/AGENTS.md +132 -0
- package/templates/tower-defense/README.md +53 -0
- package/templates/tower-defense/_gitignore +4 -0
- package/templates/tower-defense/index.html +15 -0
- package/templates/tower-defense/package.json +31 -0
- package/templates/tower-defense/room.test.ts +159 -0
- package/templates/tower-defense/room.ts +321 -0
- package/templates/tower-defense/skills/authoritative-tick.md +96 -0
- package/templates/tower-defense/skills/demo-room-lifecycle.md +145 -0
- package/templates/tower-defense/skills/state-budget.md +79 -0
- package/templates/tower-defense/skills/two-lanes.md +66 -0
- package/templates/tower-defense/skills/waves-and-timing.md +79 -0
- package/templates/tower-defense/src/Game.tsx +227 -0
- package/templates/tower-defense/src/main.tsx +9 -0
- package/templates/tower-defense/tsconfig.json +15 -0
- package/templates/tower-defense/vite.config.ts +7 -0
- package/templates/turn-based-grid/AGENTS.md +183 -0
- package/templates/turn-based-grid/README.md +72 -0
- package/templates/turn-based-grid/_gitignore +4 -0
- package/templates/turn-based-grid/index.html +15 -0
- package/templates/turn-based-grid/package.json +30 -0
- package/templates/turn-based-grid/room.ts +309 -0
- package/templates/turn-based-grid/skills/play.md +97 -0
- package/templates/turn-based-grid/src/App.tsx +141 -0
- package/templates/turn-based-grid/src/GraphicsView.tsx +148 -0
- package/templates/turn-based-grid/src/brains.ts +112 -0
- package/templates/turn-based-grid/src/main.tsx +8 -0
- package/templates/turn-based-grid/src/stubAgent.ts +82 -0
- package/templates/turn-based-grid/src/substrate/ascii.ts +87 -0
- package/templates/turn-based-grid/src/substrate/map.ts +104 -0
- package/templates/turn-based-grid/src/substrate/types.ts +59 -0
- package/templates/turn-based-grid/src/view.ts +94 -0
- package/templates/turn-based-grid/tsconfig.json +15 -0
- 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,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,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.
|