@homie-rocks/studio 0.46.2 → 0.46.3

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/rules.ts CHANGED
@@ -54,7 +54,7 @@ export const QUEUE_MAX = 16_384;
54
54
 
55
55
  /* ------------------------------------------------------------------ field types */
56
56
 
57
- export type FieldKind = 'u8' | 'u16' | 'u32' | 'i8' | 'i16' | 'i32' | 'bit' | 'fix' | 'vec3' | 'dir' | 'tick' | 'ticks' | 'ref' | 'text' | 'list' | 'map' | 'struct' | 'press' | 'pulse';
57
+ export type FieldKind = 'u8' | 'u16' | 'u32' | 'i8' | 'i16' | 'i32' | 'bit' | 'fix' | 'vec3' | 'dir' | 'tick' | 'ticks' | 'ref' | 'text' | 'list' | 'map' | 'struct' | 'press' | 'pulse' | 'held';
58
58
  export interface Field {
59
59
  readonly t: FieldKind;
60
60
  /** The value a new entity starts with. */
@@ -82,7 +82,8 @@ const plain = (t: FieldKind) => (o?: FieldOptions): Field => made(t, o);
82
82
  * false; `fix` is a fixed-point number in steps of 1/4096; `vec3` is a position or a velocity in metres; `dir` is a
83
83
  * unit vector in the declared dimensions; `tick` is a moment and `ticks` a length on the room's clock; `ref` is the id of an
84
84
  * entity or a player; `text`, `list` and `map` name their largest size. `press` (in `input` only) is a button that
85
- * fires once.
85
+ * fires once; `held` (in `input` only) is a number the browser averages over each tick, so a tap shorter than a tick
86
+ * moves for the part of the tick it was down.
86
87
  */
87
88
  export const f = brand(Object.freeze({
88
89
  u8: plain('u8'), u16: plain('u16'), u32: plain('u32'), i8: plain('i8'), i16: plain('i16'), i32: plain('i32'),
@@ -93,6 +94,8 @@ export const f = brand(Object.freeze({
93
94
  struct: (fields: Fields, o?: FieldOptions): Field => made('struct', o, { fields: Object.freeze({ ...fields }) }),
94
95
  press: (): Field => made('press', undefined),
95
96
  pulse: (): Field => made('pulse', undefined),
97
+ /** Input only (0.46.3): a number averaged over the tick. A key down for a third of a tick reads a third; a stick reads its mean. */
98
+ held: (): Field => made('held', undefined),
96
99
  }));
97
100
 
98
101
  /**
@@ -167,6 +170,8 @@ export interface RulesDef {
167
170
  bots?: { keep: number };
168
171
  records?: RoundRecords;
169
172
  historySeconds?: number;
173
+ /** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
174
+ rewindSeconds?: number;
170
175
  start?: RoomHandler;
171
176
  join?: (ctx: any, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string; character?: Character | null }) => { kind: string; at: Vec3; heading?: Vec3; fields?: Record<string, unknown>; motion?: Record<string, unknown> };
172
177
  on?: Record<string, RoomHandler>;
@@ -387,6 +392,8 @@ export interface Compiled {
387
392
  view: FieldList;
388
393
  records?: RoundRecords;
389
394
  historySeconds?: number;
395
+ /** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
396
+ rewindSeconds?: number;
390
397
  rounds: { seconds: number; breakSeconds: number } | null;
391
398
  bots: number;
392
399
  start: RoomHandler | null;
@@ -409,7 +416,7 @@ export const BUILT_IN_FIELDS = Object.freeze(['id', 'kind', 'pos', 'vel', 'headi
409
416
 
410
417
  const NAME = /^[a-z][A-Za-z0-9_]{0,31}$/;
411
418
  const INTS = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32']);
412
- const TYPES = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'bit', 'fix', 'vec3', 'dir', 'tick', 'ticks', 'ref', 'text', 'list', 'map', 'struct', 'press', 'pulse']);
419
+ const TYPES = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'bit', 'fix', 'vec3', 'dir', 'tick', 'ticks', 'ref', 'text', 'list', 'map', 'struct', 'press', 'pulse', 'held']);
413
420
 
414
421
  /**
415
422
  * One declared field, checked, and made again as the runtime's own: a frozen record of its type, its sizes and its
@@ -436,7 +443,8 @@ function checkField(fd: unknown, where: string, inInput: boolean): void {
436
443
  const x = fd as Field;
437
444
  if (!x || typeof x !== 'object' || typeof x.t !== 'string' || !TYPES.has(x.t)) throw new Error(`${where} is not a field type: declare it with f (f.u16(), f.vec3(), f.list(f.ref(), 8)…)`);
438
445
  if ((x.t === 'press' || x.t === 'pulse') && !inInput) throw new Error(`${where}: f.press() is for input only (a button that fires once)`);
439
- if (inInput && !(INTS.has(x.t) || x.t === 'bit' || x.t === 'press' || x.t === 'pulse' || x.t === 'fix')) throw new Error(`${where}: an input field is a whole number, f.fix(), f.bit() or f.press()`);
446
+ if (x.t === 'held' && !inInput) throw new Error(`${where}: f.held() is for input only (a control averaged over the tick it was held in)`);
447
+ if (inInput && !(INTS.has(x.t) || x.t === 'bit' || x.t === 'press' || x.t === 'pulse' || x.t === 'held' || x.t === 'fix')) throw new Error(`${where}: an input field is a whole number, f.fix(), f.held(), f.bit() or f.press()`);
440
448
  if (x.durable !== undefined && (typeof x.durable !== 'boolean' || inInput || !/^(entities\.[^.]+\.fields|shared)\.[^.]+$/.test(where))) throw new Error(`${where}: durable belongs on a top-level state field`);
441
449
  if (x.visibility !== undefined && !/^entities\.[^.]+\.fields\.[^.]+$/.test(where)) throw new Error(`${where}: visibility belongs on a top-level entity field`);
442
450
  if (x.visibility !== undefined && (!['owner','server','results'].includes(x.visibility) || inInput)) throw new Error(`${where}: visibility is owner, server or results on state fields only`);
@@ -646,13 +654,14 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
646
654
  }
647
655
  }
648
656
  if (room.historySeconds !== undefined && (typeof room.historySeconds !== 'number' || !Number.isFinite(room.historySeconds) || room.historySeconds < 0 || room.historySeconds > 2)) throw new Error('room.historySeconds is from 0 to 2');
657
+ if (room.rewindSeconds !== undefined && (typeof room.rewindSeconds !== 'number' || !Number.isFinite(room.rewindSeconds) || room.rewindSeconds < 0 || room.rewindSeconds > (room.historySeconds ?? 0))) throw new Error('room.rewindSeconds is the most a shot is rewound for a slow connection: a number from 0 to room.historySeconds');
649
658
  const seats = Math.max(1, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(Number(env.seats)) || 8));
650
659
  const navigation:Record<string,readonly RouteNode[]> = Object.create(null);let graphNodes=0;
651
660
  if(d.navigation){const entries=Object.entries(d.navigation);if(entries.length>8)throw new Error('navigation declares at most 8 graphs');for(const [name,graph] of entries){if(!/^[A-Za-z][A-Za-z0-9_]{0,47}$/.test(name))throw new Error('navigation graph names are identifiers up to 48 characters');navigation[name]=prepareGraph(graph);graphNodes+=navigation[name].length;if(graphNodes>8192)throw new Error('navigation declares at most 8192 nodes in total');}}
652
661
  Object.freeze(navigation);
653
662
  return {
654
663
  navigation, contract: RULES_CONTRACT, save: SAVE_REVISION, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
655
- shared: fieldList(d.shared, 'shared'), view, ...(room.records?{records:room.records}:{}), ...(room.historySeconds ? {historySeconds:room.historySeconds}:{}), rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
664
+ shared: fieldList(d.shared, 'shared'), view, ...(room.records?{records:room.records}:{}), ...(room.historySeconds ? {historySeconds:room.historySeconds}:{}), ...(room.rewindSeconds !== undefined ? {rewindSeconds:room.rewindSeconds}:{}), rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
656
665
  tune, publicTune, map, settings, seats,
657
666
  };
658
667
  }
@@ -674,6 +683,8 @@ export interface Schema {
674
683
  commands: Record<string, FieldList>;
675
684
  shared: FieldList;
676
685
  historySeconds?: number;
686
+ /** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
687
+ rewindSeconds?: number;
677
688
  rounds: { seconds: number; breakSeconds: number } | null;
678
689
  settings: RoomSettings;
679
690
  }
package/rules/types.ts CHANGED
@@ -22,6 +22,7 @@ export interface RayOptions {
22
22
  export type RayHit = Hit & { readonly dist: number };
23
23
  export interface RaySnapshot {sweep(body:{pos:Point;grounded?:boolean},delta:Point,options?:{ground?:boolean}):Hit|undefined;support(body:{pos:Point},distance?:number):(Hit&{dist:number})|undefined;ray(from:Point,direction:Point,max:number):RayHit|undefined;rayAll(from:Point,direction:Point,max:number):readonly RayHit[]}
24
24
  export interface RayQueries { rays(options?:RayOptions):RaySnapshot; ray(from: Point, direction: Point, max: number, options?: RayOptions): RayHit | undefined; rayAll(from: Point, direction: Point, max: number, options?: RayOptions): readonly RayHit[] }
25
+ export interface EffectAudience { readonly to?: string | readonly string[]; readonly within?: number }
25
26
  export type Area = { sphere: { at: Point; r: number } } | { box: { min: Point; max: Point } } | { cone: { at: Point; dir: Point; r: number; angle: number } };
26
27
  export interface MapView { readonly name: string; spot(name: string): Vec3 | undefined; spots(name: string): readonly Vec3[] }
27
28
  export interface Clock { readonly tick: number; readonly dt: number; ticks(seconds: number): number; readonly math: typeof math }
@@ -30,6 +31,8 @@ export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
30
31
  readonly tune: ReadonlyState<T>; readonly map: MapView; readonly stage: string; readonly level: number; readonly levelMax: number; readonly guideLevel: number; readonly guideSeats: readonly number[]; readonly kids: boolean; readonly levelSet: boolean;
31
32
  readonly shared: Room extends true ? S : ReadonlyState<S>;
32
33
  readonly round: Round & (Room extends true ? { end(): void; restart(): void } : {});
34
+ /** Budget units the running handler may still spend (0.46.3). */
35
+ readonly unitsLeft: number;
33
36
  random(): number;
34
37
  label(player:string):string;
35
38
  send<K extends keyof E>(target: string, event: K, ...data: Payload<E[K]>): void;
@@ -38,6 +41,8 @@ export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
38
41
  sendArea<K extends keyof E>(area: Area, event: K, ...data: Payload<E[K]>): void;
39
42
  after<K extends keyof E>(ticks: number, event: K, ...data: Payload<E[K]>): void;
40
43
  emit<K extends keyof F>(effect: K, at: Point | string, ...data: Payload<F[K]>): void;
44
+ /** With an audience (0.46.3): only the people driving the entities named in `to`, and those whose own body is within `within` metres, receive it. A watcher receives the ones with a range, never one addressed only `to` somebody. */
45
+ emit<K extends keyof F>(effect: K, at: Point | string, data: WriteState<F[K]>, audience: EffectAudience): void;
41
46
  spawn<K extends keyof C>(kind: K, at: Point, fields?: Partial<WriteState<C[K]>>): string;
42
47
  near<K extends keyof V>(at: Point, radius: number, kind: K): readonly Query<V[K]>[];
43
48
  near(at: Point, radius: number): readonly Query<V[keyof V]>[];
package/rules/view.ts CHANGED
@@ -49,7 +49,7 @@ import { lab } from '../lab/lab.ts';
49
49
  import { exposePort, type PortProbeOptions } from '../port/probe.ts';
50
50
  import { BudgetError } from './guard.ts';
51
51
  import { moveContext } from './math.ts';
52
- import { coerce, dir, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
52
+ import { coerce, coerceFields, dir, initFields, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
53
53
  import type { Unpacked } from './pack.ts';
54
54
  import { compileMap } from './rules.ts';
55
55
  import type { FieldList, MoveFn, Schema, Vec3 } from './rules.ts';
@@ -95,12 +95,37 @@ export interface OpenRoomOptions {
95
95
  game?: GameData;
96
96
  move?: Record<string, MoveFn>;
97
97
  net?: NetplayOptions;
98
+ /**
99
+ * Predict the player's OWN actions (0.46.3), by kind: `predict: { runner(me, input, ctx) { ... } }`. The room runs it
100
+ * for the own body once a predicted tick, after `move`, with that tick's input, and again on a copy between ticks so
101
+ * a press answers on the next drawn frame. `me` holds the body's declared fields and may be written: `room.me` then
102
+ * reads the predicted values. Every snapshot puts the server's values back and the pending inputs are run again,
103
+ * exactly as the own position is. `ctx.ghost` shows a provisional entity and `ctx.effect` a provisional effect until
104
+ * the server confirms or the room retracts them (`room.on('retract', ...)`). The server stays the authority.
105
+ */
106
+ predict?: Record<string, Predictor>;
98
107
  /** Camera axes on the rules' x/y plane for the testing tools. Default: x right, y up. */
99
108
  screenBasis?: PortProbeOptions['basis'];
100
109
  /** Tests: a clock in ms and a way to run without timers. */
101
110
  now?: () => number;
102
111
  timers?: boolean;
103
112
  }
113
+ /** What a predictor (`openRoom({ predict })`) is handed beside the body and the tick's input. */
114
+ export interface PredictContext extends RayQueries {
115
+ readonly tick: number; readonly dt: number;
116
+ /** The tick the other bodies are drawn at: what the player is looking at. */
117
+ readonly viewTick: number;
118
+ /** This run draws ahead of the tick; the tick's own run follows. What it shows is shown once. */
119
+ readonly preview: boolean;
120
+ /** A provisional entity, drawn by `room.each` with `provisional: true` until an entity of that kind which `match` accepts (default: within a metre of it) arrives, or the server has run the tick without one. Returns its id. */
121
+ ghost(kind: string, at: { x: number; y: number; z?: number }, fields?: Record<string, unknown>, options?: { seconds?: number; match?: (e: Entity) => boolean; key?: string }): string;
122
+ /**
123
+ * A provisional effect: listeners hear it now with `predicted: true`. The server's own effect of that name which `match` accepts (default: the next one) then arrives with `confirms: true`; if the server ran the tick without one, listeners of `retract` hear it.
124
+ * A tick's prediction runs more than once (a preview between ticks, again whenever the input changes, then the tick itself) and shows each thing once: things are told apart by their name and `key`, so give two effects of one name that mean different things (two sounds) a `key` each.
125
+ */
126
+ effect(name: string, at: { x: number; y: number; z?: number } | string, data?: Record<string, unknown>, options?: { match?: (e: any) => boolean; key?: string }): void;
127
+ }
128
+ export type Predictor = (me: any, input: any, ctx: PredictContext) => void;
104
129
  export interface Room<R = unknown> extends RayQueries {
105
130
  readonly viewTick: number;
106
131
  readonly status: RoomStatus;
@@ -163,7 +188,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
163
188
  let round: RoomRound | null = null;
164
189
  let results: RoundInfo['results'] | undefined;
165
190
  let closed = false;
166
- const fxQueue: { tick: number; name: string; at: unknown; data: Record<string, unknown> }[] = [];
191
+ const fxQueue: { tick: number; name: string; at: unknown; data: Record<string, unknown>; confirms?: boolean }[] = [];
167
192
  const setStatus = (s: RoomStatus): void => { if (s !== status) { status = s; emit('status', s); } };
168
193
 
169
194
  /* ---------------------------------------------------------------- frames in */
@@ -186,7 +211,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
186
211
 
187
212
  /* ---------------------------------------------------------------- the own body, on this browser's copy of the clock */
188
213
 
189
- interface Mine { id: string; kind: string; r: number; pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }
214
+ interface Mine { id: string; kind: string; r: number; pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown>; fields: Record<string, unknown> }
190
215
  let mine: Mine | null = null;
191
216
  /** The clock: the room tick this browser is on at `at` (ms), and the newest step it has run. */
192
217
  let base: { tick: number; at: number } | null = null;
@@ -203,8 +228,13 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
203
228
  let entries: { t: number; row: number[] }[] = [];
204
229
  let hiddenSent = false;
205
230
  const history = new Map<number, Mine>();
206
- let pending: { t: number; input: Readonly<Record<string, unknown>> }[] = [];
231
+ let pending: { t: number; input: Readonly<Record<string, unknown>>; pulses?: Record<string, number> }[] = [];
232
+ /** Valued pulses sent, numbered: the server's control row names the newest it has acted on (0.46.3). */
233
+ let pulseCount = 0;
234
+ let pulseNumber = new Map<string, number>();
207
235
  let replayHeld: Readonly<Record<string, unknown>> = Object.freeze({});
236
+ /** `f.held()` values as the server last had them: those of the newest entry it has acknowledged. */
237
+ let serverHeld: Record<string, number> = {};
208
238
  let speed = 1;
209
239
  let leadAfter = 0;
210
240
  let observedLeadAt = 0;
@@ -294,7 +324,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
294
324
  leadAfter = clock() + 2 * rtt + period;
295
325
  base = { tick: k + Math.ceil(rtt / period + target) + sendEvery - 1, at: clock() };
296
326
  stepped = Math.floor(base.tick) - 1;
297
- entries = []; pending = []; history.clear(); replayHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map();
327
+ entries = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
298
328
  speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
299
329
  const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
300
330
  if (mine) {
@@ -305,7 +335,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
305
335
  /** `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. */
306
336
  const motionOf = (kindName: string, motion: Record<string, unknown>): Record<string, unknown> => thawFields(kindOf.get(kindName)?.motion ?? [], motion);
307
337
  function adopt(u: Unpacked): void {
308
- 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) };
338
+ 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), fields: predictors[u.kind] ? thawFields(kindOf.get(u.kind)?.fields ?? [], u.fields) : u.fields };
309
339
  }
310
340
  /** The game's guarded `move` for one tick, counted as the server counts it, with the result rounded as the server rounds it. */
311
341
  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 {
@@ -321,8 +351,117 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
321
351
  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: dims === 3 ? Math.max(map.bounds.min.z, Math.min(map.bounds.max.z - height, out.pos.z)) : 0 }, dims);
322
352
  return out;
323
353
  }
354
+ /* ---------------------------------------------------------------- the own actions, predicted (0.46.3) */
355
+
356
+ const predictors = opts.predict ?? {};
357
+ interface Ghost { id: string; kind: string; tick: number; pos: Vec3; fields: Record<string, unknown>; until: number; match?: (e: Entity) => boolean; /** The server's entity that confirmed it: the ghost is drawn until that one is. */ real?: string }
358
+ const ghosts = new Map<string, Ghost>();
359
+ let ghostSeq = 0;
360
+ let provisional: { name: string; tick: number; payload: Record<string, unknown>; match?: (e: any) => boolean }[] = [];
361
+ /** What a tick's prediction has already shown, by tick and by the order it asked: a preview and the tick's own run show a thing once. */
362
+ const shown = new Map<number, Set<string>>();
363
+ let previewKey = '', previewFields: Record<string, unknown> | null = null;
364
+ const ghostEntity = (g: Ghost): Entity => Object.freeze({ id: g.id, kind: g.kind, pos: g.pos, vel: ZERO3, heading: ZERO3, grounded: false, provisional: true, ...g.fields, motion: Object.freeze({}) }) as Entity;
365
+ const ZERO3: Vec3 = Object.freeze({ x: 0, y: 0, z: 0 });
366
+ function dropPredictions(): void {
367
+ for (const g of ghosts.values()) if (!g.real) emit('retract', { ghost: g.id, kind: g.kind, at: g.pos, tick: g.tick });
368
+ for (const x of provisional) emit('retract', { ...x.payload, effect: x.name, tick: x.tick });
369
+ ghosts.clear(); provisional = []; shown.clear(); previewKey = ''; previewFields = null;
370
+ }
371
+ /**
372
+ * The game's predictor for one tick of the own body. `live` is false while a snapshot's pending inputs are run again:
373
+ * that run only brings the fields up to date, and shows nothing.
374
+ */
375
+ function runPredict(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, fields: Record<string, unknown>, input: Readonly<Record<string, unknown>>, t: number, live: boolean, preview: boolean): void {
376
+ const fn = predictors[kindName], kind = kindOf.get(kindName);
377
+ if (!fn || !kind || !mine) return;
378
+ const id = mine.id;
379
+ // What a run asks to show is told apart by what it is (the name and the game's `key`), then by how many of that this
380
+ // run has asked for: never by the order of everything asked, which changes when another action joins the tick.
381
+ const asked = new Map<string, number>();
382
+ const once = (what: string): boolean => { if (!live) return false; let set = shown.get(t); if (!set) { set = new Set(); shown.set(t, set); } const n = asked.get(what) ?? 0; asked.set(what, n + 1); const key = what + '#' + n; if (set.has(key)) return false; set.add(key); return true; };
383
+ const ctx: PredictContext = {
384
+ tick: t, dt: 1 / tickHz, preview,
385
+ get viewTick() { return viewTickNow(); },
386
+ ray: (from, direction, max, options) => viewRay(from, direction, max, options)[0],
387
+ rays: (options) => raySnapshot(map, viewTargets(), dims, options, id),
388
+ rayAll: (from, direction, max, options) => viewRay(from, direction, max, options, true),
389
+ ghost(kindOfGhost, at, data = {}, options = {}) {
390
+ const k = kindOf.get(kindOfGhost);
391
+ if (!k) { console.warn(`[room] predict: no kind "${kindOfGhost}"`); return ''; }
392
+ if (!once('g:' + kindOfGhost + ':' + (options.key ?? ''))) return '';
393
+ const g: Ghost = { id: `~${++ghostSeq}`, kind: kindOfGhost, tick: t, pos: vec3(at, dims), fields: coerceFields(k.fields, { ...initFields(k.fields, dims), ...data }, dims), until: clock() + (options.seconds ?? 2) * 1000, match: options.match };
394
+ ghosts.set(g.id, g);
395
+ emit('enter', ghostEntity(g));
396
+ return g.id;
397
+ },
398
+ effect(name, at, data = {}, options = {}) {
399
+ if (!schema.effects[name]) { console.warn(`[room] predict: no effect "${name}" is declared in shapes.effects`); return; }
400
+ if (!once('e:' + name + ':' + (options.key ?? ''))) return;
401
+ const target = typeof at === 'string' ? at : null;
402
+ const payload = { ...data, at: target ? get(target)?.pos ?? null : vec3(at as Vec3, dims), ...(target ? { id: target } : {}), tick: t };
403
+ // The server's own effect may already be here, waiting for its tick to be drawn (it acted before this browser
404
+ // stepped the tick, and its snapshot is still on the way): that one is this prediction's confirmation.
405
+ const waiting = fxQueue.find((x) => x.name === name && !x.confirms && (!options.match || safe(() => options.match!({ ...x.data, ...(typeof x.at === 'string' ? { id: x.at } : { at: unpackVec(x.at, dims) }), tick: x.tick }))));
406
+ if (waiting) waiting.confirms = true; else provisional.push({ name, tick: t, payload, match: options.match });
407
+ emit(name, Object.freeze({ ...payload, predicted: true }));
408
+ },
409
+ };
410
+ const me = Object.assign(fields, { id, kind: kindName }) as Record<string, unknown>;
411
+ for (const [key, value] of Object.entries({ pos: body.pos, vel: body.vel, heading: body.heading, grounded: body.grounded, motion: body.motion })) Object.defineProperty(me, key, { value, enumerable: false, configurable: true, writable: true });
412
+ try { fn(me, input, ctx); } catch (err) { console.warn('[room] predict', err); }
413
+ // What it wrote is held to the declared types, as the server holds its own writes.
414
+ for (const [name, fd] of kind.fields) fields[name] = coerce(fd, fields[name], dims);
415
+ for (const key of Object.keys(fields)) if (!kind.fields.some(([name]) => name === key)) delete fields[key];
416
+ }
417
+ /** After a snapshot: a ghost or a provisional effect whose tick the server has run without confirming it is taken back. */
418
+ function settlePredictions(f: Frame, before: Frame | null, ack: number): void {
419
+ if (ghosts.size) {
420
+ for (const [id, u] of f.ents) {
421
+ if (before?.ents.has(id)) continue;
422
+ const e = entityOf(u);
423
+ for (const g of ghosts.values()) if (!g.real && g.kind === u.kind && (g.match ? safe(() => g.match!(e)) : distance(u.pos, g.pos) <= 1)) { g.real = u.id; g.until = Infinity; emit('confirm', { ghost: g.id, id: u.id, kind: g.kind, tick: g.tick }); break; }
424
+ }
425
+ const now = clock();
426
+ for (const g of [...ghosts.values()]) if (g.real ? !f.ents.has(g.real) : (ack >= g.tick && f.k >= g.tick) || now >= g.until) { ghosts.delete(g.id); if (!g.real) emit('retract', { ghost: g.id, kind: g.kind, at: g.pos, tick: g.tick }); emit('leave', ghostEntity(g)); }
427
+ }
428
+ if (provisional.length) {
429
+ const gone = provisional.filter((x) => ack >= x.tick && f.k > x.tick);
430
+ if (gone.length) { provisional = provisional.filter((x) => !gone.includes(x)); for (const x of gone) emit('retract', Object.freeze({ ...x.payload, effect: x.name })); }
431
+ }
432
+ for (const t of shown.keys()) if (t < f.k - tickHz) shown.delete(t);
433
+ }
434
+ const safe = (fn: () => boolean): boolean => { try { return fn() === true; } catch { return false; } };
435
+
324
436
  /** The input values the last step held (a press is never held). */
325
437
  let held: Readonly<Record<string, unknown>> = Object.freeze({});
438
+ /*
439
+ * `f.held()` inputs (0.46.3). A step takes one value a tick, read at the tick's edge: a key down for 60 ms of a 50 ms
440
+ * tick then moves the body for one tick or for two, by where the edges fell. A held field is instead the MEAN of what
441
+ * the control read over the tick's own span, so the body moves for the part of the tick the key was down, the same on
442
+ * the server as here. Each field keeps its changes since the last edge it was read up to: [when (ms), value from then].
443
+ */
444
+ const heldTrack = new Map<string, { from: number; changes: [number, number][] }>();
445
+ const heldNumber = (v: unknown, init: unknown): number => { const x = v === undefined ? init : v; return x === true ? 1 : typeof x === 'number' && Number.isFinite(x) ? x : 0; };
446
+ /** When tick `t` begins on this browser's clock, in ms. */
447
+ const edgeOf = (t: number): number => (base ? base.at + (t - base.tick) * period / speed : clock());
448
+ /** The mean of a held field from its last edge to `end`. `advance` moves the edge there; without it the tick is not over, and what is left of it (to `fill`) counts at the value the control reads now. */
449
+ function heldMean(name: string, init: unknown, end: number, advance: boolean, fill = end): number {
450
+ const now = heldNumber(sample[name], init);
451
+ let tr = heldTrack.get(name);
452
+ if (!tr) { tr = { from: end, changes: [[end, now]] }; heldTrack.set(name, tr); }
453
+ const span = fill - tr.from;
454
+ let sum = 0, value = tr.changes[0][1];
455
+ if (span > 0.001) {
456
+ let at = tr.from;
457
+ for (const [when, next] of tr.changes) { const w = Math.min(Math.max(when, tr.from), end); if (w > at) { sum += value * (w - at); at = w; } if (when <= end) value = next; }
458
+ if (end > at) sum += value * (end - at);
459
+ if (fill > end) sum += now * (fill - end);
460
+ }
461
+ const mean = span > 0.001 ? sum / span : now;
462
+ if (advance) { tr.changes = [[end, value], ...tr.changes.filter((c) => c[0] > end)]; tr.from = end; }
463
+ return mean;
464
+ }
326
465
  /** One step of the own body on tick `t`: the input step, `move`, and an entry when the sample or the claim changed. */
327
466
  function step(t: number, fresh = true): void {
328
467
  const kind = myKind();
@@ -331,17 +470,20 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
331
470
  const input: Record<string, unknown> = {};
332
471
  let press = false;
333
472
  for (const [name, fd] of kind.input) {
334
- if (fd.t === 'press' || fd.t === 'pulse') { const on = fresh ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : (fd.t === 'press' ? false : 0); input[name] = on; values.push(fd.t === 'pulse' ? 0 : Number(on)); 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); }
473
+ if (fd.t === 'press' || fd.t === 'pulse') { const on = fresh ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : (fd.t === 'press' ? false : 0); input[name] = on; values.push(fd.t === 'pulse' ? 0 : Number(on)); if (on) press = true; } else if (fd.t === 'held') { const v = coerce(fd, heldMean(name, fd.init, edgeOf(t), true), dims) as number; input[name] = v; values.push(v); } 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); }
335
474
  }
