@homie-rocks/studio 0.37.2 → 0.38.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.
package/rules/view.ts CHANGED
@@ -21,11 +21,9 @@ import { rulesCaps, rulesRates } from '../worker/limits.mjs';
21
21
  * room.command(name, data) a reliable one-shot request to the player's own body
22
22
  * room.round, room.roster, room.shared, room.seat, room.status
23
23
  *
24
- * THE OWN BODY. In this release a player's body moves as it does today: this browser runs the game's `move` code
25
- * (the same guarded module the server runs) on its own copy of the room's clock and says where its body is; the
26
- * server holds each claim to the body's top speed (`body: { move: 'owner' }` in the rules). The stick therefore answers
27
- * on the next drawn frame. Everything the rules decide (score, pickups, rounds, where a body is placed) comes from the
28
- * server and cannot be changed from here. A later release has the server move every body and this library predict.
24
+ * THE OWN BODY. This browser predicts its own movement with the server's guarded `move` module. Snapshots are
25
+ * authoritative: the view replays pending inputs from each snapshot and eases the visual correction. Large
26
+ * disagreements draw the corrected path at catch-up speed. Scores, pickups and effects come only from the host.
29
27
  *
30
28
  * THE CLOCK AND THE INPUT (rooms-milestone-1-design.md, sections 4.4 and 6.1). Input is one step a tick, stamped with
31
29
  * its tick. This browser steps a little ahead of the server so an entry arrives before its tick runs, and holds that
