# Interpolation system — build plan

Cross-cutting plan for fixed-step → render interpolation. Spans **render**,
**physics** (a producer), and **network** (a producer + the existing primitives
this builds on). Referenced from `engine/physics/PLAN.md`.

> Filename note: created as `INTEPOLATION_SYSTEM_PLAN.md` per the request; rename
> to `INTERPOLATION_SYSTEM_PLAN.md` if the spelling was unintended.

---

## Goal

The simulation advances on a fixed step (`EntityManager.fixedUpdateStepSize`,
~60 Hz). The renderer runs at a variable, usually higher, display rate on its own
`requestAnimationFrame` loop. Between two fixed steps the rendered pose is frozen
at the last step, so motion stutters / temporally aliases — worst at low fixed
rates or high refresh. This system smooths the discrete per-tick poses across the
render rate.

## Hard constraints (these shaped every decision)

1. **Write `Transform`, not the renderable.** Scene culling, mesh BVHs, particle
   systems, trails, and attachment hierarchies are all driven off the ECS
   `Transform`. Writing an interpolated pose into a three.js object directly
   would smooth *some* visuals and desync the rest. The interpolated pose must
   land in `Transform` so the whole engine sees it uniformly. (Bonus: attachment
   children of an interpolated physics parent then track it **for free**, because
   the attachment system is push-based on the parent's `Transform.onChanged`.)
2. **Only interpolate what moved.** Per-frame cost must be O(moving bodies),
   never O(total). The engine targets massive worlds — tens of thousands of
   dynamic bodies with only a tiny awake/moving fraction at any moment.
3. **Don't fight physics.** Physics must never integrate from an interpolated
   pose. The simulation reads authoritative state; interpolation is a downstream
   render channel.
4. **Co-designed with network.** The network package can change (not yet shipped).
   Interpolation must share machinery with replication, not duplicate it.
5. **Rewind-safe.** The network can rewind and re-simulate. If a rewind+resim
   produces the same state, the observer must see the **same** interpolated
   result — no visual drift from re-running history.
6. **Agnostic to what it interpolates.** Translation+rotation is one case;
   network may interpolate other replicated quantities. The mechanism blends
   *encoded snapshots of any registered component type*.

## Key realization — reuse, don't rebuild

The network package already contains the agnostic interpolation engine. Physics
becomes a *producer* into it; we do not build a parallel system.

- `network/sim/BinaryInterpolationAdapter.js` —
  `interpolate(out, source, offA, offB, t)`: type-agnostic blend of two encoded
  snapshots. Concrete `Transform` / `Vector3` / `Quaternion` adapters exist.
  **This is constraint 6, already solved.**
- `network/sim/InterpolationLog.js` — a **tick-indexed ring** (`Map<tick, …>`)
  of per-component snapshots, recording **only components that changed that tick**
  (the package's O(mutations)-not-O(entities) bet == constraint 2). Keyed by a
  plain integer; `interpolate(out, key, type_id, tickA, tickB, t, adapter)`
  blends two ticks into an out-buffer that is then deserialized into the live
  component.
- `network/sim/SimActionExecutor.js` — `before_execute` hook, JSDoc:
  *"NetworkSession undoes render-time interpolation on remote-owned components"*
  before reading authoritative state. **The architecture already committed to
  interpolation writing the live component and the sim restoring authoritative
  before it reads** (constraints 1 + 3).
- `network/sim/ActionLog.js` + `network/sim/RewindEngine.js` — rewind restores
  prior authoritative state; resim replays forward via `onReplay`
  (constraint 5).

## Locked decisions

1. **Neutral shared primitive.** Lift `InterpolationLog`,
   `BinaryInterpolationAdapter`, and the blend adapters out of `network/` into a
   neutral `engine/interpolation/`. Physics and network both produce into it; one
   render-time pass blends for both. Replication + rewind layer on top **only**
   for networked entities — single-player physics pays no action-log cost.
2. **Restore-from-log at step start.** Before each physics tick, reset each moving
   body's live `Transform` to its latest authoritative tick snapshot (a *quiet*
   write that bypasses `onChanged`). The solver / narrowphase / broadphase /
   integrators keep reading & writing `Transform` exactly as today — **no rewiring
   of the physics hot paths.** The authoritative "cache" is the log's newest tick.

## Glossary (load-bearing terms)

- **Tick** — one fixed simulation step, identified by a monotonic integer *tick
  number*. The orchestrator's frame number when networked; an internal counter
  otherwise.
- **Authoritative pose / endpoint** — the exact pose the simulation produced at
  the *end* of a tick. The thing physics owns and rewind restores.
- **Snapshot** — an authoritative pose encoded into the log under
  `(log key, type_id, tick)` via a serialization adapter.
- **Log key** — the integer identity under which an entity's snapshots are
  recorded and looked up in the shared log. `network_id` when the entity is
  networked (so a server's recording and a client's receipt line up under one
  identity); a locally-allocated interp id otherwise. Carried on the
  `Interpolated` component. (See §"Q&A".)
- **alpha** — `accumulator / fixedUpdateStepSize ∈ [0, 1)`: the fractional
  progress between the two most-recent *completed* ticks. The blend parameter `t`.
  We render **between** the last two ticks (one step in the past), so both
  endpoints always exist — no extrapolation. (See §"Q&A".)
- **`InterpolationSystem`** — the system that owns the render-time blend pass. It
  runs in `System.update(dt)` (the variable-rate, rAF-driven cycle), NOT ad-hoc in
  a `preRender` callback. `update` can fire on ticks where no render happens —
  harmless (a blend with no following draw is just discarded). If display-exact
  timing is ever needed, the system can additionally hook `preRender` from its
  `startup`; `update` is the default.
- **`Interpolated` component** — opt-in marker physics & render cooperate around.
  Holds the **log key** (defaults to `-1`; `InterpolationSystem.link` assigns it
  `entity_id` when still `-1`, so the common case needs no manual setup — the
  network system can instead set it to the `network_id` directly for replicated
  entities), the blend type/adapter id, and a `snap` flag. A body without it keeps
  today's direct-write behavior (this is how physics "sees" who to cooperate
  with).