336
- if (fresh) pressed = new Map();
475
+ const numbered = fresh && pulseNumber.size ? Object.fromEntries(pulseNumber) : undefined;
476
+ if (fresh) { pressed = new Map(); pulseNumber = new Map(); }
337
477
  held = Object.freeze({ ...input });
338
478
  {
339
479
  const body = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: mine.motion }, held, t);
340
480
  mine.pos = body.pos; mine.vel = body.vel; mine.heading = body.heading; mine.grounded = body.grounded; mine.motion = body.motion;
481
+ runPredict(kind.name, body, mine.fields, held, t, true, false);
482
+ previewKey = '';
341
483
  }
342
484
  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] : [];
343
485
  const sig = JSON.stringify([values, claim]);
344
- if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); pending.push({ t, input: held }); lastSent = sig; }
486
+ if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); pending.push({ t, input: held, ...(numbered ? { pulses: numbered } : {}) }); lastSent = sig; }
345
487
  history.set(t, copyMine(mine));
346
488
  for (const k of history.keys()) if (k < t - tickHz && (catchTick === null || k < Math.floor(catchTick))) history.delete(k);
347
489
  }
@@ -363,12 +505,18 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
363
505
  function keepalive(t: number): void {
364
506
  const kind = myKind();
365
507
  if (!mine || !kind) return;
366
- const values = kind.input.map(([name, fd]) => ((fd.t === 'press' || fd.t === 'pulse') ? 0 : Number(coerce(fd, sample[name] ?? fd.init, dims))));
508
+ const values = kind.input.map(([name, fd]) => ((fd.t === 'press' || fd.t === 'pulse') ? 0 : fd.t === 'held' ? Number(coerce(fd, heldNumber(sample[name], fd.init), dims)) : Number(coerce(fd, sample[name] ?? fd.init, dims))));
367
509
  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] : [];
