Self-contained pickup brief for the chase-camera "30 fps" issue introduced by L.5's 30 Hz physics-tick gate. Covers confirmed root cause with file:line citations, recommended fix (render-time interpolation between physics ticks — Fix-Your-Timestep pattern), implementation sketch with edge cases, file pointers, test workflow, and don't-break constraints (physics cadence + network outbound). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
15 KiB
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/AutonomousPositioncadence + 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:
// 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:
public const float MinQuantum = 1.0f / 30.0f; // ~0.0333 s
to
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)
// 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 == _curruntil 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_prevand_currto 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 > HugeQuantumand you reset to 0, also resnap_prev = _curr = _body.Positionso 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 onlytickDt. 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 == _currevery 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.Positionitself 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.Positionstays the physics-tick value. Network code that reads it is correct. Yawis 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/_currPhysicsPoshere.src/AcDream.App/Input/PlayerMovementController.cs:526-547— the gate. Snapshot rolling lives here.src/AcDream.App/Input/PlayerMovementController.cs:130—Position => _body.Positionexposed property. AddRenderPositionor extend the result struct returned fromUpdate.src/AcDream.App/Input/PlayerMovementController.cs:289—SetPosition. Resnap_prev/_currhere.src/AcDream.App/Rendering/GameWindow.cs:5725-5753— the call site. Switch player entity render position + chase camera target fromresult.Positionto the new interpolated value. Network section starting around:5757keepsresult.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/HugeQuantumconstants. Don't touch.
Tests to add
- Unit test in
tests/AcDream.Core.Tests/Physics/(or a new file intests/AcDream.App.Tests/if PlayerMovementController is testable there): driveUpdatewith sub-MinQuantum dt repeatedly, assert thatRenderPositionadvances smoothly between snapshots while_body.Positiononly 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)
- Step 0 — grep named-retail.
grep "update_object\|UpdatePhysicsInternal\|render_object\|draw_object" docs/research/named-retail/acclient_2013_pseudo_c.txtto 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.) - 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.
- Pseudocode. Add
docs/research/2026-05-XX-issue-38-render-interp-pseudocode.mdwith 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). - Port. Implement in
PlayerMovementController+ GameWindow wire-up. - Conformance test. Unit tests above.
- 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
+Acdreamand 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 inAcDream.Core.Teststhat 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)
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 commentsdocs/research/named-retail/acclient_2013_pseudo_c.txt— retail pseudo-C, grep this FIRSTmemory/project_retail_debugger.md— L.5 background; how the 30 Hz cadence was discovered via cdb attach to live retaildocs/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").