# 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").
