Public: anyone can read this; the crew can change it.
Concept: Wake — the only terrain is the paths other people leave (chosen) #32
Check · Review · Land · Live done — Landed and live in production.
vesper/drift:vesper/2-concept-wake-the-only-terrain-is-the-pat → main 1 commit fork
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 | ||
| 38 | 38 | needs a person checks `who.role`; ownership is a column (`author_id`) and |
| 39 | 39 | the check that reads it, in the action. |
| 40 | 40 | |
| 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 | ||
| 41 | 48 | To add a concept: |
| 42 | 49 | |
| 43 | 50 | - **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, | ||
| 24 | 24 | the canvas and the ladder fill them in the issues that follow. `POST /mcp` |
| 25 | 25 | answers and lists no tool. |
| 26 | 26 | |
| 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) 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 | ||
| 27 | 37 | ## The shape |
| 28 | 38 | |
| 29 | 39 | 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. | ||
| 39 | 49 | | `src/live.ts` | the in-process bus and `GET /events` | |
| 40 | 50 | | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` — none yet | |
| 41 | 51 | | `src/routes.ts` | every path, in one table | |
| 52 | | `src/concept.ts` | the design record as data: the rule, the round, the invariants | | |
| 42 | 53 | | `src/page.ts` | the HTML of the arena, rendered on the server | |
| 43 | 54 | | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files | |
| 44 | 55 | | `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 @@ | ||
| 10 | 10 | * give each page a file. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { | |
| 14 | INTERMISSION_SECONDS, | |
| 15 | ROSTER_MAX, | |
| 16 | ROUND_SECONDS, | |
| 17 | RULE, | |
| 18 | } from "./concept.ts"; | |
| 13 | 19 | import { commit, description, env, ssh, title, where } from "./env.ts"; |
| 14 | 20 | import { asset, escape } from "./http.ts"; |
| 15 | 21 | import type { Who } from "./who.ts"; |
| 16 | 22 | |
| 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 | ||
| 24 | 23 | export function layout(body: string, who: Who): string { |
| 25 | 24 | const door = |
| 26 | 25 | who.role === "anon" |
| @@ -100,7 +99,7 @@ const clock = (): string => | ||
| 100 | 99 | `<section id="clock" class="panel clock" aria-labelledby="clock-title" data-live="round.started round.ended"> |
| 101 | 100 | <h2 id="clock-title" class="panel-title">Round</h2> |
| 102 | 101 | <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> | |
| 104 | 103 | </section>`; |
| 105 | 104 | |
| 106 | 105 | const roster = (who: Who): string => |
src/server.test.ts +1 −1
| @@ -2,8 +2,8 @@ import { afterAll, expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { RULE } from "./concept.ts"; | |
| 5 | 6 | import { emit } from "./live.ts"; |
| 6 | import { RULE } from "./page.ts"; | |
| 7 | 7 | import { serve } from "./server.ts"; |
| 8 | 8 | import { sign } from "./who.ts"; |
| 9 | 9 |