## Architecture — one engine, two producers

**Per fixed tick, for bodies carrying `Interpolated`:**

1. **Restore (step start):** reset the live `Transform` to the log's latest
   authoritative tick — a quiet write (no `onChanged`). This is `before_execute`
   / "undo render interpolation" applied to physics. Physics then reads truth.
2. **Step:** physics integrates as today → `Transform` = new authoritative pose.
3. **Record (step end):** if it actually moved (`|Δ| > ε`), snapshot the pose into
   the current tick. Sparse by construction → O(moving).

**Per `InterpolationSystem.update(dt)` (render/rAF cadence):** for each
`Interpolated` entity, pick `(tickA, tickB, t)` and blend → deserialize into the
live `Transform` (this write *does* fire `onChanged` → culling/particles/trails/
attachments update). O(moving). `t` source is per-producer: local =
`EntityManager.getFixedStepAlpha(physicsSystem)`; remote = the network's existing
render-delay/jitter playout.

```
simulate()  [fixed-step catch-up loop]      InterpolationSystem.update(dt)  [rAF cadence]
  for awake+Interpolated:                     for Interpolated e:
    restore Transform ← log.latest (quiet)      (a,b,t) = window(e)   // t = getFixedStepAlpha(physics)
  ...physics step → Transform = authoritative   blend(a,b,t) → Transform   (onChanged)
  for moved+Interpolated:                       → later draw passes read Transform
    log.record(key, tick, Transform)
```

## Why each constraint holds

1. **Writes Transform** — blend deserializes into the live component; every
   Transform-linked system works, hierarchy included.
2. **Only moving** — produce & blend only for moved bodies; the log is sparse.
3. **Doesn't fight physics** — restore-at-step-start; physics never sees the
   interpolated pose. Provable by an interp-on vs interp-off sim-state bit-identity
   test.
4. **Co-designed with network** — same log, same adapters, same `before_execute`;
   physics is a new producer into the existing pipeline.
5. **Rewind-safe** — the log is tick-keyed; resim re-runs the deterministic sim,
   which re-records the *same* tick numbers with identical bytes → identical
   blend output. Requirement: the replay path records snapshots like the live path
   (it runs the same systems), and the log supports re-recording rewound ticks.