368
510
  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]);
369
511
  }
512
+ let pumping = false;
370
513
  function pump(): void {
371
- if (closed) return;
514
+ // A predictor's own queries read the room, and reading the room pumps: a step is never begun inside a step.
515
+ if (closed || pumping) return;
516
+ pumping = true;
517
+ try { pumpOnce(); } finally { pumping = false; }
518
+ }
519
+ function pumpOnce(): void {
372
520
  if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline');
373
521
  const now = clock();
374
522
  checkSolo(now);
@@ -379,7 +527,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
379
527
  if (hidden) {
380
528
  // A hidden tab's timers are too slow to keep input alive, and a body must not run on without its player:
381
529
  // one neutral entry at once (every field at its init, no press), then no more steps until the tab shows again.
382
- if (!hiddenSent) { hiddenSent = true; sample = {}; pressed = new Map(); const t = Math.floor(tickAt(now)) + 1; keepalive(t); lastSendAt = 0; flush(now, t); }
530
+ if (!hiddenSent) { hiddenSent = true; sample = {}; pressed = new Map(); heldTrack.clear(); const t = Math.floor(tickAt(now)) + 1; keepalive(t); lastSendAt = 0; flush(now, t); }
383
531
  base = null;
384
532
  return;
385
533
  }
@@ -425,6 +573,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
425
573
  if (placed) emit('placed', entityOf(me, true));
426
574
  } else if (kindOf.get(me.kind)?.owner) {
427
575
  mine!.motion = motionOf(me.kind, me.motion);
576
+ if (predictors[me.kind]) mine!.fields = thawFields(kindOf.get(me.kind)?.fields ?? [], me.fields);
428
577
  } else {
429
578
  const previous = mine!.pos;
430
579
  const shownBody = meNow();
@@ -434,13 +583,35 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
434
583
  adopt(me);
435
584
  history.set(f.k, copyMine(mine!));
436
585
  const ack = row?.[2] ?? 0;
586
+ // A numbered pulse is still to come while the server's row has not reached its number (0.46.3): its own message
587
+ // can be later than the input frame of its tick, so the input's acknowledgement says nothing about it.
588
+ const taken = typeof row?.[4] === 'number' ? row[4] : null;
589
+ const owed = (entry: (typeof pending)[number], name: string): boolean => { const n = entry.pulses?.[name]; return taken !== null && n !== undefined ? n > taken : entry.t > ack; };
590
+ const fresh = (entry: (typeof pending)[number]): boolean => f.k + 1 - entry.t <= Math.ceil(tickHz / 4);
437
591
  const late = new Map<string, unknown>();
592
+ // Held input the server had not received when it ran its tick (0.46.3): those ticks ran on the values before, and
593
+ // the server settles the difference on its next ticks (host.ts). The replay owes the same, so the body is
594
+ // predicted where the server will bring it and not where a late frame left it for a moment.
595
+ const heldFields = myKind()!.input.filter(([, fd]) => fd.t === 'held').map(([name]) => name);
596
+ const debt: Record<string, number> = {};
597
+ if (heldFields.length) {
598
+ for (const entry of pending) if (entry.t <= ack) for (const name of heldFields) serverHeld[name] = Number(entry.input[name]) || 0;
599
+ const waiting = pending.filter((entry) => entry.t > ack && entry.t <= f.k && fresh(entry));
600
+ if (waiting.length) for (let i = waiting[0].t; i <= f.k; i++) {
601
+ let meant: Readonly<Record<string, unknown>> | null = null;
602
+ for (const entry of waiting) if (entry.t <= i) meant = entry.input;
603
+ if (meant) for (const name of heldFields) debt[name] = (debt[name] ?? 0) + (Number(meant[name]) || 0) - (serverHeld[name] ?? 0);
604
+ }
605
+ }
438
606
  for (const entry of pending) if (entry.t <= f.k) {
439
607
  replayHeld = entry.input;
440
- if (entry.t > ack && f.k + 1 - entry.t <= Math.ceil(tickHz / 4))
441
- for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name]) late.set(name, entry.input[name]);
608
+ if (fresh(entry))
609
+ for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name] && (fd.t === 'pulse' ? owed(entry, name) : entry.t > ack)) late.set(name, entry.input[name]);
442
610
  }