@@ -43,7 +41,7 @@ export function setAgentFactory(factory: typeof useAgents): void { agentFactory
43
41
  import { createNetplay } from '../netplay/netplay.ts';
44
42
  import type { Netplay, NetplayOptions, RulesHostFactory, RoundInfo, Snapshot, StepEntry } from '../netplay/netplay.ts';
45
43
  import { lab } from '../lab/lab.ts';
46
- import { exposePort } from '../port/probe.ts';
44
+ import { exposePort, type PortProbeOptions } from '../port/probe.ts';
47
45
  import { BudgetError } from './guard.ts';
48
46
  import { moveContext } from './math.ts';
49
47
  import { coerce, dir, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
@@ -68,7 +66,16 @@ export interface Entity {
68
66
  export type RoomStatus = 'connecting' | 'playing' | 'offline' | 'closed';
69
67
  export interface RoomRound { n: number; phase: 'live' | 'over'; endsAt: number; secondsLeft: number; results?: RoundInfo['results'] }
70
68
  export interface RosterRow { seat: number; name: string; driver: 'person' | 'bot' | 'ai'; score: number; me: boolean }
71
- export interface OpenRoomOptions { game?: GameData; move?: Record<string, MoveFn>; net?: NetplayOptions; /** Tests: a clock in ms and a way to run without timers. */ now?: () => number; timers?: boolean }
69
+ export interface OpenRoomOptions {
70
+ game?: GameData;
71
+ move?: Record<string, MoveFn>;
72
+ net?: NetplayOptions;
73
+ /** Camera axes on the rules' x/y plane for the testing tools. Default: x right, y up. */
74
+ screenBasis?: PortProbeOptions['basis'];
75
+ /** Tests: a clock in ms and a way to run without timers. */
76
+ now?: () => number;
77
+ timers?: boolean;
78
+ }
72
79
  export interface Room<R = unknown> {
73
80
  readonly status: RoomStatus;
74
81
  readonly seat: number | null;
@@ -113,7 +120,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
113
120
  const period = 1000 / tickHz;
114
121
  const sendEvery = Math.ceil(tickHz / Math.max(1, schema.settings.inputHz));
115
122
  const clock = opts.now ?? ((): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()));
116
- const net = createNetplay<unknown, unknown, unknown>({ game: game.id, maxPlayers: schema.seats, snapshotHz: tickHz, heartbeatMs: 0, rulesHost: { mode: schema.settings.host, offline: schema.settings.offline, load: current?.load }, ...opts.net, rules: true, rulesLimits: { output: rulesOutput, bytes: rulesCaps(schema.seats), rates: rulesRates(tickHz, schema.seats) } });
123
+ const net = createNetplay<unknown, unknown, unknown>({ game: game.id, maxPlayers: schema.seats, snapshotHz: tickHz, interpFloorMs: schema.settings.predict.interpMs ?? period * sendEvery, heartbeatMs: 0, rulesHost: { mode: schema.settings.host, offline: schema.settings.offline, load: current?.load }, ...opts.net, rules: true, rulesLimits: { output: rulesOutput, bytes: rulesCaps(schema.seats), rates: rulesRates(tickHz, schema.seats) } });
117
124
  const agents = game.vocab && agentFactory ? agentFactory(net, game.vocab, { roles: ['guide', 'party'], view: () => ({}), manual: true, viewOnly: true }) : null;
118
125
  const kindOf = new Map(schema.kinds.map((k) => [k.name, k]));
119
126
  const listeners = new Map<string, Set<(e: any) => void>>();
@@ -157,7 +164,10 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
157
164
  /** The clock: the room tick this browser is on at `at` (ms), and the newest step it has run. */
158
165
  let base: { tick: number; at: number } | null = null;
159
166
  let stepped = 0;
167
+ // Lead observations use one clock phase: steering our clock is not network jitter.
160
168
  const leads: number[] = [];
169
+ const phases: { tick: number; at: number; rate: number }[] = [];
170
+ const hostAges: number[] = [];
161
171
  /** What the player holds, the presses since the last step, what the server was last told, and the entries of this send period. */
162
172
  let sample: Record<string, unknown> = {};
163
173
  let pressed = new Set<string>();
@@ -165,52 +175,133 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
165
175
  let lastSendAt = 0;
166
176
  let entries: { t: number; row: number[] }[] = [];
167
177
  let hiddenSent = false;
168
- const TARGET_LEAD = 1;
178
+ const history = new Map<number, Mine>();
179
+ let pending: { t: number; input: Readonly<Record<string, unknown>> }[] = [];
180
+ let replayHeld: Readonly<Record<string, unknown>> = Object.freeze({});
181
+ let speed = 1;
182
+ let leadAfter = 0;
183
+ let observedLeadAt = 0;
184
+ let catchTick: number | null = null;
185
+ let catchAt = 0;
186
+ let offsets: { delta: Vec3; at: number; path: Vec3; fade: number; last: number }[] = [];
187
+ const predict = schema.settings.predict;
188
+ const quantile = (list: number[], q: number): number => [...list].sort((a, b) => a - b)[Math.floor((list.length - 1) * q)] ?? 0;
189
+ const targetLead = (): number => 0.5 + Math.max(0, quantile(leads, 0.5) - quantile(leads, 0.05));
190
+ const copyMine = (m: Mine): Mine => ({ ...m, motion: motionOf(m.kind, m.motion) });
191
+ const distance = (a: Vec3, b: Vec3): number => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
192
+ const correction = { count: 0, max: 0, last: 0, catches: 0, snaps: 0, rebases: 0, rebaseReason: '', rebaseError: 0 };
193
+ function visualOffset(now: number, path?: Vec3, settled = false): Vec3 {
194
+ if (predict.blendMs <= 0) { offsets = []; return { x: 0, y: 0, z: 0 }; }
195
+ offsets = offsets.filter(o => o.fade > 0);
196
+ const sum = { x: 0, y: 0, z: 0 };
197
+ let waiting = false;
198
+ for (const o of offsets) {
199
+ if (path) {
200
+ // Loss can make even a small correction longer than the distance travelled in
201
+ // 100 ms. Bound its fade by actual progress, or it pulls steady movement backwards.
202
+ // Only one queued fade advances at a time. A stopped body finishes its correction.
203
+ if (waiting) o.at = now;
204
+ else {
205
+ const length = Math.hypot(o.delta.x, o.delta.y, o.delta.z);
206
+ const elapsed = Math.max(0, now - o.last);
207
+ // A knock may travel faster than normal movement, and catch-up already accelerates it.
208
+ // Fading the offset must not add another fraction of that accelerated speed.
209
+ const allowance = length ? (myKind()?.maxSpeed ?? 0) * elapsed / 1000 / length : 1;
210
+ const release = settled && catchTick === null ? Math.min(allowance, elapsed / predict.blendMs) : 0;
211
+ const progress = length ? Math.min(allowance, distance(path, o.path) * 0.8 / length) : 1;
212
+ const nominal = Math.max(0, Math.min(1, 1 - (now - o.at) / predict.blendMs));
213
+ o.fade = Math.max(nominal, o.fade - Math.max(release, progress));
214
+ }
215
+ o.path = path; o.last = now;
216
+ }
217
+ waiting = true;
218
+ sum.x += o.delta.x * o.fade; sum.y += o.delta.y * o.fade; sum.z += o.delta.z * o.fade;
219
+ }
220
+ return sum;
221
+ }
222
+
169
223
  const myKind = (): (typeof schema.kinds)[number] | null => (mine ? kindOf.get(mine.kind) ?? null : null);
170
- const tickAt = (now: number): number => (base ? base.tick + (now - base.at) / period : 0);
224
+ const tickAt = (now: number): number => (base ? base.tick + (now - base.at) / period * speed : 0);
171
225
  /** The tick `move` is being run for: the step just taken, or the one after it while the own body is drawn between ticks. */
172
226
  let moveTick = 0;
173
227
  const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, dims });
174
- function rebase(k: number): void {
175
- const rtt = net.stats().rtt ?? 100;
176
- base = { tick: k + Math.ceil(rtt / period + TARGET_LEAD) + sendEvery - 1, at: clock() };
228
+ function authorityRtt(): number {
229
+ // The helper's ping ends at the relay. A browser host adds another network leg in both directions.
230
+ // Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
231
+ const relayRtt = net.stats().rtt ?? 100;
232
+ return net.offline ? 0 : schema.settings.host === 'browser' ? Math.max(relayRtt, 2 * quantile(hostAges, 0.5)) : relayRtt;
233
+ }
234
+ function phaseAt(at: number): number {
235
+ let p = phases[0];
236
+ for (let i = phases.length - 1; i >= 0; i--) if (phases[i].at <= at) { p = phases[i]; break; }
237
+ return p ? p.tick + (at - p.at) / period * p.rate - at / period : 0;
238
+ }
239
+ const medianLead = (): number => leads.length ? quantile(leads.slice(-8), 0.5) + phaseAt(clock()) : 0;
240
+ function steer(now: number): void {
241
+ if (!base || !leads.length) return;
242
+ base = { tick: tickAt(now), at: now };
243
+ speed = 1 + Math.max(-0.05, Math.min(0.05, (targetLead() - medianLead()) * 0.05));
244
+ if (phases[phases.length - 1]?.rate !== speed) phases.push({ ...base, rate: speed });
245
+ while (phases.length > 2 && phases[1].at < now - Math.max(1000, 2 * authorityRtt() + period)) phases.shift();
246
+ }
247
+ function rebase(k: number, reason = 'placement'): void {
248
+ // Recalibrating an input clock is not a placement. Keep the drawn pose while
249
+ // the new prediction catches up, just as for a snapshot reconciliation.
250
+ const shown = reason === 'lead' ? meNow()?.pos : null;
251
+ correction.rebaseReason = reason; correction.rebaseError = medianLead() - targetLead();
252
+ const rtt = authorityRtt();
253
+ // Lead samples refer to the clock which stamped them. Do not mix its offset with this clock's jitter.
254
+ const target = targetLead();
255
+ leads.length = 0;
256
+ leadAfter = clock() + 2 * rtt + period;
257
+ base = { tick: k + Math.ceil(rtt / period + target) + sendEvery - 1, at: clock() };
177
258
  stepped = Math.floor(base.tick) - 1;
178
- entries = []; lastSent = ''; leads.length = 0; pressed = new Set();
259
+ entries = []; pending = []; history.clear(); replayHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Set();
260
+ speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; correction.rebases += 1;
261
+ const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
262
+ if (mine) {
263
+ history.set(stepped, copyMine(mine));
264
+ if (shown) offsets = [{ delta: { x: shown.x - mine.pos.x, y: shown.y - mine.pos.y, z: shown.z - mine.pos.z }, at: clock(), path: mine.pos, fade: 1, last: clock() }];
265
+ }
179
266
  }
180
267
  /** `motion` as this browser's own `move` may change it in place: what a snapshot carries is frozen, so every list, map and struct in it is copied. */
181
268
  const motionOf = (kindName: string, motion: Record<string, unknown>): Record<string, unknown> => thawFields(kindOf.get(kindName)?.motion ?? [], motion);
182
269
  function adopt(u: Unpacked): void {
183
270
  mine = { id: u.id, kind: u.kind, r: u.r, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, motion: motionOf(u.kind, u.motion) };
184
- lastSent = '';
185
271
  }
186
272
  /** The game's guarded `move` for one tick, counted as the server counts it, with the result rounded as the server rounds it. */
187
273
  function runMove(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, input: Readonly<Record<string, unknown>>, t: number): typeof body {
188
274
  const fn = moves[kindName];
189
275
  if (!fn) return body;
190
276
  moveTick = t;
191
- return stepMove(fn, body, input, moveCtx, Math.max(1, Math.floor(schema.settings.budget.tick / 4)), kindOf.get(kindName)?.motion ?? [], dims, (err) => { if (!(err instanceof BudgetError)) console.warn('[room] move', err); });
277
+ const out = stepMove(fn, body, input, moveCtx, Math.max(1, Math.floor(schema.settings.budget.tick / 4)), kindOf.get(kindName)?.motion ?? [], dims, (err) => { if (!(err instanceof BudgetError)) console.warn('[room] move', err); });
278
+ const r = kindOf.get(kindName)?.radius ?? 0;
279
+ out.pos = vec3({ x: Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, out.pos.x)), y: Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, out.pos.y)), z: out.pos.z }, dims);
280
+ return out;
192
281
  }
