@homie-rocks/studio 0.48.0 → 0.48.1

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/CHANGELOG.md CHANGED
@@ -17,6 +17,58 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.48.1] - 2026-10-13
21
+
22
+ **Plugin 0.48.1** · Input history under loss.
23
+
24
+ Rules views recover missing input and acknowledge late movement scheduling.
25
+
26
+ ### Changed
27
+
28
+ - Snapshots that precede a predicted input's application receipt retain the live
29
+ movement prediction within the replay window; explicit placements still apply.
30
+ - Prediction clock steering is limited to 0.5% instead of 5%; it slews without
31
+ repeatedly rebasing stamped input when the jitter estimate changes.
32
+ - Rules input frames repeat a bounded history sized from measured latency and
33
+ jitter and missing application receipts, with input sequence numbers and sub-tick control changes. Snapshot
34
+ control rows report application ticks, including pulses. Retransmissions no
35
+ longer distort latency steering. A newer tick acknowledgement cannot stop
36
+ retries for an earlier missing input; only its own receipt does.
37
+ - Late movement replays up to one second of body history against historical
38
+ geometry, fenced by authoritative body writes and placements. Outside that
39
+ history an entry applies next tick. Entries with press handlers also apply
40
+ next tick, so historical replay cannot duplicate world actions. New views use application receipts instead
41
+ of negative held-input compensation; old wire entries retain their behaviour.
42
+ - Small visual corrections use `room.predict.blendMs` and expire within one
43
+ second (or an explicitly longer blend), including while stationary; `idleHold` no longer holds them indefinitely. Placement counters
44
+ still distinguish respawns from prediction corrections. Vertical travel cannot
45
+ erase a horizontal correction fast enough to reverse the held direction.
46
+ Remaining offsets drain over the final 250 ms rather than snapping at expiry.
47
+
48
+ ### Added
49
+
50
+ - `room.input(sample, {at})` timestamps a control inside the current predicted
51
+ tick. `player.pulses: 'hold'` queues actions while control is taken;
52
+ `'drop'` remains the default.
53
+ - Seeded virtual-time coverage of 40/80/150/300 ms each way and 0.5/1/2/5% loss,
54
+ dropped inputs and ordered stall/burst delivery, in small rooms and through Gates.
55
+ - A seeded baked-physics-world regression at 80 ms each way and 2% loss checks
56
+ zero drawn warps, application receipts, 300 Hz substeps, predictor room reads
57
+ and exact settling for two moving bodies.
58
+
59
+ ### Fixed
60
+
61
+ - Multiple pulse calls within one predicted tick queue instead of overwriting
62
+ one another. Their application ticks are distinct from the movement entry.
63
+ - Placements preserve the established prediction-clock phase, and the snapshot
64
+ body is advanced to its predicted tick instead of relabelling an old pose.
65
+ Projection is bounded to the server's one-second input window, and its controls
66
+ are stamped for that same path. Queued held state from an earlier placement
67
+ cannot later change the respawned body; discrete presses received before the
68
+ browser learns of the placement still belong to the player.
69
+ - A fresh control after a browser stall is recorded after resetting the stale
70
+ prediction clock, preventing a short press from moving by the stalled backlog.
71
+
20
72
  ## [0.48.0] - 2026-10-13
21
73
 
22
74
  **Plugin 0.48.0** · Baked physics worlds for server rules and local prediction.
@@ -1221,10 +1221,8 @@ occupied terrain nodes out of the graph, and choose reachable endpoints.
1221
1221
  `@homie-rocks/nav` is the richer mesh/crowd API for applications that own that
1222
1222
  runtime; rules graphs avoid importing mutable navigation state into handlers.
1223
1223
 
1224
- `room.predict.idleHold: true` holds small visual reconciliation offsets while
1225
- input and body are stationary. Movement resumes smoothing. Large invalid poses
1226
- still snap to authority. Opt in only after measuring stop/reversal and delayed
1227
- collision behavior for the game's mover.
1224
+ Small reconciliation offsets expire even while stationary; see “Input during loss
1225
+ and stalls” below for the visual correction settings.
1228
1226
 
1229
1227
  For multi-cast movement or scratch trajectory checks, `ctx.world.sweep(body, delta, {ground:false})` (also captured-scene `sweep`) moves and returns the same collision but leaves `body.grounded` unchanged. Use it only when your mover computes support explicitly. Omitting the option retains the existing support query and grounding behavior. This avoids paying for identical support checks after horizontal and upward probes.
1230
1228
 
@@ -1385,3 +1383,50 @@ creates the same baked world and replays pending guarded movement against it;
1385
1383
  substep drawing traces remain temporary. Physics worlds are disposed when their
1386
1384
  host or view closes. A full solver save can exceed browser-host checkpoint caps;
1387
1385
  the build measures this, and server-hosted rooms retain the full save.
1386
+
1387
+ ## Input during loss and stalls (0.48.1)
1388
+
1389
+ Rules views resend recent input automatically: a 4–64 tick history follows the
1390
+ measured round trip, jitter and missing application receipts (loss or recovery
1391
+ delay). Each tick entry carries a sequence number; each
1392
+ control change carries a field index, sequence and fraction of the predicted tick.
1393
+ Duplicates neither execute again nor count as fresh latency measurements. Both
1394
+ missing application frames and ordered TCP stalls are covered.
1395
+
1396
+ The server retains up to one second of movement history and replays late movement
1397
+ against the geometry of those ticks. It never repeats commands or effects. An
1398
+ external body/motion write or placement fences that history. Beyond a fence or
1399
+ the retained history, input applies on the next tick instead. Entries carrying
1400
+ press handlers also apply next tick: world actions are never rerun as movement. Snapshot control
1401
+ rows report the application tick and the view uses it for replay. Within the
1402
+ replay window a snapshot still missing a predicted input does not replace live
1403
+ movement; an explicit placement always does. Legacy wire
1404
+ entries retain their old compensation; current views do not receive negative
1405
+ held values to repay late releases.
1406
+
1407
+ Held controls integrate their duration, sampled controls coalesce to the latest
1408
+ value per tick, presses coalesce per field per tick, and valued pulses execute
1409
+ in order, one per tick. More sampled input never buys extra simulation steps.
1410
+ The clock resets before a fresh control after a long browser stall, so the press
1411
+ cannot inherit ten seconds of elapsed time. The prediction clock slews by at
1412
+ most 0.5%, preserving the cadence for fixed-step renderers.
1413
+
1414
+ `room.input(sample, {at: event.timeStamp})` accepts the event's monotonic time
1415
+ (same origin as `performance.now()`), clamped to this predicted tick and now.
1416
+ Omit it to use the call time. A player can declare
1417
+ `player: {control: 'canMove', pulses: 'hold'}` to keep pulses during a knock or
1418
+ stun and deliver them when control returns. The default `'drop'` preserves the
1419
+ previous behaviour. The game's action handler still decides its cooldown.
1420
+
1421
+ Residual drawing corrections use `room.predict.blendMs` (100 ms by default),
1422
+ slowed on each moving axis to avoid reversing movement (jumping cannot spend
1423
+ horizontal correction faster), with a one-second maximum
1424
+ (or the explicitly longer blendMs). Any remainder drains over the final 250 ms
1425
+ rather than disappearing on the deadline. `snapM` selects the large-error threshold;
1426
+ placements still snap. A placement keeps the established clock phase and
1427
+ projects the new body from the snapshot tick to the own tick; it never labels an
1428
+ old pose as a future one. Projection is bounded to one second and sends the
1429
+ controls it used. Queued held state from an earlier placement cannot later
1430
+ change the new body. Newly received discrete presses still belong to the player
1431
+ when the browser has not yet learned of that placement. `idleHold` no longer keeps a stopped body displaced
1432
+ indefinitely. These drawing settings never alter server truth.
@@ -469,3 +469,12 @@ save contract are in that section. Prefer kernel collision for its primitive
469
469
  maps; use physics for arbitrary static triangle levels. Initialization is awaited