611
+ // After a reset nothing is held yet: the replay runs on every field's own zero, never on an input with no fields
612
+ // (a `move` that reads `input.yaw` would be handed `undefined`).
443
613
  let input = replayHeld;
614
+ if (!Object.keys(input).length) { const zero: Record<string, unknown> = {}; for (const [name, fd] of myKind()!.input) zero[name] = fd.t === 'press' ? false : fd.t === 'pulse' ? 0 : coerce(fd, fd.init, dims); input = Object.freeze(zero); }
444
615
  for (let t = f.k + 1; t <= stepped; t++) {
445
616
  const presses = new Map(t === f.k + 1 ? late : []);
446
617
  for (const entry of pending) if (entry.t === t) {
@@ -449,10 +620,12 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
449
620
  }
450
621
  const stepInput = { ...input };
451
622
  for (const [name, fd] of myKind()!.input) if (fd.t === 'press' || fd.t === 'pulse') stepInput[name] = presses.get(name) ?? (fd.t === 'press' ? false : 0);
623
+ for (const name of heldFields) if (debt[name]) { const level = Number(stepInput[name]) || 0, range = Math.max(1, Math.abs(level)), want = level + debt[name], paid = Math.max(-range, Math.min(range, want)); stepInput[name] = paid; debt[name] = want - paid; }
452
624
  Object.assign(mine!, runMove(me.kind, mine!, Object.freeze(stepInput), t));
625
+ runPredict(me.kind, mine!, mine!.fields, stepInput, t, false, false);
453
626
  history.set(t, copyMine(mine!));
454
627
  }