6. **Agnostic** — `BinaryInterpolationAdapter`; pose is one type; any component
   with a (serialization adapter + interpolation adapter) pair interpolates the
   same way.

## Invariants

- **Interpolation never feeds back into the sim.** Guarded by restore-at-step-start
  and an interp-on/off sim bit-identity test.
- **The log's latest tick is the single authoritative cache** between steps;
  `Transform` is a derived render channel for interpolated bodies.
- **Sparse by motion** — O(moving), never O(N).

## Precision note (massive worlds)

The network *wire* Transform adapter uses Float32 position + smallest-three
quaternion for bandwidth. That quantizes badly far from the origin. The shared
**interp log stores full-precision snapshots** (Float64 position + full
quaternion) for both local and remote (remote is re-serialized at full precision
on apply); wire compression stays on the wire only.

## Contract change (interpolated bodies only)

For a body carrying `Interpolated`, raw external `Transform` writes are no longer
authoritative — restore-at-step-start would clobber them. External authoritative
changes (teleport, kinematic drive) go through a `setPose` / `teleport` API that
updates the log's latest tick **and** sets `snap` (so the next render does not
slide across the jump). Bodies *without* `Interpolated` keep today's raw-write
teleport contract.

## Phases

Each phase: implement → spec → run from `H:/git/moh` → commit. Black-box tests,
headless where possible (the log, adapters, and physics producer are
headless-testable; the render hook is thin).

> **Status (Phases 0–2 landed).** Key deltas from the original sketch, decided
> during build: the fixed-step **tick lives on `EntityManager`**
> (`fixedStepTick`), not a per-system clock, and `getFixedStepAlpha()` is the one
> global sim-clock alpha (no system arg). To make the per-step tick exact, the
> scheduler was changed to a **global lock-step loop** on a single accumulator
> (commit `0fd41f3`) — all systems' `fixedUpdate` advance together, tick++ per
> step. The physics producer is **agnostic** (iterates the component's
> interpolands, uses their adapters) and reads its log via an injected
> `PhysicsSystem.interpolationLog` (null → skipped). Restore reads `tick-1`,
> record writes `tick`; lock-step makes both exact with no per-body tick field.

- [x] **Phase 0 — Neutralize the primitive** (`0980e45`). `InterpolationLog` +
  `BinaryInterpolationAdapter` (+ spec) moved to `engine/interpolation/`; network
  importers repointed. `EntityManager.getFixedStepAlpha`. (Log key generalization
  is cosmetic — it was already a plain integer.)
- [x] **Phase 1 — `Interpolated` + `InterpolationSystem`** (`fc3c3aa`).
  `Interpoland` descriptor; `Interpolated` carries a **list** of interpolands
  (one component-per-class → many smoothed quantities); `link` auto-assigns
  `key = entity` when `-1`; `update` blends each interpoland `(tick-1, tick)` at
  `getFixedStepAlpha()` into the live component. Full-precision (Float64) pose
  adapters + `POSE_INTERPOLAND`. Tested: blend / snap / untouched-when-empty /
  key auto-assign / multi-interpoland.
