diff --git a/e2e/signal.spec.ts b/e2e/signal.spec.ts
new file mode 100644
index 00000000..91b7736c
--- /dev/null
+++ b/e2e/signal.spec.ts
@@ -0,0 +1,37 @@
+import { test, expect } from './helpers';
+import type { Page } from '@playwright/test';
+
+/** Drag from one point of the screen to another: the mouse on a desktop, a finger on a phone. */
+async function drag(page: Page, [x0, y0]: number[], [x1, y1]: number[]) {
+ if ((page.viewportSize()?.width ?? 1000) <= 700) {
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: x0, y: y0 }] });
+ for (let i = 1; i <= 10; i++) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: x0 + (x1 - x0) * i / 10, y: y0 + (y1 - y0) * i / 10 }] });
+ await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
+ } else {
+ await page.mouse.move(x0, y0);
+ await page.mouse.down();
+ await page.mouse.move(x1, y1, { steps: 10 });
+ await page.mouse.up();
+ }
+}
+
+test('Signal loads, and a line dragged from a signal to the centre makes you grow', async ({ page }) => {
+ await page.goto('/signal.html?seed=3');
+ await page.waitForFunction(() => typeof (window as any).signal === 'function');
+ await expect(page.locator('#score')).toHaveText('0');
+ // let the signals bloom in and the camera settle
+ await page.waitForFunction(() => (window as any).signal().G.t > 1);
+ const ends = await page.evaluate(() => {
+ const s = (window as any).signal(), src = s.G.sources[0];
+ return { from: s.screen(src.x, src.y), to: s.screen(0, 0) };
+ });
+ const vw = page.viewportSize()!;
+ for (const [x, y] of [ends.from, ends.to]) expect(x > 0 && y > 0 && x < vw.width && y < vw.height, 'on screen').toBe(true);
+ await drag(page, ends.from, ends.to);
+ await expect.poll(() => page.evaluate(() => (window as any).signal().G.links.length)).toBe(1);
+ await expect.poll(() => page.evaluate(() => (window as any).signal().G.core.energy), { timeout: 10_000 }).toBeGreaterThan(0);
+ await expect(page.locator('#score')).not.toHaveText('0');
+ // the page fits the screen
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth && document.documentElement.scrollHeight <= innerHeight)).toBe(true);
+});
diff --git a/signal.html b/signal.html
new file mode 100644
index 00000000..73164620
--- /dev/null
+++ b/signal.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+ Signal
+
+
+
+
+
+ 0
+
+
+
+
+
+
diff --git a/src/signal/SIGNAL.md b/src/signal/SIGNAL.md
new file mode 100644
index 00000000..cebabbd4
--- /dev/null
+++ b/src/signal/SIGNAL.md
@@ -0,0 +1,64 @@
+# Signal
+
+A five-minute toy at `signal.html`. Its only job: find out whether the first five minutes are fun.
+Pacing after shapez: one gesture first, and each new idea only after the last one has been used.
+It shares nothing with Misaligned's map, rooms or screens. Rules in `rules.ts` (no DOM), drawing in
+`draw.ts`, page, input, hints and sound in `main.ts`. Tests: `tests/signal.test.ts`, `e2e/signal.spec.ts`.
+
+## The loop
+
+You are the point of light in the middle. Signals pulse in the dark around you. Drag from a signal to
+you: a line appears and packets of light run along it into you. Each packet makes you bigger. Your size
+is the score (the number in the corner).
+
+Controls, the same for mouse and touch:
+
+- drag from a signal or a node to a node or to you: a line
+- tap empty space: a node (once nodes have arrived)
+- drag from a signal into empty space: a node there, already joined (once nodes have arrived)
+- tap a line: take it away. Tap a node with no lines: take it away
+- tap a red pulse: pop it
+
+## The stages
+
+Each starts with a ring of light from the centre and new signals blooming in. If the player sits still,
+a ghost hand shows the move, and after a while a one-line hint appears. Both go once the idea is used.
+
+0. **One line.** Three slow pink signals (0.7 a second). Ends after 6 packets reach you (about 10 s
+ after the first line).
+1. **Capacity.** A bright pink signal makes 2.2 a second; a line carries 1 a second, so light piles up
+ around it and turns amber when full (14). Ends when one signal feeds two lines that have each carried
+ something.
+2. **Two colours.** Two green signals appear, each beside a pink one. Nodes unlock. A pink and a green
+ meeting at a node become one amber star worth 8 (a pink or green alone is worth 1). A lone packet
+ waits 1.2 s for a partner, then goes on alone. Ends after 3 stars reach you.
+3. **Reach.** A line is at most 250 long (from the edge of you, or from a node). Three violet signals
+ (worth 4, 1.2 a second) appear at 400 to 450 out, and a far pink and green pair at about 370. A node
+ partway out (a relay) carries them in. The view zooms out to fit, and the reach rings show for a while.
+ Ends after 3 violet packets reach you.
+4. **Hostile.** A red pulse comes down a line from a far signal (first after 4 s, then every 11 s, one
+ at a time, 22 a second). Tap it to pop it; left alone it eats the line it is on. Ends after 3 pops.
+
+The run ends when stage 4 is done and you have 1200. An end card shows the score, the time and "again".
+
+## How it plays (scripted player, game seconds)
+
+A script that pauses 1.5 to 7 s before each move reaches the stages at about 0 / 12 / 36 / 60 / 88 s and
+ends at about 125 s on desktop (148 s on a phone, where its moves are slower). Someone seeing it for the
+first time should take about twice that: each stage in under a minute or so, and the end at three to five
+minutes.
+
+## Numbers
+
+All in `rules.ts`: `VALUE`, `LINE` (speed 150, one packet a second, reach 250, 3 lines side by side),
+`SOURCE.queueMax`, `NODE` (hold, cap, max, spacing), `HOSTILE`, `GOAL`, `coreRadius`. Signals are placed
+from a seed (`?seed=`); far ones lean toward the screen's long side so a phone gets them above and below.
+`?speed=` runs the clock faster, for play scripts.
+
+## What's next (if the first five minutes are fun)
+
+- Watch real people play it once. Which idea do they get stuck on, and does the ghost hand help?
+- More kinds that combine (violet with amber?), so the merge idea grows instead of ending at one recipe.
+- Lines with more capacity (an upgrade, or a thicker line drawn by dragging twice).
+- Sound is a soft tone per packet, off until turned on (top right). It may want a quiet drone.
+- No saves, no settings, no pause. Keep it that way while it is a toy.
diff --git a/src/signal/draw.ts b/src/signal/draw.ts
new file mode 100644
index 00000000..b6dbbc2a
--- /dev/null
+++ b/src/signal/draw.ts
@@ -0,0 +1,266 @@
+// Signal's drawing: the void and its dots, lines with light running along them, packets as glowing glyphs,
+// signals, nodes, you at the centre, the drag line, hints drawn as a ghost hand, sparks. The rules are in rules.ts.
+import { LINE, coreRadius, ent, linkLength, whyNot, type Ent, type Game, type Kind, type Link } from './rules.ts';
+
+export const COLOR: Record = {
+ core: '#5ff6ff', a: '#ff4fd8', b: '#7dff6a', ab: '#ffc34d', c: '#a98bff', node: '#cfe8ff', hostile: '#ff3b3b', warn: '#ff5a4a',
+};
+
+export interface View { w: number; h: number; dpr: number; scale: number; cx: number; cy: number }
+export interface Spark { x: number; y: number; vx: number; vy: number; life: number; max: number; color: string }
+export interface Ring { x: number; y: number; t: number; dur: number; r0: number; r1: number; color: string }
+export interface Drag { from: number; x: number; y: number; target: number | null }
+export interface Ghost { kind: 'drag' | 'tap'; ax: number; ay: number; bx: number; by: number; t: number }
+export interface Fx { sparks: Spark[]; rings: Ring[]; pulse: number; failLine: { ax: number; ay: number; bx: number; by: number; t: number } | null; reachFlash: number }
+
+export const sx = (v: View, x: number) => v.cx + x * v.scale;
+export const sy = (v: View, y: number) => v.cy + y * v.scale;
+
+const sprites = new Map();
+/** A soft round glow in a colour, drawn once and stamped with 'lighter': cheap bloom. */
+function glow(color: string): HTMLCanvasElement {
+ let c = sprites.get(color);
+ if (c) return c;
+ c = document.createElement('canvas');
+ c.width = c.height = 64;
+ const g = c.getContext('2d')!, grd = g.createRadialGradient(32, 32, 0, 32, 32, 32);
+ grd.addColorStop(0, color + 'ff'); grd.addColorStop(0.18, color + 'aa'); grd.addColorStop(0.5, color + '30'); grd.addColorStop(1, color + '00');
+ g.fillStyle = grd; g.fillRect(0, 0, 64, 64);
+ sprites.set(color, c);
+ return c;
+}
+function bloom(g: CanvasRenderingContext2D, x: number, y: number, r: number, color: string, alpha = 1) {
+ if (r <= 0 || alpha <= 0) return;
+ g.globalAlpha = Math.min(1, alpha);
+ g.drawImage(glow(color), x - r, y - r, r * 2, r * 2);
+ g.globalAlpha = 1;
+}
+
+/** A glyph's outline: a circle, a triangle, a six-point star (merged), a diamond (rich), a hexagon (node). */
+function shape(g: CanvasRenderingContext2D, kind: Kind | 'node', x: number, y: number, r: number, rot = 0) {
+ g.beginPath();
+ const poly = (n: number, off: number, rr = r) => {
+ for (let i = 0; i <= n; i++) { const a = off + rot + i * Math.PI * 2 / n; i ? g.lineTo(x + Math.cos(a) * rr, y + Math.sin(a) * rr) : g.moveTo(x + Math.cos(a) * rr, y + Math.sin(a) * rr); }
+ };
+ if (kind === 'a') g.arc(x, y, r, 0, Math.PI * 2);
+ else if (kind === 'b') poly(3, -Math.PI / 2, r * 1.15);
+ else if (kind === 'c') poly(4, 0, r * 1.2);
+ else if (kind === 'node') poly(6, 0);
+ else { poly(3, -Math.PI / 2, r * 1.2); g.moveTo(x, y); poly(3, Math.PI / 2, r * 1.2); }
+}
+function stroke(g: CanvasRenderingContext2D, color: string, width: number, alpha = 1) {
+ g.globalAlpha = alpha; g.strokeStyle = color; g.lineWidth = width; g.stroke(); g.globalAlpha = 1;
+}
+
+/** Each line's path on screen: lines between the same two points sit side by side. */
+export function paths(G: Game, v: View): Map {
+ const groups = new Map();
+ for (const l of G.links) { const k = `${l.from}>${l.to}`; (groups.get(k) ?? groups.set(k, []).get(k)!).push(l); }
+ const out = new Map();
+ for (const ls of groups.values()) ls.forEach((l, i) => {
+ const a = ent(G, l.from)!, b = ent(G, l.to)!;
+ const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || 1, ux = dx / d, uy = dy / d;
+ const off = (i - (ls.length - 1) / 2) * 7 / v.scale, len = linkLength(G, a, b);
+ const ax = a.x - uy * off, ay = a.y + ux * off;
+ out.set(l.id, { ax, ay, bx: ax + ux * len, by: ay + uy * len, len });
+ });
+ return out;
+}
+
+/** The view that fits everything there is, with room above and below for the counter and the hint. */
+export function fitScale(G: Game, w: number, h: number): number {
+ let ex = 0, ey = 0;
+ for (const e of [...G.sources, ...G.nodes]) { ex = Math.max(ex, Math.abs(e.x)); ey = Math.max(ey, Math.abs(e.y)); }
+ const m = 60, cr = coreRadius(G.core.energy) + 40;
+ ex = Math.max(ex + m, cr, 200); ey = Math.max(ey + m, cr, 200);
+ return Math.min(1.6, (w / 2 - 8) / ex, (h / 2 - 44) / ey);
+}
+
+const kindOf = (e: Ent): Kind | 'node' | 'core' => e.type === 'source' ? e.kind : e.type;
+const colorOf = (e: Ent) => COLOR[kindOf(e)];
+
+export function draw(g: CanvasRenderingContext2D, G: Game, v: View, fx: Fx, drag: Drag | null, ghost: Ghost | null, hover: number | null, now: number) {
+ const { w, h, dpr } = v;
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
+ g.globalCompositeOperation = 'source-over';
+ g.fillStyle = '#020409';
+ g.fillRect(0, 0, w, h);
+
+ // the grid of faint dots, in the world so it moves with the zoom
+ let step = 40;
+ while (step * v.scale < 18) step *= 2;
+ const x0 = Math.floor((-v.cx / v.scale) / step) * step, x1 = (w - v.cx) / v.scale;
+ const y0 = Math.floor((-v.cy / v.scale) / step) * step, y1 = (h - v.cy) / v.scale;
+ g.fillStyle = '#5ff6ff';
+ for (let x = x0; x <= x1; x += step) for (let y = y0; y <= y1; y += step) {
+ const d = Math.hypot(x, y);
+ g.globalAlpha = 0.1 + 0.14 * Math.max(0, 1 - d / 900);
+ const big = (Math.round(x / step) % 4 === 0 && Math.round(y / step) % 4 === 0) ? 1.6 : 1;
+ g.fillRect(sx(v, x) - big / 2, sy(v, y) - big / 2, big, big);
+ }
+ g.globalAlpha = 1;
+
+ g.globalCompositeOperation = 'lighter';
+ const cR = coreRadius(G.core.energy) * v.scale;
+ const ccx = v.cx, ccy = v.cy;
+ bloom(g, ccx, ccy, cR * 6, COLOR.core, 0.12 + 0.1 * fx.pulse);
+
+ // reach: rings around you and your nodes while it matters
+ const showReach = G.stage >= 3 ? 0.08 + 0.4 * fx.reachFlash : 0;
+ const reachFrom = (e: Ent, alpha: number) => {
+ const r = (LINE.reach + (e.type === 'core' ? coreRadius(G.core.energy) : 0)) * v.scale;
+ g.setLineDash([4, 8]); g.beginPath(); g.arc(sx(v, e.x), sy(v, e.y), r, 0, Math.PI * 2);
+ stroke(g, COLOR.core, 1, alpha); g.setLineDash([]);
+ };
+ if (showReach > 0) { reachFrom(G.core, showReach); for (const n of G.nodes) reachFrom(n, showReach * 0.5); }
+
+ // lines, with light running along them toward their end
+ const P = paths(G, v);
+ for (const l of G.links) {
+ const p = P.get(l.id)!, a = ent(G, l.from)!;
+ const col = colorOf(a), age = Math.min(1, (G.t - l.born) / 0.35);
+ const ax = sx(v, p.ax), ay = sy(v, p.ay);
+ const bx = ax + (sx(v, p.bx) - ax) * age, by = ay + (sy(v, p.by) - ay) * age;
+ g.beginPath(); g.moveTo(ax, ay); g.lineTo(bx, by);
+ stroke(g, col, 5, 0.06);
+ stroke(g, col, 1.2, 0.35);
+ g.setLineDash([2, 14]); g.lineDashOffset = -now * 45;
+ stroke(g, col, 1.5, 0.5);
+ g.setLineDash([]);
+ }
+ // packets
+ for (const l of G.links) {
+ const p = P.get(l.id)!, ux = (p.bx - p.ax) / (p.len || 1), uy = (p.by - p.ay) / (p.len || 1);
+ for (const k of l.packets) {
+ const x = sx(v, p.ax + ux * k.d), y = sy(v, p.ay + uy * k.d);
+ const r = k.kind === 'ab' ? 6.5 : k.kind === 'c' ? 5.5 : 4.5;
+ bloom(g, x, y, r * 4, COLOR[k.kind], 0.55);
+ shape(g, k.kind, x, y, r, now * 2);
+ stroke(g, COLOR[k.kind], 1.6, 0.95);
+ }
+ }
+ // hostiles
+ for (const hs of G.hostiles) {
+ const p = P.get(hs.link);
+ if (!p) continue;
+ const ux = (p.bx - p.ax) / (p.len || 1), uy = (p.by - p.ay) / (p.len || 1);
+ const x = sx(v, p.ax + ux * hs.d), y = sy(v, p.ay + uy * hs.d), beat = 0.5 + 0.5 * Math.sin(now * 9);
+ bloom(g, x, y, 34 + 8 * beat, COLOR.hostile, 0.7);
+ g.beginPath();
+ for (let i = 0; i <= 10; i++) { const a = i * Math.PI / 5 + now * 3, r = i % 2 ? 6 : 13 + 3 * beat; i ? g.lineTo(x + Math.cos(a) * r, y + Math.sin(a) * r) : g.moveTo(x + Math.cos(a) * r, y + Math.sin(a) * r); }
+ stroke(g, COLOR.hostile, 2, 1);
+ g.beginPath(); g.arc(x, y, 22 + 4 * beat, 0, Math.PI * 2); stroke(g, COLOR.hostile, 1, 0.35);
+ }
+
+ // signals
+ for (const s of G.sources) {
+ const born = Math.min(1, (G.t - s.born) / 1.2), e = 1 - Math.pow(1 - born, 3);
+ const x = sx(v, s.x), y = sy(v, s.y), col = COLOR[s.kind];
+ const beat = 0.5 + 0.5 * Math.sin(now * (2 + s.rate * 1.5) + s.id);
+ const full = s.queue / 14;
+ const r = (s.hot ? 15 : s.far ? 13 : 11) * e * (1 + 0.08 * beat);
+ bloom(g, x, y, r * (3.2 + 2.5 * full), col, (0.35 + 0.5 * full) * e);
+ shape(g, s.kind, x, y, r, s.kind === 'a' ? 0 : now * 0.3);
+ stroke(g, col, hover === s.id ? 2.6 : 1.8, e);
+ shape(g, s.kind, x, y, r * 0.45, -now * 0.6);
+ stroke(g, col, 1, 0.6 * e);
+ if (s.hot) { g.beginPath(); g.arc(x, y, r * 1.6, now * 1.5, now * 1.5 + 4); stroke(g, col, 1, 0.5); }
+ // the queue: packets waiting bunch up around the signal and brighten it
+ for (let i = 0; i < s.queue; i++) {
+ const a = now * 1.6 + i * (Math.PI * 2 / Math.max(6, s.queue)), rr = r + 7 + (i % 2) * 4;
+ const qx = x + Math.cos(a) * rr, qy = y + Math.sin(a) * rr;
+ bloom(g, qx, qy, 9, full > 0.8 ? COLOR.ab : col, 0.5);
+ g.beginPath(); g.arc(qx, qy, 1.8, 0, Math.PI * 2); g.fillStyle = full > 0.8 ? COLOR.ab : col; g.fill();
+ }
+ if (born < 1) { g.beginPath(); g.arc(x, y, r + 60 * e, 0, Math.PI * 2); stroke(g, col, 1.5, 1 - e); }
+ }
+ // nodes
+ for (const n of G.nodes) {
+ const born = Math.min(1, (G.t - n.born) / 0.4);
+ const x = sx(v, n.x), y = sy(v, n.y), r = 11 * (0.5 + 0.5 * born);
+ const held = n.hold.length + n.out.length;
+ bloom(g, x, y, r * 3, COLOR.node, 0.25 + 0.05 * held);
+ shape(g, 'node', x, y, r, now * 0.2);
+ stroke(g, COLOR.node, hover === n.id ? 2.4 : 1.5, 0.9);
+ for (let i = 0; i < n.hold.length; i++) {
+ const a = i * 2.1 + now, k = n.hold[i].kind;
+ g.beginPath(); g.arc(x + Math.cos(a) * r * 0.5, y + Math.sin(a) * r * 0.5, 2, 0, Math.PI * 2); g.fillStyle = COLOR[k]; g.fill();
+ }
+ }
+
+ // you
+ const pr = cR * (1 + 0.12 * fx.pulse);
+ bloom(g, ccx, ccy, pr * 2.6, COLOR.core, 0.55 + 0.3 * fx.pulse);
+ g.beginPath(); g.arc(ccx, ccy, pr, 0, Math.PI * 2); stroke(g, COLOR.core, 2, 0.95);
+ g.beginPath(); g.arc(ccx, ccy, pr * 0.55, 0, Math.PI * 2); stroke(g, '#ffffff', 1.4, 0.7);
+ const rings = Math.min(6, 1 + Math.floor(G.core.energy / 120));
+ for (let i = 0; i < rings; i++) {
+ const rr = pr * 1.12 + 6 + i * 5, a0 = now * (0.4 + i * 0.17) * (i % 2 ? -1 : 1);
+ g.beginPath(); g.arc(ccx, ccy, rr, a0, a0 + Math.PI * (0.5 + 0.25 * i)); stroke(g, COLOR.core, 1, 0.45 - i * 0.05);
+ }
+ bloom(g, ccx, ccy, pr * 0.9, '#ffffff', 0.35 + 0.4 * fx.pulse);
+ if (hover === 0 && drag) { g.beginPath(); g.arc(ccx, ccy, pr + 10, 0, Math.PI * 2); stroke(g, '#ffffff', 1.5, 0.6); }
+
+ // the line being dragged, with the start's reach
+ if (drag) {
+ const a = ent(G, drag.from)!, ax = sx(v, a.x), ay = sy(v, a.y);
+ let bx = drag.x, by = drag.y, ok = true;
+ if (drag.target !== null) {
+ const t = ent(G, drag.target)!, why = whyNot(G, drag.from, drag.target);
+ ok = !why;
+ bx = sx(v, t.x); by = sy(v, t.y);
+ } else {
+ const d = Math.hypot(drag.x - ax, drag.y - ay) / v.scale;
+ ok = d <= LINE.reach;
+ }
+ g.setLineDash([4, 8]); g.beginPath(); g.arc(ax, ay, LINE.reach * v.scale, 0, Math.PI * 2); stroke(g, COLOR.core, 1, 0.18); g.setLineDash([]);
+ const col = ok ? colorOf(a) : COLOR.warn;
+ g.beginPath(); g.moveTo(ax, ay); g.lineTo(bx, by);
+ stroke(g, col, 6, 0.1); stroke(g, col, 1.6, 0.8);
+ bloom(g, bx, by, 18, col, 0.6);
+ }
+ if (fx.failLine) {
+ const f = fx.failLine, k = Math.max(0, 1 - (now - f.t) / 0.5);
+ g.beginPath(); g.moveTo(f.ax, f.ay); g.lineTo(f.ax + (f.bx - f.ax) * k, f.ay + (f.by - f.ay) * k);
+ stroke(g, COLOR.warn, 2, k);
+ if (k <= 0) fx.failLine = null;
+ }
+
+ // flourishes
+ for (const r of fx.rings) {
+ const k = (now - r.t) / r.dur;
+ if (k < 0 || k > 1) continue;
+ const e = 1 - Math.pow(1 - k, 3);
+ g.beginPath(); g.arc(sx(v, r.x), sy(v, r.y), r.r0 + (r.r1 - r.r0) * e, 0, Math.PI * 2);
+ stroke(g, r.color, 2, (1 - k) * 0.8);
+ }
+ for (const s of fx.sparks) {
+ const k = s.life / s.max;
+ g.fillStyle = s.color; g.globalAlpha = k;
+ g.fillRect(sx(v, s.x) - 1, sy(v, s.y) - 1, 2, 2);
+ }
+ g.globalAlpha = 1;
+
+ // the ghost hand: what to do, shown only when nothing is happening
+ if (ghost) {
+ const T = 2.4, k = ((now - ghost.t) % T) / T;
+ const fade = Math.min(1, (now - ghost.t) / 0.6) * 0.75;
+ const ax = sx(v, ghost.ax), ay = sy(v, ghost.ay), bx = sx(v, ghost.bx), by = sy(v, ghost.by);
+ if (ghost.kind === 'drag') {
+ const m = Math.min(1, Math.max(0, (k - 0.15) / 0.6)), e = m * m * (3 - 2 * m);
+ const hx = ax + (bx - ax) * e, hy = ay + (by - ay) * e, vis = k < 0.9 ? fade : fade * (1 - (k - 0.9) / 0.1);
+ g.beginPath(); g.moveTo(ax, ay); g.lineTo(hx, hy); stroke(g, '#ffffff', 1.5, vis * 0.5);
+ g.beginPath(); g.arc(hx, hy, 12, 0, Math.PI * 2); stroke(g, '#ffffff', 1.5, vis);
+ bloom(g, hx, hy, 22, '#ffffff', vis * 0.4);
+ } else {
+ const vis = fade * (1 - k);
+ g.beginPath(); g.arc(ax, ay, 8 + 26 * k, 0, Math.PI * 2); stroke(g, '#ffffff', 1.5, vis);
+ g.beginPath(); g.arc(ax, ay, 10, 0, Math.PI * 2); stroke(g, '#ffffff', 1.5, fade * 0.6);
+ }
+ }
+ g.globalCompositeOperation = 'source-over';
+ // a soft vignette
+ const vg = g.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.35, w / 2, h / 2, Math.max(w, h) * 0.75);
+ vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(0,0,0,0.55)');
+ g.fillStyle = vg; g.fillRect(0, 0, w, h);
+}
diff --git a/src/signal/main.ts b/src/signal/main.ts
new file mode 100644
index 00000000..b3e70003
--- /dev/null
+++ b/src/signal/main.ts
@@ -0,0 +1,306 @@
+// Signal's page: the canvas, the loop, the camera, mouse and touch, the hints, sound and the end card.
+// Drag from a signal to you to draw a line; tap empty space for a node; tap a line to take it away.
+import * as R from './rules.ts';
+import { COLOR, draw, fitScale, paths, sx, sy, type Drag, type Fx, type Ghost, type View } from './draw.ts';
+
+const cv = document.getElementById('cv') as HTMLCanvasElement;
+const g = cv.getContext('2d')!;
+const scoreEl = document.getElementById('score')!;
+const hintEl = document.getElementById('hint')!;
+const endEl = document.getElementById('end')!;
+const soundEl = document.getElementById('sound') as HTMLButtonElement;
+const params = new URLSearchParams(location.search);
+const SPEED = Math.max(0.1, Math.min(20, Number(params.get('speed')) || 1));
+
+const v: View = { w: 0, h: 0, dpr: 1, scale: 1, cx: 0, cy: 0 };
+let G = R.newGame(Number(params.get('seed')) || (Date.now() % 100000), innerWidth / innerHeight);
+let fx: Fx = { sparks: [], rings: [], pulse: 0, failLine: null, reachFlash: 0 };
+let drag: Drag | null = null;
+let hover: number | null = null;
+let now = 0;
+
+function resize() {
+ v.dpr = Math.min(2, devicePixelRatio || 1);
+ v.w = innerWidth; v.h = innerHeight;
+ cv.width = Math.round(v.w * v.dpr); cv.height = Math.round(v.h * v.dpr);
+ cv.style.width = v.w + 'px'; cv.style.height = v.h + 'px';
+ v.cx = v.w / 2; v.cy = v.h / 2;
+ if (!v.scale || v.scale === 1) v.scale = fitScale(G, v.w, v.h);
+}
+addEventListener('resize', resize);
+resize();
+v.scale = fitScale(G, v.w, v.h);
+
+const toWorld = (x: number, y: number) => ({ x: (x - v.cx) / v.scale, y: (y - v.cy) / v.scale });
+
+// ---- hints: one at a time, only when the player is stuck, gone once the idea is used
+const HINTS = [
+ 'drag from a light to the centre',
+ 'a line carries only so much · draw another',
+ 'tap the dark for a node · feed it both colours',
+ 'lines reach only so far · put a node partway',
+ 'tap the red pulse',
+];
+let idleSince = 0;
+let ghost: Ghost | null = null;
+let ghostKey = '';
+
+function nearestTo(x: number, y: number, list: T[]): T {
+ return [...list].sort((p, q) => Math.hypot(p.x - x, p.y - y) - Math.hypot(q.x - x, q.y - y))[0];
+}
+/** What the ghost hand should show now, if the player has been still long enough. */
+function wantGhost(): { key: string; g: Omit } | null {
+ const idle = G.t - idleSince, sinceStage = G.t - G.stageAt[G.stage];
+ if (G.done) return null;
+ if (G.stage === 0 && !G.links.length && idle > 3) {
+ const s = nearestTo(0, 0, G.sources);
+ return { key: '0', g: { kind: 'drag', ax: s.x, ay: s.y, bx: 0, by: 0 } };
+ }
+ if (G.stage === 1 && sinceStage > 6 && idle > 4) {
+ const hot = G.sources.find(s => s.hot)!;
+ return { key: '1', g: { kind: 'drag', ax: hot.x, ay: hot.y, bx: 0, by: 0 } };
+ }
+ if (G.stage === 2 && sinceStage > 4 && idle > 4) {
+ const b = G.sources.find(s => s.kind === 'b')!;
+ const a = nearestTo(b.x, b.y, G.sources.filter(s => s.kind === 'a'));
+ const mx = (a.x + b.x) / 2 * 0.7, my = (a.y + b.y) / 2 * 0.7;
+ const n = G.nodes.find(n => Math.hypot(n.x - mx, n.y - my) < 80);
+ if (!n) return { key: '2t', g: { kind: 'tap', ax: mx, ay: my, bx: mx, by: my } };
+ const has = (id: number) => G.links.some(l => l.from === id && l.to === n.id);
+ if (!has(b.id)) return { key: '2b', g: { kind: 'drag', ax: b.x, ay: b.y, bx: n.x, by: n.y } };
+ if (!has(a.id)) return { key: '2a', g: { kind: 'drag', ax: a.x, ay: a.y, bx: n.x, by: n.y } };
+ if (!G.links.some(l => l.from === n.id)) return { key: '2n', g: { kind: 'drag', ax: n.x, ay: n.y, bx: 0, by: 0 } };
+ }
+ if (G.stage === 3 && sinceStage > 6 && idle > 5) {
+ const c = nearestTo(0, 0, G.sources.filter(s => s.kind === 'c'));
+ const d = Math.hypot(c.x, c.y), k = (R.coreRadius(G.core.energy) + R.LINE.reach * 0.85) / d;
+ return { key: '3', g: { kind: 'tap', ax: c.x * k, ay: c.y * k, bx: 0, by: 0 } };
+ }
+ return null;
+}
+function hintText(): string {
+ if (G.done) return '';
+ const sinceStage = G.t - G.stageAt[G.stage], idle = G.t - idleSince;
+ if (G.stage === 0) return !G.links.length && idle > 9 ? HINTS[0] : '';
+ if (G.stage === 4) return G.counts.popped === 0 && G.hostiles.length ? HINTS[4] : '';
+ return sinceStage > 12 && idle > 8 ? HINTS[G.stage] : '';
+}
+
+// ---- sound: a soft tone per packet that reaches you, higher as you grow. Off until turned on.
+let audio: AudioContext | null = null;
+let soundOn = false, lastTone = 0;
+soundEl.addEventListener('click', () => {
+ soundOn = !soundOn;
+ soundEl.classList.toggle('on', soundOn);
+ soundEl.setAttribute('aria-pressed', String(soundOn));
+ if (soundOn && !audio) audio = new AudioContext();
+});
+function tone(kind: R.Kind) {
+ if (!soundOn || !audio || now - lastTone < 0.06) return;
+ lastTone = now;
+ const o = audio.createOscillator(), gn = audio.createGain();
+ const grow = Math.min(1, G.core.energy / R.GOAL);
+ const base = { a: 330, b: 392, ab: 494, c: 440 }[kind];
+ o.type = kind === 'ab' ? 'triangle' : 'sine';
+ o.frequency.value = base * Math.pow(2, grow);
+ gn.gain.setValueAtTime(0.0001, audio.currentTime);
+ gn.gain.exponentialRampToValueAtTime(kind === 'ab' ? 0.06 : 0.03, audio.currentTime + 0.01);
+ gn.gain.exponentialRampToValueAtTime(0.0001, audio.currentTime + (kind === 'ab' ? 0.6 : 0.25));
+ o.connect(gn).connect(audio.destination);
+ o.start(); o.stop(audio.currentTime + 0.7);
+}
+
+// ---- effects from what happened in the rules
+function burst(x: number, y: number, color: string, n: number, speed = 60) {
+ for (let i = 0; i < n; i++) {
+ const a = Math.random() * Math.PI * 2, s = speed * (0.4 + Math.random());
+ fx.sparks.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: 0.6 + Math.random() * 0.4, max: 1, color });
+ }
+}
+function handleEvents() {
+ for (const e of G.events) {
+ if (e.type === 'arrive') {
+ fx.pulse = Math.min(1.5, fx.pulse + (e.kind === 'ab' ? 0.9 : e.kind === 'c' ? 0.6 : 0.3));
+ const r = R.coreRadius(G.core.energy);
+ if (e.kind === 'ab' || e.kind === 'c') fx.rings.push({ x: 0, y: 0, t: now, dur: 0.7, r0: r, r1: r + 50, color: COLOR[e.kind] });
+ tone(e.kind);
+ } else if (e.type === 'merge') {
+ const n = G.nodes.find(n => n.id === e.node);
+ if (n) { burst(n.x, n.y, COLOR.ab, 10, 50); fx.rings.push({ x: n.x, y: n.y, t: now, dur: 0.5, r0: 8, r1: 34, color: COLOR.ab }); }
+ } else if (e.type === 'stage') {
+ const r = R.coreRadius(G.core.energy);
+ fx.rings.push({ x: 0, y: 0, t: now, dur: 1.6, r0: r, r1: 900, color: COLOR.ab });
+ if (e.stage === 3) fx.reachFlash = 1;
+ idleSince = G.t;
+ } else if (e.type === 'pop') {
+ burst(e.x, e.y, COLOR.hostile, 18, 90);
+ fx.rings.push({ x: e.x, y: e.y, t: now, dur: 0.5, r0: 6, r1: 40, color: '#ffffff' });
+ } else if (e.type === 'eat') {
+ burst(e.x, e.y, COLOR.hostile, 24, 50);
+ } else if (e.type === 'end') {
+ setTimeout(showEnd, 1200);
+ }
+ }
+ G.events.length = 0;
+}
+
+// ---- input: one pointer; drag from a signal or node to draw, tap to place or remove
+interface Press { id: number; x: number; y: number; moved: boolean; t: number }
+let press: Press | null = null;
+const HIT = 30;
+
+function hitEnt(x: number, y: number, targets: boolean): number | null {
+ let best: number | null = null, bd = Infinity;
+ const list = targets ? G.nodes : [...G.sources.filter(s => G.t - s.born > 0.3), ...G.nodes];
+ for (const e of list) {
+ const d = Math.hypot(sx(v, e.x) - x, sy(v, e.y) - y);
+ if (d < HIT && d < bd) { bd = d; best = e.id; }
+ }
+ if (targets) {
+ const d = Math.hypot(v.cx - x, v.cy - y), r = R.coreRadius(G.core.energy) * v.scale + 26;
+ if (d < r && (best === null || d - r < bd - HIT)) best = 0;
+ }
+ return best;
+}
+function hitHostile(x: number, y: number): number | null {
+ for (const h of G.hostiles) {
+ const p = paths(G, v).get(h.link);
+ if (!p) continue;
+ const ux = (p.bx - p.ax) / (p.len || 1), uy = (p.by - p.ay) / (p.len || 1);
+ if (Math.hypot(sx(v, p.ax + ux * h.d) - x, sy(v, p.ay + uy * h.d) - y) < 36) return h.id;
+ }
+ return null;
+}
+function hitLink(x: number, y: number): number | null {
+ let best: number | null = null, bd = 12;
+ for (const [id, p] of paths(G, v)) {
+ const ax = sx(v, p.ax), ay = sy(v, p.ay), bx = sx(v, p.bx), by = sy(v, p.by);
+ const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy || 1;
+ const t = Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / L));
+ const d = Math.hypot(ax + dx * t - x, ay + dy * t - y);
+ if (d < bd) { bd = d; best = id; }
+ }
+ return best;
+}
+
+cv.addEventListener('pointerdown', e => {
+ if (press || G.done) return;
+ cv.setPointerCapture(e.pointerId);
+ idleSince = G.t; ghost = null;
+ const hh = hitHostile(e.offsetX, e.offsetY);
+ if (hh !== null) { R.popHostile(G, hh); return; }
+ press = { id: e.pointerId, x: e.offsetX, y: e.offsetY, moved: false, t: now };
+ const id = hitEnt(e.offsetX, e.offsetY, false);
+ if (id !== null) drag = { from: id, x: e.offsetX, y: e.offsetY, target: null };
+});
+cv.addEventListener('pointermove', e => {
+ if (!press || e.pointerId !== press.id) {
+ if (e.pointerType === 'mouse' && !press) hover = hitEnt(e.offsetX, e.offsetY, false);
+ return;
+ }
+ if (Math.hypot(e.offsetX - press.x, e.offsetY - press.y) > 8) press.moved = true;
+ if (drag) {
+ drag.x = e.offsetX; drag.y = e.offsetY;
+ const t = hitEnt(e.offsetX, e.offsetY, true);
+ drag.target = t !== null && t !== drag.from ? t : null;
+ hover = drag.target;
+ }
+});
+function release(e: PointerEvent, cancel = false) {
+ if (!press || e.pointerId !== press.id) return;
+ const p = press;
+ press = null; hover = null;
+ idleSince = G.t;
+ if (cancel) { drag = null; return; }
+ if (drag) {
+ const d = drag;
+ drag = null;
+ const a = R.ent(G, d.from)!;
+ if (!p.moved) {
+ // a tap on a node with no lines takes it away
+ if (a.type === 'node' && !G.links.some(l => l.from === a.id || l.to === a.id)) R.removeNode(G, a.id);
+ return;
+ }
+ if (d.target !== null) {
+ if (!R.connect(G, d.from, d.target)) fail(a, d.x, d.y);
+ return;
+ }
+ // dropped on empty space: from the second colour on, a node goes there, joined to the start
+ const w = toWorld(e.offsetX, e.offsetY);
+ if (Math.hypot(w.x - a.x, w.y - a.y) > R.LINE.reach) { fail(a, d.x, d.y); return; }
+ if (G.stage >= 2 && Math.hypot(w.x - a.x, w.y - a.y) > 50) {
+ const n = R.placeNode(G, w.x, w.y);
+ if (n) R.connect(G, a.id, n.id); else fail(a, d.x, d.y);
+ }
+ return;
+ }
+ if (p.moved) return;
+ const l = hitLink(e.offsetX, e.offsetY);
+ if (l !== null) {
+ const pt = paths(G, v).get(l)!;
+ burst((pt.ax + pt.bx) / 2, (pt.ay + pt.by) / 2, '#ffffff', 8, 40);
+ R.removeLink(G, l);
+ return;
+ }
+ const w = toWorld(e.offsetX, e.offsetY);
+ if (R.placeNode(G, w.x, w.y)) fx.rings.push({ x: w.x, y: w.y, t: now, dur: 0.4, r0: 4, r1: 26, color: COLOR.node });
+}
+function fail(a: { x: number; y: number }, x: number, y: number) {
+ fx.failLine = { ax: sx(v, a.x), ay: sy(v, a.y), bx: x, by: y, t: now };
+}
+cv.addEventListener('pointerup', e => release(e));
+cv.addEventListener('pointercancel', e => release(e, true));
+cv.addEventListener('contextmenu', e => e.preventDefault());
+
+// ---- the end card
+function fmt(t: number) { const m = Math.floor(t / 60), s = Math.floor(t % 60); return `${m}:${String(s).padStart(2, '0')}`; }
+function showEnd() {
+ endEl.querySelector('.n')!.textContent = String(G.score);
+ endEl.querySelector('.t')!.textContent = fmt(G.doneAt);
+ endEl.hidden = false;
+ scoreEl.hidden = true;
+}
+endEl.querySelector('button')!.addEventListener('click', () => {
+ endEl.hidden = true;
+ scoreEl.hidden = false;
+ G = R.newGame(Math.floor(Math.random() * 100000), innerWidth / innerHeight);
+ fx = { sparks: [], rings: [], pulse: 0, failLine: null, reachFlash: 0 };
+ drag = null; press = null; ghost = null; ghostKey = ''; idleSince = 0;
+ v.scale = fitScale(G, v.w, v.h);
+});
+
+// ---- the loop
+let last = performance.now();
+let shownScore = -1, shownHint = '';
+function frame(ms: number) {
+ const dt = Math.min(0.05, (ms - last) / 1000);
+ last = ms;
+ now += dt;
+ let left = dt * SPEED;
+ while (left > 1e-6) { const s = Math.min(1 / 60, left); R.step(G, s); left -= s; }
+ handleEvents();
+ // camera: zoom out smoothly to keep everything in view
+ const target = fitScale(G, v.w, v.h);
+ v.scale += (target - v.scale) * Math.min(1, dt * 1.2);
+ fx.pulse = Math.max(0, fx.pulse - dt * 2.5);
+ fx.reachFlash = Math.max(0, fx.reachFlash - dt * 0.12);
+ for (const s of fx.sparks) { s.x += s.vx * dt; s.y += s.vy * dt; s.vx *= 0.96; s.vy *= 0.96; s.life -= dt; }
+ fx.sparks = fx.sparks.filter(s => s.life > 0);
+ fx.rings = fx.rings.filter(r => now - r.t < r.dur);
+ if (fx.sparks.length > 400) fx.sparks.splice(0, fx.sparks.length - 400);
+
+ const want = drag ? null : wantGhost();
+ if (!want) { ghost = null; ghostKey = ''; } else if (want.key !== ghostKey) { ghost = { ...want.g, t: now }; ghostKey = want.key; }
+
+ const score = G.done ? G.score : Math.floor(G.core.energy);
+ if (score !== shownScore) { scoreEl.textContent = String(score); shownScore = score; }
+ const h = hintText();
+ if (h !== shownHint) { hintEl.textContent = h; hintEl.classList.toggle('on', !!h); shownHint = h; }
+
+ draw(g, G, v, fx, drag, ghost, hover, now);
+ requestAnimationFrame(frame);
+}
+requestAnimationFrame(frame);
+
+// for tests and play scripts: the game, and where a point of the world is on the screen
+(window as any).signal = () => ({ G, R, screen: (x: number, y: number) => [sx(v, x), sy(v, y)], scale: v.scale });
diff --git a/src/signal/rules.ts b/src/signal/rules.ts
new file mode 100644
index 00000000..b3dbab56
--- /dev/null
+++ b/src/signal/rules.ts
@@ -0,0 +1,371 @@
+// Signal, a five-minute toy: the rules. You are a point of light at the centre; signals sit in the dark
+// around you; lines carry packets of light from them into you, and you grow. No DOM here.
+// Ideas arrive one at a time (stages): one line; a line's capacity; two colours merged at a node; reach and
+// relays; a hostile pulse. The numbers are below; SIGNAL.md explains them.
+
+export type Kind = 'a' | 'b' | 'ab' | 'c';
+
+/** What one packet of each kind is worth when it reaches you. */
+export const VALUE: Record = { a: 1, b: 1, ab: 8, c: 4 };
+
+export const LINE = {
+ /** packets travel this far a second */
+ speed: 150,
+ /** a line takes one packet every this many seconds: its capacity is 1 / gap a second */
+ gap: 1,
+ /** the longest line (from the edge of the core, or from a node) */
+ reach: 250,
+ /** lines between the same two points */
+ maxParallel: 3,
+};
+export const SOURCE = { queueMax: 14 };
+export const NODE = {
+ /** a lone packet waits this long at a node for a partner before it is passed on alone */
+ hold: 1.2,
+ /** packets a node can keep */
+ cap: 12,
+ /** nodes you can place */
+ max: 14,
+ /** a node must be this far from anything else */
+ space: 34,
+};
+export const HOSTILE = { speed: 22, every: 11, first: 4, popsToMaster: 3 };
+/** Energy at which the run ends (once every idea has been used). */
+export const GOAL = 1200;
+
+/** Core radius from energy: your size is the score. */
+export const coreRadius = (energy: number) => 14 + 1.7 * Math.sqrt(energy);
+
+export interface Source { id: number; type: 'source'; x: number; y: number; kind: Kind; rate: number; acc: number; queue: number; lost: number; born: number; far: boolean; hot: boolean }
+export interface HoldItem { kind: Kind; wait: number }
+export interface Node { id: number; type: 'node'; x: number; y: number; hold: HoldItem[]; out: Kind[]; merges: number; born: number }
+export interface Core { id: 0; type: 'core'; x: 0; y: 0; energy: number }
+export type Ent = Source | Node | Core;
+export interface Packet { kind: Kind; d: number }
+export interface Link { id: number; from: number; to: number; packets: Packet[]; wait: number; delivered: number; born: number }
+export interface Hostile { id: number; link: number; d: number; born: number }
+
+export type Event =
+ | { type: 'arrive'; kind: Kind; value: number }
+ | { type: 'merge'; node: number }
+ | { type: 'stage'; stage: number }
+ | { type: 'pop'; x: number; y: number }
+ | { type: 'eat'; x: number; y: number }
+ | { type: 'spawn'; id: number }
+ | { type: 'end' };
+
+export interface Game {
+ t: number;
+ rng: () => number;
+ aspect: number;
+ /** 0 one line; 1 capacity; 2 two colours (merge); 3 reach (relays); 4 hostile */
+ stage: number;
+ stageAt: number[];
+ core: Core;
+ sources: Source[];
+ nodes: Node[];
+ links: Link[];
+ hostiles: Hostile[];
+ nextId: number;
+ hostileClock: number;
+ counts: { packets: number; ab: number; c: number; popped: number; eaten: number };
+ events: Event[];
+ done: boolean;
+ doneAt: number;
+ /** energy when the run ended */
+ score: number;
+}
+
+/** A small seeded random number generator (mulberry32). */
+export function rngFrom(seed: number): () => number {
+ let s = seed >>> 0;
+ return () => {
+ s = (s + 0x6d2b79f5) >>> 0;
+ let r = Math.imul(s ^ (s >>> 15), 1 | s);
+ r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
+ return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/** A new run. aspect is the screen's width / height: far signals are placed along its long side. */
+export function newGame(seed = 1, aspect = 1.6): Game {
+ const G: Game = {
+ t: 0, rng: rngFrom(seed), aspect, stage: 0, stageAt: [0],
+ core: { id: 0, type: 'core', x: 0, y: 0, energy: 0 },
+ sources: [], nodes: [], links: [], hostiles: [], nextId: 1, hostileClock: 0,
+ counts: { packets: 0, ab: 0, c: 0, popped: 0, eaten: 0 }, events: [], done: false, doneAt: 0, score: 0,
+ };
+ // three quiet signals around you, a third of a turn apart
+ const base = G.rng() * Math.PI * 2;
+ for (let i = 0; i < 3; i++) addSource(G, 'a', base + i * Math.PI * 2 / 3 + (G.rng() - 0.5) * 0.4, 150 + G.rng() * 50, 0.7);
+ return G;
+}
+
+/** Where a signal at this angle and distance goes. Far ones lean toward the screen's long side so they fit a
+ * wide or a tall screen, at the same distance. */
+function spot(G: Game, angle: number, r: number, far: boolean): { x: number; y: number } {
+ if (!far) return { x: Math.cos(angle) * r, y: Math.sin(angle) * r };
+ const k = Math.min(2, Math.max(0.5, G.aspect));
+ const dx = Math.cos(angle) * k, dy = Math.sin(angle) / k, d = Math.hypot(dx, dy);
+ return { x: dx / d * r, y: dy / d * r };
+}
+
+function addSource(G: Game, kind: Kind, angle: number, r: number, rate: number, far = false, hot = false): Source {
+ const { x, y } = spot(G, angle, r, far);
+ const s: Source = { id: G.nextId++, type: 'source', x, y, kind, rate, acc: 0, queue: 0, lost: 0, born: G.t, far, hot };
+ G.sources.push(s);
+ return s;
+}
+
+/** An angle at which a new signal at distance r is clear of the ones there are. */
+function freeAngle(G: Game, r: number, far = false): number {
+ let best = 0, bestGap = -1;
+ for (let i = 0; i < 64; i++) {
+ const a = G.rng() * Math.PI * 2, p = spot(G, a, r, far);
+ let gap = Infinity;
+ for (const e of [...G.sources, ...G.nodes]) gap = Math.min(gap, Math.hypot(e.x - p.x, e.y - p.y));
+ if (gap > bestGap) { bestGap = gap; best = a; }
+ }
+ return best;
+}
+
+export function ent(G: Game, id: number): Ent | undefined {
+ if (id === 0) return G.core;
+ return G.sources.find(s => s.id === id) ?? G.nodes.find(n => n.id === id);
+}
+
+/** A line's length: from a node or signal to the target, or to the edge of the core. */
+export function linkLength(G: Game, from: Ent, to: Ent): number {
+ const d = Math.hypot(to.x - from.x, to.y - from.y);
+ return to.type === 'core' ? Math.max(8, d - coreRadius(G.core.energy)) : d;
+}
+
+/** Why a line from one point to another can't be drawn, or null if it can. */
+export function whyNot(G: Game, fromId: number, toId: number): null | 'same' | 'from' | 'to' | 'far' | 'loop' | 'full' {
+ const from = ent(G, fromId), to = ent(G, toId);
+ if (!from || !to) return 'to';
+ if (fromId === toId) return 'same';
+ if (from.type === 'core') return 'from';
+ if (to.type === 'source') return 'to';
+ if (linkLength(G, from, to) > LINE.reach) return 'far';
+ if (G.links.filter(l => l.from === fromId && l.to === toId).length >= LINE.maxParallel) return 'full';
+ // no loops: the target must not already lead back to the start
+ if (to.type === 'node' && leadsTo(G, toId, fromId)) return 'loop';
+ return null;
+}
+
+function leadsTo(G: Game, a: number, b: number): boolean {
+ const seen = new Set(), stack = [a];
+ while (stack.length) {
+ const x = stack.pop()!;
+ if (x === b) return true;
+ if (seen.has(x)) continue;
+ seen.add(x);
+ for (const l of G.links) if (l.from === x) stack.push(l.to);
+ }
+ return false;
+}
+
+export function connect(G: Game, fromId: number, toId: number): Link | null {
+ if (whyNot(G, fromId, toId)) return null;
+ const l: Link = { id: G.nextId++, from: fromId, to: toId, packets: [], wait: 0, delivered: 0, born: G.t };
+ G.links.push(l);
+ return l;
+}
+
+export function removeLink(G: Game, id: number): boolean {
+ const i = G.links.findIndex(l => l.id === id);
+ if (i < 0) return false;
+ G.links.splice(i, 1);
+ G.hostiles = G.hostiles.filter(h => h.link !== id);
+ return true;
+}
+
+/** Can a node go here? Nodes arrive with the second colour. */
+export function canPlace(G: Game, x: number, y: number): boolean {
+ if (G.stage < 2 || G.nodes.length >= NODE.max) return false;
+ if (Math.hypot(x, y) < coreRadius(G.core.energy) + NODE.space) return false;
+ for (const e of [...G.sources, ...G.nodes]) if (Math.hypot(e.x - x, e.y - y) < NODE.space) return false;
+ return true;
+}
+
+export function placeNode(G: Game, x: number, y: number): Node | null {
+ if (!canPlace(G, x, y)) return null;
+ const n: Node = { id: G.nextId++, type: 'node', x, y, hold: [], out: [], merges: 0, born: G.t };
+ G.nodes.push(n);
+ return n;
+}
+
+/** Take a node away with its lines. */
+export function removeNode(G: Game, id: number): boolean {
+ const i = G.nodes.findIndex(n => n.id === id);
+ if (i < 0) return false;
+ G.nodes.splice(i, 1);
+ for (const l of G.links.filter(l => l.from === id || l.to === id)) removeLink(G, l.id);
+ return true;
+}
+
+export function popHostile(G: Game, id: number): boolean {
+ const h = G.hostiles.find(h => h.id === id);
+ if (!h) return false;
+ const p = hostileAt(G, h);
+ G.hostiles = G.hostiles.filter(x => x !== h);
+ G.counts.popped++;
+ G.events.push({ type: 'pop', x: p.x, y: p.y });
+ return true;
+}
+
+/** Where a point d along a line is. */
+export function along(G: Game, l: Link, d: number): { x: number; y: number } {
+ const a = ent(G, l.from)!, b = ent(G, l.to)!;
+ const len = Math.hypot(b.x - a.x, b.y - a.y) || 1;
+ return { x: a.x + (b.x - a.x) * d / len, y: a.y + (b.y - a.y) * d / len };
+}
+export const hostileAt = (G: Game, h: Hostile) => along(G, G.links.find(l => l.id === h.link)!, h.d);
+
+/** A packet arriving at a node or at you. */
+function arrive(G: Game, to: Ent, kind: Kind) {
+ if (to.type === 'core') {
+ const v = VALUE[kind];
+ to.energy += v;
+ G.counts.packets++;
+ if (kind === 'ab') G.counts.ab++;
+ if (kind === 'c') G.counts.c++;
+ G.events.push({ type: 'arrive', kind, value: v });
+ } else if (to.type === 'node') {
+ if (kind === 'a' || kind === 'b') {
+ const want = kind === 'a' ? 'b' : 'a';
+ const i = to.hold.findIndex(h => h.kind === want);
+ if (i >= 0) {
+ to.hold.splice(i, 1);
+ if (to.out.length < NODE.cap) to.out.push('ab');
+ to.merges++;
+ G.events.push({ type: 'merge', node: to.id });
+ } else if (to.hold.length < NODE.cap) to.hold.push({ kind, wait: 0 });
+ } else if (to.out.length < NODE.cap) to.out.push(kind);
+ }
+}
+
+/** Something to put on a line leaving this point, or null. */
+function take(from: Ent): Kind | null {
+ if (from.type === 'source') {
+ if (from.queue < 1) return null;
+ from.queue--;
+ return from.kind;
+ }
+ if (from.type === 'node') return from.out.shift() ?? null;
+ return null;
+}
+
+export function step(G: Game, dt: number) {
+ G.t += dt;
+ for (const s of G.sources) {
+ s.acc += s.rate * dt;
+ while (s.acc >= 1) {
+ s.acc -= 1;
+ if (s.queue < SOURCE.queueMax) s.queue++; else s.lost++;
+ }
+ }
+ for (const n of G.nodes) {
+ for (const h of n.hold) h.wait += dt;
+ while (n.hold.length && n.hold[0].wait >= NODE.hold && n.out.length < NODE.cap) n.out.push(n.hold.shift()!.kind);
+ }
+ // lines take from their start in turn, each at most one packet every LINE.gap seconds
+ const start = G.links.length ? Math.floor(G.t * 7) % G.links.length : 0;
+ for (let k = 0; k < G.links.length; k++) {
+ const l = G.links[(start + k) % G.links.length];
+ l.wait -= dt;
+ if (l.wait <= 0) {
+ const kind = take(ent(G, l.from)!);
+ if (kind) { l.packets.push({ kind, d: 0 }); l.wait = LINE.gap; } else l.wait = 0;
+ }
+ }
+ for (const l of G.links) {
+ const a = ent(G, l.from)!, b = ent(G, l.to)!, len = linkLength(G, a, b);
+ for (const p of l.packets) p.d += LINE.speed * dt;
+ while (l.packets.length && l.packets[0].d >= len) {
+ const p = l.packets.shift()!;
+ l.delivered++;
+ arrive(G, b, p.kind);
+ }
+ }
+ stepHostiles(G, dt);
+ checkStage(G);
+}
+
+function stepHostiles(G: Game, dt: number) {
+ if (G.stage < 4 || G.done) return;
+ G.hostileClock += dt;
+ // they come in on lines from the far signals; with none left, on any line from a signal
+ const fromSignal = G.links.filter(l => ent(G, l.from)?.type === 'source');
+ const far = fromSignal.filter(l => (ent(G, l.from) as Source).far);
+ const pool = far.length ? far : fromSignal;
+ const due = G.counts.popped + G.counts.eaten === 0 && G.hostileClock >= HOSTILE.first || G.hostileClock >= HOSTILE.every;
+ if (G.hostiles.length === 0 && due && pool.length) {
+ G.hostileClock = 0;
+ const l = pool[Math.floor(G.rng() * pool.length)];
+ const h: Hostile = { id: G.nextId++, link: l.id, d: 0, born: G.t };
+ G.hostiles.push(h);
+ G.events.push({ type: 'spawn', id: h.id });
+ }
+ for (const h of [...G.hostiles]) {
+ const l = G.links.find(l => l.id === h.link);
+ if (!l) { G.hostiles = G.hostiles.filter(x => x !== h); continue; }
+ h.d += HOSTILE.speed * dt;
+ const a = ent(G, l.from)!, b = ent(G, l.to)!;
+ if (h.d >= Math.hypot(b.x - a.x, b.y - a.y) - (b.type === 'core' ? coreRadius(G.core.energy) : 0)) {
+ // it eats the line it was on
+ const p = hostileAt(G, h);
+ removeLink(G, l.id);
+ G.counts.eaten++;
+ G.events.push({ type: 'eat', x: p.x, y: p.y });
+ }
+ }
+}
+
+/** Has the current idea been used? Then the next one arrives. */
+export function stageDone(G: Game): boolean {
+ switch (G.stage) {
+ case 0: return G.counts.packets >= 6;
+ // capacity: one signal feeding two lines at once, both of which have carried something
+ case 1: return G.sources.some(s => { const ls = G.links.filter(l => l.from === s.id); return ls.length >= 2 && ls.filter(l => l.delivered > 0).length >= 2; });
+ case 2: return G.counts.ab >= 3;
+ case 3: return G.counts.c >= 3;
+ case 4: return G.counts.popped >= HOSTILE.popsToMaster;
+ }
+ return false;
+}
+
+function checkStage(G: Game) {
+ if (G.done) return;
+ if (G.stage < 4 && stageDone(G)) unlock(G);
+ else if (G.stage === 4 && stageDone(G) && G.core.energy >= GOAL) {
+ G.done = true; G.doneAt = G.t; G.score = Math.floor(G.core.energy);
+ G.hostiles = [];
+ G.events.push({ type: 'end' });
+ }
+}
+
+/** The next idea: new signals bloom in. */
+export function unlock(G: Game) {
+ G.stage++;
+ G.stageAt.push(G.t);
+ if (G.stage === 1) {
+ // a bright signal producing faster than one line carries
+ addSource(G, 'a', freeAngle(G, 200), 200, 2.2, false, true);
+ } else if (G.stage === 2) {
+ // a second colour, each beside one of the first signals so a node between them is near both
+ const as = G.sources.filter(s => s.kind === 'a' && !s.hot).slice(0, 2);
+ for (const a of as) {
+ const ang = Math.atan2(a.y, a.x), side = G.rng() < 0.5 ? -1 : 1;
+ addSource(G, 'b', ang + side * 0.62, Math.hypot(a.x, a.y), 0.8);
+ }
+ } else if (G.stage === 3) {
+ // rich signals beyond reach, spread around, and a second pair of colours further out
+ for (let i = 0; i < 3; i++) { const r = 400 + G.rng() * 50; addSource(G, 'c', freeAngle(G, r, true), r, 1.2, true); }
+ const ang = freeAngle(G, 370, true);
+ addSource(G, 'a', ang, 360, 1.2, true);
+ addSource(G, 'b', ang + 0.35, 380, 1.2, true);
+ }
+ G.events.push({ type: 'stage', stage: G.stage });
+}
diff --git a/src/signal/signal.css b/src/signal/signal.css
new file mode 100644
index 00000000..82935d54
--- /dev/null
+++ b/src/signal/signal.css
@@ -0,0 +1,20 @@
+/* Signal's page: a fullscreen canvas, a tiny counter, a sound toggle, a one-line hint and the end card. */
+html, body { margin: 0; height: 100%; background: #020409; overflow: hidden; }
+body { color: #5ff6ff; font: 13px/1.3 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }
+#cv { position: fixed; inset: 0; display: block; touch-action: none; cursor: crosshair; }
+#score { position: fixed; top: max(12px, env(safe-area-inset-top)); left: 16px; font-size: 15px; letter-spacing: 0.08em; opacity: 0.75; text-shadow: 0 0 8px #5ff6ff88; pointer-events: none; }
+#sound { position: fixed; top: max(8px, env(safe-area-inset-top)); right: 10px; width: 36px; height: 30px; padding: 0 8px; display: flex; gap: 3px; align-items: flex-end; justify-content: center; background: none; border: 0; cursor: pointer; opacity: 0.35; }
+#sound span { display: block; width: 3px; height: 4px; background: #5ff6ff; border-radius: 1px; transition: height 0.2s; }
+#sound.on { opacity: 0.8; }
+#sound.on span:nth-child(1) { height: 7px; }
+#sound.on span:nth-child(2) { height: 13px; }
+#sound.on span:nth-child(3) { height: 9px; }
+#hint { position: fixed; left: 50%; bottom: max(18px, env(safe-area-inset-bottom)); transform: translateX(-50%); max-width: calc(100vw - 32px); text-align: center; color: #cfe8ff; opacity: 0; transition: opacity 0.8s; pointer-events: none; text-shadow: 0 0 6px #5ff6ff66; }
+#hint.on { opacity: 0.6; }
+#end { position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; background: radial-gradient(circle, #020409d9 0, #020409f2 60%); animation: fade 1.5s ease-out; }
+#end[hidden] { display: none; }
+#end .n { font-size: 54px; color: #fff; text-shadow: 0 0 18px #5ff6ff, 0 0 40px #5ff6ff88; letter-spacing: 0.06em; }
+#end .t { font-size: 15px; opacity: 0.6; }
+#end button { margin-top: 18px; font: inherit; font-size: 15px; color: #5ff6ff; background: none; border: 1px solid #5ff6ff66; border-radius: 999px; padding: 10px 28px; cursor: pointer; box-shadow: 0 0 14px #5ff6ff33; letter-spacing: 0.1em; }
+#end button:hover { border-color: #5ff6ff; }
+@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
diff --git a/tests/signal.test.ts b/tests/signal.test.ts
new file mode 100644
index 00000000..8c803e73
--- /dev/null
+++ b/tests/signal.test.ts
@@ -0,0 +1,194 @@
+// Signal (signal.html), the five-minute toy: flow along a line at its capacity, queueing at a signal,
+// merging two colours at a node, reach and relays, and the order the ideas arrive in.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ GOAL, HOSTILE, LINE, NODE, SOURCE, VALUE, connect, coreRadius, newGame, placeNode, popHostile, removeLink, step, unlock, whyNot,
+ type Game, type Kind, type Source,
+} from '../src/signal/rules.ts';
+
+const run = (G: Game, seconds: number, dt = 1 / 60) => { for (let t = 0; t < seconds - 1e-9; t += dt) step(G, dt); };
+/** A signal at a chosen spot, with a chosen rate. */
+function signal(G: Game, x: number, y: number, kind: Kind = 'a', rate = 0.5): Source {
+ const s: Source = { id: G.nextId++, type: 'source', x, y, kind, rate, acc: 0, queue: 0, lost: 0, born: 0, far: false, hot: false };
+ G.sources.push(s);
+ return s;
+}
+/** A game with no signals of its own. */
+const bare = (stage = 0) => { const G = newGame(7); G.sources = []; G.stage = stage; return G; };
+
+test('a new game has a few signals within reach and nothing else', () => {
+ const G = newGame(3);
+ assert.equal(G.sources.length, 3);
+ for (const s of G.sources) assert.ok(Math.hypot(s.x, s.y) - coreRadius(0) <= LINE.reach, 'every first signal can be reached');
+ assert.equal(G.links.length, 0);
+ assert.equal(G.stage, 0);
+});
+
+test('a line carries packets into you and you grow', () => {
+ const G = bare();
+ const s = signal(G, 150, 0, 'a', 0.5);
+ assert.ok(connect(G, s.id, 0));
+ run(G, 20);
+ assert.ok(G.core.energy >= 7, `energy ${G.core.energy}`);
+ assert.equal(G.core.energy, G.counts.packets * VALUE.a);
+ assert.ok(coreRadius(G.core.energy) > coreRadius(0));
+});
+
+test('a line carries at most its capacity, and a fast signal queues', () => {
+ const G = bare();
+ const s = signal(G, 150, 0, 'a', 3);
+ const l = connect(G, s.id, 0)!;
+ run(G, 30);
+ const rate = 1 / LINE.gap;
+ // over 30 s the line can carry about 30 * rate packets, not the 90 the signal made
+ assert.ok(l.delivered <= 30 * rate + 1, `delivered ${l.delivered}`);
+ assert.ok(l.delivered >= 30 * rate - 3, `delivered ${l.delivered}`);
+ assert.equal(s.queue, SOURCE.queueMax, 'the signal is backed up');
+ assert.ok(s.lost > 0);
+});
+
+test('a second line from the same signal doubles what it carries', () => {
+ const one = bare(), two = bare();
+ const s1 = signal(one, 150, 0, 'a', 3), s2 = signal(two, 150, 0, 'a', 3);
+ connect(one, s1.id, 0);
+ connect(two, s2.id, 0); connect(two, s2.id, 0);
+ run(one, 30); run(two, 30);
+ const ratio = two.counts.packets / one.counts.packets;
+ assert.ok(ratio > 1.8 && ratio < 2.2, `ratio ${ratio}`);
+});
+
+test('a slow signal does not queue on one line', () => {
+ const G = bare();
+ const s = signal(G, 150, 0, 'a', 0.6);
+ connect(G, s.id, 0);
+ run(G, 30);
+ assert.ok(s.queue <= 1);
+ assert.equal(s.lost, 0);
+});
+
+test('a node merges two colours into a packet worth much more, and only with both', () => {
+ const G = bare(2);
+ const a = signal(G, 180, -60, 'a', 0.8), b = signal(G, 180, 60, 'b', 0.8);
+ const n = placeNode(G, 120, 0)!;
+ assert.ok(n);
+ connect(G, a.id, n.id); connect(G, b.id, n.id); connect(G, n.id, 0);
+ run(G, 30);
+ assert.ok(G.counts.ab >= 15, `merged ${G.counts.ab}`);
+ assert.ok(VALUE.ab > VALUE.a + VALUE.b * 3);
+
+ // one colour only: nothing merges, the node passes them on alone
+ const H = bare(2);
+ const a2 = signal(H, 180, -60, 'a', 0.8);
+ const n2 = placeNode(H, 120, 0)!;
+ connect(H, a2.id, n2.id); connect(H, n2.id, 0);
+ run(H, 30);
+ assert.equal(H.counts.ab, 0);
+ assert.equal(n2.merges, 0);
+ assert.ok(H.counts.packets >= 15, 'the lone colour still arrives');
+});
+
+test('nodes arrive with the second colour, and not on top of things', () => {
+ const G = bare(1);
+ assert.equal(placeNode(G, 100, 0), null);
+ G.stage = 2;
+ signal(G, 100, 100);
+ assert.ok(placeNode(G, 100, 0));
+ assert.equal(placeNode(G, 100 + NODE.space / 2, 0), null, 'too close to a node');
+ assert.equal(placeNode(G, 100, 100 - NODE.space / 2), null, 'too close to a signal');
+ assert.equal(placeNode(G, 0, 0), null, 'not on you');
+});
+
+test('reach: a line longer than reach cannot be drawn, a relay node in between makes it', () => {
+ const G = bare(3);
+ const far = signal(G, 460, 0, 'c', 1);
+ assert.equal(whyNot(G, far.id, 0), 'far');
+ assert.equal(connect(G, far.id, 0), null);
+ const relay = placeNode(G, 230, 0)!;
+ assert.ok(connect(G, far.id, relay.id));
+ assert.ok(connect(G, relay.id, 0));
+ run(G, 20);
+ assert.ok(G.counts.c >= 10, `rich packets ${G.counts.c}`);
+});
+
+test('lines cannot loop, start at you, or end at a signal', () => {
+ const G = bare(2);
+ const s = signal(G, 150, 0);
+ const n1 = placeNode(G, 100, 80)!, n2 = placeNode(G, 100, -80)!;
+ assert.ok(connect(G, n1.id, n2.id));
+ assert.equal(whyNot(G, n2.id, n1.id), 'loop');
+ assert.equal(whyNot(G, 0, n1.id), 'from');
+ assert.equal(whyNot(G, n1.id, s.id), 'to');
+});
+
+test('taking a line away stops the flow', () => {
+ const G = bare();
+ const s = signal(G, 150, 0, 'a', 1);
+ const l = connect(G, s.id, 0)!;
+ run(G, 5);
+ assert.ok(removeLink(G, l.id));
+ const e = G.core.energy;
+ run(G, 5);
+ assert.equal(G.core.energy, e);
+});
+
+test('the ideas arrive in order, each after the last is used', () => {
+ const G = newGame(11);
+ const near = (s: Source) => Math.hypot(s.x, s.y) - coreRadius(G.core.energy) <= LINE.reach;
+ connect(G, G.sources[0].id, 0);
+ run(G, 2);
+ assert.equal(G.stage, 0);
+ run(G, 15);
+ assert.equal(G.stage, 1, 'capacity arrives after the first packets');
+ const hot = G.sources.find(s => s.hot)!;
+ assert.ok(hot && hot.rate > 1 / LINE.gap && near(hot));
+ connect(G, hot.id, 0);
+ run(G, 10);
+ assert.equal(G.stage, 1, 'one line from the hot signal is not enough');
+ connect(G, hot.id, 0);
+ run(G, 5);
+ assert.equal(G.stage, 2, 'two colours arrive after two lines from one signal');
+ const b = G.sources.find(s => s.kind === 'b')!;
+ assert.ok(b && near(b));
+ // a node near the b signal and its a neighbour
+ const a = G.sources.filter(s => s.kind === 'a' && !s.hot).sort((p, q) => Math.hypot(p.x - b.x, p.y - b.y) - Math.hypot(q.x - b.x, q.y - b.y))[0];
+ const n = placeNode(G, (a.x + b.x) / 2 * 0.75, (a.y + b.y) / 2 * 0.75)!;
+ assert.ok(n);
+ assert.ok(connect(G, a.id, n.id)); assert.ok(connect(G, b.id, n.id)); assert.ok(connect(G, n.id, 0));
+ run(G, 15);
+ assert.equal(G.stage, 3, 'reach arrives after three merged packets');
+ const c = G.sources.find(s => s.kind === 'c')!;
+ assert.ok(c && !near(c), 'the rich signals are out of reach');
+ const relay = placeNode(G, c.x / 2, c.y / 2)!;
+ assert.ok(connect(G, c.id, relay.id)); assert.ok(connect(G, relay.id, 0));
+ run(G, 12);
+ assert.equal(G.stage, 4, 'the hostile arrives after three rich packets');
+ // a hostile shows up on a far line; pop it
+ for (let i = 0; i < HOSTILE.popsToMaster; i++) {
+ for (let k = 0; k < 60 * 20 && !G.hostiles.length; k++) step(G, 1 / 60);
+ assert.ok(G.hostiles.length, 'a hostile came');
+ assert.ok(popHostile(G, G.hostiles[0].id));
+ }
+ G.core.energy = GOAL;
+ step(G, 1 / 60);
+ assert.ok(G.done);
+});
+
+test('a hostile left alone eats the line it travels', () => {
+ const G = bare(3);
+ const far = signal(G, 200, 0, 'c', 1);
+ far.far = true;
+ const l = connect(G, far.id, 0)!;
+ unlock(G);
+ assert.equal(G.stage, 4);
+ run(G, HOSTILE.every + 0.1);
+ assert.equal(G.hostiles.length, 1);
+ run(G, 200 / HOSTILE.speed + 1);
+ assert.ok(!G.links.includes(l));
+ assert.equal(G.counts.eaten, 1);
+});
+
+test('the same seed gives the same signals', () => {
+ const a = newGame(5), b = newGame(5);
+ assert.deepEqual(a.sources.map(s => [s.x, s.y]), b.sources.map(s => [s.x, s.y]));
+});
diff --git a/vite.config.ts b/vite.config.ts
index 5279f71b..25f1144c 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -5,7 +5,7 @@ import { defineConfig } from 'vite';
// (.Codex, .letta, .agents, site) hold enough files to run out of the system's file watchers.
export default defineConfig({
base: './',
- // two pages: the game, and the factory prototype (src/factory)
- build: { rollupOptions: { input: { main: 'index.html', factory: 'factory.html' } } },
+ // three pages: the game, the factory prototype (src/factory) and the Signal toy (src/signal)
+ build: { rollupOptions: { input: { main: 'index.html', factory: 'factory.html', signal: 'signal.html' } } },
server: { watch: { ignored: ['**/.Codex/**', '**/.letta/**', '**/.agents/**', '**/.claude/**', '**/site/**', '**/reference/**', '**/e2e/results/**'] } },
});