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