455
- pending = pending.filter(e => e.t > f.k || e.t > ack && f.k + 1 - e.t <= Math.ceil(tickHz / 4));
628
+ pending = pending.filter(e => e.t > f.k || fresh(e) && (e.t > ack || Object.keys(e.pulses ?? {}).some((name) => owed(e, name))));
456
629
  if (shownBody && distance(oldHeading, mine!.heading) > 0.0001) headingBlend = { from: shownBody.heading, at: clock() };
457
630
  const error = distance(previous, mine!.pos);
458
631
  correction.last = error;
@@ -504,7 +677,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
504
677
  steer(now);
505
678
  }
506
679
  }
507
- } else mine = null;
680
+ previewKey = '';
681
+ settlePredictions(f, before && before.e === f.e ? before : null, row?.[2] ?? 0);
682
+ } else { if (mine) dropPredictions(); mine = null; }
508
683
  if (before) {
509
684
  for (const [id, u] of f.ents) { const old = before.ents.get(id); if (!old) emit('enter', entityOf(u)); else if (old.r !== u.r && id !== mine?.id) emit('placed', entityOf(u)); }
510
685
  for (const [id, u] of before.ents) if (!f.ents.has(id)) emit('leave', entityOf(u));
@@ -532,11 +707,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
532
707
  for (let i = 0; i < fxQueue.length;) {
533
708
  const x = fxQueue[i];
534
709
  const ownTick = catchTick === null ? tickAt(clock()) : catchTick + Math.max(0, clock() - catchAt) / period * predict.catchUp;
535
- if (x.tick > (x.at === mine?.id ? Math.min(latest?.k ?? 0, ownTick) : drawn)) { i++; continue; }
710
+ if (!x.confirms && x.tick > (x.at === mine?.id ? Math.min(latest?.k ?? 0, ownTick) : drawn)) { i++; continue; }
536
711
  fxQueue.splice(i, 1);
537
712
  const id = typeof x.at === 'string' ? x.at : null;
538
713
  const where = id ? get(id)?.pos ?? null : unpackVec(x.at, dims);
539
- emit(x.name, Object.freeze({ ...x.data, at: where, ...(id ? { id } : {}), tick: x.tick }));
714
+ emit(x.name, Object.freeze({ ...x.data, at: where, ...(id ? { id } : {}), tick: x.tick, ...(x.confirms ? { confirms: true } : {}) }));
540
715
  }
541
716
  }
