@homie-rocks/studio 0.48.0 → 0.48.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/CHANGELOG.md CHANGED
@@ -17,6 +17,80 @@ 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.3] - 2026-10-13
21
+
22
+ **Plugin 0.48.3** · Restore rewind-based hits with input history.
23
+
24
+ Rules input history fits the wire without starving current aim and trigger controls.
25
+
26
+ ### Fixed
27
+
28
+ - **0.48.1 misjudged rewind-based hits.** Repeated input history and sub-tick
29
+ metadata could exceed the relay's 2 KB input limit, even with no latency or
30
+ loss. The server then kept stale aim and trigger state while the player's
31
+ predictor showed hits against the current aim. Input frames now fit that
32
+ existing byte limit, prioritizing current controls and missing receipts.
33
+ - Optional sub-tick metadata uses only the space left after complete input
34
+ values and their sequence numbers. Integrated held durations, reliable
35
+ pulses, measured round trips and the rewind cap keep their existing meaning.
36
+ Bounded history recovery, application receipts and correction blending remain.
37
+
38
+ ### Upgrade notes
39
+
40
+ - Existing games need no code changes.
41
+
42
+ ## [0.48.1] - 2026-10-13
43
+
44
+ **Plugin 0.48.1** · Input history under loss.
45
+
46
+ Rules views recover missing input and acknowledge late movement scheduling.
47
+
48
+ ### Changed
49
+
50
+ - Snapshots that precede a predicted input's application receipt retain the live
51
+ movement prediction within the replay window; explicit placements still apply.
52
+ - Prediction clock steering is limited to 0.5% instead of 5%; it slews without
53
+ repeatedly rebasing stamped input when the jitter estimate changes.
54
+ - Rules input frames repeat a bounded history sized from measured latency and
55
+ jitter and missing application receipts, with input sequence numbers and sub-tick control changes. Snapshot
56
+ control rows report application ticks, including pulses. Retransmissions no
57
+ longer distort latency steering. A newer tick acknowledgement cannot stop
58
+ retries for an earlier missing input; only its own receipt does.
59
+ - Late movement replays up to one second of body history against historical
60
+ geometry, fenced by authoritative body writes and placements. Outside that
61
+ history an entry applies next tick. Entries with press handlers also apply
62
+ next tick, so historical replay cannot duplicate world actions. New views use application receipts instead
63
+ of negative held-input compensation; old wire entries retain their behaviour.
64
+ - Small visual corrections use `room.predict.blendMs` and expire within one
65
+ second (or an explicitly longer blend), including while stationary; `idleHold` no longer holds them indefinitely. Placement counters
66
+ still distinguish respawns from prediction corrections. Vertical travel cannot
67
+ erase a horizontal correction fast enough to reverse the held direction.
68
+ Remaining offsets drain over the final 250 ms rather than snapping at expiry.
69
+
70
+ ### Added
71
+
72
+ - `room.input(sample, {at})` timestamps a control inside the current predicted
73
+ tick. `player.pulses: 'hold'` queues actions while control is taken;
74
+ `'drop'` remains the default.
75
+ - Seeded virtual-time coverage of 40/80/150/300 ms each way and 0.5/1/2/5% loss,
76
+ dropped inputs and ordered stall/burst delivery, in small rooms and through Gates.
77
+ - A seeded baked-physics-world regression at 80 ms each way and 2% loss checks
78
+ zero drawn warps, application receipts, 300 Hz substeps, predictor room reads
79
+ and exact settling for two moving bodies.
80
+
81
+ ### Fixed
82
+
83
+ - Multiple pulse calls within one predicted tick queue instead of overwriting
84
+ one another. Their application ticks are distinct from the movement entry.
85
+ - Placements preserve the established prediction-clock phase, and the snapshot
86
+ body is advanced to its predicted tick instead of relabelling an old pose.
87
+ Projection is bounded to the server's one-second input window, and its controls
88
+ are stamped for that same path. Queued held state from an earlier placement
89
+ cannot later change the respawned body; discrete presses received before the
90
+ browser learns of the placement still belong to the player.
91
+ - A fresh control after a browser stall is recorded after resetting the stale
92
+ prediction clock, preventing a short press from moving by the stalled backlog.
93
+
20
94
  ## [0.48.0] - 2026-10-13
21
95
 
22
96
  **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,90 @@
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
+ }
3030
+ },
3031
+ "0.48.3": {
3032
+ "files": {
3033
+ "CLAUDE.md": "ddf2b2a0376202f6",
3034
+ "README.md": "684b3cca8001673c",
3035
+ "HANDOFF.md": "29337e2ace95b2e7",
3036
+ "games/README.md": "5ad32fe80c61f117",
3037
+ "music/README.md": "a3dc042dadd39df8",
3038
+ "videos/README.md": "16babc3437d5545b",
3039
+ "posts/README.md": "37841aae476bd540",
3040
+ "site/README.md": "732894273a32dc3f",
3041
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3042
+ },
3043
+ "sections": {
3044
+ "AGENTS.md": {
3045
+ "(intro)": "394cbbe934e80722",
3046
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3047
+ "## Layout": "f3a280b8c96cd068",
3048
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
3049
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3050
+ "## The site": "0cb8dd9a6f8a0096",
3051
+ "## Making games": "bb715272fd1a615b",
3052
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3053
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3054
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3055
+ "## Servers and AI seats": "1ccfd549d2369a60",
3056
+ "## Selling things (the shop)": "9e020d69dd6a954e",
3057
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3058
+ "## Rules": "27881d11d3ff3803",
3059
+ "## Telling Homie": "80b46fdd9e578db8",
3060
+ "## Beta": "9d02638207a26e73"
3061
+ }
3062
+ },
3063
+ "named": {
3064
+ "Homie Arcade": {
3065
+ "sections": {
3066
+ "AGENTS.md": {
3067
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3068
+ }
3069
+ }
3070
+ }
3071
+ }
2988
3072
  }
2989
3073
  }
2990
3074
  }
@@ -2329,3 +2329,28 @@ 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. Each frame also fits the
2340
+ existing 2 KB input cap: current controls first, then missing receipts, then
2341
+ acknowledged history. Optional sub-tick metadata uses the remaining bytes; the
2342
+ complete row retains its integrated held values and sequence even when the
2343
+ metadata is omitted. Retained entries not selected for this frame remain
2344
+ available for subsequent recovery frames. The host deduplicates
2345
+ entry sequences, including history received after a missing frame. Duplicate
2346
+ history is not a fresh lead observation. Frames without the trailer keep the
2347
+ legacy input semantics.
2348
+
2349
+ Control rows keep their first six columns and append
2350
+ `[471, sequence, appliedTick, ...]`. Positive sequences identify input entries;
2351
+ negative sequences identify the independently numbered reliable pulse stream.
2352
+ The last 64 receipts repeat in snapshots, including projected Gate snapshots.
2353
+ An on-time entry names its predicted tick; a safely rewound movement entry names
2354
+ that historical tick; an entry beyond retained movement history or an authority
2355
+ boundary names the next tick. Entry receipts describe coalesced per-tick input;
2356
+ 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.3",
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
@@ -6,7 +6,7 @@ import type { RayQueries, RayOptions } from './types.ts';
6
6
  import { snapshotDecoder } from './interest.mjs';
7
7
  import { collisionMap, collisionTargets, revisionAt, type CollisionRevision } from './live.ts';
8
8
  import { rulesOutput } from '../netplay/rules-output.mjs';
9
- import { rulesCaps, rulesRates } from '../worker/limits.mjs';
9
+ import { LIMITS, rulesCaps, rulesRates } from '../worker/limits.mjs';
10
10
  /*
11
11
  * view.ts — what a rules game's view imports: the room, as a browser sees it.
12
12
  * =============================================================================
@@ -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,51 @@ 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
+ // Repeated history must fit the relay's existing byte cap, including its
612
+ // envelope and snapshot acknowledgement. Always carry the newest controls;
613
+ // spend the remaining space on missing receipts before acknowledged history.
614
+ // Sub-tick samples are optional metadata: the row already contains the held
615
+ // duration integrated by heldMean. Never truncate its values or sequence.
616
+ const budget = LIMITS.in - 128;
617
+ const selected = new Map<number, StepEntry>();
618
+ let bytes = 2;
619
+ const newest = sentHistory.at(-1);
620
+ const candidates = [...(newest ? [newest] : []),
621
+ ...sentHistory.filter(e => e !== newest && !applications.has(e.row[marker + 1])),
622
+ ...sentHistory.filter(e => e !== newest && applications.has(e.row[marker + 1])).reverse()];
623
+ for (const e of candidates) {
624
+ const row = [e.t - first, ...e.row.slice(0, marker + 2), 0];
625
+ const size = JSON.stringify(row).length + (selected.size ? 1 : 0);
626
+ if (bytes + size > budget) continue;
627
+ selected.set(e.t, row); bytes += size;
628
+ }
629
+ // Use spare bytes for sample metadata only after reserving control history.
630
+ for (const e of candidates) {
631
+ const compact = selected.get(e.t);
632
+ if (!compact) continue;
633
+ const row = [e.t - first, ...e.row];
634
+ const extra = JSON.stringify(row).length - JSON.stringify(compact).length;
635
+ if (bytes + extra <= budget) { selected.set(e.t, row); bytes += extra; }
636
+ }
637
+ const rows = [...selected].sort((a, b) => a[0] - b[0]).map(([, row]) => row);
554
638
  entries = [];
555
- if (net.steps(epoch, k, rows, mine.r)) lastSendAt = now;
639
+ if (net.steps(epoch, first, rows, mine.r)) lastSendAt = now;
556
640
  }
557
641
  /** The held values as an entry on tick `t`, without stepping the body again. */
558
642
  function keepalive(t: number): void {
@@ -560,7 +644,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
560
644
  if (!mine || !kind) return;
561
645
  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
646
  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]);
647
+ 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
648
  }
565
649
  let pumping = false;
566
650
  function pump(): void {
@@ -628,13 +712,18 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
628
712
  if (me) {
629
713
  const placed = !mine || mine.id !== me.id || mine.r !== me.r || changedEpoch;
630
714
  const row = f.rows.find((x) => x[0] === seat);
715
+ if(changedEpoch)applications.clear();
716
+ 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
717
  if (placed || !base) {
632
- adopt(me); rebase(f.k);
718
+ adopt(me); rebase(f.k, changedEpoch ? 'epoch' : 'placement');
633
719
  if (placed) emit('placed', entityOf(me, true));
634
720
  } else if (kindOf.get(me.kind)?.owner) {
635
721
  mine!.motion = motionOf(me.kind, me.motion);
636
722
  if (predictors[me.kind]) mine!.fields = thawFields(kindOf.get(me.kind)?.fields ?? [], me.fields);
637
- } else {
723
+ } else if (!pending.some(entry => entry.seq !== undefined && entry.t <= f.k && f.k - entry.t < tickHz && (applications.get(entry.seq) ?? Infinity) > f.k)) {
724
+ // This snapshot predates an input we already predicted. Within the rewind
725
+ // window it is provisional movement truth: wait for its application receipt
726
+ // rather than briefly adopting the path made with the missing control.
638
727
  const previous = mine!.pos;
639
728
  const shownBody = meNow();
640
729
  const shown = shownBody?.pos ?? previous;
@@ -643,8 +732,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
643
732
  adopt(me);
644
733
  history.set(f.k, copyMine(mine!));
645
734
  const ack = row?.[2] ?? 0;
735
+ const scheduled = row?.[6] === 471;
736
+ if (scheduled) for(let i=7;i+1<row.length;i+=2){
737
+ if(row[i]>0){const entry=pending.find(e=>e.seq===row[i]);if(entry)entry.applied=row[i+1];}
738
+ 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];}
739
+ }
740
+ const appliedAt = (entry: (typeof pending)[number]): number => entry.applied ?? entry.t;
646
741
  // A numbered pulse is still to come while the server's row has not reached its number (0.46.3): its own message
647
742
  // can be later than the input frame of its tick, so the input's acknowledgement says nothing about it.
743
+ while(pulseQueue.length && pulseQueue[0].n <= (row?.[4] ?? 0))pulseQueue.shift();
648
744
  const taken = typeof row?.[4] === 'number' ? row[4] : null;
649
745
  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
746
  const fresh = (entry: (typeof pending)[number]): boolean => f.k + 1 - entry.t <= Math.ceil(tickHz / 4);
@@ -654,7 +750,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
654
750
  // predicted where the server will bring it and not where a late frame left it for a moment.
655
751
  const heldFields = myKind()!.input.filter(([, fd]) => fd.t === 'held').map(([name]) => name);
656
752
  const debt: Record<string, number> = {};
657
- if (heldFields.length) {
753
+ if (heldFields.length && !scheduled) {
658
754
  for (const entry of pending) if (entry.t <= ack) for (const name of heldFields) serverHeld[name] = Number(entry.input[name]) || 0;
659
755
  const waiting = pending.filter((entry) => entry.t > ack && entry.t <= f.k && fresh(entry));
660
756
  if (waiting.length) for (let i = waiting[0].t; i <= f.k; i++) {
@@ -663,9 +759,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
663
759
  if (meant) for (const name of heldFields) debt[name] = (debt[name] ?? 0) + (Number(meant[name]) || 0) - (serverHeld[name] ?? 0);
664
760
  }
665
761
  }
666
- for (const entry of pending) if (entry.t <= f.k) {
762
+ for (const entry of pending) if (appliedAt(entry) <= f.k) {
667
763
  replayHeld = entry.input;
668
- if (fresh(entry))
764
+ if ((!scheduled || entry.applied === undefined) && fresh(entry))
669
765
  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
766
  }
671
767
  // 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 +770,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
674
770
  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
771
  for (let t = f.k + 1; t <= stepped; t++) {
676
772
  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]);
773
+ for (const entry of pending) {
774
+ if(appliedAt(entry)===t)input = entry.input;
775
+ 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
776
  }
681
777
  const stepInput = { ...input };
682
778
  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 +781,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
685
781
  runPredict(me.kind, mine!, mine!.fields, stepInput, t, false, false);
686
782
  history.set(t, copyMine(mine!));
687
783
  }
688
- pending = pending.filter(e => e.t > f.k || fresh(e) && (e.t > ack || Object.keys(e.pulses ?? {}).some((name) => owed(e, name))));
784
+ 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
785
  if (shownBody && distance(oldHeading, mine!.heading) > 0.0001) headingBlend = { from: shownBody.heading, at: clock() };
690
786
  const error = distance(previous, mine!.pos);
691
787
  correction.last = error;
@@ -724,6 +820,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
724
820
  }
725
821
  }
726
822
  }
823
+ 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
824
  if (row && row[3] !== -128 && base && clock() >= leadAfter) {
728
825
  const now = clock(), rtt = authorityRtt();
729
826
  // A reported lead describes an input sent roughly one authority round trip ago. Remove that
@@ -732,10 +829,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
732
829
  observedLeadAt = now;
733
830
  leads.push(row[3] / 16 - phaseAt(now - rtt)); if (leads.length > 40) leads.shift();
734
831
  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
- }
832
+ // A changing jitter estimate must not discard already stamped input. Slew the
833
+ // clock; late movement is settled by the server's retained history.
834
+ steer(now);
739
835
  }
740
836
  previewKey = '';
741
837
  settlePredictions(f, before && before.e === f.e ? before : null, row?.[2] ?? 0);
@@ -844,7 +940,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
844
940
  // A held field previews as its mean over the part of the tick that has passed. The body is drawn that fraction of
845
941
  // the way to the preview, so it stands exactly where the control has carried it: it starts from rest at a press
846
942
  // 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);
943
+ 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
944
  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
945
  // 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
946
  // 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 +1067,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
971
1067
  self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
972
1068
  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
1069
  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 }),
1070
+ 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
1071
  scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
976
1072
  score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => solo ? 'offline' : status,
977
1073
  });
@@ -1038,8 +1134,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1038
1134
  rayAll: (from,direction,max,options) => solo ? solo.rayAll(from,direction,max,options) : viewRay(from,direction,max,options,true),
1039
1135
  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
1136
  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; }
1137
+ input(s, options) {
1138
+ pump();
1139
+ if (solo) { solo.input(s, options); return; }
1043
1140
  const kind = myKind() ?? schema.kinds.find((k) => k.player) ?? null;
1044
1141
  const next: Record<string, unknown> = {};
1045
1142
  let changed = false;
@@ -1051,7 +1148,13 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1051
1148
  // a jump). Keep the pose at the instant of the press; only subsequent time
1052
1149
  // may advance the new path, rather than drawing elapsed time with new input.
1053
1150
  // A held control changed NOW, before anything else here takes time: the steps this call runs must already know it.
1054
- const at = clock();
1151
+ const now = clock();
1152
+ const at = typeof options?.at === 'number' && Number.isFinite(options.at) ? Math.max(edgeOf(stepped), Math.min(now, options.at)) : now;
1153
+ for (const [index, [name, fd]] of (kind?.input ?? []).entries()) {
1154
+ const value = Number(coerce(fd, s[name] ?? fd.init, dims));
1155
+ if(value !== Number(coerce(fd, sample[name] ?? fd.init, dims)))controlChanges.push([index, ++inputSequence, Math.max(0,Math.min(1,tickAt(at)-stepped)), value]);
1156
+ }
1157
+ controlChanges=controlChanges.slice(-256);
1055
1158
  for (const [name, fd] of kind?.input ?? []) if (fd.t === 'held') {
1056
1159
  const v = heldNumber(next[name], fd.init), tr = heldTrack.get(name);
1057
1160
  if (!tr) heldTrack.set(name, { from: at, changes: [[at, v]] });
@@ -1059,15 +1162,19 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1059
1162
  }
1060
1163
  if (changed) pump();
1061
1164
  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); }
1165
+ 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
1166
  sample = next;
1064
1167
  if (shown) {
1065
1168
  const now = clock(), nextShown = meNow()?.pos;
1066
1169
  if (nextShown && distance(shown, nextShown) > 0.00001) {
1067
1170
  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 } }];
1171
+ // A new control must not restart the lifetime of an older correction.
1172
+ const path = { x: nextShown.x-carried.x, y: nextShown.y-carried.y, z: nextShown.z-carried.z };
1173
+ for(const offset of offsets){offset.path=path;offset.last=now;}
1174
+ 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)};
1175
+ const last=offsets.at(-1);
1176
+ 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};}
1177
+ else offsets.push({delta,at:now,last:now,fade:1,path});
1071
1178
  }
1072
1179
  }
1073
1180
  },
@@ -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.3","sha256":"52a096d783a92de61024123ffd3ce7e3e73c2c805d2a7238d342d2fc77d3830d"});
@@ -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.3';