acdream/docs/superpowers/plans/2026-04-12-player-movement.md
Erik 631fd3c9bb docs(plans): Phase B.2 player movement implementation plan
6-task plan: MoveToState + AutonomousPosition message builders,
ChaseCamera (third-person), PlayerMovementController (input →
physics → motion state), CameraController chase mode, GameWindow
wiring (Tab toggle, animation, coordinate conversion), and
roadmap update.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:10:13 +02:00

37 KiB

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:

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:

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:

private uint _gameActionSequence;

/// <summary>
/// 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.
/// </summary>
public void SendGameAction(byte[] gameActionBody)
{
    SendGameMessage(gameActionBody);
}

/// <summary>
/// Get and increment the game action sequence for outbound messages.
/// </summary>
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
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) <noreply@anthropic.com>
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:

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:

using System;
using System.Numerics;

namespace AcDream.App.Rendering;

/// <summary>
/// Third-person chase camera that follows behind and above a player
/// character. Implements <see cref="ICamera"/> so it plugs into the
/// existing renderer pipeline.
/// </summary>
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;

    /// <summary>Distance behind the player.</summary>
    public float Distance { get; set; } = 8f;

    /// <summary>Camera pitch above horizontal (radians). Positive = look down.</summary>
    public float Pitch { get; set; } = 0.35f;  // ~20 degrees

    /// <summary>Vertical offset from the player's feet to the look-at point (eye height).</summary>
    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);

    /// <summary>
    /// Update the camera position to follow the player.
    /// </summary>
    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);
    }

    /// <summary>
    /// Adjust pitch by a delta (from mouse Y movement).
    /// </summary>
    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.

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) <noreply@anthropic.com>"

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):

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<CellSurface>(),
            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:

using System;
using System.Numerics;
using AcDream.Core.Physics;

namespace AcDream.App.Input;

/// <summary>
/// Input state for a single frame of player movement.
/// </summary>
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);

/// <summary>
/// Result of a single frame's movement update.
/// </summary>
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);

/// <summary>
/// Per-frame player movement controller. Reads input, drives the
/// physics engine, tracks motion state for animation + server messages.
/// </summary>
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.

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) <noreply@anthropic.com>
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.

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
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) <noreply@anthropic.com>"

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
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:

_playerServerGuid = chosen.Id;
  • Step 3: Add Tab key handler

In the existing key-press handler (near the F key toggle), add:

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:

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
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<DatReaderWriter.DBObjs.Setup>(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
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) <noreply@anthropic.com>
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.