/** * Predict — drop-in prediction layer for Colyseus 0.18+ clients. * * Combines two prediction strategies behind a single ergonomic class: * - Field smoothing (lerp / extrapolate / damped) * - Dead-reckoning (forward-simulate via a shared step function) * * THE PREDICTION FAMILY — pick by what you're predicting: * - `Predict` (this class) — PASSIVE smoothing of the server stream for * entities you DON'T control (remote players → lerp, AI → reckon). Read * with `predict.value(instance, field)`. * - `predict.reconciler(self, …)` → a {@link Reconciler} — ACTIVE server- * reconciled rollback for the entity you DO control: apply input now, * rewind to server truth + replay, smoothly correcting. Read state for * logic via `controller.state` (exact, mutable). * - `predict.sim(…)` → a {@link SimReconciler} — the same rollback over a * COMPOSITE or engine-backed world. Decoded schema instances placed in * its `world` are AUTO-BOUND (seeding/adopt/pose derive from the schema). * - `predict.defineEvent(…)` → a {@link PredictedEventChannel} — a typed * optimistic DISCRETE event (a goal, a kill, a pickup): predicted from the * sim via `ctx.predict(channel, payload)` (replay-safe) or from UI, with * confirm / auto-reject / TTL settlement against server truth. * * ONE READ IDIOM for rendering: `predict.value(instance, field)` covers every * entity — passively-smoothed remotes AND the instances a reconciler/sim * binds (the controllers register their bound fields here, overlaying any * passive slot while they live and restoring it on dispose). Consumers get * the full lifecycle for free: raw state before the controller spawns → * reconciled pose while it's alive → raw again after `dispose()`. Game logic * (hit-reg, zone checks) keeps reading EXACT state via `controller.state` / * `controller.world` — never the smoothed render read. * One `predict.tick(now)` per frame drives all three — controllers and event * channels spawned here are ticked/pruned automatically (see {@link tick}). * * Mirrors `Callbacks.get(room)` from `@colyseus/schema` — construct via the * static factory and attach prediction to schemas as they appear. The * server's `room.clock` (delivered via the TIMED protocol prefix when the * room called `defineInput()`) is consumed automatically — RTT / server * time arrive without any setup. * * import { Client } from "colyseus.js"; * import { Predict } from "./Predictor"; * * const room = await client.joinOrCreate("arena"); * const predict = Predict.get(room, { mode: "lerp", delay: 80 }); * * // Smoothing on a single schema instance: * predict.attach(room.state.boss, { x: "lerp", y: "lerp" }); * * // Dead-reckoning on every child of a collection. Pass the parent * // (e.g. `room.state`) only for nested collections — root-level * // collections take just the key: * predict.attachAll("enemies", { * mode: "reckon", step: stepEnemy, fields: ["x","y","vx"], smoothMs: 40, * }); * * // Once per render frame: * function renderLoop(t: number) { * predict.tick(t); * drawPlayer(predict.value(room.state.boss, "x"), predict.value(room.state.boss, "y")); * requestAnimationFrame(renderLoop); * } * * For side-by-side mode comparison, spin up multiple Predicts: * * const lerp = Predict.get(room, { mode: "lerp", delay: 80 }); * const damped = Predict.get(room, { mode: "damped", smoothMs: 65 }); * * The exact public surface an SDK port implements is recorded in the port * manifest (`PORTING.md`, repo root); everything else in this module is * internal machinery or a JS-only dev affordance. * * NOTE: call `attach` AFTER the instance has been delivered by the * server (e.g. inside `onAdd`). Attaching to an instance that hasn't been * decoded yet throws `Can't addCallback (refId is undefined)`. */ import { Callbacks, type Data } from "@colyseus/schema"; import { PredictedEventChannel, type PredictedEventChannelOptions } from "./predictedEventChannel.ts"; import { PredictedSpawns, type PredictedSpawnsOptions } from "./predictedSpawns.ts"; import { Reconciler, type ReconcilerOptions } from "./reconciler.ts"; import { SimReconciler, type SimReconcilerOptions } from "./simReconciler.ts"; import { type InputHandle } from "../input/InputHandle.ts"; import { type DriftStatus } from "./drift.ts"; import { type RoomClockLike } from "../RoomClock.ts"; import { type CollectionKeys, type ChildOf } from "../core/schema-reflect.ts"; import { type ConfirmOn } from "./confirmOn.ts"; /** Keys of T whose value type is `number`. We only smooth numeric fields. */ type NumericKeys = { [K in keyof T]-?: T[K] extends number ? K : never; }[keyof T] & string; export type PredictMode = "lerp" | "extrapolate" | "damped" | "reckon" | "raw"; /** * Field-level smoothing options for `lerp` / `extrapolate` / `damped`. * * `lerp` — canonical entity interpolation. Buffers recent snapshots * and renders at `renderTime - delay`, interpolating between * the bracketing pair. Smooth, lagged, sample-faithful. * The primary knob is `delay`; size it so jitter rarely * makes the buffer underrun (1–2 server tick intervals). * An optional `smoothMs` output spring (default off) keeps * rendered velocity continuous when the snapshot stream * itself is imperfect. * `extrapolate` — linear forecast from the two most recent samples. * Live, can overshoot. * `damped` — exponential smoothing toward the latest value. * Never exact, never jittery. */ export interface SmoothingOptions { mode?: "lerp" | "extrapolate" | "damped"; /** Render-time lag in ms for `lerp` (default 100). Ignored by other modes. */ delay?: number; /** * Output-smoothing time constant, in milliseconds. 0 disables. * * The smoothed value closes ~63% of any gap to its target per `smoothMs` * (~95% after 3×). The practical reading: `smoothMs` is roughly the extra * display latency the smoothing adds — during steady motion the display * trails its target by ≈ `speed × smoothMs` (260 px/s at `smoothMs: 65` * → ~17 px). * * `damped` uses it as the chase rate toward the latest value, and * `extrapolate` as its predict-then-smooth blend (default 50 for both; * 0 snaps — raw latest value / raw forward-projection). * * `lerp` uses it as an optional output spring on the interpolated result * — default 0 (off, exact interpolation). Turn it on to keep rendered * velocity CONTINUOUS when the snapshot stream itself is imperfect * (server stamp jitter, uneven per-patch motion): the eye punishes * discrete velocity jumps far harder than smooth, bounded error. ~25 * removes the discontinuities at minimal added lag; ~65 renders rough * streams buttery at the cost of that much more lag. DISPLAY-ONLY: * server-side rewind (lag compensation) reconstructs the UNSMOOTHED * interpolation, so the drawn position trails the hit position by the * `speed × smoothMs` bound above — leave at 0 where draw == hit * precision matters. */ smoothMs?: number; /** Maximum extrapolation overshoot in ms past the latest sample for `extrapolate` (default 200). Ignored by other modes. */ maxExtrapolate?: number; /** * Snap incoming sample arrival times to a regular grid of this many ms, * relative to the previous sample. 0 disables (default). * * Useful when the server emits state at a known fixed cadence (e.g. * 33.33 ms for a 30 Hz `setSimulationInterval`) and you want lerp / * extrapolate to render at a uniform playback velocity regardless of * network arrival jitter. Each new sample is timestamped at * `lastT + round(elapsed / tickInterval) * tickInterval`, bounded * to never overshoot wall-clock `now` by more than one interval. * * Has no effect on `damped` (which is keyed to render frames, not * sample arrivals). */ tickInterval?: number; /** * Value-space discontinuity threshold. When a new sample's value differs * from the previous one by MORE than this, the change is treated as a * TELEPORT (respawn, blink, warp) instead of motion: the smoothing state * resets to the new value and every mode renders it immediately — no * glide across the gap. 0 disables (default). * * Deliberately time-free: latency/jitter shift when samples arrive, not * what they carry, so bursty delivery can't false-trigger it. Size it * well above `maxSpeed × patchInterval` (per-sample motion) and below the * smallest legitimate teleport. Note the server encodes only the LATEST * value per patch — a long server stall coalesces real movement into one * sample, so leave headroom for the stalls you'd rather glide through. * For `angle` fields the delta is measured after the shortest-arc fold * (≤ π), so thresholds above π never trip. */ snap?: number; /** * Treat the field as an ANGLE in radians. Samples are stored unwrapped * (continuous) — each new value is folded onto the previous over the * SHORTEST arc — so `lerp` / `damped` / `extrapolate` interpolate correctly * across the ±π seam instead of spinning the long way round. Applies to every * field in the attach config, so attach angular fields (yaw, pitch) in a * SEPARATE call from linear ones (x, y, z). Default false. */ angle?: boolean; } /** * Entity-level dead-reckoning options. The `step` function advances a scratch * copy of the entity forward by `forwardMs` (drawn from the Predict's clock) * in fixed substeps; the result is predict-then-smoothed. * * For Predict constructor defaults `step` is required. For per-`attach` / * `attachAll` overrides `step` is optional — if omitted it falls back to the * Predict's constructor-time default. Missing on both ⇒ throw with a clear * pointer at the call site. */ export interface ReckonOptions { mode: "reckon"; /** The pure step function. Mutates the provided scratch object in place. */ step?: (state: T, dt: number, elapsedMs: number) => void; /** Predict-then-smooth time constant in ms — see {@link SmoothingOptions.smoothMs}. Default 50. 0 = snap. */ smoothMs?: number; /** Substep length in ms. Smaller = more accurate bounces / collisions. Default 16. */ substep?: number; /** Rebase discontinuities larger than this pop instead of decaying out — * see {@link SmoothingOptions.snap}. 0 disables (default). */ snap?: number; } /** * "Off" mode — `value()` returns the latest schema field as-is, with no * smoothing or prediction. Useful as a baseline in the SDK debug panel so * the visual difference each mode contributes can be A/B-compared against * the raw server stream. */ export type RawOptions = { mode: "raw"; }; /** * Discriminated union of mode-specific options. The `mode` field discriminates * — TS catches mistakes like `{ mode: "lerp", step: fn }` at compile time. */ export type PredictOptions = SmoothingOptions | ReckonOptions | RawOptions; /** * Options for {@link Predict.spawns} — the {@link PredictedSpawnsOptions} * store options plus optional dead-reckoning of the collection's confirmed * entities, replacing a separate `attachAll(key, { mode: "reckon", … })` for * spawn-style collections. */ export interface SpawnsOptions, D = undefined> extends PredictedSpawnsOptions { /** * Dead-reckon confirmed entities on these fields, using the same `step` * that advances pending locals. Each confirmed entity gets a reckon slot * (readable via `predict.value()` or, uniformly across the handoff, the * store's `value()`): foreign entities forward to server-present * (snapshot age); owned ones additionally forward by the entry's measured * input lead when {@link PredictedSpawnsOptions.spawnTime} is set. */ fields?: readonly (keyof S & string)[]; /** Reckon smoothing time constant in ms for confirmed entities. Default 0 * — a deterministic constant-step projectile rebases exactly, so smoothing * only adds lag. */ smoothMs?: number; /** Reckon substep in ms. Smaller = more accurate bounces / collisions. Default 16. */ substep?: number; } /** Reckon-mode defaults. `step` stays undefined — must be supplied at construct time * or per-attach; otherwise `attach`/`attachAll` throws. */ interface ReckonDefaults { step: ((state: any, dt: number, elapsedMs: number) => void) | undefined; smoothMs: number; substep: number; snap: number; } type CallbacksInput = Parameters[0]; /** * Extract the root state type from a Room / Decoder / Callbacks input. * Used by `Predict.get(room)` so the returned `Predict` can offer * a root-level `attachAll(key, config)` overload narrowed to `TState`'s * collection-valued keys. */ type StateOf = R extends { state: infer S; } ? S : any; /** Per-field smoothing: either a mode shorthand or full {@link SmoothingOptions}. */ export type FieldSmoothing = "lerp" | "extrapolate" | "damped" | SmoothingOptions; /** * Smoothing config — flat per-field map: `{ x: "lerp", y: { mode: "damped" } }`. * Field names are checked against `T`'s numeric keys, so a typo or non-numeric * field is a compile error. */ export type SmoothingConfig = Partial, FieldSmoothing>>; /** * Reckon attach config — apply dead-reckoning to `fields` using a step * function. `step` can be omitted if the parent Predict was constructed * with `mode: "reckon"` + a `step` default (it falls back); when missing on * both, `attach` / `attachAll` throws. */ export interface ReckonAttachConfig { /** * Per-attach mode. Falls back to the Predict's `defaultMode` when omitted. * The client's display mode is declared HERE, independently of the server's * rewind `mode` — keep them aligned ("what you see is what you hit"): render * targets the server rewinds `mode:"reckon"` with `mode:"reckon"` here, and * those it rewinds `mode:"snapshot"` with an interpolating mode (`lerp` / * `damped`). Common patterns: * - `mode: "lerp"` → smoothing-only attach (no sim state allocated). * - `mode: "reckon"` → reckon attach (requires `step` here or in Predict). * - omitted → the Predict's `defaultMode`. * Per-attach overrides go through the same profile system as the defaults * (the slot's `SLOT_PROFILE` points at a frozen profile encoding the * mode + opts), so dispatch is uniform and the panel sub-card can tune * the override at runtime. */ mode?: PredictMode; /** Numeric fields of `T` to predict. */ fields: readonly NumericKeys[]; /** Step function. Falls back to the Predict's constructor-time default. */ step?: (state: T, dt: number, elapsedMs: number) => void; /** Substep length in ms. Defaults to the Predict's setting (or 16). */ substep?: number; /** Value-space discontinuity threshold, applied to every field here — a * per-sample jump beyond it snaps instead of smoothing (teleports: * respawn, blink, warp). See {@link SmoothingOptions.snap}. Default 0 (off). */ snap?: number; /** Output-smoothing time constant in ms, applied to every field here — * see {@link SmoothingOptions.smoothMs}. On a reckon group it is the * predict-then-smooth window (default the Predict's setting, or 50); on * a `lerp` group the display-only output spring (default 0 = off). */ smoothMs?: number; /** Treat every field here as a radian ANGLE — see {@link SmoothingOptions.angle}. * Use only on smoothing-mode attaches (lerp/damped/extrapolate), not reckon. */ angle?: boolean; /** * Override how the per-frame reckon scratch is built. Leave unset for the * default fast path (a pooled schema instance refilled via `$values` by * index — monomorphic, zero-alloc). Provide one only for non-schema * instances or to copy a custom field subset; a custom snapshot uses the * generic (slower, dynamic-key) advance path. */ snapshot?: (state: T) => T; } export type AttachConfig = SmoothingConfig | ReckonAttachConfig; /** * Options passed to {@link Predict.get}. Intersects {@link PredictOptions} * with extra construction-time fields. The common case — picking the * default mode — is a flat one-liner: * * Predict.get(room, { mode: "lerp", delay: 80 }); * Predict.get(room, { mode: "reckon", step: stepEnemy, smoothMs: 40 }); * * Type alias (not interface) because PredictOptions is a discriminated union * and interface-extends-union isn't permitted in TS. */ export type PredictGetOptions = PredictOptions & { /** * Clock used as the default for the reckon attaches' `forwardMs` / * `elapsedMs`. Falls back to `room.clock` (allocated by the SDK when the * server called `defineInput()`). When neither is available, reckon * attaches must pass explicit `forwardMs` / `elapsedMs`. */ clock?: RoomClockLike; /** * Draw dead-reckoned (`mode:"reckon"`) entities on the clock's SLEW-LIMITED * **render** timeline ({@link RoomClockLike.renderNow}) instead of the raw * {@link RoomClockLike.serverNow}. Both the reckon horizon (snapshot age) * and the time-sampled absolute clock (`elapsedMs`, for closed-form motion * like sinusoids) then read `renderNow()`, so the per-patch offset-EMA * wobble stops showing as `v·Δclock` stutter on remote entities. * * **ON by default** (when the clock implements `renderNow()`; falls back to * `serverNow()` otherwise). It affects ONLY what you SEE: the lag-comp hit * path (`valueAt(when)` with an explicit instant) bypasses it and keeps * stamping on accurate `serverNow()`, so "what you see is what you hit" * holds in steady state — the render timeline just smooths the draw. * * Pass `false` to force the raw `serverNow()` horizon. The one reason to: * strict draw==hit WYSIWYG on a fast twitch game, where you want the drawn * position to equal the hit position even DURING an offset correction (the * slew briefly lags the draw behind the `serverNow()`-stamped hit; racing / * platformer don't care, a competitive shooter might). */ renderPresent?: boolean; /** * Human-friendly identifier shown in `@colyseus/sdk/debug` panels and * useful for logging. Falls back to `predict#N` (incremented per process). */ name?: string; }; /** * Stable engine-introspection contract Predict publishes. Deliberately scoped * to portable engine state so a future C# / C port can expose the same surface; * the debug *panel* shape (per-profile field labels etc.) is assembled in * `@colyseus/sdk/debug` from this core, not here. */ /** Per-reconciler drift snapshot published to the debug panel. */ export interface ReconcilerStat { /** Display label (index-based — most games drive a single reconciler). */ readonly label: string; /** The actionable read: matched / jitter / diverging (see classifyDrift). */ readonly status: DriftStatus; /** `ema / tolerance` when a `warnOnDivergence` tolerance is set — how far past * the dev's own threshold the persistent drift sits. `undefined` otherwise. */ readonly severity?: number; /** Rolling drift EMA — the persistent/divergence component. */ readonly ema: number; /** Rolling drift peak — recent jitter spikes. */ readonly peak: number; /** Most recent reconcile's max |correction| (world/pose units). */ readonly lastCorrectionMag: number; /** Reconcile counter — advances once per reconcile. */ readonly reconcileSeq: number; } export interface PredictCore { readonly name: string; readonly mode: () => PredictMode; readonly smoothingDefaults: () => { mode: PredictMode; delay: number; smoothMs: number; maxExtrapolate: number; tickInterval: number; }; readonly reckonDefaults: () => Readonly; /** Number of instances currently attached. */ readonly attachedCount: () => number; /** Drift telemetry for each driven reconciler/sim — the panel renders a * per-reconciler divergence-vs-jitter readout. Empty when this Predict * drives only passive smoothing. */ readonly reconcilers: () => ReconcilerStat[]; /** Mutate defaults. Mode flips across families freely. */ readonly setDefaults: (opts: PredictOptions) => void; /** * Snapshot every profile currently registered with this Predict. The debug * panel renders one sub-card per non-default profile so per-field overrides * (e.g. `{ vx: { mode: "extrapolate" } }`) become tunable without code edits. */ readonly profiles: () => ProfileCore[]; /** * Mutate a specific profile in place. Slots whose `SLOT_PROFILE` points * at `id` pick up the change next frame. Setting `mode` swaps the * `profileComputers[id]` function pointer in lock-step. Mode accepts any * of the five `PredictMode` values. */ readonly setProfile: (id: number, opts: { mode?: PredictMode; } & SmoothingOptions) => void; /** * Subscribe to track events: fires `(profileIdx, field)` each time a field * is tracked under a profile. The debug bridge uses this to build the * profile → field-names mapping it displays, so that panel-only mapping * never has to live in the engine. Returns an unsubscribe fn. */ readonly onTrack: (cb: (profileIdx: number, field: string) => void) => () => void; /** Unsubscribe when the Predict is disposed. */ readonly onDispose: (cb: () => void) => () => void; } /** Read-only snapshot of one profile (engine state; no panel-only fields). */ export interface ProfileCore { readonly id: number; readonly isDefault: boolean; /** Attach-group label this profile belongs to (the collection key passed to * `attachAll`, or "(attach)" for standalone attaches). The panel renders * one card per (label, mode) so each group is tuned independently. */ readonly label: string | undefined; readonly mode: PredictMode; readonly delay: number; readonly smoothMs: number; readonly maxExtrapolate: number; readonly tickInterval: number; readonly snap: number; } export declare class Predict { /** * Factory mirroring `Callbacks.get(room)`. Each call returns a fresh * Predict — instantiate multiple for side-by-side comparison overlays. * `TState` is inferred from `room.state` so `attachAll(key, config)` can * narrow `key` to the root state's collection-valued properties. */ static get(room: R, opts?: PredictGetOptions): Predict>; private callbacks; /** The construction input, kept for `sessionId` (a Room has one, a raw * Decoder doesn't) — `confirmOn.mine` resolves against it. */ private sessionSource; private slotBuf; private slotCount; private slotDetach; private slotAngle; private freeSlots; private slotByRef; private simByRef; /** MODE_BOUND side table: overlay slot → its {@link BoundSlotEntry}. An * entry here marks the slot as a controller overlay; teardown paths branch * on membership (see untrackSlot / detachByRef / registerBound). */ private boundBySlot; /** The one shared MODE_BOUND profile (lazily allocated). Bound slots carry * no tunable params and the panel's per-controller cards come from * {@link snapshotReconcilers}, so per-controller profiles would only * accumulate (profiles are never freed — a respawn loop would leak them). */ private boundProfileIdx; private renderTime; private defaultMode; private reckonDefaults; private clock; /** Draw reckon entities on the clock's render timeline — see the * `renderPresent` option on {@link PredictGetOptions}. */ private useRenderClock; private fixedStepMs; private stepAcc; private lastFrameNow; /** Spiral-of-death guard: cap fixed steps emitted per frame (after a hitch, * drop the backlog rather than chase it). */ private static readonly MAX_STEPS_PER_FRAME; private profileBuf; private profileCount; private profileKeys; /** * Human label per profile, indexed by profile id. Set from the attach * group's key (collection name) so the debug panel can render one card per * group ("enemies", "players") instead of an anonymous, cross-wired * per-Predict mode toggle. The label is also part of the dedup key, so two * different groups never share a profile even if their params match — * tuning one group's card can't bleed into another's. */ private profileLabels; /** * Per-profile read function — resolved once at profile allocation (or * when `setDefaults`/`setProfile` flips a profile's mode) and stored * here. Slot reads dispatch via `profileComputers[profileIdx](slotIdx)` * instead of an `if (mode === ...)` chain at each `value()` call. * * Every mode reads purely from the slot: the slot's SLOT_REF / SLOT_FIELD * let `reckon` find its SimState and `raw` read SLOT_V1, so no instance or * field-name is threaded through. This pure `(slotId) -> number` shape is * exactly a C function-pointer table / C# delegate array. */ private profileComputers; /** * Track-event listeners. The debug bridge subscribes via the published * core's `onTrack`; on the cold attach path each `trackWithProfile` notifies * them with `(profileIdx, field)` so the bridge can build its profile→fields * view WITHOUT the engine holding any panel-shaped state. Empty in prod (no * debug registry ⇒ never subscribed), so the per-track notify is a length * check on a cold path. */ private trackListeners; /** Public name (shown in the debug panel and useful for logs). */ readonly name: string; private disposeListeners; /** * Child primitives spawned by {@link defineEvent} / {@link spawns} / * {@link reconciler} / {@link sim} that this Predict drives from its own * {@link tick}. The Predict is the single * per-frame driver for the whole prediction stack — one `predict.tick(now)` * advances smoothing AND every controller AND prunes every event store, so * callers can't forget to tick/prune a child (a forgotten drive is a silent * visual bug). Children expose their own `tick`/`prune` for standalone use; * a `dead` child is dropped on the next tick. */ private driven; private constructor(); /** * Predictor's current default mode. Reflects mutations via {@link setDefaults} * (and therefore the SDK debug panel), so consumers that need to react to * mode changes can read this each frame. */ get mode(): PredictMode; /** * The "present" instant provider for reckon horizons + time-sampling (the * `forwardMs` snapshot-age and the `elapsedMs` absolute clock). Unless * {@link PredictGetOptions.renderPresent} was set `false`, and when the clock * implements {@link RoomClockLike.renderNow}, it's the slew-smoothed render * timeline; otherwise the raw {@link RoomClockLike.serverNow}. `undefined` * with no clock — reckon attaches then require explicit * forwardMs/elapsedMs. Resolved once on the cold attach path. * * The lag-comp read (`valueAt` with an explicit `when`) never goes through * here, so hit stamps stay on `serverNow()` even with render-present on. */ private presentFn; private makeCoreHandle; /** Drift telemetry for each driven Reconciler/SimReconciler (duck-typed by * the presence of `drift`), for the debug panel. Event/spawn stores in the * same `driven` list have no `drift` and are skipped. */ private snapshotReconcilers; private readSmoothingDefaults; /** * Look up or allocate a profile matching the given values. With * `dedup=true`, an identical existing profile is reused — so an attachAll * over 1000 entities with the same per-field config still allocates one * profile, not 1000. */ private allocProfile; private computerForMode; /** * Resolve a per-track opts shape to a profile index. * - empty opts (no own keys) → defaults profile (0). The slot follows * setDefaults mutations. * - non-empty opts that, after merging with current defaults, match * defaults exactly → defaults profile (0). Equivalent intent, same * internal state. * - non-empty opts that differ from defaults → frozen profile, value- * deduped via `profileKeys`. The slot does NOT follow later * setDefaults mutations (the override is "frozen"). */ private profileFromOpts; /** * Allocate (or reuse, within the same `label`) the profile for an attach * group. Unlike {@link profileFromOpts}, this NEVER collapses onto the * mutable default profile #0 — each labeled group owns its own profile so * the debug panel can tune it in isolation (the fix for the "changing the * enemies card moved the players" cross-wire). All children of one * `attachAll` share the profile (same label + params ⇒ deduped); different * groups never do. */ private groupProfile; /** * Mutate the Predict's default options. Within the same mode family * (smoothing modes are interchangeable; reckon is its own family). Throws * on cross-family switches — create a new Predict instead. * * Mutations take effect on the next frame for every slot that attached * with a default-shaped config (e.g. `{ x: {}, y: {} }`); attaches that * explicitly overrode a field (e.g. `{ x: { delay: 50 } }`) snapshot * their settings at attach time and are unaffected. * * Mode flips can cross families freely. The defaults profile (id 0) * always encodes the current mode (any of the five), and its computer is * swapped in lock-step so `value()` dispatches correctly without further * branching. */ setDefaults(opts: PredictOptions): void; /** * Snapshot every profile currently registered with this Predict. Profiles * include the defaults (id 0) plus one per unique `(mode, opts)` tuple that's * been frozen by per-field attach overrides. Returns engine state only — the * profile → field-names mapping the panel shows is derived by the debug * bridge from the `onTrack` stream, not here. */ private snapshotProfiles; /** * Mutate one profile in place. Used by the debug panel's per-profile * controls. Setting `mode` swaps the cached `profileComputers[id]` in * lock-step so slot reads pick the new dispatch immediately. */ private setProfile; /** * Tear down all subscriptions. Detaches every attached instance, frees * smoothing slots, removes the Predict from the debug registry, and * invokes onDispose listeners. */ dispose(): void; private untrackSlot; /** Free a bound entry's stashed passive slot (detach its listener). */ private freeStash; private allocSlot; /** * Install a MODE_BOUND overlay slot for each (source, numeric field) a * rollback controller predicts, so `predict.value(source, field)` reads the * controller's interpolated + smooth-corrected pose — the same idiom as * every passively-smoothed entity. A pre-existing passive slot (e.g. the * local player inside an `attachAll` lerp group) is STASHED — its field * listener stays subscribed and samples keep landing in its ring — and * restored when the controller disposes, so lerp resumes seamlessly. * * Returns the unregister (run on the controller's dispose): frees the * overlay slots and restores (or deletes) each mapping. */ private registerBound; /** Wire a just-spawned controller's bound instances into the `value()` * overlay (all controllers share the one MODE_BOUND profile — the side * table, not the profile, carries the per-slot backing) and arrange the * restore on its dispose. No-op for controllers with nothing bound (opaque * sim worlds, plain-fixture reconcilers). */ private installBoundOverlay; private trackSimulated; private untrackSimulated; /** * Attach prediction to a single schema instance via a declarative config. * * For collections, prefer {@link attachAll}. For a root-level instance * that arrives lazily, wrap this call in * `Callbacks.get(room).listen(state, "field", ..., true)`. */ attach(instance: T, config: AttachConfig): () => void; /** One group per attach()/attachAll(): base plan built eagerly (so config * errors throw at the call site, like before); per-type sub-plans lazy. */ private makeGroup; /** The plan for one child — the group's base plan unless the child's TYPE * lacks some of the configured fields (those are dropped). Cached per * constructor: homogeneous collections hit one entry. */ private planFor; /** * Once per (group, constructor): drop fields the child type doesn't declare * (they'd subscribe to nothing and, in reckon scratch, read garbage). Mode * is the group's — the client's prediction mode is explicit, never inferred * from the schema. Returns the base plan when every field is present; * otherwise builds a `label:TypeName` sub-plan with its own profile. */ private resolveCtorPlan; /** * Resolve a group's profiles ONCE. Profiles are allocated via * {@link groupProfile} (labeled, never the mutable default #0), so every * child of the group shares the group's own profile and the panel can tune * it without bleeding into other groups. */ private buildGroupPlan; /** Attach one child using a pre-resolved {@link GroupPlan}. `forwardMs` * overrides the reckon horizon for THIS instance only (e.g. the spawns * store's per-entity input lead); omitted → snapshot age, as usual. */ private attachToGroup; private attachWithPlan; /** Detach a previously {@link attach}'d instance. No-op if not attached. */ detach(instance: object): void; private detachByRef; /** * Attach prediction to every child of a collection on the root state. * Mirrors `callbacks.onAdd("enemies", cb)`'s shape — when the collection * lives on `room.state` you can omit the parent. */ attachAll>(key: K, config: AttachConfig>): () => void; /** * Attach prediction to every child of a nested collection at `parent[key]`. * Wires `onAdd` to {@link attach} the child and `onRemove` to detach it. * Works for MapSchema / ArraySchema / SetSchema. * * @returns A detacher that unsubscribes add/remove AND detaches every * child still tracked. */ attachAll

>(parent: P, key: K, config: AttachConfig>): () => void; /** * Call once per render frame — the single per-frame driver for the whole * prediction stack. Returns your SEND BUDGET: how many fixed input steps are * due this frame — mutate + send exactly that many inputs through your input * handle (`for (n) { input.data.x = …; input.send(); }`), and the reconcilers * observe + predict each send. Besides pacing, the call reconciles + steps + * decays every {@link reconciler}/{@link sim} spawned here, advances * smoothing, and prunes every event channel/spawn store. * * Returns 0 while the fixed step is UNKNOWN: the rate is adopted from the * first {@link reconciler}/{@link sim} spawned here, so pre-spawn frames — * and passive smoothing-only Predicts — pace nothing. That is the send loop * self-gating before the local player exists, not an error; keep calling * `tick()` and the budget starts flowing the frame the controller spawns. * * ORDER WITHIN THE FRAME MATTERS: send the returned steps FIRST, then read * render values (`value()`/`pose()`). A read between this call and the frame's * sends is one fixed step stale — the interpolation clamps at the latest * applied step (never extrapolates), so late frames flat-top and fast objects * visibly stutter. The reconciler warns once when it detects that pattern. * Game logic that wants the exact predicted state (hit-reg, zone checks) * should read `.state`/`.world` instead, which this ordering doesn't affect. * In engines that run per-object update callbacks (rather than one frame * function), tick AND pump in the earliest registered callback — the frame * driver owns input; objects only read. `room.input()` returns the same * handle everywhere, so the driver and the entity that predicts through it * don't need to share plumbing. * * `now` defaults to `performance.now()`. When you drive from * `requestAnimationFrame`, pass ITS timestamp argument — not `performance.now()` * (or `room.clock.now()`) sampled inside the callback: the rAF timestamp is * vsync-aligned and evenly spaced, whereas an in-callback reading folds JS * scheduling jitter into the frame `dt`, which makes render interpolation advance * unevenly (motion looks "not smooth" though it never stutters). Pass it * explicitly, too, when ticking multiple Predicts in one frame so they share one * frame-time reference. */ tick(now?: number): number; /** * Declare a typed optimistic-event CHANNEL — one logical event type * (a goal, a kill, a pickup) owned end-to-end: predicted from the sim * (`ctx.predict(channel, payload)` inside a reconciler `step`, replay-safe * by construction) or from UI (`channel.predict(payload)`), optimistic * feedback via `onPredict`, and settlement against the server: * `channel.confirm()` on the authoritative signal, or auto-reject once * the server has processed past the prediction without confirming (see * the channel header's SETTLEMENT notes). * * The channel OBJECT is the identity — no string key; call sites hold the * binding. Auto-driven by this Predict's {@link tick} — no manual * `prune()`. * * const goals = predict.defineEvent({ * onPredict: (team) => { celebrate(team); hidePuck(); }, * onReject: () => showPuck(), * }); * // in the reconciler step: if (crossed) ctx.predict(goals, team); * // on the server's broadcast: room.onMessage("score", () => goals.confirm()); * * When the authoritative signal is a STATE change rather than a broadcast, * declare it with `confirmOn` and skip the hand-wired listener entirely — * the Predict subscribes it for you and tears it down with the channel: * * const breaks = predict.defineEvent({ * label: "break", * // when a crate's `alive` flips false, confirm the entry keyed * // by that crate's collection key * confirmOn: { collection: "crates", field: "alive", equals: false }, * }); * * Also `{ collection, event: "add" | "remove" }` when membership itself is * the signal (`add` settles keyless, optionally gated by `mine`). The * field/remove forms require channel entry keys (the `uniqueBy` output) to * BE collection keys; root-level collections only — mismatched schemes * confirm manually (see the `confirmOn` module header). */ defineEvent(opts: PredictedEventChannelOptions & { confirmOn?: ConfirmOn; }): PredictedEventChannel; /** * Spawn a {@link PredictedSpawns} store for a collection of optimistically- * spawned entities (bullets, grenades, dropped items) at `state[key]`. * * Predicted locals (added via the store's `spawn(...)`) render instantly; * when the authoritative entity arrives in the collection it's correlated * to the matching prediction and the two collapse onto one logical entry * with a stable `id`. Wires the collection's `onAdd`/`onRemove` and is * auto-ticked + pruned by this Predict's {@link tick} — no separate drive. * * With `fields`, the store also owns the collection's **motion** (no * separate `attachAll` needed): confirmed entities are dead-reckoned with * the same `step` that advances pending locals, and `store.value(entry, * field)` is one read path across the whole life of the entity. Foreign * entities reckon to server-present; with `spawnTime`, owned ones reckon * to server-present *plus the measured input lead*, so the authoritative * entity continues the prediction's flight on the shooter's timeline — * no snap-back at the handoff, and the rendered trajectory is the one a * favor-the-shooter lag-comp hit test actually judges. * * The server element type `S` is inferred from `key`; the predicted-local * shape defaults to `Partial`, so `spawn()` is type-checked against the * server fields with no annotations. To carry client-only fields, annotate * a callback param (e.g. `step: (b: { x: number; speed: number }, dt) => …`) * and `L` is inferred from it. An optional `data` factory gives each entry * an auto-cleaned render-scratch slot (`entry.data: D`), inferred from its * return. * * ```ts * const rockets = predict.spawns("rockets", { * owned: r => r.owner === room.sessionId, // r: Rocket * spawnTime: r => r.bornMs, // exact per-shot lead * step: stepRocket, // shared client/server sim * fields: ["x", "z"], // reckon confirmed entities * }); * // on the predicted fire (live input step): * rockets.spawn({ x, z, heading }); * // render — one path, handoff-invisible, keyed on the stable entry id: * for (const e of rockets.entries()) { * draw(e.id, rockets.value(e, "x"), rockets.value(e, "z")); * } * ``` */ spawns, L = Partial>, D = undefined>(key: K, opts?: SpawnsOptions, L, D>): PredictedSpawns, L, D>; /** * The canonical interpolation `delay` (the default profile's `delay`, from * `Predict.get(room, { delay })` / {@link setDefaults}). Lag compensation's * `renderDelay` is bound to this by {@link reconciler}/{@link sim} so the * interp buffer the remotes render at and the server's rewind instant are * derived from ONE number — they can't drift out of sync. */ private canonicalDelay; /** * Bind lag-comp's `renderDelay` on the input handle to this Predict's lerp * `delay` (the impl's `bindRenderDelay`) so the remote interp * buffer and the server's rewind instant stay one value — no "keep the two * delays equal" footgun. `instanceof`-narrowed (not cast), so a non-impl * input (a test mock) is a no-op; an explicit `room.input({ renderDelay })` * still wins inside `bindRenderDelay`. */ private bindInputRenderDelay; /** * Spawn a {@link Reconciler} for a locally-controlled entity — server- * reconciled rollback (predict your inputs immediately, rewind to the server's * authoritative state + replay unacked inputs, smoothly correcting * mispredictions). The active counterpart to this Predict's passive modes: use * `reconciler()` for the entity you control, lerp/reckon for the rest. * * A pure OBSERVER of `opts.input` (`room.input(...)`): you mutate + send through * the handle (`input.data.x = …; input.send()`) and the reconciler steps each * send + reads the server ack (`input.lastProcessed`) off it — the channel you * send on is the channel that knows what's acked. `predict.tick(now)` returns * how many fixed input steps are due this frame. * * Auto-ticked by this Predict's {@link tick} each frame (reconcile + smooth- * correction decay) — no separate `tick()` call to forget. * * `opts.fields` defaults to every scalar field of `instance`'s schema — see * {@link Reconciler} for the derivation and when to subset explicitly. */ reconciler(instance: S, opts: Omit>, "input"> & { input: InputHandle; }): Reconciler>; /** * Spawn a {@link SimReconciler} for the entity (or entities) your inputs * control — server-reconciled rollback (predict immediately, rewind to the * server's authoritative state + replay unacked inputs, smoothly correcting * mispredictions) — when their truth isn't a single flat scalar `fields` list: * composite scalar state across several schema instances (a paddle + the puck * it strikes, reconciled together), or an opaque physics-engine handle. Your * `world` owns the state via `step` / `adopt` / `pose` callbacks; the controller * runs the rollback loop and passes the world handle to each. * * Like {@link reconciler}, it OBSERVES `opts.input` (you mutate + send through * the handle; `predict.tick(now)` returns the fixed-step count) and is * auto-ticked by this Predict's {@link tick} each frame (reconcile + decay). */ sim = {}, E = any>(opts: Omit, P, E>, "input"> & { input: InputHandle; }): SimReconciler, P, E>; /** * Adopt a spawned reconciler's fixed step as this Predict's room-wide pacing * rate (the first one wins — the server has a single tick rate). Warns if a * later reconciler advertises a different step, since one accumulator can't * pace two rates. */ private adoptFixedStep; /** * Smoothed/predicted RENDER value for a numeric field — the one read idiom: * passively-smoothed entities (lerp/reckon/…) and instances bound by a * live `reconciler()`/`sim()` controller all resolve here (the controller * overlays the slot while it lives; dispose restores the passive slot or * the raw fallback). Falls through to raw `instance[field]` if the field * isn't being tracked — so the read is valid across the entity's whole * lifecycle. For game logic on a controlled entity read the controller's * `.state`/`.world` instead (exact, no smoothing offset). */ value(instance: T, field: NumericKeys): number; /** * RAW reckoned value at an ARBITRARY server-time instant `time` * (server-clock ms) — the reckoned position WITHOUT the decaying * smooth-correction offset that {@link value} adds for rendering. * * Use this for GAME LOGIC (collision / hit tests), and {@link value} for * RENDERING. The offset exists only to hide snapshot-rebase pops on screen; * feeding it into a hit test makes the client judge an overlap against a * position a few cm off the physical prediction, which flips knife-edge * stomp/hit calls vs the server (the server reads the exact timeline, no * offset). Controller-bound fields' exact predicted state lives on the * controller (`me.state` / `me.world`), not behind this read. * * For client-side collision/hit prediction, sample remote entities at the * input's `ctx.reckonTime` (the instant the server rewinds to) so the client's * hit call matches the server's lag-comp read BY CONSTRUCTION — on the live * step AND deterministically on rollback replay (same `time` per seq). For * logic reads at the present instant outside a step, pass * `room.clock.serverNow()`. * * Perf: the render read's per-frame reckon is cached; `valueAt` re-runs the * forward projection on EVERY call. Hot per-frame consumers should batch * with {@link readAt} — one projection per instance instead of one per field. * * Reckons FORWARD from the latest server snapshot to `time`: integrates the * tracked `step` from `lastServerTime()` to `time`, evaluating time-sampled * formulas (sinusoids, cooldown snaps) at `time`. The SDK keeps no per-entity * history, so `time ≤ lastServerTime()` CLAMPS to the snapshot (reckoning * into the past is the server rewind buffer's job — a non-reckonable discrete * motion you replay backward must be reconstructed from its own schedule). On * the live step `time = reckonTime ≈ serverNow() > lastServerTime()`, so it's * exact. Non-reckon / untracked fields ignore `time` and return {@link value}. */ valueAt(instance: T, field: NumericKeys, time: number): number; /** * Batch {@link value} reads — the render value of each listed field written * into one object. The `fields` YOU list define the result's shape * (`Record`). Pass `out` to fill (and return) a reused * scratch instead of allocating — its properties beyond `fields` are left * untouched, so a scratch can carry extra context (an `alive` flag, say). * Mirrors the server's `seen.read` — the same batch-read concept on both * sides of the wire. * * Hot-path guidance (per-frame loops): hoist `fields` as a module-level * const and reuse the scratch — a fresh array or object literal per frame * allocates. The batch resolves the instance's ref once for the whole * group, then reads each field through the same computers as {@link value}. */ read, O extends Record = Record>(instance: T, fields: readonly F[], out?: O): O; /** * Batch {@link valueAt} reads — every listed field sampled at the same * server-time instant `time`, with {@link read}'s scratch contract. * * For client-side hit prediction, sample a remote's pose at the input's * `ctx.reckonTime` in one call: the batch runs the forward reckon * integration ONCE per instance instead of once per field, so a * four-field pose read costs one `step` walk, not four. Non-reckon and * untracked fields ignore `time`, exactly like {@link valueAt}. */ readAt, O extends Record = Record>(instance: T, fields: readonly F[], time: number, out?: O): O; /** * Exponential smoothing toward the latest server value (`v1`). Reads * `smoothMs` from the slot's profile. */ private computeDamped; /** * Canonical entity interpolation: * 1. Render at `target = now - delay` (delay sized so the snapshot * ring almost always brackets the target). * 2. Find the latest pair (k, k+1) with ts(k) <= target. * 3. Lerp between them. On underrun (target past newest snapshot) or * warmup (only one snapshot), hold at the newest sample — *don't* * extrapolate. Extrapolation here is what produced the "flickery" * feel; bracketing changes happen at predictable render-time * crossings, not at jittered packet arrivals. * 4. Optionally chase the result with an output spring (`smoothMs`, * default 0 = off) — display-side velocity continuity for imperfect * snapshot streams; see {@link SmoothingOptions.smoothMs}. */ private computeLerp; /** Steps 1–3 of {@link computeLerp} — the undamped interpolant. */ private computeLerpRaw; /** * Ring-driven forward projection with predict-then-smooth output EMA. * 1. Anchor on the ring's newest snapshot. * 2. Slope from a 2-step lookback in the ring (~2 tick intervals) * when available, else last-2 fallback. * 3. raw = anchor + slope · clamp(now − anchor.t, 0, maxExtrapolate). * 4. EMA-blend raw → smoothed using `smoothMs` (0 disables → return raw). */ private computeExtrapolate; /** * Dead-reckoning dispatch. Recovers (refId, fieldId) from the slot, finds * the `SimState` registered by `trackStepped`, and runs predict-then-smooth. * If the slot's profile flipped to reckon at runtime but no SimState was * ever allocated (e.g. attach was smoothing-only), falls back to the latest * server value mirrored in SLOT_V1 — degraded but functional, no object * access. */ private computeReckon; /** * Raw dispatch — return the latest server value as-is, no smoothing/ * prediction. SLOT_V1 mirrors the latest sample on every update, so this * needs no object access. The slot ring still receives samples from the * listener (so a panel flip back to a smoothing mode works without * re-attach), but they're unused while raw is active. */ private computeRaw; /** * Bound-overlay dispatch — the slot's value is a rollback controller's * interpolated + smooth-corrected pose read (`ctrl.value(poseKey)`). Same * dispatch class as reckon's `simByRef` read: one side-table lookup, then * the controller read — which also runs the read-before-pump bookkeeping, * so `predict.value(entity, f)` and `me.value(f)` warn identically. */ private computeBound; /** `pos` indexes the SoA buffers (`sim.smoothed` / `sim.out`). * * Predict + OFFSET-DECAY smoothing (not an EMA chase): the display is * `out + offset`. Between snapshots the forward sim is continuous, so the * display moves at the target's full velocity — STEADY-STATE EXACT, no * systematic lag on a moving entity (an EMA chasing a mover lags it by * ~v × smoothMs forever — enough to flip knife-edge hit calls vs the * server, which always reads the exact timeline). When a new SNAPSHOT * rebases the sim and the trajectory jumps (a real misprediction), the * discontinuity is captured into `offset` and decays out — the pop-hiding * the smoothing exists for. Same construction as the Reconciler's * error-decay for the local player. * * Without a clock (`lastBaseT` stays NaN — rebase undetectable), falls * back to the EMA chase. */ private applySimulation; } export {};