542
717
 
@@ -549,7 +724,18 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
549
724
  net.on('event', (e) => {
550
725
  if (e.k === 'fx' && Array.isArray(e.d)) {
551
726
  const [tick, list] = e.d as [number, [number, unknown, unknown][]];
552
- for (const [i, at, data] of Array.isArray(list) ? list : []) { const name = schema.effectNames[i]; if (name) fxQueue.push({ tick: Number(tick) || 0, name, at, data: unpackFields(schema.effects[name] as FieldList, data, dims) }); }
727
+ for (const [i, at, data] of Array.isArray(list) ? list : []) {
728
+ const name = schema.effectNames[i];
729
+ if (!name) continue;
730
+ const item: (typeof fxQueue)[number] = { tick: Number(tick) || 0, name, at, data: unpackFields(schema.effects[name] as FieldList, data, dims) };
731
+ // The server's word on something this browser already showed (a predictor's `ctx.effect`): it confirms it.
732
+ if (provisional.length) {
733
+ const probe = { ...item.data, ...(typeof at === 'string' ? { id: at } : { at: unpackVec(at, dims) }), tick: item.tick };
734
+ const n = provisional.findIndex((x) => x.name === name && (!x.match || safe(() => x.match!(probe))));
735
+ if (n >= 0) { provisional.splice(n, 1); item.confirms = true; }
736
+ }
737
+ fxQueue.push(item);
738
+ }
553
739
  return;
554
740
  }
555
741
  // A player's speech and emotes, and a guide's lines and goals, as the relay carries them today.
@@ -594,7 +780,10 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
594
780
  if (kind && base) {
595
781
  const a = Math.max(0, Math.min(1, tickAt(clock()) - stepped));
596
782
  const now: Record<string, unknown> = {};
597
- for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : coerce(fd, sample[name] ?? fd.init, dims);
783
+ // A held field previews as its mean over the part of the tick that has passed. The body is drawn that fraction of
784
+ // the way to the preview, so it stands exactly where the control has carried it: it starts from rest at a press
785
+ // and stops dead at a release, with nothing to blend away afterwards.
786
+ for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : fd.t === 'held' ? coerce(fd, heldMean(name, fd.init, Math.min(clock(), edgeOf(stepped + 1)), false), dims) : coerce(fd, sample[name] ?? fd.init, dims);
598
787
  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);
599
788
  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);
600
789
  heading = blendHeading(mine.heading, next.heading, a, dims);
@@ -602,6 +791,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
602
791
  // A takeoff is airborne as soon as it rises; a landing waits for its contact.
603
792
  grounded = mine.grounded && (a === 0 || next.grounded);
604
793
  preview = next;
794
+ if (predictors[kind.name]) {
795
+ // The own actions between ticks: the predictor on a copy of the fields, for the tick about to be stepped.
796
+ const key = stepped + JSON.stringify(now);
797
+ if (key !== previewKey) { previewKey = key; previewFields = thawFields(kind.fields, mine.fields); runPredict(kind.name, next, previewFields, Object.freeze(now), stepped + 1, true, true); }
798
+ }
605
799
  settled = distance(next.pos, mine.pos) < 0.00001;
606
800
  }
