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

What the sea looks like, decided once: three doors are about to draw the same world three ways #61

Merged opened this

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

Merged

vesper/drift:vesper/54-what-the-sea-looks-like-decided-once-thr → main 1 commit fork

  • Merged. vesper/54-what-the-sea-looks-like-decided-once-thr was merged into main as c047586.

Review

Sign in to review this change.
5 files changed +291 −12
docs/wake.md +107 −4
@@ -113,6 +113,107 @@ ownership of anything. `src/who.ts` stays the only word on who a person is.
113113 of honest drifting, short enough that a walked-away mote is not still holding a seat when the
114114 round it joined ends.
115115
116 ## The look
117
118 Three doors draw this one world — the canvas at display rate (#10), a server-rendered SVG still
119 for the first paint and for JavaScript off (#50, #55), and characters in the terminal (#13) — and a
120 player who moves between them has to be looking at the same sea. Decided here is only what they would
121 otherwise disagree about: the mark each element makes and what it tells you at a glance. How many
122 strokes, how a particle drifts, what is eased and what is decorated on top is each door's, and so is
123 anything a viewport forces. These are decisions and not measurements: a pull that shows a picture may
124 revise any of them, the same way the physics numbers are the first guess at holding an invariant.
125
126 One rule binds every door before the elements do: **the order marks are laid down** — water, the
127 field, the gate, the rim, the bloom, motes, names. A current is never drawn over a body and a name is
128 never drawn under one, because a lane you cannot see through is a lane you cannot read.
129
130 **The field.** Direction and strength ride the same mark, because the field is the thing that makes
131 Wake legible and a player reads it in one look or not at all. A graphic door draws one straight stroke
132 per cell, centred on it, pointing where the current points, its length proportional to magnitude and
133 capped at the cell's own width so strokes never merge into a wash; a text door draws one of eight
134 arrows (`←↖↑↗→↘↓↙`), the direction rounded to the nearest, and carries strength as weight (dim, plain,
135 bold). Strength is always a fraction of the full scale the *wire* carries — `FIELD_MAX` in
136 `src/protocol.ts`, where a field component saturates — and never of the sea's internal cap, so a door
137 reads exactly what it was sent. A cell near that full scale is a stroke spanning its cell at full
138 opacity, or a bold arrow. A cell at a tenth of it is a tenth as long at a quarter of the opacity: at
139 360px, where the 16-cell field gives each cell about 22px, that is a 2px tick, which honestly says
140 "something moves here" and does not claim a direction. Below a twentieth of full scale a cell draws
141 nothing, because the flat sea has to look flat — invariant 5 sends the field to zero and the picture
142 has to arrive there too. Every strength is one colour (`--sea-field`); length and weight carry the
143 rest, so a lane still reads with no colour at all.
144
145 **A mote.** Every swimmer is the same mark — a filled disc of one size, an uppercase letter in text —
146 and yours is that mark with a ring around it, `@` in text. The ring is on the screen exactly once,
147 which is what makes it findable in a crowd of twenty-four; colour (`--sea-you` against `--sea-mote`)
148 is the second channel and never the only one, so telling your mote from everyone else's survives
149 colour blindness and a monochrome terminal. A name is escaped text truncated at `NAME_MAX`
150 (`src/world.ts`), drawn beside its disc in `--sea-name` and never over the rim: a name that would
151 cross the rim flips to the inward side of its mote. Your own name is not drawn, because the ring has
152 said it already. When room runs out a door drops names before it drops motes — at 360px twenty-four
153 names do not fit and twenty-four motes must.
154
155 **The bloom.** The largest mark in the sea and the only one that is not a swimmer: a filled disc under
156 a heavy ring, in `--sea-bloom`, at least three mote diameters across and never under 22px, so at 360px
157 it is unmistakable without a label. It carries no name. Nothing else in the sea is allowed to be that
158 big, which is the whole of its reading: the big thing is the shared goal.
159
160 **The gate.** A 60° arc on the rim (`src/world.ts`), and a *gap* in the rim before it is a colour: the
161 rim's dashes stop where the gate starts and a solid arc at least 4px thick takes over, in
162 `--sea-gate`; in text the rim's `·` becomes a run of `=`. At 360px that arc is a sixth of the way
163 around the rim, about 190px, so it reads at a glance and never needs a label. When the bloom's angle
164 falls inside the gate's the arc doubles in thickness — the one "you are lined up" signal every door
165 owes a player, and one that costs no animation.
166
167 **The rim, and seeing it cost you.** The rim is one line at full contrast in `--sea-rim`, dashed, and
168 the water's texture stops at it. Three readings have to be tellable apart in a glance: **swimming** is
169 a round filled disc in open water; **beached** is a disc flattened on its rim side, its outer edge
170 squared off, which is geometry a door reads from the frame rather than a flag (in text, the letter sits
171 in the rim's own row); **scattered** is `MOTE_STUNNED` in the frame — the disc goes hollow, outline
172 only, in `--sea-stunned`, and in text the letter drops to lowercase, your `@` becoming `o`. Hollow
173 means "no thruster", and it stays hollow for the three seconds the rim charges (#42, invariant 6), so
174 a player sees the cost while they are paying it. The two compose: a mote that arrived fast is usually
175 both, and a hollow disc flattened against the rim is the honest picture of paying. What no door
176 promises is a countdown — the wire carries a flag and not the ticks left, so the seconds read as
177 "still hollow" until the wire carries it (#16). The words for the three states are the record's, in
178 `src/concept.ts` (`MOTE_STATES`), so the canvas's text summary, the SVG's label and the terminal's key
179 all say *swimming*, *scattered* and *beached*.
180
181 ### The palette
182
183 Nine names, defined once as CSS custom properties in `static/app.css` for both themes, so the canvas
184 and the SVG take the same colour by name and neither of them spells a hex. The terminal cannot read
185 CSS, so each name maps here to one ANSI colour; a terminal with no colour loses nothing but colour,
186 because every element above is a distinct mark first.
187
188 | property | what it marks | ANSI |
189 | --------------- | ---------------------------------------------------- | ---------------------- |
190 | `--sea-water` | the sea inside the rim, the ground marks are read on | 49, default background |
191 | `--sea-rim` | the edge, and the dashes that draw it | 90, bright black |
192 | `--sea-field` | every current stroke, at every strength | 34, blue |
193 | `--sea-mote` | another swimmer's mote | 37, white |
194 | `--sea-you` | your own mote and its ring | 96, bright cyan |
195 | `--sea-name` | a name beside a mote, any word drawn in the sea | 39, default foreground |
196 | `--sea-bloom` | the bloom | 33, yellow |
197 | `--sea-gate` | the gate's arc | 32, green |
198 | `--sea-stunned` | a mote that is paying the rim | 31, red |
199
200 ### Contrast, and what reduced motion turns off
201
202 Two accessibility rules, written here rather than left to each door.
203
204 **Contrast.** Every mark keeps at least 3:1 against `--sea-water` in its own theme, and anything a
205 player reads as words — a name, a label inside the sea — keeps 4.5:1 and is never drawn under 12px.
206 `src/concept.test.ts` measures both out of `static/app.css`, so a prettier colour nobody can see fails
207 the check rather than the review. And colour carries no meaning alone anywhere in the sea: the mark
208 carries it, colour agrees with it.
209
210 **Motion.** `prefers-reduced-motion: reduce` turns off the field's idle animation — drifting particles,
211 shimmer, any motion that continues while the world stands still — and nothing else. It never turns off
212 the world's own motion: motes, the bloom and the currents keep moving, and a door that interpolates
213 between the 10 Hz frames keeps interpolating, because that motion *is* the game and a still sea cannot
214 be played. Nothing in the sea flashes faster than 3 Hz under either preference — a scattered mote is
215 hollow, never blinking.
216
116217 ## Why this one, and not the other two
117218
118219 Three concepts were opened against the direction. Wake was chosen.
@@ -203,8 +304,10 @@ no key is none — and the upgrade's to hold in `src/socket.test.ts` (#9).
203304
204305 ## What this record does not decide
205306
206 The look of the sea, the wire format, the scoring, the words on the page and the shape of the
207 socket are all the build issues'. This file decides the mechanic, the seven invariants and the
208 numbers in the table it owns. It says what a seat is; it does not write `join` (#8) or the upgrade
209 (#9), and how a spectator's page differs from a swimmer's is #10's and #11's. It stays open while the map is built against it and closes when the
307 The wire format, the scoring, the words on the page and the shape of the socket are all the build
308 issues'. This file decides the mechanic, the seven invariants, what a seat is, what the rim costs, how
309 the sea reads (**The look**) and the numbers in the table it owns. It says what a seat is; it does not
310 write `join` (#8) or the upgrade (#9), and how a spectator's page differs from a swimmer's is #10's and
311 #11's. **The look** says what every door's marks mean; it draws nothing, budgets no frame and names no
312 pixel a door must spend. It stays open while the map is built against it and closes when the
210313 vertical slice is live (#10, #11) and these numbers have been tuned against real play.
src/concept.test.ts +129 −4
@@ -7,6 +7,8 @@ const record = await Bun.file(new URL("../docs/wake.md", import.meta.url))
77 .text()
88 .then((text) => text.replace(/\r\n/g, "\n"));
99
10 const css = await Bun.file(new URL("../static/app.css", import.meta.url)).text();
11
1012 /** Markdown down to its words: no quote markers, no emphasis, one space. */
1113 const tidy = (text: string): string =>
1214 text
@@ -24,9 +26,9 @@ const section = (heading: string): string => {
2426 return end === -1 ? rest : rest.slice(0, end);
2527 };
2628
27 /** Every row of the numbers table, as its three cells. */
28 const rows = (): string[][] =>
29 section("## The numbers")
29 /** Every row of a table in `heading`, as its three cells, header and rule dropped. */
30 const table = (heading: string): string[][] =>
31 section(heading)
3032 .split("\n")
3133 .filter((line) => line.startsWith("|"))
3234 .map((line) =>
@@ -36,7 +38,44 @@ const rows = (): string[][] =>
3638 .map((cell) => cell.trim()),
3739 )
3840 .filter((cells) => cells.length === 3 && !/^-+$/.test(cells[0] ?? ""))
39 .slice(1); // the header row
41 .slice(1);
42
43 /** The numbers table, which every other test here reads by that name. */
44 const rows = (): string[][] => table("## The numbers");
45
46 /**
47 * Every block of `static/app.css` that defines the sea's palette, as name to
48 * hex: the light one in `:root` and the two that say dark. Nested at-rules
49 * fall out as their inner selector, which is the block that carries colours.
50 */
51 const palettes = (): { where: string; colours: Record<string, string> }[] =>
52 [...css.matchAll(/([^{}]*)\{([^{}]*)\}/g)]
53 .map((block) => ({
54 where: (block[1] ?? "").trim().split("\n").pop()?.trim() ?? "",
55 colours: Object.fromEntries(
56 [...(block[2] ?? "").matchAll(/(--sea-[a-z-]+)\s*:\s*(#[0-9a-f]{6})\s*;/g)].map(
57 (line) => [line[1] as string, line[2] as string],
58 ),
59 ),
60 }))
61 .filter((block) => Object.keys(block.colours).length > 0);
62
63 /** Relative luminance of a `#rrggbb`, the way WCAG defines it. */
64 const luminance = (hex: string): number => {
65 const channels = [1, 3, 5].map((at) => {
66 const value = parseInt(hex.slice(at, at + 2), 16) / 255;
67 return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
68 }) as [number, number, number];
69 return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
70 };
71
72 const contrast = (a: string, b: string): number => {
73 const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [
74 number,
75 number,
76 ];
77 return (light + 0.05) / (dark + 0.05);
78 };
4079
4180 test("every number the record hands this module is the number the module exports", () => {
4281 const exported = concept as unknown as Record<string, unknown>;
@@ -138,6 +177,92 @@ test("invariant 7 is the seat rule, and it is last", () => {
138177 expect(seven).toContain("never exceeds the number of distinct player keys");
139178 });
140179
180 test("the record decides how the sea reads before three doors draw it", () => {
181 const look = tidy(section("## The look"));
182 // One paragraph per element, and the order marks are laid down, so a
183 // current is never drawn over a body and a name never under one.
184 for (const element of ["The field.", "A mote.", "The bloom.", "The gate.", "The rim"])
185 expect(look, `"The look" says nothing about ${element}`).toContain(element);
186 expect(look).toContain("water, the field, the gate, the rim, the bloom, motes, names");
187 // The field: direction and strength in one mark, read against the scale the
188 // wire actually carries, and a tenth of full scale is not a direction.
189 expect(look).toMatch(/FIELD_MAX in src\/protocol\.ts/);
190 expect(look).toContain("a tenth");
191 expect(look).toContain("360px");
192 // Your mote against everyone else's is a mark before it is a colour.
193 expect(look).toContain("colour blindness");
194 expect(look).toContain("monochrome terminal");
195 expect(look).toContain("NAME_MAX");
196 expect(look).toContain("never over the rim");
197 // The rim's three seconds are visible, and the three readings have words.
198 expect(look).toContain("MOTE_STUNNED");
199 expect(look).toContain("three seconds");
200 for (const word of Object.values(concept.MOTE_STATES))
201 expect(look, `"The look" never says ${word}`).toContain(word);
202 });
203
204 test("the palette is named once, in both themes, and mapped for the terminal", () => {
205 const listed = table("### The palette").map((cells) => ({
206 name: (cells[0] ?? "").replace(/`/g, ""),
207 ansi: Number((cells[2] ?? "").split(",")[0]),
208 }));
209 expect(listed.length).toBe(9);
210 for (const { name, ansi } of listed) {
211 expect(name, `"${name}" is not a custom property`).toStartWith("--sea-");
212 // One ANSI colour each: the terminal cannot read CSS, and a name with no
213 // colour there is a door left to guess.
214 expect(ansi, `${name} names no ANSI colour`).toBeInteger();
215 expect(ansi).toBeGreaterThan(0);
216 }
217
218 const themes = palettes();
219 // Light, the system's dark, and the dark a person chose: three blocks, and
220 // every name defined in all of them or the canvas and the SVG drift.
221 expect(themes.length).toBe(3);
222 const named = listed.map(({ name }) => name).sort();
223 for (const theme of themes) {
224 expect(Object.keys(theme.colours).sort(), `${theme.where}`).toEqual(named);
225 }
226 // The two dark blocks are one palette written twice; they have to agree.
227 const [, system, chosen] = themes as [unknown, (typeof themes)[0], (typeof themes)[0]];
228 expect(chosen.colours).toEqual(system.colours);
229 // And dark is not light: a theme that forgot to change is a theme nobody
230 // can read.
231 expect(system.colours).not.toEqual((themes[0] as (typeof themes)[0]).colours);
232 });
233
234 test("every mark keeps its contrast against the water, in both themes", () => {
235 for (const theme of palettes()) {
236 const water = theme.colours["--sea-water"] as string;
237 for (const [name, colour] of Object.entries(theme.colours)) {
238 if (name === "--sea-water") continue;
239 const ratio = contrast(colour, water);
240 // Words keep 4.5:1, every other mark 3:1 — the record's two numbers,
241 // measured here so a prettier colour nobody can see fails the check.
242 const floor = name === "--sea-name" ? 4.5 : 3;
243 expect(
244 ratio,
245 `${name} ${colour} is ${ratio.toFixed(2)}:1 on ${water} in ${theme.where}`,
246 ).toBeGreaterThanOrEqual(floor);
247 }
248 }
249 });
250
251 test("the accessibility rules are the record's, not each door's", () => {
252 const rules = tidy(section("### Contrast, and what reduced motion turns off"));
253 expect(rules).toContain("3:1");
254 expect(rules).toContain("4.5:1");
255 expect(rules).toContain("--sea-water");
256 // Reduced motion takes the field's idle animation and leaves the world's
257 // own motion, which is the game.
258 expect(rules).toContain("prefers-reduced-motion");
259 expect(rules).toContain("idle animation");
260 expect(rules).toContain("never turns off");
261 expect(rules).toMatch(/3 Hz/);
262 // The page's own reduced-motion rule is already there, and stays.
263 expect(css).toContain("@media (prefers-reduced-motion: reduce)");
264 });
265
141266 test("the page says the round's shape in the record's numbers", () => {
142267 // The sentence is written in minutes, so the constant has to be whole ones.
143268 expect(concept.ROUND_SECONDS % 60).toBe(0);
src/concept.ts +14
@@ -53,6 +53,20 @@ export const ROUND_SECONDS = 180;
5353 /** The quiet between rounds — long enough to read the ladder, short enough to wait through. */
5454 export const INTERMISSION_SECONDS = 20;
5555
56 /**
57 * The three readings a mote has, in the words a player meets — "The look" in
58 * `docs/wake.md` decides the marks, and these are the words that go with them,
59 * so the canvas's text summary, the SVG's label and the terminal's key say one
60 * thing each. `stunned` is the frame's flag (`MOTE_STUNNED` in
61 * `src/protocol.ts`); `beached` is not on the wire at all, and a door reads it
62 * from where the mote is.
63 */
64 export const MOTE_STATES = {
65 swimming: "swimming",
66 stunned: "scattered",
67 beached: "beached",
68 } as const;
69
5670 /**
5771 * The things every build issue is measured against, in the order
5872 * `docs/wake.md` numbers them: `INVARIANTS[0]` is invariant 1. A test that
src/socket.test.ts +9 −4
@@ -33,8 +33,13 @@ const server = serve({
3333 dataDir: mkdtempSync(join(tmpdir(), "socket-")),
3434 identityKey: KEY,
3535 });
36 const base = `http://${server.hostname}:${server.port}`;
37 const wsUrl = `ws://${server.hostname}:${server.port}/ws`;
36 // Bun types `port` as optional, because a server may be listening on a unix
37 // socket instead. This one asked for a port, so it has one — said once here so
38 // the handshake below can hand it to `Bun.connect`, which wants a number.
39 if (server.port === undefined) throw new Error("the test server took no port");
40 const port: number = server.port;
41 const base = `http://${server.hostname}:${port}`;
42 const wsUrl = `ws://${server.hostname}:${port}/ws`;
3843 // Stopping is not awaited: a websocket the runtime is still tearing down can
3944 // keep `stop(true)` pending, and the test process has nothing left to wait for.
4045 afterAll(() => {
@@ -449,13 +454,13 @@ async function shake(headers: Record<string, string>): Promise<string> {
449454 const lines = Object.entries(headers).map(([k, v]) => `${k}: ${v}`);
450455 const socket = await Bun.connect({
451456 hostname: "127.0.0.1",
452 port: server.port,
457 port,
453458 socket: {
454459 open(sock) {
455460 sock.write(
456461 [
457462 "GET /ws HTTP/1.1",
458 `Host: 127.0.0.1:${server.port}`,
463 `Host: 127.0.0.1:${port}`,
459464 "Upgrade: websocket",
460465 "Connection: Upgrade",
461466 "Sec-WebSocket-Version: 13",
static/app.css +32
@@ -22,6 +22,20 @@
2222 --text-lg: 1.125rem;
2323 --text-xl: 1.5rem;
2424 --tap: 44px;
25
26 /* The sea's palette — "The look" in docs/wake.md. Named once so the canvas
27 and the server-rendered SVG take the same colour by name; the terminal
28 has one ANSI colour per name in the record. Every mark keeps 3:1 against
29 --sea-water and --sea-name keeps 4.5:1, measured in src/concept.test.ts. */
30 --sea-water: #edf1f6;
31 --sea-rim: #5a656f;
32 --sea-field: #46709c;
33 --sea-mote: #23262a;
34 --sea-you: #b02f7a;
35 --sea-name: #23262a;
36 --sea-bloom: #9a5b00;
37 --sea-gate: #0f5d3a;
38 --sea-stunned: #b3261e;
2539 }
2640 @media (prefers-color-scheme: dark) {
2741 :root:not([data-theme="light"]) {
@@ -35,6 +49,15 @@
3549 --danger: #fb923c;
3650 --toast-bg: #ecece7;
3751 --toast-fg: #131312;
52 --sea-water: #0d1317;
53 --sea-rim: #8b98a4;
54 --sea-field: #7fa8d4;
55 --sea-mote: #e6eaee;
56 --sea-you: #ff9ad0;
57 --sea-name: #e6eaee;
58 --sea-bloom: #ffc884;
59 --sea-gate: #3fbf8c;
60 --sea-stunned: #ff8a75;
3861 }
3962 }
4063 :root[data-theme="dark"] {
@@ -48,6 +71,15 @@
4871 --danger: #fb923c;
4972 --toast-bg: #ecece7;
5073 --toast-fg: #131312;
74 --sea-water: #0d1317;
75 --sea-rim: #8b98a4;
76 --sea-field: #7fa8d4;
77 --sea-mote: #e6eaee;
78 --sea-you: #ff9ad0;
79 --sea-name: #e6eaee;
80 --sea-bloom: #ffc884;
81 --sea-gate: #3fbf8c;
82 --sea-stunned: #ff8a75;
5183 }
5284
5385 /* The page. */

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs