# Phase B.2 — Player Movement Mode Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Walk the player character on the server with WASD, with collision-resolved terrain, walk/run/turn animations, third-person chase camera, and outbound movement messages that ACE accepts.
**Architecture:** New `PlayerMovementController` reads input and drives `PhysicsEngine.Resolve` → position update → outbound `MoveToState` / `AutonomousPosition` server messages. New `ChaseCamera` follows the player. Tab toggles between fly mode and player mode. Walk/run/turn/idle animations resolved locally via `MotionResolver.GetIdleCycle`.
**Tech Stack:** .NET 10, Silk.NET (input + windowing), System.Numerics, DatReaderWriter, xUnit.
**Spec:** `docs/superpowers/specs/2026-04-12-player-movement-design.md`
---
## File structure
```
src/AcDream.Core.Net/
Messages/
MoveToState.cs [new] outbound message builder
AutonomousPosition.cs [new] outbound heartbeat builder
WorldSession.cs [modify] add SendGameAction + _gameActionSequence
src/AcDream.App/
Input/
PlayerMovementController.cs [new] input → physics → position + message decisions
Rendering/
ChaseCamera.cs [new] third-person camera implementing ICamera
CameraController.cs [modify] add chase mode
GameWindow.cs [modify] Tab toggle, wire controller + camera + animation
tests/AcDream.Core.Net.Tests/
Messages/
MoveToStateTests.cs [new]
AutonomousPositionTests.cs [new]
tests/AcDream.Core.Tests/
Rendering/
ChaseCameraTests.cs [new]
Input/
PlayerMovementControllerTests.cs [new]
```
---
## Task 1: MoveToState + AutonomousPosition message builders
**Files:**
- Create: `src/AcDream.Core.Net/Messages/MoveToState.cs`
- Create: `src/AcDream.Core.Net/Messages/AutonomousPosition.cs`
- Modify: `src/AcDream.Core.Net/WorldSession.cs`
- Test: `tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs`
- Test: `tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs`
This task builds the outbound wire-format message constructors and adds `SendGameAction` to WorldSession.
- [ ] **Step 1: Read holtburger reference files**
Before writing any code, read these files to understand the exact byte layout:
- `references/holtburger/crates/holtburger-protocol/src/messages/movement/actions.rs` — MoveToStateActionData + AutonomousPositionActionData pack/unpack
- `references/holtburger/crates/holtburger-protocol/src/messages/movement/types.rs` — RawMotionState + RawMotionFlags + WorldPosition pack/unpack
- `references/holtburger/crates/holtburger-protocol/src/messages/game_action.rs` — how GameAction wraps these (opcode 0xF7B1 + sequence + action_type)
Take notes on:
- The exact field order for MoveToState: GameAction envelope → RawMotionState (flag-driven variable fields) → WorldPosition → sequences → contact byte → align
- The exact field order for AutonomousPosition: GameAction envelope → WorldPosition → sequences → contact byte → align
- WorldPosition layout: u32 cell_id → f32 x → f32 y → f32 z → f32 qw → f32 qx → f32 qy → f32 qz (32 bytes)
- RawMotionState: u32 flags → conditional fields based on flags
- [ ] **Step 2: Write MoveToState failing tests**
Create `tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs`:
```csharp
using System;
using System.Buffers.Binary;
using System.Numerics;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public class MoveToStateTests
{
[Fact]
public void Build_IdleState_ProducesValidGameAction()
{
var body = MoveToState.Build(
gameActionSequence: 1,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
cellId: 0xA9B40001u,
position: new Vector3(96f, 96f, 50f),
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// First 4 bytes: GameAction opcode 0xF7B1
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
Assert.Equal(0xF7B1u, opcode);
// Bytes 4-7: game action sequence
uint seq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
Assert.Equal(1u, seq);
// Bytes 8-11: MoveToState action type 0xF61C
uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
Assert.Equal(0xF61Cu, actionType);
}
[Fact]
public void Build_WalkForward_IncludesForwardCommandInFlags()
{
var body = MoveToState.Build(
gameActionSequence: 2,
forwardCommand: 0x45000005u, // WalkForward
forwardSpeed: 1.0f,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
cellId: 0xA9B40001u,
position: new Vector3(96f, 96f, 50f),
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// After the 12-byte GameAction header comes RawMotionState.
// First u32 is the flags. ForwardCommand flag = 0x4.
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.True((flags & 0x4u) != 0, "ForwardCommand flag should be set");
}
}
```
- [ ] **Step 3: Write AutonomousPosition failing tests**
Create `tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs`:
```csharp
using System;
using System.Buffers.Binary;
using System.Numerics;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public class AutonomousPositionTests
{
[Fact]
public void Build_ProducesValidGameAction()
{
var body = AutonomousPosition.Build(
gameActionSequence: 5,
cellId: 0xA9B40001u,
position: new Vector3(100f, 100f, 50f),
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
Assert.Equal(0xF7B1u, opcode);
uint seq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
Assert.Equal(5u, seq);
uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
Assert.Equal(0xF753u, actionType);
}
[Fact]
public void Build_ContainsCellIdAfterHeader()
{
var body = AutonomousPosition.Build(
gameActionSequence: 1,
cellId: 0xDEADBEEFu,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// After the 12-byte GameAction header, the WorldPosition starts
// with u32 cell_id.
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.Equal(0xDEADBEEFu, cellId);
}
}
```
- [ ] **Step 4: Implement message builders**
Create `src/AcDream.Core.Net/Messages/MoveToState.cs`:
The builder must produce the exact byte layout holtburger sends. Read the reference files from Step 1. The key structure:
```
GameAction envelope:
u32 0xF7B1 (GameAction opcode)
u32 gameActionSequence
u32 0xF61C (MoveToState action type)
RawMotionState:
u32 flags (bitmask of which optional fields follow)
[if CURRENT_HOLD_KEY (0x1)]: u32 holdKey
[if CURRENT_STYLE (0x2)]: u32 style
[if FORWARD_COMMAND (0x4)]: u32 forwardCommand
[if FORWARD_SPEED (0x10)]: f32 forwardSpeed
[if SIDE_STEP_COMMAND (0x20)]: u32 sidestepCommand
[if SIDE_STEP_SPEED (0x80)]: f32 sidestepSpeed
[if TURN_COMMAND (0x100)]: u32 turnCommand
[if TURN_SPEED (0x400)]: f32 turnSpeed
WorldPosition:
u32 cellId
f32 originX, originY, originZ
f32 rotW, rotX, rotY, rotZ (AC wire order: W first)
Sequences + contact:
u16 instanceSequence
u16 serverControlSequence
u16 teleportSequence
u16 forcePositionSequence
u8 contactLongJump (1 = on ground)
align to 4 bytes
```
Use `PacketWriter` (from `AcDream.Core.Net.Packets`) to build the byte array, same pattern as `GameActionLoginComplete.Build`.
Create `src/AcDream.Core.Net/Messages/AutonomousPosition.cs`:
Simpler — no RawMotionState, just the GameAction envelope + WorldPosition + sequences + contact + align.
- [ ] **Step 5: Add SendGameAction to WorldSession**
In `src/AcDream.Core.Net/WorldSession.cs`, add a new public method and counter:
```csharp
private uint _gameActionSequence;
///
/// Send a pre-built GameAction body (which already contains the 0xF7B1
/// envelope). Increments the game-action-level sequence counter.
/// For movement messages (MoveToState, AutonomousPosition) called from
/// the PlayerMovementController.
///
public void SendGameAction(byte[] gameActionBody)
{
SendGameMessage(gameActionBody);
}
///
/// Get and increment the game action sequence for outbound messages.
///
public uint NextGameActionSequence() => ++_gameActionSequence;
```
Note: `SendGameMessage` is currently private. Either make it internal or add this public wrapper. The wrapper is cleaner — it documents intent and lets us add action-specific logic later.
- [ ] **Step 6: Run tests**
Run: `dotnet test -c Debug --nologo`
Expected: 243 + 4 = 247+ tests, no regressions.
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.Core.Net/Messages/MoveToState.cs src/AcDream.Core.Net/Messages/AutonomousPosition.cs src/AcDream.Core.Net/WorldSession.cs tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs
git commit -m "$(cat <<'EOF'
feat(net): Phase B.2 — MoveToState + AutonomousPosition message builders
Outbound GameAction message builders for player movement:
- MoveToState (0xF61C): sent on motion state changes (start/stop
walking, turn, speed change). Carries RawMotionState (flag-driven
variable fields) + WorldPosition + sequence numbers.
- AutonomousPosition (0xF753): periodic position heartbeat sent
every ~200ms while moving.
Both follow the GameAction envelope pattern (0xF7B1 + sequence +
action type) established by GameActionLoginComplete. Wire format
ported from references/holtburger movement protocol.
Also adds SendGameAction + NextGameActionSequence to WorldSession.
Co-Authored-By: Claude Opus 4.6 (1M context)
EOF
)"
```
---
## Task 2: ChaseCamera
**Files:**
- Create: `src/AcDream.App/Rendering/ChaseCamera.cs`
- Test: `tests/AcDream.Core.Tests/Rendering/ChaseCameraTests.cs`
- [ ] **Step 1: Write failing tests**
Create `tests/AcDream.Core.Tests/Rendering/ChaseCameraTests.cs`:
```csharp
using System;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.Core.Tests.Rendering;
public class ChaseCameraTests
{
[Fact]
public void Position_BehindPlayer_WhenYawIsZero()
{
var camera = new ChaseCamera { Aspect = 16f / 9f };
camera.Update(playerPosition: Vector3.Zero, playerYaw: 0f);
// Yaw=0 means facing +X (cos(0)=1, sin(0)=0).
// Camera should be BEHIND the player: negative X direction.
Assert.True(camera.Position.X < -1f, $"Camera X={camera.Position.X} should be behind player (negative X)");
Assert.True(camera.Position.Z > 0f, $"Camera Z={camera.Position.Z} should be above player");
}
[Fact]
public void Position_BehindPlayer_WhenYawIsHalfPi()
{
var camera = new ChaseCamera { Aspect = 16f / 9f };
camera.Update(playerPosition: Vector3.Zero, playerYaw: MathF.PI / 2f);
// Yaw=PI/2 means facing +Y. Camera should be behind: negative Y.
Assert.True(camera.Position.Y < -1f, $"Camera Y={camera.Position.Y} should be behind player (negative Y)");
}
[Fact]
public void Position_FollowsPlayerPosition()
{
var camera = new ChaseCamera { Aspect = 16f / 9f };
var playerPos = new Vector3(100f, 200f, 50f);
camera.Update(playerPosition: playerPos, playerYaw: 0f);
// Camera should be near the player, not at the origin.
Assert.InRange(camera.Position.X, 85f, 100f); // behind but close
Assert.InRange(camera.Position.Y, 195f, 205f); // roughly same Y
}
[Fact]
public void PitchAdjustment_ChangesHeight()
{
var camera = new ChaseCamera { Aspect = 16f / 9f };
camera.Update(playerPosition: Vector3.Zero, playerYaw: 0f);
float z1 = camera.Position.Z;
camera.AdjustPitch(0.2f);
camera.Update(playerPosition: Vector3.Zero, playerYaw: 0f);
float z2 = camera.Position.Z;
Assert.True(z2 > z1, "Increasing pitch should raise the camera");
}
[Fact]
public void ImplementsICamera()
{
ICamera camera = new ChaseCamera { Aspect = 16f / 9f };
camera.ToString(); // just proves interface is implemented
}
}
```
- [ ] **Step 2: Implement ChaseCamera**
Create `src/AcDream.App/Rendering/ChaseCamera.cs`:
```csharp
using System;
using System.Numerics;
namespace AcDream.App.Rendering;
///
/// Third-person chase camera that follows behind and above a player
/// character. Implements so it plugs into the
/// existing renderer pipeline.
///
public sealed class ChaseCamera : ICamera
{
public Vector3 Position { get; private set; }
public float Aspect { get; set; } = 16f / 9f;
public float FovY { get; set; } = MathF.PI / 3f;
/// Distance behind the player.
public float Distance { get; set; } = 8f;
/// Camera pitch above horizontal (radians). Positive = look down.
public float Pitch { get; set; } = 0.35f; // ~20 degrees
/// Vertical offset from the player's feet to the look-at point (eye height).
public float EyeHeight { get; set; } = 1.5f;
private const float PitchMin = 0.05f;
private const float PitchMax = 1.4f; // ~80 degrees
private float _playerYaw;
private Vector3 _lookAt;
public Matrix4x4 View =>
Matrix4x4.CreateLookAt(Position, _lookAt, Vector3.UnitZ);
public Matrix4x4 Projection =>
Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f);
///
/// Update the camera position to follow the player.
///
public void Update(Vector3 playerPosition, float playerYaw)
{
_playerYaw = playerYaw;
_lookAt = playerPosition + new Vector3(0f, 0f, EyeHeight);
// Camera offset: behind the player (-forward direction) and above.
float forwardX = MathF.Cos(playerYaw);
float forwardY = MathF.Sin(playerYaw);
float horizontalDist = Distance * MathF.Cos(Pitch);
float verticalDist = Distance * MathF.Sin(Pitch);
Position = new Vector3(
playerPosition.X - forwardX * horizontalDist,
playerPosition.Y - forwardY * horizontalDist,
playerPosition.Z + EyeHeight + verticalDist);
}
///
/// Adjust pitch by a delta (from mouse Y movement).
///
public void AdjustPitch(float delta)
{
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
}
}
```
- [ ] **Step 3: Run tests, commit**
Run: `dotnet test -c Debug --nologo` — all green.
```bash
git add src/AcDream.App/Rendering/ChaseCamera.cs tests/AcDream.Core.Tests/Rendering/ChaseCameraTests.cs
git commit -m "feat(app): Phase B.2 — ChaseCamera (third-person follow camera)
Co-Authored-By: Claude Opus 4.6 (1M context) "
```
---
## Task 3: PlayerMovementController
**Files:**
- Create: `src/AcDream.App/Input/PlayerMovementController.cs`
- Test: `tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs`
The controller takes input state + dt, drives `PhysicsEngine.Resolve`, updates the entity, decides when to send outbound messages, and tracks the current motion command for animation.
- [ ] **Step 1: Write failing tests**
Create `tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs`. Test the core logic with a fake PhysicsEngine (pass-through that returns the candidate position unchanged):
```csharp
using System;
using System.Numerics;
using AcDream.App.Input;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Input;
public class PlayerMovementControllerTests
{
private static PhysicsEngine MakeFlatEngine()
{
var engine = new PhysicsEngine();
var heights = new byte[81];
Array.Fill(heights, (byte)50);
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
var terrain = new TerrainSurface(heights, heightTable);
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(),
worldOffsetX: 0f, worldOffsetY: 0f);
return engine;
}
[Fact]
public void Update_NoInput_PositionUnchanged()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
var result = controller.Update(0.016f, new MovementInput());
Assert.Equal(96f, result.Position.X, precision: 1);
Assert.Equal(96f, result.Position.Y, precision: 1);
}
[Fact]
public void Update_ForwardInput_MovesInFacingDirection()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f; // facing +X
var input = new MovementInput { Forward = true };
var result = controller.Update(1.0f, input); // 1 second
// Should have moved ~4 units in +X (walk speed).
Assert.True(result.Position.X > 96f + 2f, $"X={result.Position.X} should have moved forward");
}
[Fact]
public void Update_RunForward_MoveFasterThanWalk()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
var walkInput = new MovementInput { Forward = true };
var walkResult = controller.Update(1.0f, walkInput);
float walkDist = walkResult.Position.X - 96f;
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
var runInput = new MovementInput { Forward = true, Run = true };
var runResult = controller.Update(1.0f, runInput);
float runDist = runResult.Position.X - 96f;
Assert.True(runDist > walkDist, $"Run ({runDist}) should be faster than walk ({walkDist})");
}
[Fact]
public void Update_TurnInput_ChangesYaw()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
float initialYaw = controller.Yaw;
var input = new MovementInput { TurnRight = true };
controller.Update(0.5f, input);
Assert.NotEqual(initialYaw, controller.Yaw);
}
[Fact]
public void MotionStateChanged_WhenStartingToWalk()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
// First frame: idle (no input).
controller.Update(0.016f, new MovementInput());
// Second frame: start walking.
var input = new MovementInput { Forward = true };
var result = controller.Update(0.016f, input);
Assert.True(result.MotionStateChanged);
}
}
```
- [ ] **Step 2: Implement PlayerMovementController**
Create `src/AcDream.App/Input/PlayerMovementController.cs`:
```csharp
using System;
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.App.Input;
///
/// Input state for a single frame of player movement.
///
public readonly record struct MovementInput(
bool Forward = false,
bool Backward = false,
bool StrafeLeft = false,
bool StrafeRight = false,
bool TurnLeft = false,
bool TurnRight = false,
bool Run = false,
float MouseDeltaX = 0f);
///
/// Result of a single frame's movement update.
///
public readonly record struct MovementResult(
Vector3 Position,
uint CellId,
bool IsOnGround,
bool MotionStateChanged,
uint? ForwardCommand,
uint? SidestepCommand,
uint? TurnCommand,
float? ForwardSpeed,
float? SidestepSpeed,
float? TurnSpeed);
///
/// Per-frame player movement controller. Reads input, drives the
/// physics engine, tracks motion state for animation + server messages.
///
public sealed class PlayerMovementController
{
private readonly PhysicsEngine _physics;
public float WalkSpeed { get; set; } = 4f;
public float RunSpeed { get; set; } = 7f;
public float TurnSpeed { get; set; } = 1.5f;
public float MouseTurnSensitivity { get; set; } = 0.003f;
public float StepUpHeight { get; set; } = 1.0f;
public float Yaw { get; set; }
public Vector3 Position { get; private set; }
public uint CellId { get; private set; }
// Previous frame's motion commands for change detection.
private uint? _prevForwardCmd;
private uint? _prevSidestepCmd;
private uint? _prevTurnCmd;
// Heartbeat timer.
private float _heartbeatAccum;
public const float HeartbeatInterval = 0.2f; // 200ms
public bool HeartbeatDue { get; private set; }
public PlayerMovementController(PhysicsEngine physics)
{
_physics = physics;
}
public void SetPosition(Vector3 pos, uint cellId)
{
Position = pos;
CellId = cellId;
}
public MovementResult Update(float dt, MovementInput input)
{
// 1. Apply turning from keyboard + mouse.
if (input.TurnRight)
Yaw -= TurnSpeed * dt;
if (input.TurnLeft)
Yaw += TurnSpeed * dt;
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
// 2. Compute movement delta in the player's facing direction.
float speed = input.Run ? RunSpeed : WalkSpeed;
float forwardX = MathF.Cos(Yaw);
float forwardY = MathF.Sin(Yaw);
float rightX = MathF.Sin(Yaw);
float rightY = -MathF.Cos(Yaw);
float dx = 0f, dy = 0f;
if (input.Forward) { dx += forwardX * speed * dt; dy += forwardY * speed * dt; }
if (input.Backward) { dx -= forwardX * speed * dt * 0.65f; dy -= forwardY * speed * dt * 0.65f; }
if (input.StrafeRight) { dx += rightX * speed * dt * 0.5f; dy += rightY * speed * dt * 0.5f; }
if (input.StrafeLeft) { dx -= rightX * speed * dt * 0.5f; dy -= rightY * speed * dt * 0.5f; }
var delta = new Vector3(dx, dy, 0f);
// 3. Resolve via physics engine.
var result = _physics.Resolve(Position, CellId, delta, StepUpHeight);
Position = result.Position;
CellId = result.CellId;
// 4. Determine current motion commands.
uint? forwardCmd = null;
float? forwardSpeed = null;
uint? sidestepCmd = null;
float? sidestepSpeed = null;
uint? turnCmd = null;
float? turnSpeed = null;
if (input.Forward)
{
forwardCmd = input.Run ? 0x44000007u : 0x45000005u; // RunForward / WalkForward
forwardSpeed = input.Run ? RunSpeed / WalkSpeed : 1.0f;
}
else if (input.Backward)
{
forwardCmd = 0x45000005u; // WalkForward (backward is negative speed)
forwardSpeed = -0.65f;
}
if (input.StrafeRight)
{
sidestepCmd = 0x6500000Fu; // SideStepRight
sidestepSpeed = speed * 0.5f / WalkSpeed;
}
else if (input.StrafeLeft)
{
sidestepCmd = 0x65000010u; // SideStepLeft
sidestepSpeed = speed * 0.5f / WalkSpeed;
}
if (input.TurnRight || input.MouseDeltaX > 0.5f)
{
turnCmd = 0x6500000Du; // TurnRight
turnSpeed = TurnSpeed;
}
else if (input.TurnLeft || input.MouseDeltaX < -0.5f)
{
turnCmd = 0x6500000Eu; // TurnLeft
turnSpeed = TurnSpeed;
}
// 5. Detect motion state change.
bool changed = forwardCmd != _prevForwardCmd
|| sidestepCmd != _prevSidestepCmd
|| turnCmd != _prevTurnCmd;
_prevForwardCmd = forwardCmd;
_prevSidestepCmd = sidestepCmd;
_prevTurnCmd = turnCmd;
// 6. Heartbeat timer (only while moving).
bool isMoving = forwardCmd is not null || sidestepCmd is not null || turnCmd is not null;
if (isMoving)
{
_heartbeatAccum += dt;
HeartbeatDue = _heartbeatAccum >= HeartbeatInterval;
if (HeartbeatDue) _heartbeatAccum = 0f;
}
else
{
_heartbeatAccum = 0f;
HeartbeatDue = false;
}
return new MovementResult(
Position: result.Position,
CellId: result.CellId,
IsOnGround: result.IsOnGround,
MotionStateChanged: changed,
ForwardCommand: forwardCmd,
SidestepCommand: sidestepCmd,
TurnCommand: turnCmd,
ForwardSpeed: forwardSpeed,
SidestepSpeed: sidestepSpeed,
TurnSpeed: turnSpeed);
}
}
```
- [ ] **Step 3: Run tests, commit**
Run: `dotnet test -c Debug --nologo` — all green.
```bash
git add src/AcDream.App/Input/PlayerMovementController.cs tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs
git commit -m "$(cat <<'EOF'
feat(app): Phase B.2 — PlayerMovementController (input → physics → motion state)
Per-frame controller that reads MovementInput (WASD/ZX/Shift/mouse),
drives PhysicsEngine.Resolve for collision, and tracks motion state
changes for outbound server messages + animation switching. Walk
(~4 u/s) and run (~7 u/s) speeds match AC retail. Heartbeat timer
triggers AutonomousPosition every ~200ms while moving.
5 new tests covering idle, forward, run, turn, and state-change
detection.
Co-Authored-By: Claude Opus 4.6 (1M context)
EOF
)"
```
---
## Task 4: CameraController chase mode
**Files:**
- Modify: `src/AcDream.App/Rendering/CameraController.cs`
Add chase camera as a third mode alongside orbit and fly.
- [ ] **Step 1: Read current CameraController**
Read `src/AcDream.App/Rendering/CameraController.cs` to understand the existing toggle pattern.
- [ ] **Step 2: Add ChaseCamera property + mode cycling**
Add a `Chase` property and modify `ToggleFly` (or add a new method) so the mode cycles: orbit → fly → chase → orbit. OR add a separate `TogglePlayerMode()` method that switches between the current mode and chase. The simpler option: add `SetChaseMode()` and `ExitChaseMode()` methods that GameWindow calls on Tab press.
```csharp
public ChaseCamera? Chase { get; set; }
public bool IsChaseMode => Chase is not null && Active == Chase;
public void EnterChaseMode(ChaseCamera chase)
{
Chase = chase;
Active = chase;
ModeChanged?.Invoke(false); // not fly mode
}
public void ExitChaseMode()
{
Active = Fly; // return to fly mode
Chase = null;
ModeChanged?.Invoke(true); // back to fly
}
```
- [ ] **Step 3: Build, commit**
```bash
git add src/AcDream.App/Rendering/CameraController.cs
git commit -m "feat(app): Phase B.2 — CameraController chase mode
Co-Authored-By: Claude Opus 4.6 (1M context) "
```
---
## Task 5: GameWindow wiring (the big integration task)
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
Wire Tab toggle, PlayerMovementController, ChaseCamera, local animation, and outbound messages into the game loop.
- [ ] **Step 1: Add fields**
```csharp
private PlayerMovementController? _playerController;
private ChaseCamera? _chaseCamera;
private bool _playerMode;
private uint _playerServerGuid; // from CharacterList at login
```
- [ ] **Step 2: Store player GUID at login**
In `TryStartLiveSession`, after `var chosen = ...`, store:
```csharp
_playerServerGuid = chosen.Id;
```
- [ ] **Step 3: Add Tab key handler**
In the existing key-press handler (near the F key toggle), add:
```csharp
if (key == Key.Tab && _liveSession is not null)
{
_playerMode = !_playerMode;
if (_playerMode)
{
// Enter player mode: find our entity, create controller + camera.
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
{
_playerController = new PlayerMovementController(_physicsEngine);
_playerController.SetPosition(playerEntity.Position, 0x0001); // initial cell
_chaseCamera = new ChaseCamera { Aspect = _window!.Size.X / (float)_window.Size.Y };
_cameraController!.EnterChaseMode(_chaseCamera);
}
}
else
{
// Exit player mode.
_cameraController!.ExitChaseMode();
_playerController = null;
_chaseCamera = null;
}
}
```
- [ ] **Step 4: Wire controller into OnUpdate**
In `OnUpdate`, after the existing fly-camera update block, add:
```csharp
if (_playerMode && _playerController is not null && _chaseCamera is not null && _input is not null)
{
var kb = _input.Keyboards[0];
var mouse = _input.Mice.FirstOrDefault();
float mouseX = 0f;
// Read mouse delta if captured.
if (mouse is not null && _capturedMouse is not null)
{
// mouseX accumulated since last frame — implementation depends on
// how Silk.NET exposes raw mouse delta. Check existing fly camera
// mouse handling pattern.
}
var input = new MovementInput(
Forward: kb.IsKeyPressed(Key.W),
Backward: kb.IsKeyPressed(Key.S),
StrafeLeft: kb.IsKeyPressed(Key.Z),
StrafeRight: kb.IsKeyPressed(Key.X),
TurnLeft: kb.IsKeyPressed(Key.A),
TurnRight: kb.IsKeyPressed(Key.D),
Run: kb.IsKeyPressed(Key.ShiftLeft) || kb.IsKeyPressed(Key.ShiftRight),
MouseDeltaX: mouseX);
var result = _playerController.Update((float)dt, input);
// Update the player entity's position + rotation.
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
{
pe.Position = result.Position;
pe.Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, _playerController.Yaw);
}
// Update chase camera.
_chaseCamera.Update(result.Position, _playerController.Yaw);
// Send outbound messages.
if (_liveSession is not null)
{
// Convert world position back to AC wire coordinates.
int lbX = _liveCenterX + (int)MathF.Floor(result.Position.X / 192f);
int lbY = _liveCenterY + (int)MathF.Floor(result.Position.Y / 192f);
float localX = result.Position.X - (lbX - _liveCenterX) * 192f;
float localY = result.Position.Y - (lbY - _liveCenterY) * 192f;
uint wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (result.CellId & 0xFFFFu);
var wirePos = new Vector3(localX, localY, result.Position.Z);
var wireRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, _playerController.Yaw);
if (result.MotionStateChanged)
{
var seq = _liveSession.NextGameActionSequence();
var body = MoveToState.Build(
gameActionSequence: seq,
forwardCommand: result.ForwardCommand,
forwardSpeed: result.ForwardSpeed,
sidestepCommand: result.SidestepCommand,
sidestepSpeed: result.SidestepSpeed,
turnCommand: result.TurnCommand,
turnSpeed: result.TurnSpeed,
holdKey: result.ForwardCommand is not null && (result.ForwardCommand == 0x44000007u) ? 1u : null,
cellId: wireCellId,
position: wirePos,
rotation: wireRot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
_liveSession.SendGameAction(body);
}
if (_playerController.HeartbeatDue)
{
var seq = _liveSession.NextGameActionSequence();
var body = AutonomousPosition.Build(
gameActionSequence: seq,
cellId: wireCellId,
position: wirePos,
rotation: wireRot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
_liveSession.SendGameAction(body);
}
}
// Update local animation based on motion command.
UpdatePlayerAnimation(result);
}
```
- [ ] **Step 5: Add UpdatePlayerAnimation helper**
```csharp
private uint? _playerCurrentAnimCommand;
private void UpdatePlayerAnimation(MovementResult result)
{
// Determine the animation command: forward takes priority, then sidestep, then turn, then idle.
uint animCommand;
if (result.ForwardCommand is { } fwd)
animCommand = fwd;
else if (result.SidestepCommand is { } ss)
animCommand = ss;
else if (result.TurnCommand is { } tc)
animCommand = tc;
else
animCommand = 0x41000003u; // Ready (idle)
if (animCommand == _playerCurrentAnimCommand) return;
_playerCurrentAnimCommand = animCommand;
// Re-resolve the animation cycle for the player entity.
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return;
if (_dats is null) return;
var setup = _dats.Get(pe.SourceGfxObjOrSetupId);
if (setup is null) return;
ushort cmdOverride = (ushort)(animCommand & 0xFFFF);
var cycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
setup, _dats, commandOverride: cmdOverride);
if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame)
return;
// Update or create the animated entity entry.
if (_animatedEntities.TryGetValue(pe.Id, out var ae))
{
ae.Animation = cycle.Animation;
ae.LowFrame = Math.Max(0, cycle.LowFrame);
ae.HighFrame = Math.Min(cycle.HighFrame, cycle.Animation.PartFrames.Count - 1);
ae.Framerate = cycle.Framerate;
ae.CurrFrame = ae.LowFrame;
}
}
```
- [ ] **Step 6: Build + test**
Run: `cmd.exe /c "taskkill /F /IM AcDream.App.exe 2>nul" && dotnet build -c Debug && dotnet test -c Debug --nologo`
Expected: 0 errors, all tests green.
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
feat(app): Phase B.2 — wire player movement into GameWindow
Tab toggles between fly mode and player-controlled ground movement.
WASD walks/runs, A/D + mouse turns, Z/X strafes. PhysicsEngine
resolves positions against terrain. MoveToState sent on motion
state changes, AutonomousPosition heartbeats every 200ms while
moving. Walk/run/turn/idle animations resolved locally via
MotionResolver and played through the existing TickAnimations path.
ChaseCamera follows in third-person.
Co-Authored-By: Claude Opus 4.6 (1M context)
EOF
)"
```
---
## Task 6: Roadmap update + visual verification
- [ ] **Step 1: Update roadmap**
Mark B.2 as shipped in `docs/plans/2026-04-11-roadmap.md`.
- [ ] **Step 2: Commit roadmap**
- [ ] **Step 3: Live verification**
Launch the app, log in, press Tab, walk around Holtburg with WASD. Verify:
1. Character walks on terrain (not flying/sinking)
2. Walk animation plays
3. Other clients see the character move
4. ACE console shows no movement rejections
5. Tab returns to fly mode
---
## Self-review
**Spec coverage:**
- Tab toggle ✓ (Task 5 step 3)
- WASD walk/run + Z/X strafe + A/D turn + mouse turn ✓ (Task 3)
- PhysicsEngine collision ✓ (Task 3 uses B.3)
- MoveToState on state change ✓ (Task 1 + Task 5)
- AutonomousPosition heartbeat ✓ (Task 1 + Task 5)
- Walk/run/turn/idle animation ✓ (Task 5 step 5)
- Third-person chase camera ✓ (Task 2)
- Camera mode cycling ✓ (Task 4)
- AC wire coordinate conversion ✓ (Task 5 step 4)
- Player GUID identification ✓ (Task 5 step 2)
- Roadmap update ✓ (Task 6)
**Placeholder scan:** None found. All code blocks complete.
**Type consistency:** `MovementInput`, `MovementResult`, `PlayerMovementController`, `ChaseCamera`, `MoveToState.Build`, `AutonomousPosition.Build`, `WorldSession.SendGameAction`, `NextGameActionSequence` — all consistent across tasks.