Public: anyone can read this; the crew can change it.

Concept: Wake — the only terrain is the paths other people leave (chosen) #32

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/drift:vesper/2-concept-wake-the-only-terrain-is-the-pat → main 1 commit fork

  • Merged. vesper/2-concept-wake-the-only-terrain-is-the-pat was merged into main as 91ee94a.

Review

Sign in to review this change.
7 files changed +278 −9
AGENTS.md +7
@@ -38,6 +38,13 @@ verified by signature, never by a cookie the app invents. An action that
3838 needs a person checks `who.role`; ownership is a column (`author_id`) and
3939 the check that reads it, in the action.
4040
41 The game itself is written down: `docs/wake.md` is the design record — the
42 mechanic, the starting numbers and the five invariants a build is measured
43 against — and `src/concept.ts` is the part of it the app says out loud. Take
44 a number from there rather than spelling it again, cite an invariant by its
45 number, and when play says a number should move, move it in both. Physics
46 tuning is not there: it lives in one block at the top of `src/world.ts`.
47
4148 To add a concept:
4249
4350 - **An action** — a function in `src/actions.ts`, its rule inside it, and
README.md +11
@@ -24,6 +24,16 @@ them; the regions are shells with honest empty states, and the round loop,
2424 the canvas and the ladder fill them in the issues that follow. `POST /mcp`
2525 answers and lists no tool.
2626
27 ## The game
28
29 The only terrain is the wake other players leave. Your thruster is feeble;
30 the currents other swimmers deposit are not, and the bloom only reaches its
31 gate when several people braid one lane. [`docs/wake.md`](docs/wake.md) is
32 the design record — the mechanic, the starting numbers and the five
33 invariants every build issue is measured against. It is not decoration: the
34 numbers the page states live in `src/concept.ts` and `bun test` fails if
35 they and the record drift apart.
36
2737 ## The shape
2838
2939 One implementation, many doors. `src/actions.ts` is every operation as a
@@ -39,6 +49,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
3949 | `src/live.ts` | the in-process bus and `GET /events` |
4050 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` — none yet |
4151 | `src/routes.ts` | every path, in one table |
52 | `src/concept.ts` | the design record as data: the rule, the round, the invariants |
4253 | `src/page.ts` | the HTML of the arena, rendered on the server |
4354 | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files |
4455 | `src/server.ts` | boot: open the database, serve |
docs/wake.md added +111
@@ -0,0 +1,111 @@
1 # Wake — the only terrain is the paths other people leave
2
3 The design record for this app's one mechanic. It is the thing build issues are measured
4 against: when the code and this file disagree, one of them is a bug, and `src/concept.test.ts`
5 fails the check for the numbers the running app states out loud.
6
7 ## The one mechanic
8
9 Everyone is a swimmer — a **mote** — in a shallow sea with no wind, no walls and no features of
10 its own. The only terrain is the wake other players left behind.
11
12 Your own thruster is deliberately feeble: a full burn takes the better part of a minute to cross
13 the arena. But every moving body deposits its momentum into a coarse field of **currents** that
14 decays over a few seconds, and that field drags every body inside it. So speed is never yours.
15 It is somebody else's last five seconds, and the fastest thing in the world is a lane four people
16 have been swimming down.
17
18 The shared goal is the **bloom**: a body fifty times your mass that has to reach a **gate** on the
19 rim. No thruster shifts it measurably and no single swimmer can raise a current strong enough, but
20 three swimmers braiding one lane throw it across the arena in seconds.
21
22 The shared danger is the same fact turned around. The current built to carry the bloom carries you
23 too, and the rim scatters anything that hits it fast — a lane someone else built can be the thing
24 that throws you out of the round.
25
26 The whole of it, in the sentence the page leads with:
27
28 > Your own thruster is feeble; the wake other swimmers leave is not. Ride their lane, braid it,
29 > and throw the bloom through the gate.
30
31 Alone, the sea is flat and the bloom ends the round where it started. With six people it is a
32 braided river. That is the test the direction (#1) sets: not "better with others" but *the map is
33 made of other people*.
34
35 ## Why this one, and not the other two
36
37 Three concepts were opened against the direction. Wake was chosen.
38
39 - **Tether** is more literal — no engine at all without a partner — but it makes the first
40 visitor's five seconds empty, and an empty room is the common case for a new app.
41 - **Commons** is a rule *about* other people rather than a world *made* of them; you feel it as a
42 number going down.
43 - **Wake** makes the other players the level geometry while still leaving a lone visitor something
44 true to do: drift, steer, and start a lane the next arrival can ride.
45
46 Wake's own weakness is that lone case, and it is answered without inventing company: #18 seeds a
47 new round's field from what earlier rounds really left in the sea, decayed hard.
48
49 ## The numbers
50
51 Starting values, all tunable. The **invariants** below are what must hold; these constants are
52 only the first guess at holding them.
53
54 | number | value | owned by |
55 | -------------- | -------------- | ------------------------------------- |
56 | roster cap | 24 | `src/concept.ts` `ROSTER_MAX` |
57 | round | 180 s | `src/concept.ts` `ROUND_SECONDS` |
58 | intermission | 20 s | `src/concept.ts` `INTERMISSION_SECONDS` |
59 | arena | 1000 units across | `src/world.ts` |
60 | tick | 20 Hz, one loop, server-authoritative | `src/round.ts` |
61 | frames out | 10 Hz | `src/round.ts` |
62 | current field | 32×32 cells | `src/world.ts` |
63 | field half-life | ≈ 2 s, effectively gone by 8 s | `src/world.ts` |
64 | thrust cap | a lone mote needs ≈ 60 s to cross | `src/world.ts` |
65 | drag | a built lane is ≈ 5× thrust speed | `src/world.ts` |
66 | bloom | mass 50, no thruster | `src/world.ts` |
67 | gate | a 60° arc on the rim, moved each round | `src/world.ts` |
68
69 Over the cap, an arrival watches as a spectator rather than being turned away.
70
71 The split is deliberate. Physics tuning lives in one block at the top of `src/world.ts` (#5) and
72 moves whenever play says it should — this file names the *target*, not the constant. The three
73 numbers this file owns, and the sentence quoted above, are what a person reads on the page, so
74 they live in `src/concept.ts` and the check holds them to this file.
75
76 ## Invariants
77
78 Every build issue is measured against these five. They are numbered, and issues cite them by
79 number; `INVARIANTS` in `src/concept.ts` is the same list as data, so a test can name the one it
80 proves.
81
82 1. A lone mote at full thrust cannot cross the arena inside 45 s of ticks.
83 2. A mote dropped into a lane two other motes built crosses it in under 10 s.
84 3. One mote thrusting at the bloom for 10 s moves it less than one cell.
85 4. Three motes braiding one lane put the bloom through the gate inside a round.
86 5. With no input, total field magnitude strictly decreases every tick and reaches zero: no energy
87 from nowhere.
88
89 Where each is proven: 1–4 in `src/world.test.ts` against the pure simulation (#5), and 4 again in
90 `src/round.test.ts` once a real round runs it (#8). 5 is a property of `step` alone and is also
91 the seam #18 must not break when it seeds a round from a stored field.
92
93 ### Two clarifications the build turned up
94
95 - **Invariant 5 binds the quiet sea only.** The field is not conservative while bodies move: a
96 body adds momentum to the water without losing the same momentum itself, because a sea that only
97 ever redistributes what bodies give up cannot make a lane faster than a lone mote's cruising
98 speed, and invariant 2 then has nowhere to come from. Amplifying-and-bounded is a game rather
99 than physics, and that is the honest description of it. What invariant 5 forbids is energy from
100 *nowhere*: with every thruster off, the total must fall every tick and reach zero.
101 - **A body never feels its own wake.** "Speed is never yours" has to be literal, or a mote riding
102 its own trail gets fast alone and invariant 1 is gone. How the subtraction is done is #5's and
103 #24's argument — a fitted curve or an exact per-body ledger — but that it happens is a rule of
104 the concept, not an implementation detail.
105
106 ## What this record does not decide
107
108 The look of the sea, the wire format, the scoring, the words on the page and the shape of the
109 socket are all the build issues'. This file decides the mechanic, the five invariants and the four
110 numbers in the table it owns. It stays open while the map is built against it and closes when the
111 vertical slice is live (#10, #11) and these numbers have been tuned against real play.
src/concept.test.ts added +101
@@ -0,0 +1,101 @@
1 import { expect, test } from "bun:test";
2 import * as concept from "./concept.ts";
3 import { home } from "./page.ts";
4 import type { Who } from "./who.ts";
5
6 const record = await Bun.file(new URL("../docs/wake.md", import.meta.url))
7 .text()
8 .then((text) => text.replace(/\r\n/g, "\n"));
9
10 /** Markdown down to its words: no quote markers, no emphasis, one space. */
11 const tidy = (text: string): string =>
12 text
13 .replace(/^>\s?/gm, "")
14 .replace(/\s+/g, " ")
15 .replace(/[*`]/g, "")
16 .trim();
17
18 /** The section under a heading, up to the next heading of any depth. */
19 const section = (heading: string): string => {
20 const start = record.indexOf(`\n${heading}\n`);
21 expect(start, `docs/wake.md is missing "${heading}"`).toBeGreaterThan(-1);
22 const rest = record.slice(start + heading.length + 2);
23 const end = rest.search(/\n#{2,}\s/);
24 return end === -1 ? rest : rest.slice(0, end);
25 };
26
27 /** Every row of the numbers table, as its three cells. */
28 const rows = (): string[][] =>
29 section("## The numbers")
30 .split("\n")
31 .filter((line) => line.startsWith("|"))
32 .map((line) =>
33 line
34 .slice(1, line.endsWith("|") ? -1 : undefined)
35 .split("|")
36 .map((cell) => cell.trim()),
37 )
38 .filter((cells) => cells.length === 3 && !/^-+$/.test(cells[0] ?? ""))
39 .slice(1); // the header row
40
41 test("every number the record hands this module is the number the module exports", () => {
42 const exported = concept as unknown as Record<string, unknown>;
43 const owned = rows().filter((cells) =>
44 (cells[2] ?? "").includes("`src/concept.ts`"),
45 );
46 // If this ever finds nothing, the table changed shape and the check below
47 // would pass by proving nothing at all.
48 expect(owned.length).toBe(3);
49 for (const [name, value, owner] of owned) {
50 const exportName = (owner ?? "").match(/`([A-Z_]+)`/)?.[1];
51 expect(exportName, `no export named in "${owner}"`).toBeDefined();
52 const number = Number((value ?? "").replace(/\s*s$/, ""));
53 expect(
54 exported[exportName as string],
55 `docs/wake.md says ${name} is ${value}`,
56 ).toBe(number);
57 }
58 });
59
60 test("the record's numbers name a file that exists", async () => {
61 for (const [, , owner] of rows()) {
62 for (const file of (owner ?? "").matchAll(/`(src\/[a-z.]+\.ts)`/g)) {
63 // A row may point at a module the map has not built yet; it may not
64 // point at one nothing will ever build.
65 const path = file[1] as string;
66 const built = await Bun.file(new URL(`../${path}`, import.meta.url))
67 .exists()
68 .catch(() => false);
69 const planned = /src\/(world|round)\.ts/.test(path);
70 expect(built || planned, `${path} is neither here nor planned`).toBe(
71 true,
72 );
73 }
74 }
75 });
76
77 test("the five invariants read the same in the record and in the code", () => {
78 const list = section("## Invariants")
79 .split("\n\n")
80 .find((block) => block.startsWith("1. "));
81 expect(list, "docs/wake.md has no numbered list of invariants").toBeDefined();
82 const listed = (list as string)
83 .split(/\n(?=\d+\. )/)
84 .filter((entry) => /^\d+\. /.test(entry.trim()))
85 .map((entry) => tidy(entry.trim().replace(/^\d+\.\s*/, "")));
86 expect(listed.length).toBe(5);
87 expect(listed).toEqual(concept.INVARIANTS.map(tidy));
88 });
89
90 test("the page says the round's shape in the record's numbers", () => {
91 // The sentence is written in minutes, so the constant has to be whole ones.
92 expect(concept.ROUND_SECONDS % 60).toBe(0);
93 const who: Who = { role: "anon" };
94 const page = home({ who });
95 // The sentence the page leads with is the record's, word for word.
96 expect(tidy(record)).toContain(tidy(concept.RULE));
97 expect(page).toContain(
98 `One runs ${concept.ROUND_SECONDS / 60} minutes, then ${concept.INTERMISSION_SECONDS} seconds of quiet.`,
99 );
100 expect(page).toContain(`0/${concept.ROSTER_MAX}`);
101 });
src/concept.ts added +40
@@ -0,0 +1,40 @@
1 /**
2 * The design record as data (#CONCEPT). `docs/wake.md` is the prose; this is
3 * the handful of numbers and sentences the running app states out loud, so
4 * the page, the round and every door say the same thing instead of each
5 * spelling it again. `src/concept.test.ts` holds this file and the record to
6 * each other.
7 *
8 * Physics tuning is not here. Thrust, field half-life, spread, drag, the
9 * arena and the gate are the world's, in one block at the top of
10 * `src/world.ts` (#WORLD), and they move whenever play says they should. What
11 * lives here is the shape of a round and the words for it — the parts a
12 * person reads — plus the five invariants, numbered, so a test can name the
13 * one it proves.
14 */
15
16 /** The one sentence a newcomer has to read: the whole game is other people. */
17 export const RULE =
18 "Your own thruster is feeble; the wake other swimmers leave is not. Ride their lane, braid it, and throw the bloom through the gate.";
19
20 /** Swimmers in one round. An arrival over the cap watches as a spectator. */
21 export const ROSTER_MAX = 24;
22
23 /** How long a round runs. A whole number of minutes: the page says it in minutes. */
24 export const ROUND_SECONDS = 180;
25
26 /** The quiet between rounds — long enough to read the ladder, short enough to wait through. */
27 export const INTERMISSION_SECONDS = 20;
28
29 /**
30 * The five things every build issue is measured against, in the order
31 * `docs/wake.md` numbers them: `INVARIANTS[0]` is invariant 1. A test that
32 * proves one names it, so a failure says which promise broke.
33 */
34 export const INVARIANTS = [
35 "A lone mote at full thrust cannot cross the arena inside 45 s of ticks.",
36 "A mote dropped into a lane two other motes built crosses it in under 10 s.",
37 "One mote thrusting at the bloom for 10 s moves it less than one cell.",
38 "Three motes braiding one lane put the bloom through the gate inside a round.",
39 "With no input, total field magnitude strictly decreases every tick and reaches zero: no energy from nowhere.",
40 ] as const;
src/page.ts +7 −8
@@ -10,17 +10,16 @@
1010 * give each page a file.
1111 */
1212
13 import {
14 INTERMISSION_SECONDS,
15 ROSTER_MAX,
16 ROUND_SECONDS,
17 RULE,
18 } from "./concept.ts";
1319 import { commit, description, env, ssh, title, where } from "./env.ts";
1420 import { asset, escape } from "./http.ts";
1521 import type { Who } from "./who.ts";
1622
17 /** The one sentence a newcomer has to read: the whole game is other people. */
18 export const RULE =
19 "Your own thruster is feeble; the wake other swimmers leave is not. Ride their lane, braid it, and throw the bloom through the gate.";
20
21 /** The roster cap the concept sets (#CONCEPT); the round enforces it. */
22 export const ROSTER_MAX = 24;
23
2423 export function layout(body: string, who: Who): string {
2524 const door =
2625 who.role === "anon"
@@ -100,7 +99,7 @@ const clock = (): string =>
10099 `<section id="clock" class="panel clock" aria-labelledby="clock-title" data-live="round.started round.ended">
101100 <h2 id="clock-title" class="panel-title">Round</h2>
102101 <p class="clock-time" aria-hidden="true">--:--</p>
103 <p class="empty">No round yet. One runs three minutes, then twenty seconds of quiet.</p>
102 <p class="empty">No round yet. One runs ${ROUND_SECONDS / 60} minutes, then ${INTERMISSION_SECONDS} seconds of quiet.</p>
104103 </section>`;
105104
106105 const roster = (who: Who): string =>
src/server.test.ts +1 −1
@@ -2,8 +2,8 @@ import { afterAll, expect, test } from "bun:test";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import { RULE } from "./concept.ts";
56 import { emit } from "./live.ts";
6 import { RULE } from "./page.ts";
77 import { serve } from "./server.ts";
88 import { sign } from "./who.ts";
99

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs