diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
new file mode 100644
index 0000000..6db677c
--- /dev/null
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -0,0 +1,178 @@
+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);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs
new file mode 100644
index 0000000..aceb1e1
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs
@@ -0,0 +1,103 @@
+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);
+ }
+}