470
470
  before the first room tick, calls in handlers are synchronous, and the complete
471
471
  physics snapshot is saved with the room. No opt-in means no physics load or cost.
472
+
473
+ ### Input under loss
474
+
475
+ Rules games inherit redundant sequenced input, late movement replay and applied-tick
476
+ receipts. Do not add a game-side resend loop or interpret negative held values as
477
+ late-release repayment. Use `f.held()` for duration, sampled fields for latest
478
+ state and `f.pulse()` for ordered actions. Choose `player.pulses: 'hold'` when a
479
+ knock should buffer an action; the default drops it. See RULES.md, “Input during
480
+ loss and stalls”, for timestamps, replay boundaries and visual correction settings.
@@ -119,7 +119,7 @@ export type Room<R = never> = Omit<BaseRoom<R>, 'me' | 'each' | 'get' | 'on' | '
119
119
  each<K extends keyof Entities>(kind: K, fn: (entity: Entities[K]) => void): void;
120
120
  get(id: string): Entity | null;
121
121
  on<K extends keyof Effects>(name: K, fn: (event: Effects[K]) => void): () => void;
122
- input(sample: ${input}): void;
122
+ input(sample: ${input}, options?: {at?: number}): void;
123
123
  command<K extends keyof Commands>(name: K, ...data: {} extends Commands[K] ? [data?: Commands[K]] : [data: Commands[K]]): void;
124
124
  };
125
125
  export declare function openRoom<R = never>(options?: OpenRoomOptions): Room<R>;
@@ -2985,6 +2985,48 @@
2985
2985
  }
2986
2986
  }
2987
2987
  }
2988
+ },
2989
+ "0.48.1": {
2990
+ "files": {
2991
+ "CLAUDE.md": "ddf2b2a0376202f6",
2992
+ "README.md": "684b3cca8001673c",
2993
+ "HANDOFF.md": "29337e2ace95b2e7",
2994
+ "games/README.md": "5ad32fe80c61f117",
2995
+ "music/README.md": "a3dc042dadd39df8",
2996
+ "videos/README.md": "16babc3437d5545b",
2997
+ "posts/README.md": "37841aae476bd540",
2998
+ "site/README.md": "732894273a32dc3f",
2999
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3000
+ },
3001
+ "sections": {
3002
+ "AGENTS.md": {
3003
+ "(intro)": "394cbbe934e80722",
3004
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3005
+ "## Layout": "f3a280b8c96cd068",
3006
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
3007
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3008
+ "## The site": "0cb8dd9a6f8a0096",
3009
+ "## Making games": "bb715272fd1a615b",
3010
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3011
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3012
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3013
+ "## Servers and AI seats": "1ccfd549d2369a60",
3014
+ "## Selling things (the shop)": "9e020d69dd6a954e",
3015
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3016
+ "## Rules": "27881d11d3ff3803",
3017
+ "## Telling Homie": "80b46fdd9e578db8",
3018
+ "## Beta": "9d02638207a26e73"
3019
+ }
3020
+ },
3021
+ "named": {
3022
+ "Homie Arcade": {
3023
+ "sections": {
3024
+ "AGENTS.md": {
3025
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3026
+ }
3027
+ }
3028
+ }
3029
+ }
2988
3030
  }
2989
3031
  }
2990
3032
  }
@@ -2329,3 +2329,23 @@ private round imports no room save, match score or simulation state into the onl
2329
2329
  room. When a body is assigned, the private round closes and the view joins the
2330
2330
  online match. Ember Vale retains its separate cloud hero: experience earned in
2331
2331
  solo play still belongs to that hero, whose level is sent through the usual command.
2332
+
2333
+ ### Rules input history trailer (studio 0.47.1)
2334
+
2335
+ A rules step keeps its `[tickOffset, ...fields, ...ownerClaim]` prefix. Current
2336
+ views append `[-471, sequence, changeCount, ...changes]`, where each change is
2337
+ `[fieldIndex, changeSequence, fractionOfTick, value]`. All components are numbers.
2338
+ Input frames repeat recent entries, sorted by tick; the retained window is
2339
+ 4–64 ticks based on measured authority RTT and jitter. The host deduplicates
2340
+ entry sequences, including history received after a missing frame. Duplicate
2341
+ history is not a fresh lead observation. Frames without the trailer keep the
2342
+ legacy input semantics.
2343
+
2344
+ Control rows keep their first six columns and append
2345
+ `[471, sequence, appliedTick, ...]`. Positive sequences identify input entries;
2346
+ negative sequences identify the independently numbered reliable pulse stream.
2347
+ The last 64 receipts repeat in snapshots, including projected Gate snapshots.
2348
+ An on-time entry names its predicted tick; a safely rewound movement entry names
2349
+ that historical tick; an entry beyond retained movement history or an authority
2350
+ boundary names the next tick. Entry receipts describe coalesced per-tick input;
2351
+ command/effect handlers are not rerun in the past.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.48.0",
3
+ "version": "0.48.1",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/rules/core.ts CHANGED
@@ -158,6 +158,7 @@ export interface Core {
158
158
  readonly tick: number;
159
159
  readonly epoch: number;
160
160
  step(inputs?: ReadonlyMap<number, StepInput>, guides?: () => void): void;
161
+ reviseMovement(seat: number, at: number, values: Record<string, unknown>): boolean;
161
162
  guide(seat: number, floor?: unknown): Record<string, unknown> | null;
162
163
  vocabulary(value: Vocabulary | null): void;
163
164
  goal(seat: number, value?: unknown): unknown;
@@ -177,7 +178,7 @@ export interface Core {
177
178
  listeners(): { seat: number; id: string; pos: Vec3 }[];
178
179
  /** The body in a seat, as the host runtime needs it for the input protocol. */
179
180
  character(seat: number): import('./rules.ts').Character | null;
180
- bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
181
+ bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; controlled: boolean; driver: Driver } | null;
181
182
  /** What happened since the last call: rounds, effects, a changed roster, changed shared state, a new epoch, a failure. */
182
183
  drain(): CoreOut[];
183
184
  save(): SavedCore;
@@ -1091,7 +1092,46 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1091
1092
  // Inside the cone: the angle to its axis is at most half its opening, compared by cosine.
1092
1093
  return (dx * a.dir.x + dy * a.dir.y + dz * a.dir.z) / l >= exact.cos(a.half);
1093
1094
  }
1095
+ // Movement-only rewind never repeats effects or commands. External body writes fence history.
1096
+ type MovementState = {pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown>};
1097
+ type MovementFrame = {tick: number; r: number; before: MovementState; after: MovementState; input: Readonly<Record<string, unknown>>; geometry: typeof moveGeometry; map: typeof currentMap};
1098
+ const movementHistory = new Map<string, MovementFrame[]>();
1099
+ const movementState = (e: Ent): MovementState => ({pos:e.pos,vel:e.vel,heading:e.heading,grounded:e.grounded,motion:{...e.m}});
1100
+ const sameMovement = (a: MovementState,b: MovementState): boolean => JSON.stringify(a) === JSON.stringify(b);
1101
+ function putMovement(e: Ent, b: MovementState): void {
1102
+ e.pos=b.pos;e.vel=b.vel;e.heading=b.heading;e.grounded=b.grounded;
1103
+ Object.assign(e.m,b.motion);
1104
+ }
1105
+ function reviseMovement(seat: number, at: number, values: Record<string, unknown>): boolean {
1106
+ const id=seats.get(seat)?.id, e=id?ents.get(id):null;
1107
+ if(!e || !e.kind.move || e.kind.body?.owner || e.away || e.driver!=='person') return false;
1108
+ const frames=movementHistory.get(e.id), start=frames?.findIndex(f=>f.tick===at)??-1;
1109
+ if(!frames || start<0 || frames.at(-1)!.tick!==tick || frames[start].r!==e.r || !sameMovement(frames.at(-1)!.after,movementState(e)))return false;
1110
+ const control=e.kind.player?.control;
1111
+ if(control && frames.slice(start).some(f=>!f.before.motion[control] || !f.after.motion[control]))return false;
1112
+ for(let i=start+1;i<frames.length;i++)if(frames[i].r!==e.r || !sameMovement(frames[i-1].after,frames[i].before))return false;
1113
+ const saved=movementState(e), savedTick=tick, geometry=moveGeometry, map=currentMap;
1114
+ const old=frames.map(f=>({...f}));
1115
+ try {
1116
+ putMovement(e,frames[start].before);
1117
+ for(let i=start;i<frames.length;i++){
1118
+ const f=frames[i];tick=f.tick;moveGeometry=f.geometry;currentMap=f.map;moveShape=e.kind.body!;
1119
+ f.before=movementState(e);
1120
+ // An entry holds until the next explicitly different input.
1121
+ if(i===start || JSON.stringify(old[i].input)===JSON.stringify(old[i-1].input))f.input=Object.freeze(coerceFields(e.kind.input,values,dims));else values={...f.input};
1122
+ const b={pos:e.pos,vel:e.vel,heading:e.heading,grounded:e.grounded,motion:e.mself};
1123
+ const errors=stats.errors, stops=stats.budgetStops;
1124
+ if(!run(e.kind.name,'move',e,'move',()=>e.kind.move!(b,f.input,moveCtx),'body'))throw new Error('rewind budget');
1125
+ if(stats.errors!==errors || stats.budgetStops!==stops)throw new Error('rewind failed');
1126
+ e.pos=clampIn(V(own(b,'pos')),e.kind.body!.radius,e.kind.body!.height||2*e.kind.body!.radius);e.vel=V(own(b,'vel'));e.heading=dir(own(b,'heading'),dims);e.grounded=own(b,'grounded')===true;
1127
+ f.after=movementState(e);
1128
+ }
1129
+ return true;
1130
+ } catch {putMovement(e,saved);movementHistory.set(e.id,old);return false;}
1131
+ finally {tick=savedTick;moveGeometry=geometry;currentMap=map;}
1132
+ }
1094
1133
  function step(inputs: ReadonlyMap<number, StepInput> = new Map(), guides?: () => void): void {
1134
+ for(const id of movementHistory.keys())if(!ents.has(id))movementHistory.delete(id);
1095
1135
  tick = (tick + 1) >>> 0;
1096
1136
  if(pendingMap&&pendingMap[0]<=tick){currentMap=pendingGeometry!;mapRevision=pendingMap;currentMapCells=pendingMapCells;mapCells=currentMapCells;pendingMapCells=0;pendingMap=null;pendingGeometry=null;moveGeometry=currentMap;}
1097
1137
  // The tick pays first for the state it sends: what the last snapshot packed. A room that holds more than a tick can send has nothing left for its handlers, and ends as any room does whose budget trips on every tick.
@@ -1205,6 +1245,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1205
1245
  }
1206
1246
  if (!k.move) continue;
1207
1247
  const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
1248
+ const rewindBefore = k.player && e.driver === 'person' && !e.away ? movementState(e) : null;
1208
1249
  const beforeMove = opts.moved ? { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } } : null;
1209
1250
  moveShape = body;
1210
1251
  run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
@@ -1215,6 +1256,11 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1215
1256
  naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius, body.height || 2 * body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
1216
1257
  G.note = null;
1217
1258
  e.grounded = own(b, 'grounded') === true;
1259
+ if (rewindBefore) {
1260
+ let frames=movementHistory.get(e.id);if(!frames){frames=[];movementHistory.set(e.id,frames);}
1261
+ frames.push({tick,r:e.r,before:rewindBefore,after:movementState(e),input:e.input,geometry:moveGeometry,map:currentMap});
1262
+ while(frames.length>tickHz)frames.shift();
1263
+ }
1218
1264
  if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } }, moveGeometry, e.id);
1219
1265
  }
1220
1266
  // Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
@@ -1492,6 +1538,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1492
1538
  return {
1493
1539
  round: () => ({ t: 'round', ...round, ...(round.phase === 'over' && !overAt ? { results: results() } : {}) }),
1494
1540
  get tick() { return tick; },
1541
+ reviseMovement,
1495
1542
  get epoch() { return epoch; },
1496
1543
  step,
1497
1544
  answer: (n, result) => { const a = pendingAsks.find((x) => x.n === n); if (a && !a.result) a.result = decisionResult(result); },
@@ -1568,7 +1615,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1568
1615
  character: (seat) => { const e = playerBodies().find(e => e.seat === seat); return e ? JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })) : null; },
1569
1616
  bodies: () => playerBodies().map((e) => ({ seat: e.seat, id: e.id, kind: e.kind.name, driver: e.driver, owner: e.owner, away: e.away, score: e.kind.score ? Number(e.f[e.kind.score]) || 0 : 0, r: e.r })),
1570
1617
  listeners: () => playerBodies().filter((e) => e.driver === 'person').map((e) => ({ seat: e.seat, id: e.id, pos: e.pos })),
1571
- bodyOf: (seat) => { const s = seats.get(seat); const e = s?.id ? ents.get(s.id) : null; return e ? { id: e.id, kind: e.kind, r: e.r, away: e.away, driver: e.driver } : null; },
1618
+ bodyOf: (seat) => { const s = seats.get(seat); const e = s?.id ? ents.get(s.id) : null; return e ? { id: e.id, kind: e.kind, r: e.r, away: e.away, controlled: !e.kind.player?.control || Boolean(e.m[e.kind.player.control]), driver: e.driver } : null; },
1572
1619
  drain: () => { const o = out; out = []; return o; },
1573
1620
  save,
1574
1621
  dispose: () => physics?.dispose(),
package/rules/host.ts CHANGED
@@ -135,8 +135,11 @@ export interface Host {
135
135
  readonly core: Core;
136
136
  }
137
137
 
138
- interface Entry { at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim']; /** How many ticks before its own tick it arrived (0.46.3): the time the input itself waited here. */ early?: number }
138
+ interface Entry { placement?: number; source?: number; at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim']; /** How many ticks before its own tick it arrived (0.46.3): the time the input itself waited here. */ early?: number }
139
139
  interface SeatIn {
140
+ heldAt?: number;
141
+ receipts?: [number, number][];
142
+ received?: number[];
140
143
  /** A valued pulse, and the tick its browser predicted it on (0: none given, act at once). */
141
144
  pulses?: [string,number,number?,number?][];
142
145
  /** The wait of the entry now held, in ticks (see Entry.early). */
@@ -259,9 +262,11 @@ export function createHost(o: HostOptions): Host {
259
262
  check(Array.isArray(saved.inputs) && saved.inputs.length <= c.seats);
260
263
  for (const [seat, q] of saved.inputs) {
261
264
  check(uint(seat) && seat < c.seats && q && valuesOk(seat, q.held) && claimOk(q.claim) && uint(q.newest) && uint(q.ack) && uint(q.lastFrameTick) && Number.isInteger(q.lead) && q.lead >= -127 && q.lead <= 127);
265
+ if(q.received!==undefined)check(Array.isArray(q.received)&&q.received.length<=256&&q.received.every(uint));
266
+ if(q.receipts!==undefined)check(Array.isArray(q.receipts)&&q.receipts.length<=64&&q.receipts.every(r=>Array.isArray(r)&&r.length===2&&Number.isSafeInteger(r[0])&&r[0]!==0&&uint(r[1])));
262
267
  check(Array.isArray(q.stamps) && q.stamps.length <= 256 && q.stamps.every(uint) && Array.isArray(q.entries) && q.entries.length <= c.settings.tickHz + 1);
263
268
  if(q.pulses!==undefined)check(Array.isArray(q.pulses)&&q.pulses.length<=16&&q.pulses.every(p=>Array.isArray(p)&&(p.length===2||p.length>=3&&p.length<=4&&uint(p[2])&&(p[3]===undefined||uint(p[3])))&&c.kinds.some(k=>k.input.some(([n,f])=>n===p[0]&&f.t==='pulse'))&&Number.isInteger(p[1])&&p[1]>0&&p[1]<=255));
264
- for (const e of q.entries) check(uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => (typeof p === 'string' ? name === p && fd.t === 'press' : Array.isArray(p) && p.length === 2 && name === p[0] && fd.t === 'pulse' && Number.isInteger(p[1]) && p[1] > 0 && p[1] <= 255)))));
269
+ for (const e of q.entries) check((e.placement === undefined || uint(e.placement)) && uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => (typeof p === 'string' ? name === p && fd.t === 'press' : Array.isArray(p) && p.length === 2 && name === p[0] && fd.t === 'pulse' && Number.isInteger(p[1]) && p[1] > 0 && p[1] <= 255)))));
265
270
  }
266
271
  }
267
272
  }
@@ -474,8 +479,8 @@ export function createHost(o: HostOptions): Host {
474
479
  stats.ins += 1;
475
480
  q.lastFrameTick = K;
476
481
  // Lead: from this frame's arrival to the moment its first tick was due, in sixteenths of a tick. Negative is late.
477
- const lead = Math.max(-127, Math.min(126, Math.round(((base.ms + (k - base.tick) * period - o.clock.now()) / period) * 16)));
478
- if (lead < q.lead) q.lead = lead;
482
+ const lead = Math.max(-127, Math.min(126, Math.round(((base.ms + (k + num(m.s.at(-1)?.[0]) - base.tick) * period - o.clock.now()) / period) * 16)));
483
+
479
484
  const r = num(m.r) & 0xffff;
480
485
  let before = -1;
481
486
  for (const raw of (m.s as unknown[]).slice(0, 64)) {
@@ -485,10 +490,18 @@ export function createHost(o: HostOptions): Host {
485
490
  before = off;
486
491
  const j = (k + off) >>> 0;
487
492
  // A duplicate: not above the newest stamp this seat has sent. It still counted as the player being there.
488
- if (j <= q.newest) continue;
493
+ const marker = 1 + kind.input.length + (kind.body?.owner ? 9 : 0);
494
+ const seq = raw[marker] === -471 && Number.isSafeInteger(raw[marker+1]) && raw[marker+1] > 0 ? raw[marker+1] as number : 0;
495
+ // A discrete action sent before the client learns of a placement still
496
+ // belongs to its player. Held state from that old placement does not.
497
+ const action = kind.input.some(([, fd], index) => fd.t === 'press' && raw[index + 1] === 1);
498
+ if (seq && r !== body.r && !action) continue;
499
+ if (seq ? q.received?.includes(seq) : j <= q.newest) continue;
489
500
  // Too early: more than a second ahead. This entry and the rest of the frame are dropped.
490
501
  if (j > K + tickHz) { stats.dropped += 1; break; }
491
- q.newest = j;
502
+ if(j >= q.newest && lead < q.lead)q.lead=lead;
503
+ q.newest = Math.max(q.newest,j);
504
+ if(seq){q.received??=[];q.received.push(seq);if(q.received.length>256)q.received.shift();}
492
505
  const values: Record<string, unknown> = {};
493
506
  const presses: (string | [string, number])[] = [];
494
507
  let i = 1;
@@ -499,9 +512,22 @@ export function createHost(o: HostOptions): Host {
499
512
  }
500
513
  const claim = kind.body?.owner && raw.length >= i + 9 ? { pos: unpackVec([raw[i], raw[i + 1], raw[i + 2]], c.dims), vel: unpackVec([raw[i + 3], raw[i + 4], raw[i + 5]], c.dims), heading: unpackVec([raw[i + 6], raw[i + 7], raw[i + 8]], c.dims), r } : null;
501
514
  q.stamps.push(j);
502
- if (j > K) { q.entries.push({ at: j, values, presses, claim, early: Math.min(lateTicks, j - K) }); continue; }
515
+ if(seq){q.receipts??=[];q.receipts.push([seq,j>K?j:K+1]);if(q.receipts.length>64)q.receipts.shift();}
516
+ if (j > K) { q.entries.push({ ...(seq ? { placement: body.r } : {}), source: j, at: j, values, presses, claim, early: Math.min(lateTicks, j - K) }); continue; }
503
517
  // Late: its values hold from the next tick, and its presses fire there if that is within a quarter of a second of its stamp.
504
518
  stats.lateEntries += 1;
519
+ if(seq){
520
+ if(!presses.length && K-j<tickHz && core.reviseMovement(seat as number,j,values))q.receipts![q.receipts!.length-1][1]=j;
521
+ const at=K+1;
522
+ const same=q.entries.find(e=>e.at===at);
523
+ if(j >= (q.heldAt??0) && (!same || j >= (same.source??0))){
524
+ if(same){same.placement=body.r;same.source=j;same.values=values;same.claim=claim;same.presses.push(...presses);}else q.entries.push({placement:body.r,source:j,at,values,claim,presses});
525
+ } else if(presses.length){
526
+ if(same)same.presses.push(...presses);else q.entries.push({placement:body.r,source:q.heldAt,at,values:{...q.held},claim:q.claim,presses});
527
+ }
528
+ q.entries.sort((a,b)=>a.at-b.at);
529
+ continue;
530
+ }
505
531
  const book = heldBook.get(seat as number);
506
532
  if (book) for (const row of book.ran) if (row[0] >= j) { for (const name of heldOf(kind)) { const meant = Number(values[name]) || 0; book.debt[name] = (book.debt[name] ?? 0) + meant - (row[1][name] ?? 0); row[1][name] = meant; } book.until = K + 1 + lateTicks; }
507
533
  const at = (K + 1) >>> 0;
@@ -704,7 +730,8 @@ export function createHost(o: HostOptions): Host {
704
730
  const presses: (string | [string, number])[] = [];
705
731
  while (q.entries.length && q.entries[0].at <= t) {
706
732
  const e = q.entries.shift() as Entry;
707
- q.held = e.values; q.claim = e.claim; q.early = e.early ?? 1;
733
+ if (e.placement !== undefined && e.placement !== body.r) continue;
734
+ q.heldAt=e.source??e.at; q.held = e.values; q.claim = e.claim; q.early = e.early ?? 1;
708
735
  if (e.at === t) for (const p of e.presses) presses.push(p);
709
736
  }
710
737
  // Silence: no frame for a second. Its input is neutral until the next entry.
@@ -723,7 +750,7 @@ export function createHost(o: HostOptions): Host {
723
750
  }
724
751
  for (const p of presses) { if (typeof p === 'string') values[p] = true; else values[p[0]] = p[1]; }
725
752
  // One pulse a tick, in order: the head waits for its own tick when that is no more than a quarter of a second ahead.
726
- const head=q.pulses?.[0];if(head&&!((head[2]??0)>t&&(head[2] as number)-t<=Math.ceil(tickHz/4))){q.pulses!.shift();values[head[0]]=head[1];if(head[3])pulsesTaken.set(seat,head[3]);}
753
+ const head=q.pulses?.[0];if(head&&(body.controlled||body.kind.player?.pulses!=='hold')&&(head[2]??0)<=t){q.pulses!.shift();values[head[0]]=head[1];if(head[3]){pulsesTaken.set(seat,head[3]);q.receipts??=[];q.receipts.push([-head[3],t]);if(q.receipts.length>64)q.receipts.shift();}}
727
754
  inputs.set(seat, { values, claim: q.claim });
728
755
  }
729
756
  if (roundRebasePending) publishRebase(now);
@@ -760,6 +787,7 @@ export function createHost(o: HostOptions): Host {
760
787
  // ray may look right now, so its browser can predict against the pose the server will judge, not a guess at it.
761
788
  if (historyTicks) row.push(taken ?? 0, Math.min(historyTicks, rewindTicks(b.seat)));
762
789
  else if (taken !== undefined) row.push(taken);
790
+ if(q?.receipts){while(row.length<6)row.push(0);row.push(471,...q.receipts.flat());}
763
791
  rows.push(row);
764
792
  if (q) q.lead = 127;
765
793
  }
package/rules/rules.ts CHANGED
@@ -144,7 +144,7 @@ export interface BodyDef { shape: 'circle' | 'sphere' | 'capsule' | 'box'; radiu
144
144
  export interface GuideDef { view: (world: RuntimeWorld, self: RuntimeSelf) => unknown; floor?: (world: RuntimeWorld, self: RuntimeSelf, view: any) => unknown }
145
145
  export interface RoundRecords {key:string; identity:string; eligible?:string; fields:Readonly<Record<string,string>>}
146
146
  export interface EntityDef {
147
- player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; takeover?: string };
147
+ player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; pulses?: 'drop' | 'hold'; takeover?: string };
148
148
  fields?: Fields;
149
149
  motion?: Fields;
150
150
  input?: Fields;
@@ -388,7 +388,7 @@ export type FieldList = readonly (readonly [string, Field])[];
388
388
  export interface KindTable {
389
389
  name: string;
390
390
  index: number;
391
- player: null | { away: 'neutral' | 'think'; leave: 'despawn' | 'bot'; control?: string; takeover?: string };
391
+ player: null | { away: 'neutral' | 'think'; leave: 'despawn' | 'bot'; control?: string; pulses?: 'drop' | 'hold'; takeover?: string };
392
392
  fields: FieldList;
393
393
  motion: FieldList;
394
394
  input: FieldList;
@@ -589,10 +589,11 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
589
589
  if (!p || typeof p !== 'object') throw new Error(`${at}.player is true, or { away, leave }`);
590
590
  if (p.away !== undefined && p.away !== 'neutral' && p.away !== 'think') throw new Error(`${at}.player.away is 'neutral' or 'think'`);
591
591
  if (p.leave !== undefined && p.leave !== 'despawn' && p.leave !== 'bot') throw new Error(`${at}.player.leave is 'despawn' or 'bot'`);
592
+ if (p.pulses !== undefined && !['drop','hold'].includes(p.pulses)) throw new Error(`${at}.player.pulses is 'drop' or 'hold'`);
592
593
  if (p.control !== undefined && !motion.some(([key, field]) => key === p.control && field.t === 'bit')) throw new Error(`${at}.player.control names a bit in motion`);
593
594
  if (p.takeover !== undefined && !fields.some(([key, field]) => key === p.takeover && ['fix','i8','i16','i32','u8','u16','u32'].includes(field.t))) throw new Error(`${at}.player.takeover names a numeric field`);
594
595
  if (p.control && typeof e.think !== 'function') throw new Error(`${at}.player.control needs think`);
595
- player = { away: p.away ?? 'neutral', leave: p.leave ?? 'despawn', ...(p.control ? {control:p.control}:{}), ...(p.takeover ? {takeover:p.takeover}:{}) };
596
+ player = { ...(p.pulses ? {pulses:p.pulses}:{}), away: p.away ?? 'neutral', leave: p.leave ?? 'despawn', ...(p.control ? {control:p.control}:{}), ...(p.takeover ? {takeover:p.takeover}:{}) };
596
597
  if ((player.away === 'think' || player.leave === 'bot') && typeof e.think !== 'function') throw new Error(`${at}: away: 'think' and leave: 'bot' need a think(world, self) handler to steer the body`);
597
598
  }
598
599
  let body: KindTable['body'] = null;
package/rules/types.ts CHANGED
@@ -67,7 +67,7 @@ export type RoomEvents<E = Record<string, never>> = RoundEvents & { seatJoined:
67
67
  export type RoundEvents = { roundStart: { n: number }; roundOver: { n: number; results: readonly { seat: number; id: string; driver: 'person' | 'bot' | 'ai'; score: number; place: number }[] } };
68
68
  type GuideWorld<W> = Pick<W, Extract<keyof W, 'tick' | 'dt' | 'ticks' | 'math' | 'map' | 'tune' | 'stage' | 'level' | 'levelMax' | 'guideLevel' | 'guideSeats' | 'kids' | 'levelSet' | 'round' | 'shared' | 'near' | 'inBox' | 'ray'>>;
69
69
  export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A = GuideRequest, D = GuideDecision> = {
70
- player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; takeover?: string }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef; collider?: ColliderDef; query?: QueryDef;
70
+ player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; pulses?: 'drop' | 'hold'; takeover?: string }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef; collider?: ColliderDef; query?: QueryDef;
71
71
  tick?: (world: W, self: S) => void;
72
72
  think?: (world: W, self: S) => Partial<I>;
73
73
  on?: { [K in keyof (Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })]?: (world: W, self: S, event: EventData<(Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })[K]>) => void };
package/rules/view.ts CHANGED
@@ -150,7 +150,7 @@ export interface Room<R = unknown> extends RayQueries {
150
150
  get(id: string): Entity | null;
151
151
  on(name: string, fn: (e: any) => void): () => void;
152
152
  readonly shared: Readonly<Record<string, unknown>>;
153
- input(sample: Record<string, number | boolean>): void;
153
+ input(sample: Record<string, number | boolean>, options?: { at?: number }): void;
154
154
  command(name: string, data?: Record<string, unknown>): void;
155
155
  readonly round: RoomRound | null;
156
156
  readonly roster: RosterRow[];
@@ -256,22 +256,29 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
256
256
  let lastSent = '';
257
257
  let lastSendAt = 0;
258
258
  let entries: { t: number; row: number[] }[] = [];
259
+ let inputSequence = 0;
260
+ let controlChanges: number[][] = [];
261
+ let sentHistory: { t: number; row: number[] }[] = [];
259
262
  let hiddenSent = false;
260
263
  const history = new Map<number, Mine>();
261
- let pending: { t: number; input: Readonly<Record<string, unknown>>; pulses?: Record<string, number> }[] = [];
264
+ let pending: { seq?: number; applied?: number; t: number; input: Readonly<Record<string, unknown>>; pulses?: Record<string, number>; pulseTicks?: Record<string, number> }[] = [];
262
265
  /** Valued pulses sent, numbered: the server's control row names the newest it has acted on (0.46.3). */
263
266
  let pulseCount = 0;
267
+ const pulseQueue: {name:string; value:number; n:number; t:number}[] = [];
264
268
  let pulseNumber = new Map<string, number>();
265
269
  let replayHeld: Readonly<Record<string, unknown>> = Object.freeze({});
266
270
  /** `f.held()` values as the server last had them: those of the newest entry it has acknowledged. */
267
271
  let serverHeld: Record<string, number> = {};
268
272
  let speed = 1;
269
273
  let leadAfter = 0;
274
+ let calibratedRtt = 0;
275
+ let latencyCalibrated = false;
276
+ const applications = new Map<number, number>();
270
277
  let observedLeadAt = 0;
271
278
  let catchTick: number | null = null;
272
279
  let catchAt = 0;
273
280
  let headingBlend: { from: Vec3; at: number } | null = null;
274
- let offsets: { delta: Vec3; at: number; path: Vec3; fade: number; last: number }[] = [];
281
+ let offsets: { delta: Vec3; at: number; path: Vec3; fade: number; last: number; end?: number }[] = [];
275
282
  const predict = schema.settings.predict;
276
283
  const quantile = (list: number[], q: number): number => [...list].sort((a, b) => a - b)[Math.floor((list.length - 1) * q)] ?? 0;
277
284
  const targetLead = (): number => 0.5 + Math.max(0, quantile(leads, 0.5) - quantile(leads, 0.05));
@@ -284,6 +291,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
284
291
  const sum = { x: 0, y: 0, z: 0 };
285
292
  let waiting = false;
286
293
  for (const o of offsets) {
294
+ o.end ??= o.at + Math.max(1000, predict.blendMs);
295
+ const deadlineFade = Math.max(0, Math.min(1, (o.end - now) / 250));
287
296
  if (path) {
288
297
  // Loss can make even a small correction longer than the distance travelled in
289
298
  // 100 ms. Bound its fade by actual progress, or it pulls steady movement backwards.
@@ -297,13 +306,22 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
297
306
  // A knock may travel faster than normal movement, and catch-up already accelerates it.
298
307
  // Fading the offset must not add another fraction of that accelerated speed.
299
308
  const allowance = length ? (myKind()?.maxSpeed ?? 0) * elapsed / 1000 / length : 1;
300
- const release = settled && !predict.idleHold && catchTick === null ? Math.min(allowance, elapsed / predict.blendMs) : 0;
301
- const progress = length ? Math.min(allowance, distance(path, o.path) * 0.8 / length) : 1;
302
- const nominal = Math.max(0, Math.min(1, 1 - (now - o.at) / predict.blendMs));
309
+ const release = settled && catchTick === null ? Math.min(allowance, elapsed / predict.blendMs) : 0;
310
+ let progress = length ? Math.min(allowance, distance(path, o.path) * 0.8 / length) : 1;
311
+ // Travel on one axis cannot pay for a correction on another. In particular,
312
+ // a jump must not erase a horizontal offset faster than running advances it.
313
+ for (const axis of ['x', 'y', 'z'] as const) {
314
+ const travelled = periodicDelta(o.path[axis], path[axis], map.wrap?.[axis]);
315
+ if (o.delta[axis] * travelled > 0) progress = Math.min(progress, .8 * Math.abs(travelled / o.delta[axis]));
316
+ }
317
+ const nominal = now >= o.end ? 0 : Math.max(0, Math.min(1, 1 - (now - o.at) / predict.blendMs));
303
318
  o.fade = Math.max(nominal, o.fade - Math.max(release, progress));
304
319
  }
305
320
  o.path = path; o.last = now;
306
321
  }
322
+ // Drain any remaining correction over the final quarter second, including
323
+ // queued offsets. Expiring it in one frame would itself create a warp.
324
+ o.fade = Math.min(o.fade, deadlineFade);
307
325
  waiting = true;
308
326
  sum.x += o.delta.x * o.fade; sum.y += o.delta.y * o.fade; sum.z += o.delta.z * o.fade;
309
327
  }
@@ -339,7 +357,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
339
357
  // The helper's ping ends at the relay. A browser host adds another network leg in both directions.
340
358
  // Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
341
359
  const relayRtt = net.stats().rtt ?? 100;
342
- return net.offline ? 0 : schema.settings.host === 'browser' ? Math.max(relayRtt, 2 * quantile(hostAges, 0.5)) : relayRtt;
360
+ return net.offline ? 0 : Math.max(relayRtt, 2 * quantile(hostAges, 0.5));
343
361
  }
344
362
  function phaseAt(at: number): number {
345
363
  let p = phases[0];
@@ -350,7 +368,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
350
368
  function steer(now: number): void {
351
369
  if (!base || !leads.length) return;
352
370
  base = { tick: tickAt(now), at: now };
353
- speed = 1 + Math.max(-0.05, Math.min(0.05, (targetLead() - medianLead()) * 0.05));
371
+ // Input replay absorbs delivery jitter. Keep the physical tick cadence close to
372
+ // wall time: aggressive steering makes fixed-step custom renderers bunch ticks.
373
+ speed = 1 + Math.max(-0.005, Math.min(0.005, (targetLead() - medianLead()) * 0.005));
354
374
  if (phases[phases.length - 1]?.rate !== speed) phases.push({ ...base, rate: speed });
355
375
  while (phases.length > 2 && phases[1].at < now - Math.max(1000, 2 * authorityRtt() + period)) phases.shift();
356
376
  }
@@ -358,19 +378,41 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
358
378
  // Recalibrating an input clock is not a placement. Keep the drawn pose while
359
379
  // the new prediction catches up, just as for a snapshot reconciliation.
360
380
  const shown = reason === 'lead' ? meNow()?.pos : null;
381
+ const continuousTick = reason === 'placement' && base && tickAt(clock()) >= k && tickAt(clock()) - k <= tickHz ? tickAt(clock()) : null;
382
+ if(reason==='placement' || reason==='epoch')pulseQueue.length=0;
361
383
  correction.rebaseReason = reason; correction.rebaseError = medianLead() - targetLead();
362
- const rtt = authorityRtt();
384
+ const rtt = authorityRtt(); calibratedRtt = rtt;
363
385
  // Lead samples refer to the clock which stamped them. Do not mix its offset with this clock's jitter.
364
386
  const target = targetLead();
365
387
  leads.length = 0;
366
388
  leadAfter = clock() + 2 * rtt + period;
367
- base = { tick: k + Math.ceil(rtt / period + target) + sendEvery - 1, at: clock() };
368
- stepped = Math.floor(base.tick) - 1;
369
- entries = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
389
+ base = { tick: continuousTick ?? k + Math.min(tickHz, Math.ceil(rtt / period + target) + sendEvery - 1), at: clock() };
390
+ stepped = Math.max(k, Math.floor(base.tick) - (continuousTick === null ? 1 : 0));
391
+ entries = []; sentHistory = []; controlChanges = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
370
392
  speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
371
393
  const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
372
394
  if (mine) {
395
+ // A snapshot is the body at k, not at the clock's future input tick.
396
+ // Project the intervening movement instead of relabelling that old pose;
397
+ // otherwise the next reconciliation suddenly adds a latency's worth of travel.
398
+ const kind = myKind()!;
399
+ const values: Record<string, unknown> = {};
400
+ for (const [name, fd] of kind.input) values[name] = fd.t === 'press' ? false : fd.t === 'pulse' ? 0 : coerce(fd, sample[name] ?? fd.init, dims);
401
+ held = replayHeld = Object.freeze(values);
402
+ history.set(k, copyMine(mine));
403
+ for (let t = k + 1; !kind.owner && t <= stepped; t++) {
404
+ Object.assign(mine, runMove(kind.name, mine, held, t));
405
+ runPredict(kind.name, mine, mine.fields, held, t, false, false);
406
+ history.set(t, copyMine(mine));
407
+ }
373
408
  history.set(stepped, copyMine(mine));
409
+ if (!kind.owner && stepped > k) {
410
+ // The projection used these controls from k+1. Stamp that first input too,
411
+ // so authority can recover the identical post-placement path.
412
+ const row = kind.input.map(([name]) => Number(held[name]));
413
+ entries.push({ t: k + 1, row: [...row, -471, ++inputSequence, 0] });
414
+ pending.push({ seq: inputSequence, t: k + 1, input: held });
415
+ }
374
416
  if (shown) offsets = [{ delta: { x: periodicDelta(mine.pos.x,shown.x,map.wrap?.x), y: periodicDelta(mine.pos.y,shown.y,map.wrap?.y), z: periodicDelta(mine.pos.z,shown.z,map.wrap?.z) }, at: clock(), path: mine.pos, fade: 1, last: clock() }];
375
417
  }
376
418
  }
@@ -519,6 +561,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
519
561
  function step(t: number, fresh = true): void {
520
562
  const kind = myKind();
521
563
  if (!mine || !kind) return;
564
+ if(fresh && pulseQueue.length && pulseQueue[0].t<=t){const p=pulseQueue.shift()!;pressed.set(p.name,p.value);pulseNumber.set(p.name,p.n);}
522
565
  const values: number[] = [];
523
566
  const input: Record<string, unknown> = {};
524
567
  let press = false;
@@ -536,7 +579,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
536
579
  }
537
580
  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] : [];
538
581
  const sig = JSON.stringify([values, claim]);
539
- if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); pending.push({ t, input: held, ...(numbered ? { pulses: numbered } : {}) }); lastSent = sig; }
582
+ if (sig !== lastSent || press || controlChanges.length) { entries.push({ t, row: [...values, ...claim, -471, ++inputSequence, controlChanges.length, ...controlChanges.flat()] }); pending.push({ seq: inputSequence, t, input: held, ...(numbered ? { pulses: numbered } : {}) }); lastSent = sig; }
583
+ controlChanges = [];
540
584
  history.set(t, copyMine(mine));
541
585
  for (const k of history.keys()) if (k < t - tickHz && (catchTick === null || k < Math.floor(catchTick))) history.delete(k);
542
586
  }
@@ -548,11 +592,25 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
548
592
  function flush(now: number, t: number): void {
549
593
  if (!mine) { entries = []; return; }
550
594
  if (!entries.length && now - lastSendAt >= 250) keepalive(t);
551
- if (!entries.length) return;
552
- const k = Math.min(entries[0].t, t - (t % sendEvery));
553
- const rows: StepEntry[] = entries.map((e) => [e.t - k, ...e.row]);
595
+ const marker = myKind()!.input.length + (myKind()!.owner ? 9 : 0);
596
+ // A newer tick's acknowledgement cannot acknowledge a hole in the history.
597
+ if (!entries.length && !sentHistory.some(e => !applications.has(e.row[marker + 1]))) return;
598
+ const k = Math.min(entries[0]?.t ?? t, t - (t % sendEvery));
599
+ for (const entry of entries) {
600
+ const old = sentHistory.findIndex(e => e.t === entry.t);
601
+ if (old >= 0) sentHistory[old] = entry; else sentHistory.push(entry);
602
+ }
603
+ // Cover an authority round trip and jitter. Missing application receipts measure
604
+ // recovery delay (whether a dropped frame or a TCP stall): widen the history
605
+ // until they return, without allowing an unbounded packet or retained history.
606
+ const missing = sentHistory.find(e => e.t <= (latest?.k ?? 0) && !applications.has(e.row[marker + 1]));
607
+ const recoveryTicks = missing ? t - missing.t + 2 * sendEvery : 0;
608
+ const windowTicks = Math.min(64, Math.max(4, recoveryTicks, Math.ceil((2 * authorityRtt() + 250) / period + targetLead())));
609
+ sentHistory = sentHistory.filter(e => e.t >= t - windowTicks).slice(-64);
610
+ const first = Math.min(k, sentHistory[0]?.t ?? k);
611
+ const rows: StepEntry[] = sentHistory.map((e) => [e.t - first, ...e.row]);
554
612
  entries = [];
555
- if (net.steps(epoch, k, rows, mine.r)) lastSendAt = now;
613
+ if (net.steps(epoch, first, rows, mine.r)) lastSendAt = now;
556
614
  }
557
615
  /** The held values as an entry on tick `t`, without stepping the body again. */
558
616
  function keepalive(t: number): void {
@@ -560,7 +618,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
560
618
  if (!mine || !kind) return;
561
619
  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))));
562
620
  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] : [];
563
- 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]);
621
+ entries = [{ t, row: [...values, ...claim, -471, ++inputSequence, controlChanges.length, ...controlChanges.flat()] }]; pending.push({ seq: inputSequence, t, input: Object.fromEntries(kind.input.map(([name, fd], i) => [name, fd.t === 'press' ? false : values[i]])) }); lastSent = JSON.stringify([values, claim]);
564
622
  }
565
623
  let pumping = false;
566
624
  function pump(): void {
@@ -628,13 +686,18 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
628
686
  if (me) {
629
687
  const placed = !mine || mine.id !== me.id || mine.r !== me.r || changedEpoch;
630
688
  const row = f.rows.find((x) => x[0] === seat);
689
+ if(changedEpoch)applications.clear();
690
+ if(row?.[6]===471){for(let i=7;i+1<row.length;i+=2)applications.set(row[i],row[i+1]);while(applications.size>128)applications.delete(applications.keys().next().value!);}
631
691
  if (placed || !base) {
632
- adopt(me); rebase(f.k);
692
+ adopt(me); rebase(f.k, changedEpoch ? 'epoch' : 'placement');
633
693
  if (placed) emit('placed', entityOf(me, true));
634
694
  } else if (kindOf.get(me.kind)?.owner) {
635
695
  mine!.motion = motionOf(me.kind, me.motion);
636
696
  if (predictors[me.kind]) mine!.fields = thawFields(kindOf.get(me.kind)?.fields ?? [], me.fields);
637
- } else {
697
+ } else if (!pending.some(entry => entry.seq !== undefined && entry.t <= f.k && f.k - entry.t < tickHz && (applications.get(entry.seq) ?? Infinity) > f.k)) {
698
+ // This snapshot predates an input we already predicted. Within the rewind
699
+ // window it is provisional movement truth: wait for its application receipt
700
+ // rather than briefly adopting the path made with the missing control.
638
701
  const previous = mine!.pos;
639
702
  const shownBody = meNow();
640
703
  const shown = shownBody?.pos ?? previous;
@@ -643,8 +706,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
643
706
  adopt(me);
644
707
  history.set(f.k, copyMine(mine!));
645
708
  const ack = row?.[2] ?? 0;
709
+ const scheduled = row?.[6] === 471;
710
+ if (scheduled) for(let i=7;i+1<row.length;i+=2){
711
+ if(row[i]>0){const entry=pending.find(e=>e.seq===row[i]);if(entry)entry.applied=row[i+1];}
712
+ else for(const entry of pending)for(const [name,n] of Object.entries(entry.pulses??{}))if(n===-row[i]){entry.pulseTicks??={};entry.pulseTicks[name]=row[i+1];}
713
+ }
714
+ const appliedAt = (entry: (typeof pending)[number]): number => entry.applied ?? entry.t;
646
715
  // A numbered pulse is still to come while the server's row has not reached its number (0.46.3): its own message
647
716
  // can be later than the input frame of its tick, so the input's acknowledgement says nothing about it.
717
+ while(pulseQueue.length && pulseQueue[0].n <= (row?.[4] ?? 0))pulseQueue.shift();
648
718
  const taken = typeof row?.[4] === 'number' ? row[4] : null;
649
719
  const owed = (entry: (typeof pending)[number], name: string): boolean => { const n = entry.pulses?.[name]; return taken !== null && n !== undefined ? n > taken : entry.t > ack; };
650
720
  const fresh = (entry: (typeof pending)[number]): boolean => f.k + 1 - entry.t <= Math.ceil(tickHz / 4);
@@ -654,7 +724,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
654
724
  // predicted where the server will bring it and not where a late frame left it for a moment.
655
725
  const heldFields = myKind()!.input.filter(([, fd]) => fd.t === 'held').map(([name]) => name);
656
726
  const debt: Record<string, number> = {};
657
- if (heldFields.length) {
727
+ if (heldFields.length && !scheduled) {
658
728
  for (const entry of pending) if (entry.t <= ack) for (const name of heldFields) serverHeld[name] = Number(entry.input[name]) || 0;
659
729
  const waiting = pending.filter((entry) => entry.t > ack && entry.t <= f.k && fresh(entry));
660
730
  if (waiting.length) for (let i = waiting[0].t; i <= f.k; i++) {
@@ -663,9 +733,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
663
733
  if (meant) for (const name of heldFields) debt[name] = (debt[name] ?? 0) + (Number(meant[name]) || 0) - (serverHeld[name] ?? 0);
664
734
  }
665
735
  }
666
- for (const entry of pending) if (entry.t <= f.k) {
736
+ for (const entry of pending) if (appliedAt(entry) <= f.k) {
667
737
  replayHeld = entry.input;
668
- if (fresh(entry))
738
+ if ((!scheduled || entry.applied === undefined) && fresh(entry))
669
739
  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]);
670
740
  }
671
741
  // After a reset nothing is held yet: the replay runs on every field's own zero, never on an input with no fields
@@ -674,9 +744,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
674
744
  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); }
675
745
  for (let t = f.k + 1; t <= stepped; t++) {
676
746
  const presses = new Map(t === f.k + 1 ? late : []);
677
- for (const entry of pending) if (entry.t === t) {
678
- input = entry.input;
679
- for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name]) presses.set(name, entry.input[name]);
747
+ for (const entry of pending) {
748
+ if(appliedAt(entry)===t)input = entry.input;
749
+ for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name] && (fd.t==='pulse' ? (entry.pulseTicks?.[name]??entry.t)===t : appliedAt(entry)===t)) presses.set(name, entry.input[name]);
680
750
  }
681
751
  const stepInput = { ...input };