193
282
  /** The input values the last step held (a press is never held). */
194
283
  let held: Readonly<Record<string, unknown>> = Object.freeze({});
195
284
  /** One step of the own body on tick `t`: the input step, `move`, and an entry when the sample or the claim changed. */
196
- function step(t: number): void {
285
+ function step(t: number, fresh = true): void {
197
286
  const kind = myKind();
198
287
  if (!mine || !kind) return;
199
288
  const values: number[] = [];
200
289
  const input: Record<string, unknown> = {};
201
290
  let press = false;
202
291
  for (const [name, fd] of kind.input) {
203
- if (fd.t === 'press') { const on = pressed.has(name); input[name] = on; values.push(on ? 1 : 0); if (on) press = true; } else { const v = coerce(fd, sample[name] ?? fd.init, dims); input[name] = v; values.push(v === true ? 1 : v === false ? 0 : v as number); }
292
+ if (fd.t === 'press') { const on = fresh && pressed.has(name); input[name] = on; values.push(on ? 1 : 0); if (on) press = true; } else { const v = coerce(fd, (fresh ? sample[name] : held[name]) ?? fd.init, dims); input[name] = v; values.push(v === true ? 1 : v === false ? 0 : v as number); }
204
293
  }
205
- pressed = new Set();
294
+ if (fresh) pressed = new Set();
206
295
  held = Object.freeze({ ...input });
207
- if (kind.owner) {
296
+ {
208
297
  const body = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: mine.motion }, held, t);
209
298
  mine.pos = body.pos; mine.vel = body.vel; mine.heading = body.heading; mine.grounded = body.grounded; mine.motion = body.motion;
210
299
  }
211
300
  const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
212
301
  const sig = JSON.stringify([values, claim]);
213
- if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); lastSent = sig; }
302
+ if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); pending.push({ t, input: held }); lastSent = sig; }
303
+ history.set(t, copyMine(mine));
304
+ for (const k of history.keys()) if (k < t - tickHz && (catchTick === null || k < Math.floor(catchTick))) history.delete(k);
214
305
  }