607
801
  if (catchTick !== null && base) {
@@ -625,7 +819,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
625
819
  heading = blendHeading(headingBlend.from, heading, t, dims);
626
820
  if (t >= 1) headingBlend = null;
627
821
  }
628
- return entityOf({ ...server, pos, vel: velocity, heading, grounded, motion }, true);
822
+ return entityOf({ ...server, ...(kind && predictors[kind.name] ? { fields: (base ? previewFields : null) ?? mine.fields } : {}), pos, vel: velocity, heading, grounded, motion }, true);
629
823
  }
630
824
  function drawn(id: string): Entity | null {
631
825
  if (solo) return solo.get(id);
@@ -641,7 +835,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
641
835
  const h = blendHeading(ea.heading, eb.heading, t, dims);
642
836
  return entityOf({ ...(t < 1 ? ea : eb), grounded: t >= 1 ? eb.grounded : ea.grounded && (t === 0 || eb.grounded), 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: vec3({ x: lerp(ea.vel.x, eb.vel.x, t), y: lerp(ea.vel.y, eb.vel.y, t), z: lerp(ea.vel.z, eb.vel.z, t) }, dims), heading: h });
643
837
  }
644
- function get(id: string): Entity | null { return drawn(id); }
838
+ function get(id: string): Entity | null { const g = ghosts.get(id); return g ? ghostEntity(g) : drawn(id); }
645
839
  function each(kind: string, fn: (e: Entity) => void): void {
646
840
  pump();
647
841
  if (solo) { solo.each(kind, fn); return; }
@@ -653,6 +847,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
653
847
  for (const f of [a, s && s.alpha >= 1 ? b : null, a ? null : b]) if (f) for (const u of f.ents.values()) if (u.kind === kind) ids.add(u.id);
654
848
  if (mine && mine.kind === kind) ids.add(mine.id);
655
849
  for (const id of ids) { const e = drawn(id); if (e) fn(e); }
850
+ // A confirmed ghost stands in until the server's own entity is drawn (it arrives a moment behind, interpolated).
851
+ for (const g of [...ghosts.values()]) if (g.kind === kind) { if (g.real && ids.has(g.real)) { ghosts.delete(g.id); emit('leave', ghostEntity(g)); } else fn(ghostEntity(g)); }
656
852
  }
