# Issue #38 handoff — chase-camera + player feel "30 fps" since L.5 physics-tick gate **Use this whole document as the prompt** when handing off to a fresh agent. Everything they need to pick up cold is below. --- ## Background you'll need You're working in `acdream`, a from-scratch C# .NET 10 reimplementation of Asheron's Call's retail client. The project's house rule (in `CLAUDE.md`) is **the code is modern, the behavior is retail**: every gameplay-affecting algorithm is ported faithfully from the named retail decomp at `docs/research/named-retail/` (Sept 2013 EoR build PDB, 99.6% function-name recovery, full pseudo-C). Read `CLAUDE.md` end-to- end before touching code — the workflow ("grep named-retail first, decompile, pseudocode, port, conformance test, integrate") is mandatory. Phase L is the movement / collision conformance work. Phase L.5 (2026-04-30) ported retail's `CPhysicsObj::update_object` quantum gate — physics integration runs at 30 Hz (`MinQuantum = 1/30 s`) even when the renderer is at 60+ Hz. That fixed real correctness bugs (the "steep-roof wedge" problem). It also introduced the gameplay-feel regression this handoff is about. ## The bug, in one paragraph In third-person / chase camera the player's character + camera motion **look** like they're updating at ~30 fps even though the FPS counter reads 60+. The world rotates with yaw smoothly (mouse-look stays at render rate), but translation — running forward / strafing / falling — visibly steps in 33 ms increments. First-person is much less affected because the camera origin is the eye and rotation dominates the percept; third-person is hit hardest because the *character* is the moving object you're staring at. This is **not a correctness bug** — physics, collision, network, and animation are all running fine at the retail-correct 30 Hz cadence. It's purely a render-time visual smoothness issue. ## Acceptance criterion - Running around in chase camera at 60+ FPS feels as smooth as the render rate suggests (no perceptual stepping). User confirms visually. - **Don't break:** physics tick rate stays at 30 Hz (the L.5 fix must keep working — wedge / steep-roof scenarios still resolve). - **Don't break:** outbound network (`MoveToState` / `AutonomousPosition` cadence + values) unchanged. Observers in a parallel retail client see the same wire as today. - **Don't break:** collision resolution unchanged. The player still cannot walk through walls / clip into geometry / unstick from steep slopes. User has retail running in parallel for side-by-side comparison; the final acceptance is **smoother chase-camera motion at the same physics cadence**, not "match retail at 30 fps render." ## Confirmed root cause Retail integrates physics at 30 Hz with `MinQuantum = 1/30 s`. We ported that faithfully in L.5: `_physicsAccum` accumulates per-frame `dt`, integration runs only when accumulator hits `MinQuantum`, and the integration step uses the accumulated value. Side effect: `_body.Position` only updates on physics ticks, every 33 ms. The renderer reads the position **directly** every frame: ```csharp // GameWindow.cs:5725 var result = _playerController.Update((float)dt, input); // 5731 — player entity render position comes from result.Position pe.Position = result.Position; // 5753 — chase camera target also reads result.Position _chaseCamera.Update(result.Position, _playerController.Yaw, ...); ``` Between physics ticks `result.Position` is the *same value*, so the player renders at the same world coordinate for ~2 render frames in a row, then jumps to the next sample. That's the "30 fps" visual percept. In retail (2013) this wasn't visible because render was also ~30 fps — render rate ≈ physics rate. Our 60+ Hz render exposes the gap. ## Quick confirmation test (do this first) Before any code change, prove the diagnosis is correct by temporarily defeating the gate and seeing if the chase camera feels smooth again. **Do not ship this** — it undoes the L.5 collision fixes. In `src/AcDream.Core/Physics/PhysicsBody.cs:74`, change: ```csharp public const float MinQuantum = 1.0f / 30.0f; // ~0.0333 s ``` to ```csharp public const float MinQuantum = 1.0f / 60.0f; ``` Build, launch with the standard live env-vars (`CLAUDE.md → "Running the client against the live server"`), run around in chase view, verify smoothness. Then **revert that change before committing anything**. With confirmation in hand, implement the proper fix below. ## Recommended fix — render-time interpolation This is the standard fixed-timestep + interpolated-rendering pattern (Quake / Source / Unreal use it). At each physics tick, snapshot the position before and after; render at a linear interpolation between the two based on `_physicsAccum / MinQuantum`. The integration cadence stays at 30 Hz; the rendered position updates every render frame. ### Sketch (in `PlayerMovementController`) ```csharp // New fields alongside _physicsAccum: private Vector3 _prevPhysicsPos; // body.Position at the start of the last completed tick private Vector3 _currPhysicsPos; // body.Position at the end of the last completed tick // (Initialize both to the spawn position when the controller is created // or when the player teleports — search for SetPosition / RelocateLocalPlayer.) // Inside Update, around lines 526-547 — when integration actually runs, // roll the snapshots: if (_physicsAccum >= PhysicsBody.MinQuantum) { _prevPhysicsPos = _currPhysicsPos; // old "current" becomes "previous" // ... existing integrate + collision-resolve sequence ... _body.Position = resolveResult.Position; _currPhysicsPos = _body.Position; // new resolved position _physicsAccum -= tickDt; } // Compute the interpolated render position at the END of Update, // AFTER the gate decision and any collision resolve. Use _physicsAccum // (the leftover) to compute alpha. Cap alpha at 1.0 — never extrapolate // past the most recent tick. float alpha = MathF.Min(_physicsAccum / PhysicsBody.MinQuantum, 1f); Vector3 renderPos = Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha); ``` Expose `renderPos` on the controller — either as a new property `RenderPosition` or fold it into the result struct returned from `Update`. Two callers in `GameWindow.cs:5731` and `:5753` need to switch from `result.Position` (physics, 30 Hz) to the new interpolated value. **Network outbound (everything below `~5760` in GameWindow that builds `MoveToState` / `AutonomousPosition`) keeps using the discrete physics value** — observers must see the authoritative tick position, not the interpolation. ### Edge cases to think about - **First frame:** `_prev == _curr` until the second integration tick fires. That's fine — `Lerp(p, p, alpha) == p`. Interpolation is a no-op until two ticks have run. - **Teleport / login-position-set:** when you call `SetPosition` (line ~289), set both `_prev` and `_curr` to the new position so the lerp doesn't visibly tween from the old location. Same for any RelocateLocalPlayer / network teleport path. - **Stale-frame discard:** when `_physicsAccum > HugeQuantum` and you reset to 0, also resnap `_prev = _curr = _body.Position` so the lerp doesn't dredge up a value from before the pause. - **MaxQuantum clamp:** when `tickDt = min(_physicsAccum, MaxQuantum)`, you may have leftover accum > MinQuantum. The current code subtracts only `tickDt`. With render-time interp, consider running multiple integration ticks in one Update call to drain the accumulator — or accept that one frame after a long pause does a single big tick. Match whatever the existing L.5 code does; don't change correctness behavior. - **Camera at rest:** when the player isn't moving, `_prev == _curr` every tick. Interpolation is a no-op. ✓ ### Cost - ~33 ms visual latency between input and what you see (input affects velocity, velocity integrates on the next tick, render shows the lerp toward the new position). Retail had the same latency in 2013 because render and physics were both 30 Hz; the perceived feel matches retail. - Trivial CPU cost per frame: one Vector3 lerp. - Zero network impact — outbound stays on physics-tick values. ### Things that should NOT change behavior - `_body.Position` itself stays the authoritative physics position. Don't write the interpolated value back to `_body.Position` — that would feed the next physics tick a non-tick-aligned position and re-introduce the 60 Hz integration cadence we're avoiding. - The collision resolve `result.Position` stays the physics-tick value. Network code that reads it is correct. - `Yaw` is already render-rate (mouse input → instantaneous yaw update). Don't touch it. ## Files most likely to need edits - `src/AcDream.App/Input/PlayerMovementController.cs:215` — declaration of `_physicsAccum`. Add `_prevPhysicsPos` / `_currPhysicsPos` here. - `src/AcDream.App/Input/PlayerMovementController.cs:526-547` — the gate. Snapshot rolling lives here. - `src/AcDream.App/Input/PlayerMovementController.cs:130` — `Position => _body.Position` exposed property. Add `RenderPosition` or extend the result struct returned from `Update`. - `src/AcDream.App/Input/PlayerMovementController.cs:289` — `SetPosition`. Resnap `_prev`/`_curr` here. - `src/AcDream.App/Rendering/GameWindow.cs:5725-5753` — the call site. Switch player entity render position + chase camera target from `result.Position` to the new interpolated value. Network section starting around `:5757` keeps `result.Position`. - `src/AcDream.App/Rendering/ChaseCamera.cs:70` — no change needed IF you feed it the interpolated position; the camera's existing smoothing applies on top. - `src/AcDream.Core/Physics/PhysicsBody.cs:74-76` — `MinQuantum` / `MaxQuantum` / `HugeQuantum` constants. **Don't touch.** ## Tests to add - Unit test in `tests/AcDream.Core.Tests/Physics/` (or a new file in `tests/AcDream.App.Tests/` if PlayerMovementController is testable there): drive `Update` with sub-MinQuantum dt repeatedly, assert that `RenderPosition` advances smoothly between snapshots while `_body.Position` only changes on tick boundaries. - Edge cases: alpha clamps at 1 when leftover accum exceeds MinQuantum (no extrapolation past the most recent tick); SetPosition resnaps both endpoints; HugeQuantum stale-frame path resnaps both. ## Workflow (per `CLAUDE.md`) 1. **Step 0 — grep named-retail.** `grep "update_object\|UpdatePhysicsInternal\|render_object\|draw_object" docs/research/named-retail/acclient_2013_pseudo_c.txt` to confirm retail's render-vs-physics integration story matches the description above. Look for any retail render-time interpolation we might want to mirror exactly. (Heads-up: retail probably DOESN'T interpolate — it ran render at 30 Hz, so it didn't need to. This is one of the few places acdream legitimately diverges from retail because we render at 60+ Hz. Document the divergence in the commit message.) 2. **Cross-reference.** Check ACE / ACME / holtburger for any render-time-interp patterns. They probably don't either, since ACE is server-only and ACME / holtburger don't render animated characters at 60+ Hz the way we do. 3. **Pseudocode.** Add `docs/research/2026-05-XX-issue-38-render-interp-pseudocode.md` with the algorithm in plain language before porting. Cite the precedent (Quake/Source/Unreal fixed-timestep article — Glenn Fiedler's "Fix Your Timestep!" is the canonical reference). 4. **Port.** Implement in `PlayerMovementController` + GameWindow wire-up. 5. **Conformance test.** Unit tests above. 6. **Visual verification.** User runs the client, runs around in chase view, confirms smoothness without breaking the L.5 wedge scenarios (run up a steep roof, fall back, jump on rooftops, walk off cliffs). ## Constraints / don't-break - Physics tick rate **must** stay 30 Hz. Don't change `MinQuantum`. L.5 wedge / steep-roof tests are the regression suite. - Network outbound **must** be unchanged. Run with a parallel retail client observing your `+Acdream` and confirm the same blippy/laggy baseline (separately tracked as #46). - Tests must stay green: `dotnet build AcDream.slnx -c Debug`, `dotnet test AcDream.slnx`. There are 8 pre-existing motion test failures in `AcDream.Core.Tests` that aren't yours — leave them. ## When to stop and ask Per `CLAUDE.md`, ask only for: - Visual verification (user looking at the client) - Genuine architectural disagreements (e.g. if you discover this needs a different approach than render-time interpolation) - Hard-to-reverse destructive actions Otherwise act. ## Test workflow (live verification) ```powershell Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force Start-Sleep -Seconds 4 $env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" $env:ACDREAM_LIVE = "1" $env:ACDREAM_TEST_HOST = "127.0.0.1" $env:ACDREAM_TEST_PORT = "9000" $env:ACDREAM_TEST_USER = "testaccount" $env:ACDREAM_TEST_PASS = "testpassword" dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch_issue38.log" ``` Spawn point is Holtburg. Run forward / strafe / jump / run up the nearest building's slope / fall off — should feel smooth, not stepped. Drudges nearby, NPCs, retail observer (a parallel retail client) all work as control comparisons. ## References to consult - `src/AcDream.App/Input/PlayerMovementController.cs:191-215` — the L.5 gate, with full retail decomp citation in comments - `docs/research/named-retail/acclient_2013_pseudo_c.txt` — retail pseudo-C, **grep this FIRST** - `memory/project_retail_debugger.md` — L.5 background; how the 30 Hz cadence was discovered via cdb attach to live retail - `docs/research/2026-04-30-retail-motion-trace/` — L.5 trace data - Glenn Fiedler, "Fix Your Timestep!" — https://gafferongames.com/post/fix_your_timestep/ — canonical fixed-timestep + interpolated rendering article ## Final note This is a **render-only fix**. Don't change physics, network, or collision behavior. The L.5 30 Hz integration is correct and load-bearing for retail-faithful collision; you're just smoothing the *display* of the position between ticks. If you find yourself touching `MinQuantum`, `_body`, or any wire-side code, stop and reconsider. When this lands, update `docs/ISSUES.md` to mark #38 DONE with the commit SHA, update the pseudocode doc with anything you learned, and add a one-line memory entry if there's a durable lesson (e.g. "render- rate-vs-physics-rate gap is the standard fixed-timestep-interp pattern; we now apply this for the player and may want to extend to remote entities later if they show similar stepping").