215
306
  /**
216
307
  * The end of a send period (`sendEvery` ticks, counted from tick 0): one frame with the entries made in it, `k` the
@@ -232,7 +323,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
232
323
  if (!mine || !kind) return;
233
324
  const values = kind.input.map(([name, fd]) => (fd.t === 'press' ? 0 : Number(coerce(fd, sample[name] ?? fd.init, dims))));
234
325
  const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
235
- entries = [{ t, row: [...values, ...claim] }]; lastSent = JSON.stringify([values, claim]);
326
+ entries = [{ t, row: [...values, ...claim] }]; pending.push({ t, input: Object.fromEntries(kind.input.map(([name, fd], i) => [name, fd.t === 'press' ? false : values[i]])) }); lastSent = JSON.stringify([values, claim]);
236
327
  }
237
328
  function pump(): void {
238
329
  if (closed) return;
@@ -250,12 +341,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
250
341
  return;
251
342
  }
252
343
  hiddenSent = false;
344
+ // Feedback can be sparse (idle keepalives or dropped snapshots). Re-evaluate the
345
+ // steering every frame so a stale +/-5% command cannot drive past its target.
346
+ steer(now);
253
347
  let t = Math.floor(tickAt(now));
254
348
  // A frame longer than a quarter of a second: set the clock again from the newest snapshot and carry on from there.
255
- if (t - stepped > Math.ceil(tickHz / 4) && latest) { rebase(latest.k); t = Math.floor(tickAt(now)); }
349
+ if (t - stepped > Math.ceil(tickHz / 4) && latest) { rebase(latest.k, 'frame'); t = Math.floor(tickAt(now)); }
256
350
  while (stepped < t) {
257
351
  stepped += 1;
258
- step(stepped);
352
+ step(stepped, stepped === t);
259
353
  if (stepped % sendEvery === sendEvery - 1) flush(now, stepped);
260
354
  }
261
355
  }
@@ -264,24 +358,99 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
264
358
  const f = frameOf(s);
265
359
  if (!f) return;
266
360
  const before = latest;
361
+ if (before && before.e === f.e && f.k <= before.k) return;
267
362
  latest = f;
363
+ if (before && before.e !== f.e) hostAges.length = 0;
364
+ hostAges.push(Math.max(0, net.now() - s.st)); if (hostAges.length > 40) hostAges.shift();
365
+ if (before && before.e !== f.e) fxQueue.length = 0;
268
366
  const changedEpoch = f.e !== epoch;
269
367
  epoch = f.e;
270
368
  const seat = net.seat;
271
369
  let me: Unpacked | null = null;
272
- if (seat !== null) for (const u of f.ents.values()) if (u.seat === seat && u.driver !== 'bot') { me = u; break; }
370
+ if (seat !== null) for (const u of f.ents.values()) if (u.seat === seat && u.driver === 'person') { me = u; break; }
273
371
  if (me) {
274
- // The server placed the body (a spawn, a round reset, a seat taken over), or the room began again: jump there.
275
372
  const placed = !mine || mine.id !== me.id || mine.r !== me.r || changedEpoch;
276
- if (placed) { adopt(me); emit('placed', entityOf(me, true)); }
277
- else if (mine) { mine.motion = motionOf(me.kind, me.motion); if (!kindOf.get(me.kind)?.owner) { mine.pos = me.pos; mine.vel = me.vel; mine.heading = me.heading; mine.grounded = me.grounded; } }
278
- if (!base || changedEpoch) rebase(f.k);
279
373
  const row = f.rows.find((x) => x[0] === seat);
280
- if (row && row[3] !== -128 && base) {
281
- // Hold the lead: the median of the last 8, nudged by at most a twentieth of a tick a snapshot; far off, set the clock again.
282
- leads.push(row[3] / 16); if (leads.length > 8) leads.shift();
283
- const med = [...leads].sort((a, b) => a - b)[Math.floor(leads.length / 2)];
284
- if (Math.abs(med - TARGET_LEAD) > 4) rebase(f.k); else base.tick += Math.max(-0.05, Math.min(0.05, (TARGET_LEAD - med) * 0.1));
374
+ if (placed || !base) {
375
+ adopt(me); rebase(f.k);
376
+ if (placed) emit('placed', entityOf(me, true));
377
+ } else if (kindOf.get(me.kind)?.owner) {
378
+ mine!.motion = motionOf(me.kind, me.motion);
379
+ } else {
380
+ const previous = mine!.pos;
381
+ const shown = meNow()?.pos ?? previous;
382
+ const oldOffset = visualOffset(clock()), oldOffsets = offsets;
383
+ adopt(me);
384
+ history.set(f.k, copyMine(mine!));
385
+ const ack = row?.[2] ?? 0;
386
+ const late = new Set<string>();
387
+ for (const entry of pending) if (entry.t <= f.k) {
388
+ replayHeld = entry.input;
389
+ if (entry.t > ack && f.k + 1 - entry.t <= Math.ceil(tickHz / 4))
390
+ for (const [name, fd] of myKind()!.input) if (fd.t === 'press' && entry.input[name]) late.add(name);
391
+ }
392
+ let input = replayHeld;
393
+ for (let t = f.k + 1; t <= stepped; t++) {
394
+ const presses = new Set(t === f.k + 1 ? late : []);
395
+ for (const entry of pending) if (entry.t === t) {
396
+ input = entry.input;
397
+ for (const [name, fd] of myKind()!.input) if (fd.t === 'press' && entry.input[name]) presses.add(name);
398
+ }
399
+ const stepInput = { ...input };
400
+ for (const [name, fd] of myKind()!.input) if (fd.t === 'press') stepInput[name] = presses.has(name);
401
+ Object.assign(mine!, runMove(me.kind, mine!, Object.freeze(stepInput), t));
402
+ history.set(t, copyMine(mine!));
403
+ }
404
+ pending = pending.filter(e => e.t > f.k || e.t > ack && f.k + 1 - e.t <= Math.ceil(tickHz / 4));
405
+ const error = distance(previous, mine!.pos);
406
+ correction.last = error;
407
+ if (error > 0.00001) { correction.count++; correction.max = Math.max(correction.max, error); }
408
+ // A snapshot can replace the historical segment being drawn even when replay reaches
409
+ // the same current position. Preserve that drawn pose on every catch-up reconciliation.
410
+ if (error > 0.00001 || catchTick !== null) {
411
+ if (catchTick === null && error > (predict.catchM ?? myKind()!.maxSpeed * 3 / tickHz) + 0.00001) {
412
+ catchTick = f.k; catchAt = clock(); correction.catches++;
413
+ }
414
+ // Older history belongs to the previous reconciliation. Crossing from it into
415
+ // the newly adopted snapshot can draw a backwards segment even though both
416
+ // paths move forward. Start on the new path and preserve the shown pose below.
417
+ if (catchTick !== null && catchTick < f.k) {
418
+ catchTick = f.k; catchAt = clock();
419
+ }
420
+ offsets = [];
421
+ const corrected = meNow()?.pos ?? mine!.pos;
422
+ // Limit the new reconciliation, not an offset already being blended (which
423
+ // can include a clock recalibration). Clipping that carried offset would
424
+ // turn a small new error into a visible snap.
425
+ const change = { x: shown.x - corrected.x - oldOffset.x, y: shown.y - corrected.y - oldOffset.y, z: shown.z - corrected.z - oldOffset.z };
426
+ const gap = Math.hypot(change.x, change.y, change.z);
427
+ const ahead = Math.max(0, tickAt(clock()) - f.k) / tickHz;
428
+ const cap = predict.snapM ?? myKind()!.maxSpeed * (2 * ahead + 0.1);
429
+ const scale = gap > cap && gap > 0 ? cap / gap : 1;
430
+ if (scale < 1) correction.snaps++;
431
+ const delta = { x: oldOffset.x + change.x * scale, y: oldOffset.y + change.y * scale, z: oldOffset.z + change.z * scale };
432
+ if (catchTick !== null || scale < 1) offsets = [{ delta, at: clock(), path: corrected, fade: 1, last: clock() }];
433
+ else {
434
+ // Each small correction gets its own blend. Restarting one fade for their accumulated
435
+ // offset can erase several ticks of travel in 100 ms and send a steadily moving body backwards.
436
+ offsets = oldOffsets;
437
+ for (const o of offsets) { o.path = corrected; o.last = clock(); }
438
+ offsets.push({ delta: { x: delta.x - oldOffset.x, y: delta.y - oldOffset.y, z: delta.z - oldOffset.z }, at: clock(), path: corrected, fade: 1, last: clock() });
439
+ }
440
+ }
441
+ }
442
+ if (row && row[3] !== -128 && base && clock() >= leadAfter) {
443
+ const now = clock(), rtt = authorityRtt();
444
+ // A reported lead describes an input sent roughly one authority round trip ago. Remove that
445
+ // clock's phase before measuring jitter; project the control median onto today's phase. Otherwise
446
+ // our own +/-5% steering inflates the 40-sample jitter window and winds the clock ever further ahead.
447
+ observedLeadAt = now;
448
+ leads.push(row[3] / 16 - phaseAt(now - rtt)); if (leads.length > 40) leads.shift();
449
+ const med = medianLead(), target = targetLead();
450
+ if (leads.length >= 8 && Math.abs(med - target) > 4) rebase(f.k, 'lead');
451
+ else {
452
+ steer(now);
453
+ }
285
454
  }
286
455
  } else mine = null;
287
456
  if (before) {
@@ -302,13 +471,16 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
302
471
  const serverTick = latest.k + ((net.connected || net.rulesHosting) ? (clock() - latest.at) / period : 0);
303
472
  return { ...round, secondsLeft: round.endsAt ? Math.max(0, (round.endsAt - serverTick) / tickHz) : Infinity, ...(results ? { results } : {}) };
304
473
  }
305
- /** Effects wait for the tick they were emitted on to be drawn (an effect on the player's own body plays at once). */
474
+ /** Effects wait for their authoritative snapshot and the body's drawn tick, including its catch-up path. */
306
475
  function fire(): void {
307
476
  if (!fxQueue.length) return;
308
477
  const s = net.sample();
309
478
  const drawn = s ? lerp(s.a.k, s.b.k, s.alpha) : latest ? latest.k : 0;
310
- while (fxQueue.length && (fxQueue[0].tick <= drawn || fxQueue[0].at === mine?.id || fxQueue.length > 128)) {
311
- const x = fxQueue.shift() as (typeof fxQueue)[number];
479
+ for (let i = 0; i < fxQueue.length;) {
480
+ const x = fxQueue[i];
481
+ const ownTick = catchTick === null ? tickAt(clock()) : catchTick + Math.max(0, clock() - catchAt) / period * predict.catchUp;
482
+ if (x.tick > (x.at === mine?.id ? Math.min(latest?.k ?? 0, ownTick) : drawn)) { i++; continue; }
483
+ fxQueue.splice(i, 1);
312
484
  const id = typeof x.at === 'string' ? x.at : null;
313
485
  const where = id ? get(id)?.pos ?? null : unpackVec(x.at, dims);
314
486
  emit(x.name, Object.freeze({ ...x.data, at: where, ...(id ? { id } : {}), tick: x.tick }));
@@ -361,14 +533,32 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
361
533
  const kind = kindOf.get(mine.kind);
362
534
  let pos = mine.pos;
363
535
  let heading = mine.heading;
364
- if (kind?.owner && base) {
536
+ let settled = true;
537
+ let preview: { pos: Vec3; heading: Vec3 } | null = null;
538
+ if (kind && base) {
365
539
  const a = Math.max(0, Math.min(1, tickAt(clock()) - stepped));
366
540
  const now: Record<string, unknown> = {};
367
541
  for (const [name, fd] of kind.input) now[name] = fd.t === 'press' ? pressed.has(name) : coerce(fd, sample[name] ?? fd.init, dims);
368
542
  const next = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
369
543
  pos = vec3({ x: lerp(mine.pos.x, next.pos.x, a), y: lerp(mine.pos.y, next.pos.y, a), z: lerp(mine.pos.z, next.pos.z, a) }, dims);
370
544
  heading = next.heading;
545
+ preview = next;
546
+ settled = distance(next.pos, mine.pos) < 0.00001;
547
+ }
548
+ if (catchTick !== null && base) {
549
+ const now = clock();
550
+ catchTick += Math.max(0, now - catchAt) / period * predict.catchUp; catchAt = now;
551
+ if (catchTick >= tickAt(now)) catchTick = null;
552
+ else {
553
+ // Catch-up can reach the fractional current tick before its next whole step exists.
554
+ // Use the same preview as normal drawing, rather than hold and jump when that step arrives.
555
+ const t = Math.floor(catchTick), a = history.get(t), b = history.get(t + 1) ?? (t === stepped ? preview : null);
556
+ if (a && b) { const f = catchTick - t; pos = { x: lerp(a.pos.x, b.pos.x, f), y: lerp(a.pos.y, b.pos.y, f), z: lerp(a.pos.z, b.pos.z, f) }; heading = b.heading; }
557
+ else if (a) pos = a.pos;
558
+ }
371
559
  }
560
+ const offset = visualOffset(clock(), pos, settled);
561
+ pos = { x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z };
372
562
  return entityOf({ ...server, pos, vel: mine.vel, heading, grounded: mine.grounded, motion: mine.motion }, true);
373
563
  }
374
564
  function drawn(id: string): Entity | null {
@@ -379,7 +569,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
379
569
  const ea = a?.ents.get(id); const eb = b?.ents.get(id);
380
570
  if (!ea || !eb) return ea ? entityOf(ea) : eb && s && s.alpha >= 1 ? entityOf(eb) : eb && !a?.ents.size ? entityOf(eb) : null;
381
571
  // A placed body jumps: nobody's view glides across a placement.
382
- if (ea.r !== eb.r || !s) return entityOf(eb);
572
+ if (ea.r !== eb.r || a?.e !== b?.e || !s) return entityOf(eb);
383
573
  const t = s.alpha;
384
574
  const h = dir({ x: lerp(ea.heading.x, eb.heading.x, t), y: lerp(ea.heading.y, eb.heading.y, t), z: lerp(ea.heading.z, eb.heading.z, t) }, dims);
385
575
  return entityOf({ ...eb, pos: vec3({ x: lerp(ea.pos.x, eb.pos.x, t), y: lerp(ea.pos.y, eb.pos.y, t), z: lerp(ea.pos.z, eb.pos.z, t) }, dims), vel: eb.vel, heading: h });
@@ -408,13 +598,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
408
598
  net.expose({
409
599
  self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
410
600
  peer: (seat: number) => { if (!latest) return null; for (const u of latest.ents.values()) if (u.seat === seat) return { x: u.pos.x, y: u.pos.y, z: u.pos.z }; return null; },
601
+ prediction: () => ({ ...correction, observedLeadAt, lastSendAt, now: clock(), serverTick: latest?.k, tick: stepped, catchTick, lead: targetLead(), medianLead: medianLead(), rate: speed, rtt: authorityRtt(), hostAge: quantile(hostAges, 0.5), pending: pending.length }),
411
602
  scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
412
603
  score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => status,
413
604
  });
414
605
  if (typeof window !== 'undefined') {
415
606
  exposePort(net as Netplay<unknown, unknown, unknown>, {
416
607
  view: 'top', size: schema.kinds.find((k) => k.player)?.radius ?? 0.5,
417
- self: () => { const m = meNow(); return m ? { x: m.pos.x, y: -m.pos.y } : null; },
608
+ self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y } : null; },
609
+ basis: opts.screenBasis ?? (() => ({ right: [1, 0], up: [0, 1] })),
418
610
  score: myScore, busy: () => (typeof extra.busy === 'function' ? Boolean(extra.busy()) : busy()),
419
611
  extra: new Proxy({}, { get: (_t, key) => (typeof key === 'string' ? extra[key] : undefined), ownKeys: () => Object.keys(extra), getOwnPropertyDescriptor: (_t, key) => (typeof key === 'string' && extra[key] ? { enumerable: true, configurable: true, value: extra[key] } : undefined) }) as Record<string, () => unknown>,
420
612
  });
@@ -0,0 +1,17 @@
1
+ {
2
+ "v": 1,
3
+ "default": "run",
4
+ "takes": {
5
+ "run": {
6
+ "note": "Run right, then turn upward on the same rules clock",
7
+ "seconds": 2,
8
+ "seed": 7,
9
+ "device": "desk",
10
+ "fps": 60,
11
+ "inputs": [
12
+ { "at": 0.2, "key": "ArrowRight", "hold": 0.6 },
13
+ { "at": 1, "key": "ArrowUp", "hold": 0.6 }
14
+ ]
15
+ }
16
+ }
17
+ }
@@ -10,8 +10,8 @@
10
10
  * on a later tick. So a runner does not take a coin: it asks the coin (`take`), the coin answers the first one that
11
11
  * asked (`score`) and is gone, and a second `take` finds nobody there.
12
12
  *
13
- * In this release a person's runner moves as today: its own browser says where it is, and the server holds each claim
14
- * to the body's top speed (`move: 'owner'`). The server moves the bots with the same code (src/move.ts).
13
+ * The server moves every runner with src/move.ts. The view predicts its own runner using that same module,
14
+ * so controls answer immediately while the server decides what is true.
15
15
  */
16
16
  import { defineRules, f } from '@homie-rocks/studio/rules';
17
17
  import { move } from './move';
@@ -31,7 +31,7 @@ export default defineRules({
31
31
  fields: { score: f.u16({ score: true }) },
32
32
  motion: { frozenUntil: f.tick() },
33
33
  input: { ax: f.i8(), ay: f.i8() },
34
- body: { shape: 'circle', radius: 0.5, maxSpeed: 6, move: 'owner' },
34
+ body: { shape: 'circle', radius: 0.5, maxSpeed: 6 },
35
35
  tick(world, self) {
36
36
  if (world.round.phase !== 'live') return;
37
37
  // A second `take` sent before the coin is gone is dropped: the coin no longer exists.
@@ -2,9 +2,24 @@
2
2
  "id": "gem-rush",
3
3
  "name": "Gem Rush",
4
4
  "blurb": "Grab gems, knock rivals away. Sixty-second rounds that never stop, and everyone who presses Play is in the same room.",
5
- "players": { "min": 1, "max": 8 },
5
+ "players": {
6
+ "min": 1,
7
+ "max": 8
8
+ },
6
9
  "roundSeconds": 60,
7
- "entry": "src/main.ts",
8
- "netplay": { "v": 1, "public": true, "movement": "owner" },
9
- "landing": { "pitch": "Grab the gems. Bump your rivals away.", "controls": { "phone": "Drag to move; a second finger bumps.", "computer": "WASD or arrows to move, Space to bump." } }
10
+ "entry": "src/view.ts",
11
+ "netplay": {
12
+ "v": 1,
13
+ "public": true
14
+ },
15
+ "landing": {
16
+ "pitch": "Grab the gems. Bump your rivals away.",
17
+ "controls": {
18
+ "phone": "Drag to move; a second finger bumps.",
19
+ "computer": "WASD or arrows to move, Space to bump."
20
+ }
21
+ },
22
+ "room": {
23
+ "host": "server"
24
+ }
10
25
  }
@@ -0,0 +1,50 @@
1
+ {
2
+ "bounds": {
3
+ "min": [
4
+ 0,
5
+ 0
6
+ ],
7
+ "max": [
8
+ 32,
9
+ 20
10
+ ]
11
+ },
12
+ "boxes": [],
13
+ "circles": [],
14
+ "spots": {
15
+ "start": [
16
+ [
17
+ 22.6,
18
+ 10.0
19
+ ],
20
+ [
21
+ 20.66690475583121,
22
+ 13.535533905932738
23
+ ],
24
+ [
25
+ 16.0,
26
+ 15.0
27
+ ],
28
+ [
29
+ 11.333095244168787,
30
+ 13.535533905932738
31
+ ],
32
+ [
33
+ 9.4,
34
+ 10.0
35
+ ],
36
+ [
37
+ 11.333095244168785,
38
+ 6.464466094067262
39
+ ],
40
+ [
41
+ 15.999999999999998,
42
+ 5.0
43
+ ],
44
+ [
45
+ 20.66690475583121,
46
+ 6.464466094067261
47
+ ]
48
+ ]
49
+ }
50
+ }
@@ -0,0 +1,23 @@
1
+ import { defineMove } from '@homie-rocks/studio/rules';
2
+
3
+ export const move = defineMove({
4
+ runner(body, input, ctx) {
5
+ const M = ctx.math, m = body.motion, T = ctx.tune;
6
+ if (m.knockAt > 0) {
7
+ const last = ctx.tick + 1 >= m.knockUntil;
8
+ const u = M.clamp((ctx.tick - m.knockAt) / (m.knockUntil - m.knockAt), 0, 1);
9
+ const ease = last ? 1 : 1 - M.pow(1 - u, T.knockEase);
10
+ const to = M.add(m.knockFrom, M.scale(m.knockDir, T.knockDistance * ease));
11
+ const delta = M.sub(to, body.pos);
12
+ ctx.map.sweep(body, delta);
13
+ body.vel = last ? { x: 0, y: 0, z: 0 } : M.scale(delta, 1 / ctx.dt);
14
+ if (last) m.knockAt = 0;
15
+ return;
16
+ }
17
+ const want = M.scale(M.clampLen({ x: input.ax / 127, y: input.ay / 127, z: 0 }, 1), m.speed);
18
+ const keep = M.pow(1 - m.accel / 60, ctx.dt * 60);
19
+ body.vel = M.add(want, M.scale(M.sub(body.vel, want), keep));
20
+ if (M.len(body.vel) > 0.6) body.heading = M.norm(body.vel);
21
+ ctx.map.sweep(body, M.scale(body.vel, ctx.dt));
22
+ },
23
+ });