657
853
  function roster(): RosterRow[] {
658
854
  if (solo) return solo.roster;
@@ -684,7 +880,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
684
880
  // traffic, writes no room save to the server, and closes as soon as a body arrives.
685
881
  const g = globalThis as unknown as Record<string, unknown>;
686
882
  const probes = [g.__homieNet, g.__homiePort];
687
- solo = openRoom<R>({ game, move: moves, now: opts.now, timers: opts.timers, net: { config: null, post: null, linkOverlay: false } });
883
+ solo = openRoom<R>({ game, move: moves, predict: opts.predict, now: opts.now, timers: opts.timers, net: { config: null, post: null, linkOverlay: false } });
688
884
  [g.__homieNet, g.__homiePort] = probes;
689
885
  for (const name of listeners.keys()) forwardSolo(name);
690
886
  net.line(net.full ? 'This room is full · playing on your own until a seat is free' : 'Playing on your own until the next round');
@@ -715,6 +911,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
715
911
  const onShow = (): void => { if (typeof document !== 'undefined' && !document.hidden) { base = null; if (latest) rebase(latest.k); } };
716
912
  if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onShow);
717
913
 
914
+ function viewTickNow(): number { const s=net.sample();const a=s?frameOf(s.a):latest,b=s?frameOf(s.b):latest;return a&&b?a.k+(b.k-a.k)*(s?.alpha??0):latest?.k??0; }
718
915
  const viewTargets = () => {
719
916
  pump();
720
917
  const targets: QueryTarget[] = collisionTargets(collisionMap(map, collisionHistory.at(-1)?.[2] ?? []));
@@ -726,7 +923,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
726
923
  };
727
924
  const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(map,viewTargets(),dims,from,direction,max,options,mine?.id,all);
728
925
  return {
729
- get viewTick() { if(solo)return solo.viewTick;const s=net.sample();const a=s?frameOf(s.a):latest,b=s?frameOf(s.b):latest;return a&&b?a.k+(b.k-a.k)*(s?.alpha??0):latest?.k??0; },
926
+ get viewTick() { return solo ? solo.viewTick : viewTickNow(); },
730
927
  get status() { return solo ? 'offline' : status; },
731
928
  get seat() { return solo ? solo.seat : net.seat; },
732
929
  get me() { pump(); return meNow(); },
@@ -748,9 +945,16 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
748
945
  // A fresh control can replace a preview partway through a tick (especially
749
946
  // a jump). Keep the pose at the instant of the press; only subsequent time
750
947
  // may advance the new path, rather than drawing elapsed time with new input.
948
+ // A held control changed NOW, before anything else here takes time: the steps this call runs must already know it.
949
+ const at = clock();
950
+ for (const [name, fd] of kind?.input ?? []) if (fd.t === 'held') {
951
+ const v = heldNumber(next[name], fd.init), tr = heldTrack.get(name);
952
+ if (!tr) heldTrack.set(name, { from: at, changes: [[at, v]] });
953
+ else if (tr.changes[tr.changes.length - 1][1] !== v) { tr.changes.push([at, v]); if (tr.changes.length > 256) tr.changes.splice(1, tr.changes.length - 256); }
954
+ }
751
955
  if (changed) pump();
752
956
  const shown = changed ? meNow()?.pos : null;
753
- for (const [name, fd] of kind?.input ?? []) if ((fd.t === 'press' || fd.t === 'pulse') && coerce(fd,s[name],dims)) { const value=coerce(fd,s[name],dims) as number | boolean; if(fd.t==='pulse'&&!pressed.has(name))net.send('pulse',[epoch,name,value]); pressed.set(name,value); }
957
+ for (const [name, fd] of kind?.input ?? []) if ((fd.t === 'press' || fd.t === 'pulse') && coerce(fd,s[name],dims)) { const value=coerce(fd,s[name],dims) as number | boolean; if(fd.t==='pulse'&&!pressed.has(name)){pulseCount+=1;pulseNumber.set(name,pulseCount);net.send('pulse',[epoch,name,value,stepped+1,pulseCount]);} pressed.set(name,value); }
754
958
  sample = next;
755
959
  if (shown) {
756
960
  const now = clock(), nextShown = meNow()?.pos;