682
752
  for (const [name, fd] of myKind()!.input) if (fd.t === 'press' || fd.t === 'pulse') stepInput[name] = presses.get(name) ?? (fd.t === 'press' ? false : 0);
@@ -685,7 +755,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
685
755
  runPredict(me.kind, mine!, mine!.fields, stepInput, t, false, false);
686
756
  history.set(t, copyMine(mine!));
687
757
  }
688
- pending = pending.filter(e => e.t > f.k || fresh(e) && (e.t > ack || Object.keys(e.pulses ?? {}).some((name) => owed(e, name))));
758
+ pending = pending.filter(e => appliedAt(e) > f.k || (scheduled && ((e.applied === undefined && f.k-e.t<tickHz) || Object.keys(e.pulses??{}).some(name=>owed(e,name)))) || !scheduled && fresh(e) && (e.t > ack || Object.keys(e.pulses ?? {}).some((name) => owed(e, name))));
689
759
  if (shownBody && distance(oldHeading, mine!.heading) > 0.0001) headingBlend = { from: shownBody.heading, at: clock() };
690
760
  const error = distance(previous, mine!.pos);
691
761
  correction.last = error;
@@ -724,6 +794,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
724
794
  }
725
795
  }
726
796
  }
797
+ if (!latencyCalibrated && authorityRtt() > calibratedRtt + period && !Object.values(sample).some(Boolean) && !pending.some(e=>Object.values(e.input).some(Boolean))) { rebase(f.k, 'lead'); latencyCalibrated = true; }
727
798
  if (row && row[3] !== -128 && base && clock() >= leadAfter) {
728
799
  const now = clock(), rtt = authorityRtt();
729
800
  // A reported lead describes an input sent roughly one authority round trip ago. Remove that
@@ -732,10 +803,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
732
803
  observedLeadAt = now;
733
804
  leads.push(row[3] / 16 - phaseAt(now - rtt)); if (leads.length > 40) leads.shift();
734
805
  const med = medianLead(), target = targetLead();
735
- if (leads.length >= 8 && Math.abs(med - target) > 4) rebase(f.k, 'lead');
736
- else {
737
- steer(now);
738
- }
806
+ // A changing jitter estimate must not discard already stamped input. Slew the
807
+ // clock; late movement is settled by the server's retained history.
808
+ steer(now);
739
809
  }
740
810
  previewKey = '';
741
811
  settlePredictions(f, before && before.e === f.e ? before : null, row?.[2] ?? 0);
@@ -844,7 +914,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
844
914
  // A held field previews as its mean over the part of the tick that has passed. The body is drawn that fraction of
845
915
  // the way to the preview, so it stands exactly where the control has carried it: it starts from rest at a press
846
916
  // and stops dead at a release, with nothing to blend away afterwards.
847
- 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);
917
+ for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'pulse' && pulseQueue[0]?.name===name ? pulseQueue[0].value : 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);
848
918
  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);
849
919
  // A landing does not take the whole tick (0.46.5): the body met the ground part way through it. How far through is
850
920
  // read from the fall it would have made at the speed it entered the tick with; it is drawn down to the ground by
@@ -971,7 +1041,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
971
1041
  self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
972
1042
  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; },
973
1043
  movement: () => { const kinds = schema.kinds.filter(k => k.player); return kinds.length && kinds.every(k => k.maxSpeed === 0) ? 'stationary' : 'spatial'; },
974
- 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 }),
1044
+ 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, applications: [...applications] }),
975
1045
  scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
976
1046
  score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => solo ? 'offline' : status,
977
1047
  });
@@ -1038,8 +1108,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1038
1108
  rayAll: (from,direction,max,options) => solo ? solo.rayAll(from,direction,max,options) : viewRay(from,direction,max,options,true),
1039
1109
  on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); forwardSolo(name); return () => { set?.delete(fn); }; },
1040
1110
  get shared() { if (solo) return solo.shared; return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
1041
- input(s) {
1042
- if (solo) { solo.input(s); return; }
1111
+ input(s, options) {
1112
+ pump();
1113
+ if (solo) { solo.input(s, options); return; }
1043
1114
  const kind = myKind() ?? schema.kinds.find((k) => k.player) ?? null;
1044
1115
  const next: Record<string, unknown> = {};
1045
1116
  let changed = false;
@@ -1051,7 +1122,13 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1051
1122
  // a jump). Keep the pose at the instant of the press; only subsequent time
1052
1123
  // may advance the new path, rather than drawing elapsed time with new input.
1053
1124
  // A held control changed NOW, before anything else here takes time: the steps this call runs must already know it.
1054
- const at = clock();
1125
+ const now = clock();
1126
+ const at = typeof options?.at === 'number' && Number.isFinite(options.at) ? Math.max(edgeOf(stepped), Math.min(now, options.at)) : now;
1127
+ for (const [index, [name, fd]] of (kind?.input ?? []).entries()) {
1128
+ const value = Number(coerce(fd, s[name] ?? fd.init, dims));
1129
+ if(value !== Number(coerce(fd, sample[name] ?? fd.init, dims)))controlChanges.push([index, ++inputSequence, Math.max(0,Math.min(1,tickAt(at)-stepped)), value]);
1130
+ }
1131
+ controlChanges=controlChanges.slice(-256);
1055
1132
  for (const [name, fd] of kind?.input ?? []) if (fd.t === 'held') {
1056
1133
  const v = heldNumber(next[name], fd.init), tr = heldTrack.get(name);
1057
1134
  if (!tr) heldTrack.set(name, { from: at, changes: [[at, v]] });
@@ -1059,15 +1136,19 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1059
1136
  }
1060
1137
  if (changed) pump();
1061
1138
  const shown = changed ? meNow()?.pos : null;
1062
- 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); }
1139
+ 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'){pulseCount+=1;const target=Math.max(stepped+1,(pulseQueue.at(-1)?.t??stepped)+1);pulseQueue.push({name,value:Number(value),n:pulseCount,t:target});net.send('pulse',[epoch,name,value,target,pulseCount]);} else pressed.set(name,value); }
1063
1140
  sample = next;
1064
1141
  if (shown) {
1065
1142
  const now = clock(), nextShown = meNow()?.pos;
1066
1143
  if (nextShown && distance(shown, nextShown) > 0.00001) {
1067
1144
  const carried = visualOffset(now);
1068
- // Coalesce the carried blend: analogue controls may change every frame.
1069
- offsets = [{ delta: { x: carried.x + periodicDelta(nextShown.x,shown.x,map.wrap?.x), y: carried.y + periodicDelta(nextShown.y,shown.y,map.wrap?.y), z: carried.z + periodicDelta(nextShown.z,shown.z,map.wrap?.z) },
1070
- at: now, last: now, fade: 1, path: { x: nextShown.x - carried.x, y: nextShown.y - carried.y, z: nextShown.z - carried.z } }];
1145
+ // A new control must not restart the lifetime of an older correction.
1146
+ const path = { x: nextShown.x-carried.x, y: nextShown.y-carried.y, z: nextShown.z-carried.z };
1147
+ for(const offset of offsets){offset.path=path;offset.last=now;}
1148
+ const delta={x:periodicDelta(nextShown.x,shown.x,map.wrap?.x),y:periodicDelta(nextShown.y,shown.y,map.wrap?.y),z:periodicDelta(nextShown.z,shown.z,map.wrap?.z)};
1149
+ const last=offsets.at(-1);
1150
+ if(last?.at===now && last.fade===1){last.delta={x:last.delta.x+delta.x,y:last.delta.y+delta.y,z:last.delta.z+delta.z};}
1151
+ else offsets.push({delta,at:now,last:now,fade:1,path});
1071
1152
  }
1072
1153
  }
1073
1154
  },
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.48.0","sha256":"3c0bdb436a2a600fc529827f340b470cdce1cfa40f7d692ff0b9963b5b07c622"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.48.1","sha256":"01670eff45e75c7fca0bee9df359442e097aa916cc837e2d8b772a452a105dea"});
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.48.0';
2
+ export const STUDIO_VERSION_TAG = '0.48.1';