- [x] **Phase 2 — Physics as producer** (`a05db76` tick + `dade4d6` producer;
  scheduler `0fd41f3`). `__interp_restore` (top of `fixedUpdate`, undo render
  interpolation) + `__interp_record` (after solve/CCD, before sleep test).
  **Determinism guard passes**: interp ON (live Transform polluted every awake
  step) vs OFF → bit-identical pose AND velocity every step through fall →
  contact → settle → sleep.
  - [x] *`setPose` teleport API* (`691f9a0`). `PhysicsSystem.setPose(rb, pos, rot)`
    writes the Transform, wakes, and sets `snap`. `snap` semantics finalized:
    the consumer **leaves the live pose untouched** for that frame (a teleport's
    new pose isn't in the log yet) and the producer's restore **skips** a snapped
    body. *Known edge:* clean for the common ~1:1 sim:render rate; when render
    rate ≫ sim rate a teleport could show a brief flicker (the latest tick isn't
    retroactively overwritten) — fixable later with an in-place latest-tick
    overwrite if it ever matters.
  - [x] *End-to-end wiring verified* (`691f9a0`,
    `InterpolationSystem.integration.spec`). A real `EntityManager` driving
    physics + `InterpolationSystem` with `physics.interpolationLog = interp.log`
    interpolates a body's `Transform` every render frame between fixed steps
    (control with interp off is frozen between steps). *App wiring* (registering
    the systems + connecting the log in the actual game world) is still the app
    layer's job — the recipe is the integration spec.
- **Phase 3 — Network unification.** *Architecture + coherence landed; live
  NetworkSession migration is the remaining integration.*
  - **Finding:** the network package *already* does remote interpolation on the
    shared primitive — `NetworkSession` owns an `InterpolationLog`, records
    remote-owned components on `onFrameApplied` (`#on_frame_applied`), undoes
    render interpolation via `normalize_if_dirty` (the `before_execute` hook),
    and blends each remote entity through the SAME `log.interpolate → deserialize`
    path as the local system, on an `AdaptiveRenderDelay`-smoothed playout frame
    (`#render_interpolated_entities`). So the *primitive* was unified by Phase 0;
    what was missing was a single *consumer* spanning both timelines.
  - [x] **Unified consumer.** Component stays pure data: `Interpolated.sourceId`
    (an int; `INTERPOLATION_SOURCE_LOCAL = 0` default = local fixed-step clock +
    the system's own log). The timelines' logs + window providers live on the
    SYSTEM, registered via `InterpolationSystem.registerSource(id, log, sample)`
    (the network owns its playout window). One `update` blends local and
    source-driven entities, each source sampled once per frame. *Test:* a local
    sim-clock entity and a remote playout-source entity both interpolate
    correctly in one `update` (local + remote coherent in one scene).
  - [ ] **Live migration (remaining):** point `NetworkSession`'s remote entities
    at a `NetworkInterpolationSource` (wrapping its playout window + log), mark
    them with `Interpolated`, and drop `#render_interpolated_entities` so the
    `InterpolationSystem` is the sole render consumer. Deferred to avoid
    destabilizing the tested netcode; needs exposing the session's playout window
    as a side-effect-free `sample()`.
- **Phase 4 — Rewind/resim correctness.** Interp log supports resim re-recording:
  on rewind to target, truncate ticks ≥ target; resim re-records forward.
  *Test:* sim to T, capture interp-log bytes; rewind to T−k, resim to T with
  identical inputs; assert interp-log bytes over (T−k, T] are bit-identical →
  observer-identical when nothing changed.
- **Phase 5 — Camera + edges.** Camera following an interpolated body reads the
  interpolated source pose. Spawn/despawn & key reuse via generation; verify a
  visual child of an interpolated physics parent tracks it (hierarchy-for-free);
  kinematic/parked filtered by the moved-ε test.

## Open items (flagged, not yet decided)

- **Local-vs-remote render timeline.** Local predicted-present vs remote
  delayed-playout is the classic netcode coherence question. v1 can render both
  at local-present; refine when the predict/reconcile client lands.
- **`onChanged` suppression at sim time.** Interpolated bodies could suppress
  `integrate_position`'s `onChanged` (the render blend will set the final pose).
  Keep it for now — it keeps culling current between slow render frames.

## Source touchpoints (verify at implementation)

- `engine/physics/integration/integrate_position.js` — writes `Transform` via
  `.set()` (fires `onChanged`); restore must be a quiet sibling write.
- `engine/physics/body/BodyStorage.js` — awake set (`awake_at`/`awake_count`) =
  moving candidates; generation = spawn/despawn/reuse detection.
- `engine/ecs/EntityManager.js` — `fixedUpdateStepSize`, the per-system
  accumulator `systemAccumulatedFixedStepTime`, the fixed-step catch-up loop.
- `engine/graphics/GraphicsEngine.js` — `on.preRender` (the blend hook, fired
  before the draw passes).
- `engine/network/sim/{InterpolationLog,BinaryInterpolationAdapter,
  SimActionExecutor,RewindEngine}.js` — the primitives to neutralize/extend.
