@homie-rocks/studio 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,719 @@
1
+ /*
2
+ * GEM RUSH — the netplay v1 reference game (@homie-rocks/studio/netplay/NETPLAY.md).
3
+ *
4
+ * Move, collect gems, score; 60 s rounds that cycle forever; the round starts
5
+ * the moment the first browser arrives, with two bots, and every human who
6
+ * arrives takes a bot's body. Every browser renders the game itself with its
7
+ * own camera. One browser is host: it runs pickups, scoring, bots and the
8
+ * clock, and broadcasts snapshots. Everyone else moves their own avatar
9
+ * locally (instant) and sends it to the host; everything else is drawn from
10
+ * interpolated snapshots. If the host leaves, a survivor continues the SAME
11
+ * round from the relay's checkpoint.
12
+ *
13
+ * It also exercises the rest of the contract: a wave near another body knocks
14
+ * it back (the host TAKES a client-owned body, drives the knockback itself, and
15
+ * GIVES it back), replica movement is speed-capped on the host, and the hot
16
+ * zone (gems inside it score double) is slow state on the keyed state channel,
17
+ * so a joiner has it in its welcome and a promoted host inherits it.
18
+ *
19
+ * Canvas 2D on purpose: the point is the contract, in ~700 readable lines.
20
+ */
21
+ import { createNetplay, Roster, q, lerp, capMove, type RoleChange, type RoundInfo, type RoundResult, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
22
+
23
+ /* ------------------------------------------------------------------ rules */
24
+ const W = 1600;
25
+ const H = 1000;
26
+ const R_AV = 22;
27
+ const R_GEM = 13;
28
+ const SPEED = 340;
29
+ const BOT_SPEED = 250;
30
+ const ROUND_MS = 60_000;
31
+ const BREAK_MS = 7_000;
32
+ const GEM_COUNT = 14;
33
+ const MIN_SLOTS = 3; // 1 human + 2 bots from the first frame
34
+ const MAX_SLOTS = 8;
35
+ const KNOCK_RANGE = 110;
36
+ const KNOCK_SPEED = 950;
37
+ const KNOCK_MS = 420;
38
+ const ZONE_MS = 12_000;
39
+ const PALETTE = ['#8fe36a', '#ffd166', '#ef6f6c', '#6cb4ee', '#c792ea', '#f4a261', '#2ec4b6', '#ff8fab', '#a7c957', '#e9c46a', '#90e0ef', '#f28482'];
40
+ const BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];
41
+ const botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as string;
42
+ const label = (name: string, bot: boolean): string => (bot ? `${name} · bot` : name);
43
+
44
+ /* ------------------------------------------------------------ wire shapes */
45
+ /** Snapshot: compact arrays, quantized. ~40 B per body, ~16 B per gem. (Reset epochs ride in the helper's `c` table.) */
46
+ type P = [slot: number, seat: number, x: number, y: number, score: number, vx: number, vy: number];
47
+ type G = [id: number, x: number, y: number];
48
+ interface Snap { r: [n: number, phase: number, startedAt: number, endsAt: number]; p: P[]; g: G[] }
49
+ /**
50
+ * Replica input: the avatar (owner movement) AND the stick intent (host movement), so either mode reads the same
51
+ * frame. The helper stamps the reset epoch the replica has adopted.
52
+ */
53
+ type Avatar = [x: number, y: number, vx: number, vy: number, mx: number, my: number];
54
+ interface Body { slot: number; seat: number | null; name: string; bot: boolean; x: number; y: number; vx: number; vy: number; score: number; tx: number; ty: number; kvx: number; kvy: number; knockUntil: number }
55
+ interface Gem { id: number; x: number; y: number }
56
+ /** Slow state, on the keyed state channel (net.state('zone', ...)), not in the 20 Hz snapshot. */
57
+ interface Zone { n: number; x: number; y: number; r: number; until: number }
58
+ interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[]; tick: number; gemSeq: number }
59
+
60
+ /* --------------------------------------------------------------- the net */
61
+ /**
62
+ * `?movement=host` plays host movement: the host moves every body from the seats' stick intents (its rules own
63
+ * movement), and each replica PREDICTS its own body by replaying its unacknowledged intents on the host's position.
64
+ * Default `owner`: each browser moves its own body and the host bounds it.
65
+ */
66
+ const MOVEMENT: 'owner' | 'host' = (() => { try { return new URLSearchParams(location.search).get('movement') === 'host' ? 'host' : 'owner'; } catch { return 'owner'; } })();
67
+ const net = createNetplay<Snap, Avatar, Ckpt>({ game: 'gem-rush', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, checkpoint: () => checkpoint() });
68
+
69
+ /* ------------------------------------------------------------ host state */
70
+ let roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName });
71
+ let bodies = new Map<number, Body>();
72
+ let gems: Gem[] = [];
73
+ let gemSeq = 0;
74
+ let round: RoundInfo | null = null;
75
+ let zone: Zone | null = null;
76
+ let tick = 0;
77
+ let hosting = false;
78
+ let cheatResets = 0;
79
+ let controlResets = 0;
80
+ let predictionError = 0;
81
+
82
+ /* ------------------------------------------------ this browser's avatar */
83
+ const me = { x: W / 2, y: H / 2, vx: 0, vy: 0, kvx: 0, kvy: 0, knockUntil: 0, has: false };
84
+ /** Offline (no shell) plays as a local seat 0, with keys or touch. */
85
+ const mySeat = (): number | null => (net.offline ? 0 : net.seat);
86
+
87
+ /* ------------------------------------------------------- replica view */
88
+ const drawn = new Map<number, { x: number; y: number; seat: number; score: number; slot: number }>();
89
+ const waves: { x: number; y: number; at: number; colour: string; knock?: boolean }[] = [];
90
+ let wavesSeen = 0;
91
+ let knocksSeen = 0;
92
+ let firstStateAt = 0;
93
+ let firstSnapAt = 0;
94
+ let frames = 0;
95
+
96
+ const rnd = (a: number, b: number): number => a + Math.random() * (b - a);
97
+ const spawnPoint = (i: number): { x: number; y: number } => {
98
+ const a = (i / MAX_SLOTS) * Math.PI * 2;
99
+ return { x: W / 2 + Math.cos(a) * 330, y: H / 2 + Math.sin(a) * 250 };
100
+ };
101
+ const newGem = (): Gem => ({ id: ++gemSeq, x: rnd(60, W - 60), y: rnd(60, H - 60) });
102
+
103
+ function bodyFor(slot: Slot): Body {
104
+ const sp = spawnPoint(slot.slot);
105
+ return { slot: slot.slot, seat: slot.seat, name: slot.name, bot: slot.bot, x: sp.x, y: sp.y, vx: 0, vy: 0, score: 0, tx: sp.x, ty: sp.y, kvx: 0, kvy: 0, knockUntil: 0 };
106
+ }
107
+
108
+ function syncBodiesFromRoster(): void {
109
+ const seen = new Set<number>();
110
+ for (const s of roster.slots) {
111
+ seen.add(s.slot);
112
+ const b = bodies.get(s.slot);
113
+ if (!b) { bodies.set(s.slot, bodyFor(s)); continue; }
114
+ b.seat = s.seat; b.name = s.name; b.bot = s.bot;
115
+ }
116
+ for (const k of [...bodies.keys()]) if (!seen.has(k)) bodies.delete(k);
117
+ }
118
+
119
+ function publishRoster(): void { net.roster(roster.toJSON()); }
120
+
121
+ /** The host moved body `b` itself: tell its owner (a replica adopts it; the host's own body is `me`). */
122
+ function hostMoved(b: Body): void {
123
+ if (b.bot || b.seat === null) return;
124
+ if (b.seat === mySeat()) { me.x = b.x; me.y = b.y; me.vx = 0; me.vy = 0; me.has = true; return; }
125
+ net.reset(b.seat);
126
+ }
127
+
128
+ function newZone(n: number): Zone {
129
+ return { n, x: rnd(220, W - 220), y: rnd(200, H - 200), r: 150, until: net.now() + ZONE_MS };
130
+ }
131
+ function setZone(z: Zone): void { zone = z; net.state('zone', z); }
132
+
133
+ function startRound(n: number): void {
134
+ const now = net.now();
135
+ roster.trim();
136
+ syncBodiesFromRoster();
137
+ let i = 0;
138
+ for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
139
+ const sp = spawnPoint(i++);
140
+ b.x = sp.x; b.y = sp.y; b.vx = 0; b.vy = 0; b.score = 0; b.tx = sp.x; b.ty = sp.y; b.kvx = 0; b.kvy = 0; b.knockUntil = 0;
141
+ hostMoved(b);
142
+ }
143
+ gems = Array.from({ length: GEM_COUNT }, newGem);
144
+ round = { n, phase: 'live', startedAt: now, endsAt: now + ROUND_MS };
145
+ setZone(newZone((zone?.n ?? 0) + 1));
146
+ net.round(round);
147
+ publishRoster();
148
+ net.snapshot(buildSnap(), tick, true);
149
+ }
150
+
151
+ function endRound(): void {
152
+ if (!round) return;
153
+ const now = net.now();
154
+ const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
155
+ const results: RoundResult[] = ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1 }));
156
+ round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + BREAK_MS, results };
157
+ net.round(round);
158
+ }
159
+
160
+ /* ---------------------------------------------------- becoming the host */
161
+ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
162
+ hosting = true;
163
+ if (e.promoted) restore(e);
164
+ else {
165
+ roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName });
166
+ bodies = new Map();
167
+ const seat = mySeat();
168
+ if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
169
+ syncBodiesFromRoster();
170
+ startRound((e.round?.n ?? 0) + 1);
171
+ }
172
+ // Whoever is connected now is who plays: seats that left during the gap become bots.
173
+ const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null);
174
+ const { claimed } = roster.reconcile(peers);
175
+ syncBodiesFromRoster();
176
+ for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) hostMoved(b); }
177
+ // My own body is wherever my local avatar is: it was client-owned a moment ago and still is.
178
+ const mine = mySeat() !== null ? [...bodies.values()].find((b) => b.seat === mySeat()) : undefined;
179
+ if (mine && me.has) { mine.x = me.x; mine.y = me.y; }
180
+ else if (mine) { me.x = mine.x; me.y = mine.y; me.has = true; }
181
+ // The hot zone is keyed state: the relay handed it over with the role.
182
+ zone = net.stateOf<Zone>('zone') ?? zone;
183
+ if (!zone && round) setZone(newZone(1));
184
+ publishRoster();
185
+ }
186
+
187
+ function restore(e: RoleChange<Snap, Ckpt>): void {
188
+ const ck = e.ckpt?.d ?? null;
189
+ if (ck) {
190
+ roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName });
191
+ bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
192
+ gems = ck.gems.map((g) => ({ ...g }));
193
+ gemSeq = ck.gemSeq;
194
+ round = ck.round;
195
+ tick = ck.tick;
196
+ } else {
197
+ roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName });
198
+ bodies = new Map();
199
+ syncBodiesFromRoster();
200
+ }
201
+ // The snapshot is newer than the checkpoint (20 Hz vs 1 Hz): positions, scores and gems from it.
202
+ const s = e.snap;
203
+ if (s && (!e.ckpt || s.st >= e.ckpt.st)) {
204
+ for (const [slot, seat, x, y, score] of s.d.p) {
205
+ let b = bodies.get(slot);
206
+ if (!b) {
207
+ const rs0 = roster.slots.find((r) => r.slot === slot) ?? { slot, seat: seat >= 0 ? seat : null, name: seat >= 0 ? `Player ${seat + 1}` : botName(slot), bot: seat < 0 };
208
+ b = bodyFor(rs0);
209
+ bodies.set(slot, b);
210
+ }
211
+ b.x = x; b.y = y; b.score = score; b.tx = x; b.ty = y;
212
+ }
213
+ gems = s.d.g.map(([id, x, y]) => ({ id, x, y }));
214
+ gemSeq = Math.max(gemSeq, ...gems.map((g) => g.id));
215
+ const [n, ph, startedAt, endsAt] = s.d.r;
216
+ round = { n, phase: ph ? 'over' : 'live', startedAt, endsAt, ...(round?.n === n && round.results ? { results: round.results } : {}) };
217
+ tick = Math.max(tick, s.k);
218
+ }
219
+ // A round message the relay kept is at least as authoritative as the checkpoint's copy.
220
+ if (e.round && (!round || e.round.n > round.n || (e.round.n === round.n && e.round.phase === 'over' && round.phase === 'live'))) round = e.round;
221
+ if (!round) { startRound(1); return; }
222
+ net.round(round);
223
+ }
224
+
225
+ function checkpoint(): Ckpt {
226
+ return {
227
+ round: round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 },
228
+ bodies: [...bodies.values()].map((b) => ({ ...b })),
229
+ gems: gems.map((g) => ({ ...g })),
230
+ roster: roster.toJSON(),
231
+ tick,
232
+ gemSeq,
233
+ };
234
+ }
235
+
236
+ net.on('role', (e) => {
237
+ if (e.role === 'host') becomeHost(e);
238
+ else { hosting = false; }
239
+ });
240
+ net.on('join', (p) => {
241
+ if (!hosting || p.seat === null) return;
242
+ const c = roster.claim(p.seat, p.name);
243
+ if (!c) return; // full: they watch
244
+ syncBodiesFromRoster();
245
+ const b = bodies.get(c.slot.slot);
246
+ // The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
247
+ if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
248
+ publishRoster();
249
+ net.snapshot(buildSnap(), tick, true);
250
+ });
251
+ net.on('leave', (p) => {
252
+ if (!hosting || p.seat === null) return;
253
+ roster.release(p.seat); // their body stays, driven by a bot
254
+ syncBodiesFromRoster();
255
+ publishRoster();
256
+ });
257
+ net.on('event', (e) => {
258
+ if (e.k === 'wave') addWave(e.d as { slot: number });
259
+ if (e.k === 'knock') addWave(e.d as { slot: number }, true);
260
+ });
261
+ net.on('round', (r) => { round = hosting ? round : r; });
262
+ net.on('state', (e) => { if (e.k === 'zone' && !hosting) zone = (e.d as Zone | null) ?? null; if (!firstStateAt) firstStateAt = performance.now(); });
263
+ net.on('snapshot', () => { if (!firstSnapAt) firstSnapAt = performance.now(); });
264
+ /** My body's control changed: on a reset, stand where the host put me (a new round, a takeover, a knockback's end). */
265
+ net.on('control', (e) => {
266
+ if (!e.reset) return;
267
+ controlResets += 1;
268
+ const p = (e.snap as Snapshot<Snap>).d.p.find((x) => x[1] === net.seat);
269
+ if (p) { me.x = p[2]; me.y = p[3]; me.vx = 0; me.vy = 0; me.has = true; }
270
+ });
271
+
272
+ /* ----------------------------------------------------------------- input */
273
+ const keys = new Set<string>();
274
+ addEventListener('keydown', (e) => {
275
+ if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();
276
+ if (e.code === 'Space' && !keys.has('Space')) wave();
277
+ keys.add(e.code);
278
+ });
279
+ addEventListener('keyup', (e) => keys.delete(e.code));
280
+ addEventListener('blur', () => keys.clear());
281
+
282
+ const stick = { id: -1, ox: 0, oy: 0, x: 0, y: 0, active: false };
283
+ const canvas = document.getElementById('c') as HTMLCanvasElement;
284
+ const ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D;
285
+ canvas.addEventListener('pointerdown', (e) => {
286
+ if (e.pointerType === 'mouse') return;
287
+ if (stick.active && e.pointerId !== stick.id) { wave(); return; } // a second finger waves
288
+ stick.id = e.pointerId; stick.ox = e.clientX; stick.oy = e.clientY; stick.x = e.clientX; stick.y = e.clientY; stick.active = true;
289
+ canvas.setPointerCapture(e.pointerId);
290
+ });
291
+ canvas.addEventListener('pointermove', (e) => { if (e.pointerId === stick.id) { stick.x = e.clientX; stick.y = e.clientY; } });
292
+ const endStick = (e: PointerEvent): void => { if (e.pointerId === stick.id) { stick.active = false; stick.id = -1; } };
293
+ canvas.addEventListener('pointerup', endStick);
294
+ canvas.addEventListener('pointercancel', endStick);
295
+
296
+ function moveVector(): { x: number; y: number } {
297
+ let x = 0; let y = 0;
298
+ if (keys.has('KeyA') || keys.has('ArrowLeft')) x -= 1;
299
+ if (keys.has('KeyD') || keys.has('ArrowRight')) x += 1;
300
+ if (keys.has('KeyW') || keys.has('ArrowUp')) y -= 1;
301
+ if (keys.has('KeyS') || keys.has('ArrowDown')) y += 1;
302
+ if (stick.active) {
303
+ const dx = stick.x - stick.ox; const dy = stick.y - stick.oy;
304
+ const len = Math.hypot(dx, dy);
305
+ if (len > 6) { const m = Math.min(1, len / 56); x += (dx / len) * m; y += (dy / len) * m; }
306
+ }
307
+ const len = Math.hypot(x, y);
308
+ return len > 1 ? { x: x / len, y: y / len } : { x, y };
309
+ }
310
+
311
+ function wave(): void {
312
+ const seat = mySeat();
313
+ if (seat === null) return;
314
+ if (hosting) {
315
+ const b = [...bodies.values()].find((x) => x.seat === seat);
316
+ if (b) hostWave(b);
317
+ } else net.press('wave');
318
+ }
319
+ /** Host: a wave rings out, and knocks back every body within reach (bots, the host itself, and replicas). */
320
+ function hostWave(from: Body): void {
321
+ addWave({ slot: from.slot }); net.send('wave', { slot: from.slot });
322
+ for (const b of bodies.values()) {
323
+ if (b === from || Math.hypot(b.x - from.x, b.y - from.y) > KNOCK_RANGE) continue;
324
+ knock(b, from.x, from.y);
325
+ }
326
+ }
327
+ /**
328
+ * Host: knock a body back. A replica's body is TAKEN for KNOCK_MS (its owner's avatar frames are ignored; the host
329
+ * drives the knockback; the owner draws its body from snapshots), then the helper GIVES it back with a reset, so
330
+ * the owner stands where the knockback ended. The owner cannot ignore it: the host holds the pen.
331
+ */
332
+ function knock(b: Body, fx: number, fy: number): void {
333
+ let dx = b.x - fx; let dy = b.y - fy;
334
+ const len = Math.hypot(dx, dy);
335
+ if (len < 1) { const a = Math.random() * Math.PI * 2; dx = Math.cos(a); dy = Math.sin(a); } else { dx /= len; dy /= len; }
336
+ const until = net.now() + KNOCK_MS;
337
+ if (!b.bot && b.seat !== null && b.seat === mySeat()) { me.kvx = dx * KNOCK_SPEED; me.kvy = dy * KNOCK_SPEED; me.knockUntil = until; }
338
+ else { b.kvx = dx * KNOCK_SPEED; b.kvy = dy * KNOCK_SPEED; b.knockUntil = until; if (!b.bot && b.seat !== null) net.take(b.seat, KNOCK_MS); }
339
+ addWave({ slot: b.slot }, true); net.send('knock', { slot: b.slot });
340
+ }
341
+ function addWave(d: { slot: number }, isKnock = false): void {
342
+ const pos = hosting ? bodies.get(d.slot) : [...drawn.values()].find((x) => x.slot === d.slot);
343
+ const mineSlot = hosting ? [...bodies.values()].find((x) => x.seat !== null && x.seat === mySeat())?.slot : [...drawn.values()].find((x) => x.seat === net.seat)?.slot;
344
+ const at = mineSlot === d.slot && me.has ? me : pos;
345
+ if (!at) return;
346
+ waves.push({ x: at.x, y: at.y, at: performance.now(), colour: isKnock ? '#ffffff' : PALETTE[d.slot % PALETTE.length] as string, knock: isKnock });
347
+ if (isKnock) knocksSeen += 1; else wavesSeen += 1;
348
+ }
349
+
350
+ /* ---------------------------------------------------------------- simulate */
351
+ /** The one movement rule, shared by the owner, the host (host movement) and a replica's prediction. */
352
+ function integrate(o: { x: number; y: number; vx: number; vy: number }, mx: number, my: number, dt: number): void {
353
+ mx = Number(mx) || 0; my = Number(my) || 0;
354
+ const len = Math.hypot(mx, my);
355
+ if (len > 1) { mx /= len; my /= len; } // an intent is at most full stick: no speed hack through intents
356
+ const k = Math.min(1, dt * 14);
357
+ o.vx += (mx * SPEED - o.vx) * k;
358
+ o.vy += (my * SPEED - o.vy) * k;
359
+ o.x = Math.max(R_AV, Math.min(W - R_AV, o.x + o.vx * dt));
360
+ o.y = Math.max(R_AV, Math.min(H - R_AV, o.y + o.vy * dt));
361
+ }
362
+
363
+ function stepMe(dt: number): void {
364
+ const seat = mySeat();
365
+ if (seat === null) { me.has = false; return; }
366
+ // Host-driven (host movement, or knocked back on a replica): stepReplica predicts me instead.
367
+ if (!net.owned) return;
368
+ if (me.knockUntil > net.now()) {
369
+ me.x = Math.max(R_AV, Math.min(W - R_AV, me.x + me.kvx * dt)); me.y = Math.max(R_AV, Math.min(H - R_AV, me.y + me.kvy * dt));
370
+ me.kvx *= Math.max(0, 1 - dt * 5); me.kvy *= Math.max(0, 1 - dt * 5);
371
+ return;
372
+ }
373
+ const v = moveVector();
374
+ integrate(me, v.x, v.y, dt);
375
+ }
376
+
377
+ /**
378
+ * Replica, host-driven body. My stick moves me locally every frame (instant response), with the same rule the host
379
+ * runs. When a new snapshot lands: start from where the host has me, replay every intent it had not acknowledged,
380
+ * and pull toward that by 30% (or jump, if it is far). The host stays the truth; I never wait for it.
381
+ */
382
+ let predictedK = -1;
383
+ function predictMe(dt: number): void {
384
+ const v = moveVector();
385
+ integrate(me, v.x, v.y, dt);
386
+ const snap = net.latest();
387
+ if (!snap || snap.k === predictedK) return;
388
+ predictedK = snap.k;
389
+ const p = snap.d.p.find((x) => x[1] === net.seat);
390
+ if (!p) return;
391
+ const pred = { x: p[2], y: p[3], vx: p[5] ?? 0, vy: p[6] ?? 0 };
392
+ for (const f of net.pending()) {
393
+ let left = Math.min(0.25, f.dt / 1000);
394
+ while (left > 0) { const h = Math.min(left, 1 / 60); integrate(pred, f.a[4], f.a[5], h); left -= h; }
395
+ }
396
+ const err = Math.hypot(pred.x - me.x, pred.y - me.y);
397
+ predictionError = err;
398
+ if (!me.has || err > 150) { me.x = pred.x; me.y = pred.y; me.vx = pred.vx; me.vy = pred.vy; }
399
+ else { me.x += (pred.x - me.x) * 0.3; me.y += (pred.y - me.y) * 0.3; }
400
+ me.has = true;
401
+ }
402
+
403
+ function stepKnocked(b: Body, dt: number): void {
404
+ b.x = Math.max(R_AV, Math.min(W - R_AV, b.x + b.kvx * dt)); b.y = Math.max(R_AV, Math.min(H - R_AV, b.y + b.kvy * dt));
405
+ b.kvx *= Math.max(0, 1 - dt * 5); b.kvy *= Math.max(0, 1 - dt * 5);
406
+ b.vx = b.kvx; b.vy = b.kvy;
407
+ }
408
+
409
+ function stepBots(dt: number): void {
410
+ const taken = new Set<number>();
411
+ const now = net.now();
412
+ for (const b of bodies.values()) {
413
+ if (!b.bot) continue;
414
+ if (b.knockUntil > now) { stepKnocked(b, dt); continue; }
415
+ let best: Gem | null = null; let bd = Infinity;
416
+ for (const g of gems) {
417
+ if (taken.has(g.id)) continue;
418
+ const d = Math.hypot(g.x - b.x, g.y - b.y);
419
+ if (d < bd) { bd = d; best = g; }
420
+ }
421
+ if (best) { taken.add(best.id); b.tx = best.x; b.ty = best.y; }
422
+ const dx = b.tx - b.x; const dy = b.ty - b.y; const len = Math.hypot(dx, dy) || 1;
423
+ const k = Math.min(1, dt * 5);
424
+ b.vx += ((dx / len) * BOT_SPEED - b.vx) * k;
425
+ b.vy += ((dy / len) * BOT_SPEED - b.vy) * k;
426
+ b.x = Math.max(R_AV, Math.min(W - R_AV, b.x + b.vx * dt));
427
+ b.y = Math.max(R_AV, Math.min(H - R_AV, b.y + b.vy * dt));
428
+ }
429
+ }
430
+
431
+ function stepHost(dt: number): void {
432
+ tick += 1;
433
+ const seat = mySeat();
434
+ const now0 = net.now();
435
+ for (const b of bodies.values()) {
436
+ if (b.bot) continue;
437
+ if (b.seat === seat) {
438
+ me.has = true;
439
+ b.x = me.x; b.y = me.y; b.vx = me.vx; b.vy = me.vy;
440
+ continue;
441
+ }
442
+ if (b.seat === null) continue;
443
+ const ctl = net.control(b.seat);
444
+ if (ctl.taken || b.knockUntil > now0) { stepKnocked(b, dt); if (net.takePresses(b.seat)['wave']) hostWave(b); continue; } // the host drives the knockback
445
+ if (MOVEMENT === 'host') {
446
+ // Host movement: the seat sends intents; the rules move the body.
447
+ const f = net.inputOf(b.seat);
448
+ integrate(b, f ? Number(f.a?.[4]) : 0, f ? Number(f.a?.[5]) : 0, dt);
449
+ if (net.takePresses(b.seat)['wave']) hostWave(b);
450
+ continue;
451
+ }
452
+ const a = net.avatar(b.seat); // null while taken, or until the owner has adopted the last reset
453
+ if (a && Array.isArray(a)) {
454
+ // Client-owned movement, bounded: to the arena, and to 1.3x top speed per frame. A legitimate avatar catches
455
+ // up within a frame; a teleport crawls, and a claim more than half a second of running away is reset.
456
+ const claim = { x: Math.max(R_AV, Math.min(W - R_AV, Number(a[0]))), y: Math.max(R_AV, Math.min(H - R_AV, Number(a[1]))) };
457
+ const m = capMove(b, claim, SPEED * 1.3 * dt + 6);
458
+ if (m.over > SPEED * 0.5) { net.reset(b.seat); cheatResets += 1; }
459
+ else { b.x = m.x; b.y = m.y; b.vx = Number(a[2]) || 0; b.vy = Number(a[3]) || 0; }
460
+ }
461
+ if (net.takePresses(b.seat)['wave']) hostWave(b);
462
+ }
463
+ stepBots(dt);
464
+ const now = net.now();
465
+ if (round && round.phase === 'live') {
466
+ if (!zone || now >= zone.until) setZone(newZone((zone?.n ?? 0) + 1));
467
+ for (const b of bodies.values()) {
468
+ for (let i = 0; i < gems.length; i += 1) {
469
+ const g = gems[i] as Gem;
470
+ if (Math.hypot(g.x - b.x, g.y - b.y) < R_AV + R_GEM) {
471
+ b.score += zone && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r ? 2 : 1;
472
+ gems[i] = newGem();
473
+ }
474
+ }
475
+ }
476
+ if (now >= round.endsAt) endRound();
477
+ } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
478
+ if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
479
+ }
480
+
481
+ function buildSnap(): Snap {
482
+ const r = round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 };
483
+ return {
484
+ r: [r.n, r.phase === 'over' ? 1 : 0, r.startedAt, r.endsAt],
485
+ p: [...bodies.values()].map((b) => [b.slot, b.seat ?? -1, q(b.x, 1), q(b.y, 1), b.score, q(b.vx, 0), q(b.vy, 0)] as P),
486
+ g: gems.map((g) => [g.id, q(g.x, 0), q(g.y, 0)] as G),
487
+ };
488
+ }
489
+
490
+ function stepReplica(dt: number): void {
491
+ const seat = net.seat;
492
+ if (seat !== null) {
493
+ // Host-driven (host movement, or taken for a knockback): predict from the host's position and my intents.
494
+ if (!net.owned) predictMe(dt);
495
+ // Only once I have a body (the first control event adopts it): before that my position is nobody's.
496
+ if (me.has) {
497
+ const v = moveVector();
498
+ // Stick direction as held "buttons": a new direction is a press edge, so it is sent within 16 ms.
499
+ const held = [v.x > 0.2 ? 'R' : v.x < -0.2 ? 'L' : '', v.y > 0.2 ? 'D' : v.y < -0.2 ? 'U' : ''].filter(Boolean);
500
+ net.input([q(me.x, 1), q(me.y, 1), q(me.vx, 0), q(me.vy, 0), q(v.x, 2), q(v.y, 2)], held);
501
+ }
502
+ }
503
+ const smp = net.sample();
504
+ drawn.clear();
505
+ if (!smp) return;
506
+ const bySlot = new Map<number, P>(smp.a.d.p.map((p) => [p[0], p]));
507
+ for (const pb of smp.b.d.p) {
508
+ const pa = bySlot.get(pb[0]) ?? pb;
509
+ drawn.set(pb[0], { slot: pb[0], seat: pb[1], x: lerp(pa[2], pb[2], smp.alpha), y: lerp(pa[3], pb[3], smp.alpha), score: pb[4] });
510
+ }
511
+ const [n, ph, startedAt, endsAt] = smp.b.d.r;
512
+ if (!round || round.n !== n || (round.phase === 'over') !== (ph === 1)) {
513
+ const kept = net.roundInfo && net.roundInfo.n === n ? net.roundInfo : null;
514
+ round = kept ?? { n, phase: ph ? 'over' : 'live', startedAt, endsAt };
515
+ }
516
+ }
517
+
518
+ /* ------------------------------------------------------------------ draw */
519
+ let dpr = 1;
520
+ function resize(): void {
521
+ dpr = Math.min(2, devicePixelRatio || 1);
522
+ canvas.width = Math.round(innerWidth * dpr);
523
+ canvas.height = Math.round(innerHeight * dpr);
524
+ }
525
+ addEventListener('resize', resize);
526
+ resize();
527
+
528
+ const cam = { x: W / 2, y: H / 2 };
529
+ const gemGlow = (() => {
530
+ const g = ctx.createRadialGradient(0, 0, 0, 0, 0, R_GEM * 2.8);
531
+ g.addColorStop(0, 'rgba(255,209,102,0.55)'); g.addColorStop(0.4, 'rgba(255,170,60,0.18)'); g.addColorStop(1, 'rgba(255,150,40,0)');
532
+ return g;
533
+ })();
534
+ function draw(t: number): void {
535
+ const cw = innerWidth; const ch = innerHeight;
536
+ const phone = Math.min(cw, ch) <= 540;
537
+ const spectating = mySeat() === null;
538
+ const scale = spectating ? Math.min(cw / (W + 80), ch / (H + 80)) : Math.min(cw, ch) / (phone ? 560 : 820);
539
+ const target = spectating || !me.has ? { x: W / 2, y: H / 2 } : me;
540
+ cam.x += (target.x - cam.x) * 0.2; cam.y += (target.y - cam.y) * 0.2;
541
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
542
+ const bg = ctx.createRadialGradient(cw / 2, ch / 2, 0, cw / 2, ch / 2, Math.max(cw, ch) * 0.8);
543
+ bg.addColorStop(0, '#0d1426'); bg.addColorStop(1, '#04060c');
544
+ ctx.fillStyle = bg; ctx.fillRect(0, 0, cw, ch);
545
+
546
+ ctx.save();
547
+ ctx.translate(cw / 2, ch / 2); ctx.scale(scale, scale); ctx.translate(-cam.x, -cam.y);
548
+ // arena
549
+ ctx.strokeStyle = 'rgba(125,240,255,0.07)'; ctx.lineWidth = 1 / scale;
550
+ ctx.beginPath();
551
+ for (let x = 0; x <= W; x += 100) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
552
+ for (let y = 0; y <= H; y += 100) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
553
+ ctx.stroke();
554
+ ctx.strokeStyle = 'rgba(125,240,255,0.55)'; ctx.lineWidth = 4; ctx.strokeRect(0, 0, W, H);
555
+ // hot zone (keyed state): gems inside score double
556
+ if (zone) {
557
+ const pulse = 0.5 + 0.5 * Math.sin(t / 300);
558
+ ctx.fillStyle = `rgba(255,120,190,${0.06 + 0.05 * pulse})`; ctx.beginPath(); ctx.arc(zone.x, zone.y, zone.r, 0, Math.PI * 2); ctx.fill();
559
+ ctx.strokeStyle = 'rgba(255,140,200,0.6)'; ctx.lineWidth = 3; ctx.setLineDash([14, 10]); ctx.stroke(); ctx.setLineDash([]);
560
+ ctx.fillStyle = 'rgba(255,170,215,0.85)'; ctx.font = '700 16px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center';
561
+ ctx.fillText('×2', zone.x, zone.y + 6);
562
+ }
563
+
564
+ // gems
565
+ const gemList: { x: number; y: number; id: number }[] = hosting ? gems : (net.latest()?.d.g ?? []).map(([id, x, y]) => ({ id, x, y }));
566
+ for (const g of gemList) {
567
+ const spin = t / 500 + g.id;
568
+ const r = R_GEM * (1 + 0.12 * Math.sin(t / 200 + g.id));
569
+ ctx.save(); ctx.translate(g.x, g.y);
570
+ ctx.globalCompositeOperation = 'lighter';
571
+ ctx.fillStyle = gemGlow; ctx.beginPath(); ctx.arc(0, 0, r * 2.6, 0, Math.PI * 2); ctx.fill();
572
+ ctx.globalCompositeOperation = 'source-over';
573
+ ctx.rotate(spin);
574
+ ctx.fillStyle = '#ffd166'; ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(r * 0.8, 0); ctx.lineTo(0, r); ctx.lineTo(-r * 0.8, 0); ctx.closePath(); ctx.fill();
575
+ ctx.restore();
576
+ }
577
+
578
+ // waves
579
+ for (let i = waves.length - 1; i >= 0; i -= 1) {
580
+ const w = waves[i] as (typeof waves)[number];
581
+ const age = (t - w.at) / 900;
582
+ if (age > 1) { waves.splice(i, 1); continue; }
583
+ ctx.strokeStyle = w.colour; ctx.globalAlpha = 1 - age; ctx.lineWidth = w.knock ? 3 : 5;
584
+ ctx.beginPath(); ctx.arc(w.x, w.y, w.knock ? R_AV + 6 + age * 40 : R_AV + age * (KNOCK_RANGE + 10), 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1;
585
+ }
586
+
587
+ // avatars
588
+ const names = new Map<number, Slot>((net.slots ?? roster.slots).map((s) => [s.slot, s]));
589
+ const list: { slot: number; seat: number | null; x: number; y: number; bot: boolean; name: string; mine: boolean }[] = [];
590
+ if (hosting) {
591
+ for (const b of bodies.values()) list.push({ slot: b.slot, seat: b.seat, x: b.x, y: b.y, bot: b.bot, name: b.name, mine: b.seat !== null && b.seat === mySeat() });
592
+ } else {
593
+ for (const d of drawn.values()) {
594
+ const mine = d.seat >= 0 && d.seat === net.seat;
595
+ const s = names.get(d.slot);
596
+ list.push({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, x: mine && me.has ? me.x : d.x, y: mine && me.has ? me.y : d.y, bot: d.seat < 0, name: s?.name ?? (d.seat >= 0 ? `Player ${d.seat + 1}` : botName(d.slot)), mine });
597
+ }
598
+ }
599
+ for (const a of list) {
600
+ const colour = PALETTE[a.slot % PALETTE.length] as string;
601
+ ctx.globalAlpha = a.bot ? 0.62 : 1;
602
+ ctx.fillStyle = colour; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV, 0, Math.PI * 2); ctx.fill();
603
+ ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV * 0.45, 0, Math.PI * 2); ctx.fill();
604
+ if (a.mine) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV + 7, 0, Math.PI * 2); ctx.stroke(); }
605
+ ctx.globalAlpha = 1;
606
+ ctx.font = `600 ${Math.round(15 / Math.max(0.6, scale))}px ui-sans-serif, system-ui, sans-serif`;
607
+ ctx.textAlign = 'center'; ctx.fillStyle = a.mine ? '#ffffff' : 'rgba(232,236,245,0.8)';
608
+ ctx.fillText(a.mine ? 'You' : label(a.name, a.bot), a.x, a.y - R_AV - 12);
609
+ }
610
+ ctx.restore();
611
+
612
+ hud(cw, ch, phone, list);
613
+ if (stick.active) {
614
+ ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 3;
615
+ ctx.beginPath(); ctx.arc(stick.ox, stick.oy, 56, 0, Math.PI * 2); ctx.stroke();
616
+ const dx = stick.x - stick.ox; const dy = stick.y - stick.oy; const len = Math.hypot(dx, dy); const m = len > 56 ? 56 / len : 1;
617
+ ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.beginPath(); ctx.arc(stick.ox + dx * m, stick.oy + dy * m, 24, 0, Math.PI * 2); ctx.fill();
618
+ }
619
+ }
620
+
621
+ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name: string; bot: boolean; mine: boolean }[]): void {
622
+ const pad = phone ? 14 : 20;
623
+ const debugStrip = new URLSearchParams(location.search).get('debug') === '1' || (window as unknown as { HOMIE_NET?: { debug?: boolean } }).HOMIE_NET?.debug;
624
+ const top = pad + (debugStrip ? (phone ? 50 : 26) : 0);
625
+ const now = net.now();
626
+ const r = round;
627
+ ctx.textAlign = 'left'; ctx.fillStyle = '#e8ecf5';
628
+ ctx.font = `700 ${phone ? 20 : 24}px ui-sans-serif, system-ui, sans-serif`;
629
+ const left = r ? Math.max(0, Math.ceil((r.endsAt - now) / 1000)) : 0;
630
+ const clock = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
631
+ ctx.fillText(r ? (r.phase === 'live' ? `Round ${r.n} · ${clock}` : `Next round in ${left}`) : 'Joining…', pad, top + 18);
632
+ // scores
633
+ const scores = new Map<number, number>();
634
+ if (hosting) for (const b of bodies.values()) scores.set(b.slot, b.score);
635
+ else for (const d of drawn.values()) scores.set(d.slot, d.score);
636
+ const rows = [...list].sort((a, b) => (scores.get(b.slot) ?? 0) - (scores.get(a.slot) ?? 0)).slice(0, phone ? 4 : 6);
637
+ ctx.font = `600 ${phone ? 14 : 16}px ui-sans-serif, system-ui, sans-serif`;
638
+ ctx.textAlign = 'right';
639
+ rows.forEach((a, i) => {
640
+ ctx.fillStyle = a.mine ? '#ffffff' : a.bot ? 'rgba(232,236,245,0.55)' : 'rgba(232,236,245,0.85)';
641
+ ctx.fillText(`${a.mine ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, top + 18 + i * (phone ? 20 : 22));
642
+ });
643
+ // role badge
644
+ ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace'; ctx.fillStyle = 'rgba(125,240,255,0.7)';
645
+ ctx.fillText(net.offline ? 'OFFLINE HOST' : net.role.toUpperCase(), pad, ch - pad);
646
+ if (r && r.phase === 'over' && r.results) {
647
+ const w = Math.min(360, cw - 40); const h = 44 + Math.min(6, r.results.length) * 26;
648
+ const x = (cw - w) / 2; const y = (ch - h) / 2;
649
+ ctx.fillStyle = 'rgba(6,10,20,0.86)'; ctx.fillRect(x, y, w, h);
650
+ ctx.strokeStyle = 'rgba(125,240,255,0.5)'; ctx.strokeRect(x, y, w, h);
651
+ ctx.textAlign = 'center'; ctx.fillStyle = '#7df0ff'; ctx.font = '700 18px ui-sans-serif, system-ui, sans-serif';
652
+ ctx.fillText(`Round ${r.n} results`, cw / 2, y + 28);
653
+ ctx.font = '600 16px ui-sans-serif, system-ui, sans-serif';
654
+ r.results.slice(0, 6).forEach((row, i) => {
655
+ const mine = row.seat !== null && row.seat === mySeat();
656
+ ctx.fillStyle = mine ? '#ffffff' : row.bot ? 'rgba(232,236,245,0.6)' : '#e8ecf5';
657
+ ctx.fillText(`${row.place}. ${mine ? 'You' : label(row.name, row.bot)} — ${row.score}`, cw / 2, y + 56 + i * 26);
658
+ });
659
+ }
660
+ if (!net.offline && !net.connected && net.role !== 'host') {
661
+ ctx.textAlign = 'center'; ctx.fillStyle = '#ffd166'; ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif';
662
+ ctx.fillText('Reconnecting…', cw / 2, ch - pad);
663
+ }
664
+ if (frames < 240 && mySeat() !== null) {
665
+ ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(232,236,245,0.7)'; ctx.font = '500 14px ui-sans-serif, system-ui, sans-serif';
666
+ ctx.fillText(phone ? 'Drag to move · second finger bumps' : 'WASD / arrows to move · Space bumps', cw / 2, ch - pad - 18);
667
+ }
668
+ }
669
+
670
+ /* ------------------------------------------------------------------ loop */
671
+ let last = performance.now();
672
+ function frame(t: number): void {
673
+ const dt = Math.min(0.05, (t - last) / 1000);
674
+ last = t;
675
+ stepMe(dt);
676
+ if (hosting) stepHost(dt);
677
+ else stepReplica(dt);
678
+ draw(t);
679
+ frames += 1;
680
+ requestAnimationFrame(frame);
681
+ }
682
+
683
+ net.expose({
684
+ self: () => (me.has ? { x: me.x, y: me.y } : null),
685
+ peer: (seat: number) => {
686
+ if (hosting) { const b = [...bodies.values()].find((x) => x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
687
+ const d = [...drawn.values()].find((x) => x.seat === seat);
688
+ return d ? { x: d.x, y: d.y } : null;
689
+ },
690
+ frames: () => frames,
691
+ scores: () => (hosting ? [...bodies.values()].map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })) : [...drawn.values()].map((d) => ({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, bot: d.seat < 0, score: d.score }))),
692
+ waves: () => wavesSeen,
693
+ knocks: () => knocksSeen,
694
+ zone: () => zone,
695
+ owned: () => net.owned,
696
+ cheatResets: () => cheatResets,
697
+ movement: () => MOVEMENT,
698
+ controlResets: () => controlResets,
699
+ predictionError: () => predictionError,
700
+ /** performance.now() of the first keyed-state value and the first live snapshot this browser received. */
701
+ arrivals: () => ({ firstStateAt, firstSnapAt }),
702
+ /** Harness hook (host only): knock the body of `seat` back, toward the middle of the arena. */
703
+ debugKnock: (seat: number) => {
704
+ if (!hosting) return false;
705
+ const b = [...bodies.values()].find((x) => x.seat === seat);
706
+ if (!b) return false;
707
+ knock(b, b.x < W / 2 ? b.x - 1 : b.x + 1, b.y); // push toward the middle, away from the nearer wall
708
+ return true;
709
+ },
710
+ });
711
+
712
+ void net.ready.then(() => {
713
+ const seat = mySeat();
714
+ if (seat !== null && hosting) {
715
+ const b = [...bodies.values()].find((x) => x.seat === seat);
716
+ if (b) { me.x = b.x; me.y = b.y; me.has = true; }
717
+ }
718
+ });
719
+ requestAnimationFrame(frame);