NPC Slow-World Performance Fix — Implementation Plan (v2, corrected root cause)
You are an engineering assistant. The message below is a finished, reviewed implementation plan for a performance bug in the `not-ai-game` monorepo — a React Three Fiber (R3F) + Colyseus multiplayer 3D game engine. Your job is to present this plan to your user as a complete document: render it in full, preserve every heading, code block, file path, line-number reference, and checkbox exactly as written, and do not summarize, trim, or rewrite it unless they explicitly ask. If they ask questions, help them read, review, or implement it. The checkboxes marked `[x]` reflect work already completed during an implementation pass; `[ ]` items are still pending (mostly live `?mode=play` manual verification).
One-line context for your user: this plan diagnoses why worlds containing NPCs make the entire game client run in slow motion (animations, physics, vehicles, and terrain tree/grass sway all slow together, worst while moving), and it corrects an earlier, wrong server-side diagnosis. The two real root causes are both client-side: (A) the client runs a full per-NPC terrain KCC physics step up to 8× per frame for bodies whose positions are actually rendered from server sync, and (B) a "stolen-delta" footgun where `useFrame` callbacks call `THREE.Clock.getElapsedTime()`, which resets the clock's `oldTime` and shrinks R3F's next-frame `delta`.
=== BEGIN PLAN ===
# NPC Slow-World Performance Fix Implementation Plan (v2 — corrected root cause)
## Overview
Worlds with atom NPCs make the **entire client** run in slow motion — character
animations, physics, vehicle movement, and even purely-cosmetic terrain tree/grass
sway all slow down together, worst while the player is moving. A previous
implementation attempt assumed this was **server** fixed-step KCC cost exceeding
the 16.6ms budget and tuned server NPC KCC sleep + a server `kccStride` + raised
the client physics catch-up cap from 3 to 8. None of it cleared the slowdown.
That attempt was aimed at the wrong machine. The slowdown is **client-side**, and
it has **two compounding root causes**, both confirmed against the bundled
`three@0.183.1` / `@react-three/fiber@9.5.0` source:
- **Cause A — the client runs a full per-NPC terrain KCC step, up to 8× per
rendered frame, for nothing.** Each brain NPC creates a real Rapier kinematic
character body *on the client* (`KinematicCharacterPhysics.awake()` has no
client/server gate). The client-side idle-sleep optimization lives in
`NpcLocomotion.update`, which early-returns on the client, so client NPC bodies
never sleep and pay the ~5ms terrain heightfield shape-cast every step.
`useGameLoop` runs `session.stepFixed()` up to `MAX_STEPS_PER_FRAME=8` times per
render frame, so a heavy NPC frame re-pays the whole N-NPC KCC cost up to 8
times — a feedback spiral. Raising the cap 3→8 made heavy frames *worse*.
`NpcModelHost` renders NPCs from server-synced positions, so this client KCC
cost is pure waste — its computed transform is never displayed.
- **Cause B — the stolen-delta footgun.** R3F derives the per-frame `delta` once
via `THREE.Clock.getDelta()`, which resets the clock's `oldTime`.
`Clock.getElapsedTime()` *also* calls `getDelta()` internally, so any `useFrame`
callback that calls `clock.getElapsedTime()` advances `oldTime` mid-frame and
silently steals the elapsed useFrame-prologue time from the *next* frame's
`delta`. ~6 live `useFrame` callbacks do exactly this. The heavier the NPC
frame, the larger the prologue that gets stolen — so **everything driven by
`delta` or `clock.elapsedTime` (animation mixers, the physics accumulator,
vehicle interpolation, and the tree/grass sway shader uniform) runs slow,
uniformly.** Tree sway slowing is the tell: it is a pure `clock.elapsedTime`
read with no physics in its path, so its slowdown can only be the shared clock
being starved.
Cause A bloats the frame; Cause B converts the bloated frame directly into lost
simulated/animation time. Fixing both is required.
This plan fixes B first (highest impact, lowest risk, single-property edits),
then A (stop stepping client NPC KCC), then right-sizes the catch-up cap, then
clears the known `NpcInteractionBubbleHost` console exception, and keeps a
demoted diagnostics phase as a fallback.
## Current State Analysis
The slow-motion symptom is global and client-side. The server `[TICK PERF]`
profiling the prior attempt relied on can show a slow *server* tick in dense NPC
worlds, but a slow server tick does **not** slow client-only tree sway or the
client animation mixers — those are driven entirely by the client render clock.
The reported symptom set (tree sway + mixers + vehicles + physics all slow,
worst while moving) is only explained by the client render clock / `delta` being
starved, which is exactly what Causes A and B produce.
The prior attempt's surviving artifacts:
- `useGameLoop.MAX_STEPS_PER_FRAME` was raised 3→8 (a band-aid that widened the
per-frame multiplier of Cause A — see `apps/client1/src/hooks/game/useGameLoop.ts:34`).
- `NpcLocomotion.kccStride` (server-side KCC throttle) was added and set to 3 on
`BasicEnemyNpc`/`GuardNpc`. This is server-only and harmless; leave it.
Brain NPCs (`BasicEnemyNpc`, `GuardNpc`) attach `KinematicCharacterPhysics`
without `staticBody`, so their **client** bodies never auto-sleep. `StaticDummyNpc`
sets `staticBody=true`, so the `sleepWhenGrounded` path in `PhysicsManager`
(runs on both sides) does put static-dummy client bodies to sleep — which is why a
world of static dummies is far less slow than a world of idle brain NPCs.
## Desired End State
Loading a production or test world that contains several brain NPCs:
- Tree/grass sway, character animations, vehicle movement, and physics all run at
real-time speed while standing still **and** while moving near NPCs.
- No `useFrame` callback calls `clock.getElapsedTime()` / `clock.getDelta()`; the
R3F frame clock is never starved by user code.
- The client does not run per-tick KCC for NPC (server-authoritative) bodies; only
the locally-controlled player body runs client KCC (for prediction).
- The physics catch-up cap is sized so ordinary heavy frames keep real time
without multiplying simulation cost.
- The browser console stays clean of `key wo:player not found` from
`NpcInteractionBubbleHost`.
- If any residual slowdown remains, one diagnostic flag produces structured
per-tick / per-subsystem evidence.
### Key Discoveries
- R3F computes `delta` once per frame via `state.clock.getDelta()`, which resets
`THREE.Clock.oldTime` to `performance.now()` (bundled `three@0.183.1`
`Clock.js:120-123`; R3F `events-*.esm.js` `let delta = state.clock.getDelta()`).
- `Clock.getElapsedTime()` calls `getDelta()` internally (`Clock.js:95-100`), so it
*also* resets `oldTime`. Reading the `clock.elapsedTime` **property** does NOT —
it is the safe, correct cumulative-time source for `useFrame` code.
- Live `clock.getElapsedTime()` calls inside `useFrame` (the delta thieves):
- `apps/client1/src/components/canvas/world/vehicles/VehicleInputHandler.tsx:125`
- `apps/client1/src/lib/wawa-vfx/VFXParticles.tsx:68`
- `apps/client1/src/lib/wawa-vfx/VFXEmitter.tsx:103`
- `apps/client1/src/components/canvas/gameplay/CaptureFlagRenderer.tsx:98`
- `apps/client1/src/components/canvas/gameplay/WeaponSpawnerRenderer.tsx:76`, `:542`, `:673`
- `apps/client1/src/components/canvas/editor/WeaponSpawnerEditorRenderer.tsx:161`, `:223`
- Tree sway reads the safe property: `TreeSystem.tsx:548` `clock.elapsedTime` →
`uTime` uniform at `:561`. Grass: `GrassSystem.tsx:159-161`. They slow only
because the shared clock's `elapsedTime` is being starved.
- `KinematicCharacterPhysics.awake()` creates a client body with no client/server
gate. See `packages/scripts-stdlib/src/character/KinematicCharacterPhysics.ts:53-67`.
- The client physics adapter already exposes `setCharacterKccEnabled(id, enabled)`
→ `physics.setSkipKCCStep(id, !enabled)`. See
`apps/client1/src/scripts/sceneApi.client.ts:815`.
- The client KCC loop steps every non-`inVehicle`, non-`skipKCCStep` character,
calling `computeColliderMovement` (terrain shape-cast, ~5ms) per character.
See `packages/physics/src/PhysicsManager.ts:4355` and `:4610`.
- `NpcLocomotion.update` (the only place client KCC could be disabled for NPCs)
early-returns on the client: `if (!this.isServer) return;` at
`packages/scripts-stdlib/src/npc/NpcLocomotion.ts:335`. So client NPC bodies are
never put to sleep by the locomotion atom.
- `NpcAi.update` / `NpcAttack.update` also early-return on the client; the client
NPC brain atoms are inert @sync mirrors. So no NPC brain logic runs client-side —
only the wasted KCC physics.
- `NpcModelHost` renders NPCs from the **server-synced** `NpcLocomotion.posX/Y/Z`
and only falls back to the client physics body transform when the synced pose is
still at default origin. See `apps/client1/src/components/canvas/player/NpcModelHost.tsx:259-289`.
→ The client NPC KCC body's *stepped* position is essentially never rendered.
- `MAX_STEPS_PER_FRAME=8` and the post-frame clamp in
`apps/client1/src/hooks/game/useGameLoop.ts:34-39,107-131`.
- `PhysicsManager.getCharacter()` throws on a missing key; `hasCharacter()` exists.
`NpcInteractionBubbleHost` does unguarded `getCharacter()` calls. See
`apps/client1/src/components/canvas/world/npc/NpcInteractionBubbleHost.tsx:98,253`.
## What We're NOT Doing
- Not treating this as a server-tick problem; the prior server-KCC tuning stays
as-is (harmless), but is not where the fix lives.
- Not reverting the server `kccStride` change (server-only, harmless).
- Not solving "NPCs don't move" — that is a separate server-side navmesh/FSM issue
the user explicitly excluded from scope.
- Not changing how NPC positions are authored/synced; the client keeps rendering
NPCs from the server-synced transform.
- Not deleting legacy NPC/player/render code.
- Not adding dependencies.
- Not treating type-check alone as proof; this needs live `?mode=play` verification.
## Implementation Approach
Five phases, ordered by impact-to-risk. Phases 1–3 are the actual fix; Phase 4 is
correctness hygiene; Phase 5 is a fallback diagnostic only if a residual remains.
1. Stop user code from stealing the R3F frame clock (Cause B).
2. Stop the client from running per-tick KCC on server-authoritative NPC bodies
(Cause A).
3. Right-size the physics catch-up cap now that the per-frame cost is gone.
4. Make `NpcInteractionBubbleHost` lookups non-throwing (known console exception).
5. Demoted diagnostics + decision tree, only if slowdown persists.
## Phase 1: Stop Stealing the R3F Frame Clock (Cause B)
### Overview
Replace every `clock.getElapsedTime()` call made inside a `useFrame`/render loop
with a read of the `clock.elapsedTime` **property**. The property is the
already-accumulated value R3F advances once per frame; reading it does not reset
`oldTime` and therefore does not shrink the next frame's `delta`. This is a
behavior-preserving change for the callers (they want cumulative seconds), and it
removes the global slow-motion multiplier.
### Changes Required
> **CORRECTION (2026-05-30, evidence-driven).** The first pass edited
> `apps/client1/src/lib/wawa-vfx/{VFXParticles,VFXEmitter}.tsx`, but those are a
> **dead duplicate** — the client imports `@not-ai-game/wawa-vfx` (the package,
> served from `packages/wawa-vfx/src/index.ts`, no build step). The live delta
> thieves are `packages/wawa-vfx/src/VFXParticles.tsx:68` and
> `VFXEmitter.tsx:103`, mounted every frame by `VehicleSmokeTrail` (the reporter's
> "harvester"). Both now read `clock.elapsedTime`. The client `[CLIENT PERF]`
> diagnostic added to `useGameLoop` proved the theft was still live after the
> first pass: `rafDeltaMs≈4.7` while `wallMs≈13.6` → R3F's `delta` was ~⅓ of real
> time → physics ran at ~0.34× = slow motion, while `clockStarv≈0%` (so tree-sway
> `elapsedTime` was actually fine — the plan's tree-sway tell was a red herring;
> the real victim is everything fed by `delta`).
#### 1. Convert `getElapsedTime()` → `elapsedTime` property in render-loop callers
**Files**:
- **`packages/wawa-vfx/src/VFXParticles.tsx:68`** ← the live thief (done)
- **`packages/wawa-vfx/src/VFXEmitter.tsx:103`** ← the live thief (done)
- `apps/client1/src/lib/wawa-vfx/{VFXParticles,VFXEmitter}.tsx` (dead duplicate, also converted)
- `apps/client1/src/components/canvas/world/vehicles/VehicleInputHandler.tsx:125`
- `apps/client1/src/lib/wawa-vfx/VFXParticles.tsx:68`
- `apps/client1/src/lib/wawa-vfx/VFXEmitter.tsx:103`
- `apps/client1/src/components/canvas/gameplay/CaptureFlagRenderer.tsx:98`
- `apps/client1/src/components/canvas/gameplay/WeaponSpawnerRenderer.tsx:76,542,673`
- `apps/client1/src/components/canvas/editor/WeaponSpawnerEditorRenderer.tsx:161,223`
**Changes**: In each, change `clock.getElapsedTime()` to `clock.elapsedTime`. For
`VFXEmitter.tsx:103` the call is `emitterCoreRef.current.update(clock.getElapsedTime(), delta)`
— pass `clock.elapsedTime` for the first arg, keep the existing `delta`. Verify
each call site is reading cumulative seconds (it is in all listed cases — sway /
orbit / levitate phase and VFX time base), not intentionally sampling a fresh
delta. Add a short comment at one representative site explaining why the property
(not the method) must be used, referencing the `oldTime` reset.
**Do NOT touch** `clock.getElapsedTime()` calls made on a script's **own private**
`new THREE.Clock()` (e.g. `wawa-vfx` `VFXParticlesCore.ts:520` uses a separate
clock instance — harmless). Only the shared R3F `state.clock` is the footgun.
#### 2. Add a lint guard (optional, if cheap)
**File**: client ESLint config or a colocated `no-restricted-syntax` rule.
**Changes**: If practical without churn, add a `no-restricted-syntax` rule banning
`CallExpression[callee.property.name='getElapsedTime']` and
`getDelta` inside `apps/client1/src` so the footgun can't be reintroduced. If the
rule is noisy against private-clock usage, skip it and rely on the code comment +
this plan instead. Do not block the phase on this.
### Success Criteria
#### Automated Verification
- [x] Client type-check passes: `pnpm --filter @not-ai-game/client1 type-check`
- [x] Client lint passes: edited files lint clean (0 errors). NOTE: repo-wide lint
has 1 pre-existing error in `apps/client1/src/genex/runtime/scriptLoader.ts:37`
(`no-assign-module-variable`, unmodified by this plan, from the Genex merge).
- [ ] Client tests pass: `pnpm --filter @not-ai-game/client1 test` — BLOCKED in this
environment: global `test/setupEnv.ts` throws because `DATABASE_URL_TEST` is not
set (guards against resetting the dev DB). Affects all 70 client test files
regardless of changes; Phase 1 touches only render-loop code (no DB unit tests).
- [x] No remaining `getElapsedTime(` on `state.clock` in client render loops:
`grep -rn "getElapsedTime" apps/client1/src` returns only comments + the private
`this.clock` usage in `VFXParticlesCore.ts:520` (documented exception).
#### Manual Verification
- [ ] Open a world that has VFX or a vehicle (e.g. the NPC test world in `?mode=play`).
- [ ] Tree/grass sway runs at normal speed while standing still.
- [ ] Sway speed does NOT visibly drop when the camera/player starts moving.
- [ ] Player animation and vehicle motion speed look normal during movement.
## Phase 2: Stop Stepping Client-Side NPC KCC (Cause A)
### Overview
NPC bodies are server-authoritative; the client renders them from synced poses and
must not run their KCC. Disable the per-tick client KCC step for every character
body that is not the locally-controlled player. The body still exists (for the rare
default-origin fallback read and any collision presence) — it just stops paying the
terrain shape-cast every step, which removes the up-to-8×-per-frame cost.
### Changes Required
#### 1. Disable client KCC for non-owned (NPC / remote) bodies
**File**: `packages/scripts-stdlib/src/character/KinematicCharacterPhysics.ts`
**Changes**: After the body is created, on the **client only** (`!this.isServer`),
disable the KCC step for bodies that are not the locally-controlled player:
- Determine "locally-controlled player" via the `LocalPlayer` marker sibling on the
same object (`this.scene`'s sibling/`getScript('LocalPlayer')` query — match the
existing marker-query pattern). The player prefab carries `LocalPlayer`; NPC and
remote prefabs do not.
- If `!this.isServer` and there is no `LocalPlayer` sibling, call
`this.scene.physics.setCharacterKccEnabled(this.self.id, false)` so the client
never steps this body's KCC. The server instance (`this.isServer`) is untouched
and keeps full authority KCC.
- Sequence this after `createCharacter` (same `awake`, or in `start` if the marker
sibling is only resolvable post-awake — verify dispatch order; awake fires for all
siblings before any start, so a `start`-time query is safe).
Because `NpcLocomotion.update` early-returns on the client, nothing re-enables the
client KCC later — it stays disabled for the body's lifetime, which is what we want.
**Guardrail**: The local player's client body MUST keep KCC (client prediction).
Confirm via the `LocalPlayer` marker check and the manual verification below.
#### 2. Verify NPC render still tracks the server pose with client KCC off
**File**: `apps/client1/src/components/canvas/player/NpcModelHost.tsx`
**Changes**: No code change expected — `readLocomotion` already drives the model
from synced `posX/Y/Z`. Confirm during manual verification that NPCs still appear at
their server positions and animate (idle/walk/run) with client KCC disabled. If a
specific NPC renders at origin, that means its synced pose is default-origin and it
was relying on the client body transform — note it; the body still exists, only its
*stepping* is disabled, so the fallback read still returns a valid (un-stepped)
transform.
#### 3. Tests
**File**: `packages/scripts-stdlib/src/character/__tests__/KinematicCharacterPhysics.test.ts`
**Changes**: Add cases asserting:
- Client instance with no `LocalPlayer` sibling calls `setCharacterKccEnabled(id, false)`.
- Client instance WITH a `LocalPlayer` sibling does NOT disable KCC.
- Server instance never calls `setCharacterKccEnabled` from this path.
### Success Criteria
#### Automated Verification
- [x] Scripts-stdlib type-check: `pnpm --filter @not-ai-game/scripts-stdlib type-check`
- [x] `KinematicCharacterPhysics` tests pass: 6/6 (3 new — client-no-marker disables,
client-with-`LocalPlayer` keeps, server never disables).
- [x] NPC atom tests still pass: 122/122 in `src/npc/__tests__`.
(Package-wide, 2 PRE-EXISTING M9-wip failures remain — `JumpAbility`,
`CrouchAbility` — both unmodified by this plan and unreachable from the
client-only `start()` gate.)
- [x] Rebuild consumed dist: `pnpm --filter @not-ai-game/scripts-stdlib build`
- [x] Client type-check: `pnpm --filter @not-ai-game/client1 type-check`
Implementation note: the gate lives in a new `start()` on `KinematicCharacterPhysics`
(`if (!this.isClient) return;` then `if (no LocalPlayer sibling) setCharacterKccEnabled(id,false)`).
`start()` (not `awake`) so the `LocalPlayer` marker sibling is constructed. This also
disables KCC for **remote-player** bodies (also `LocalPlayer`-less, also rendered from
synced poses) — an additional, harmless win.
#### Manual Verification
- [ ] In the NPC world (`?mode=play`), brain NPCs still render at their positions
and animate; static dummies still render and take damage.
- [ ] The **local player** still moves and is gravity-grounded normally (prediction
intact — this is the critical regression check).
- [ ] In DevTools, `__debugPhysics.hasCharacter('<npc id>')` is still true, but the
NPC body no longer drives client cost (FPS is high while standing among NPCs).
- [ ] Removing NPCs vs adding many NPCs no longer changes overall game speed.
## Phase 3: Right-Size the Physics Catch-Up Cap
### Overview
With Cause A removed, `session.stepFixed()` is cheap again, so the catch-up loop no
longer multiplies a heavy frame's cost. Re-evaluate `MAX_STEPS_PER_FRAME`. The 3→8
raise was compensation for the (now-removed) client NPC KCC cost; an oversized cap
still lets a genuinely pathological frame run 8 expensive steps.
### Changes Required
#### 1. Re-tune the cap
**File**: `apps/client1/src/hooks/game/useGameLoop.ts:34-39`
**Changes**: After Phases 1–2 are verified, profile a dense-NPC frame and set
`MAX_STEPS_PER_FRAME` to the smallest value that keeps real time without slow
motion (likely back to 4–5). Update the stale comment block (`:26-34`) that blames
the cap for the slow motion — the real cause was per-step cost, not the cap height.
Keep `clampPhysicsAccumulatorAfterFrame` as the long-pause backstop.
Do not change this until Phases 1–2 are live-verified; the cap is a knob, not the
fix.
### Success Criteria
#### Automated Verification
- [ ] Client type-check: `pnpm --filter @not-ai-game/client1 type-check`
- [ ] Client tests pass (including any `clampPhysicsAccumulatorAfterFrame` test):
`pnpm --filter @not-ai-game/client1 test`
#### Manual Verification
- [ ] No slow motion at the chosen cap in the NPC world while moving.
- [ ] Recovering from a long tab pause does not produce a long simulation surge.
## Phase 4: Make NPC Bubble Lookups Non-Throwing (Console Hygiene)
### Overview
Independent of the slowdown, `NpcInteractionBubbleHost` throws
`key wo:player not found` every frame when a `LocalPlayer` marker exists before the
matching client physics body. This is a correctness bug and a (minor) per-frame
exception cost. Fix it with the existing safe-lookup pattern.
### Changes Required
#### 1. Safe character helper
**File**: `apps/client1/src/components/canvas/world/npc/NpcInteractionBubbleHost.tsx`
**Changes**: Add a local helper that prefers `hasCharacter()` and falls back to
try/catch, then use it in both `readNpcPosition()` (`:98`) and the local-player scan
(`:253`):
```ts
function safeGetCharacter(physics: ReturnType<typeof getPhysics>, characterId: string) {
if (!physics) return null;
try {
if (typeof physics.hasCharacter === 'function' && !physics.hasCharacter(characterId)) {
return null;
}
return physics.getCharacter({ characterId });
} catch {
return null;
}
}
```
#### 2. Clear stale nearby state when the player body is missing
**File**: same.
**Changes**: If `LocalPlayer` is attached but no body is readable, clear `nearbyRef`
and `npc.store.nearbyNpcId` rather than leaving the last nearby NPC active. Gate any
dev warning to development and throttle it.
### Success Criteria
#### Automated Verification
- [ ] Client type-check: `pnpm --filter @not-ai-game/client1 type-check`
- [ ] Client tests pass: `pnpm --filter @not-ai-game/client1 test`
- [ ] Client lint passes: `pnpm --filter @not-ai-game/client1 lint`
#### Manual Verification
- [ ] Browser console stays clean of `key wo:player not found` while loading,
standing still, and moving in the NPC world.
## Phase 5: Fallback Diagnostics (Only If Slowdown Persists)
### Overview
If Phases 1–3 do not fully clear the slowdown, add a gated client+server trace
rather than guessing. This is the demoted remnant of the prior plan's profiling
phases — kept because it is genuinely useful, but it is no longer the primary work.
### Changes Required
#### 1. Client frame-time / delta-starvation trace
**File**: `apps/client1/src/hooks/game/useGameLoop.ts`
**Changes**: Under a `__NPC_PERF` window flag, every ~2s log: average raw rAF
`delta`, average `clock.elapsedTime` advance per frame (to detect residual clock
starvation), steps-per-frame distribution, and the post-clamp accumulator. If the
`elapsedTime` advance per frame is materially below the wall-clock frame time, a
delta thief remains — re-run the Phase 1 grep.
#### 2. Physics KCC counters (server + client)
**File**: `packages/physics/src/PhysicsManager.ts`
**Changes**: Under `DEV_NPC_PERF_TRACE=1`, count `charactersTotal`, `kccActive`,
`kccSkipped` in the loop at `PhysicsManager.ts:4355`. Verify client `kccActive`
drops to ~1 (local player only) after Phase 2. Keep off by default.
#### 3. Decision tree
**Changes** (documented in a short handoff, not code):
- Client `elapsedTime` advance < frame time → delta thief remains (Phase 1).
- Client `kccActive` > 1 in an NPC world → a non-player body still steps KCC (Phase 2 gate missed a prefab).
- Server `[TICK PERF]` slow but client clock healthy → server NPC budgeting (out of this plan's slowdown scope).
- Client FPS low but clock healthy and `kccActive`≈1 → profile `NpcModelHost` /
mixers / VFX render cost, not timestep.
### Success Criteria
#### Automated Verification
- [ ] Profiling compiles with flags off; no noisy logs when unset.
- [ ] Tests do not depend on env/window flags.
#### Manual Verification
- [ ] One reproduction yields evidence assigning any residual to clock, client KCC,
server tick, or render cost — not "unknown".
## Testing Strategy
### Unit Tests
- `KinematicCharacterPhysics`: client non-player body disables KCC; client
`LocalPlayer` body does not; server never disables via this path.
- `NpcInteractionBubbleHost` safe lookups for missing player/NPC bodies.
- `clampPhysicsAccumulatorAfterFrame` behavior at the re-tuned cap.
### Integration / Manual
1. `?mode=play` in the reported world (`81a4fb48-d70f-40bf-aecd-00bed26b8776`) and
the `NPC Test Bed` (`10f57ba8-68a6-47c5-91dc-9ebd149b1299`).
2. Baseline before changes: confirm tree sway slows when moving (Cause B) and FPS
collapses among brain NPCs (Cause A).
3. After Phase 1: tree sway no longer slows on movement.
4. After Phase 2: FPS no longer collapses among NPCs; local player still predicts.
5. After Phase 3: no slow motion at the chosen cap; no post-pause surge.
6. After Phase 4: console clean of `wo:player`.
7. Cross-check: vehicle worlds (VehicleInputHandler thief) and VFX-heavy worlds no
longer slow on activity.
## Performance Considerations
- Phase 1 is the highest-leverage, lowest-risk change: property vs method, ~9 sites.
It alone should remove most of the uniform global slow-motion.
- Phase 2 removes the N×8-per-frame client KCC cost — the FPS-crushing half.
- After both, the catch-up cap (Phase 3) is a minor knob, not a fix.
- Keep all default-off diagnostics aggregated; never per-frame console logging.
## Migration Notes
- Rebuild `packages/scripts-stdlib` after Phase 2 (server/runtime consume built output).
- Production rooms cache definitions; restart `pnpm dev` after any test-world change.
- All changes additive/reversible; do not modify production world data.
## References
- Root-cause research: `thoughts/shared/research/2026-05-30-npc-slow-world-and-bubble-error.md`
- Client game loop: `apps/client1/src/hooks/game/useGameLoop.ts`
- Stolen-delta mechanism: `three@0.183.1` `Clock.js:95-123`; `@react-three/fiber@9.5.0` render loop.
- NPC body atom: `packages/scripts-stdlib/src/character/KinematicCharacterPhysics.ts`
- Client physics adapter: `apps/client1/src/scripts/sceneApi.client.ts:815`
- NPC render host: `apps/client1/src/components/canvas/player/NpcModelHost.tsx`
- Physics KCC loop: `packages/physics/src/PhysicsManager.ts:4355`
- Bubble host: `apps/client1/src/components/canvas/world/npc/NpcInteractionBubbleHost.tsx`
- Historical handoff: `thoughts/shared/handoffs/2026-05-30-m9-npc-integration-fixes.md`
=== END PLAN ===