acdream/docs/superpowers/specs/2026-04-12-player-movement-design.md
Erik fe0c76364c docs(specs): Phase B.2 — player movement mode design
Tab-toggled player mode with WASD ground walking, A/D + mouse
turning, Z/X strafing, Shift for run, third-person chase camera,
local walk/run/turn/idle animations, and outbound MoveToState +
AutonomousPosition server messages. Uses PhysicsEngine from B.3
for collision-resolved positions.

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

13 KiB
Raw Blame History

Phase B.2 — Player Movement Mode Design

Status: Spec, 2026-04-12, brainstormed with user. Scope: A new player-controlled movement mode where WASD walks the character on collision-resolved terrain, the server sees valid movement, other clients see the character at the correct position, and the character plays walk/run/turn/idle animations locally. Toggled with Tab; coexists with the existing fly and orbit camera modes. Parent: docs/plans/2026-04-11-roadmap.md — Phase B (Gameplay), sub-piece B.2. Depends on: Phase B.3 (physics collision engine) — already shipped.

Goals

  1. Walk the player character on the server with WASD controls.
  2. The server accepts the movement (no rejection / rubber-banding).
  3. Other clients (original AC client or another acdream instance) see the character at the correct position.
  4. The character plays walk/run/turn/idle animations locally (not sliding).
  5. Third-person chase camera follows behind the character.
  6. Mouse + A/D keyboard turning rotates the character; the turn is sent to the server.
  7. Toggle between player mode and fly mode with Tab.

Non-goals

  • Jump (physics arc + landing — adds gravity state complexity, deferred).
  • Combat stance switching (deferred to Phase B.4+).
  • Indoor movement (B.3's PhysicsEngine supports it; we just don't test it in the acceptance criteria).
  • NPC/entity collision (only terrain + EnvCell floor collision via PhysicsEngine).
  • Smooth position interpolation for other players' movement (existing UpdatePosition snap behavior is acceptable for now).

Input Mapping (Player Mode Active)

Key Action
W / S Walk forward / backward (~4 u/s)
Shift + W / Shift + S Run forward / backward (~7 u/s)
A / D Turn left / right (keyboard turning)
Z / X Strafe left / right
Mouse X Turn (mouse turning, same yaw axis as A/D)
Mouse Y Adjust chase camera pitch (look up/down)
Tab Exit player mode, return to fly mode

Speed values are approximate — the exact values should match what ACE's MovementHandler accepts. Check references/ACE/Source/ACE.Server/ and references/holtburger/ for the validated run/walk rates.

Outbound Server Messages

MoveToState (GameAction, opcode 0xF61C)

Sent when the player's motion state changes: starts walking, stops, starts turning, changes speed (walk↔run), starts strafing.

Payload (per holtburger MoveToStateActionData):

  • RawMotionState:
    • flags: RawMotionFlags — bitmask of which fields are present
    • current_hold_key: Option<u32> — hold key (Run = shift held)
    • current_style: Option<u32> — combat stance (NonCombat for MVP)
    • forward_command: Option<u32> — WalkForward (0x0005) / RunForward (0x0007)
    • forward_speed: Option<f32> — movement speed multiplier
    • sidestep_command: Option<u32> — SideStepRight (0x000F) / SideStepLeft (0x0010)
    • sidestep_speed: Option<f32>
    • turn_command: Option<u32> — TurnRight (0x000D) / TurnLeft (0x000E)
    • turn_speed: Option<f32>
  • WorldPosition:
    • cell_id: u32 — full landblock+cell ID (from PhysicsEngine.Resolve)
    • position: Vector3 — world XYZ
    • rotation: Quaternion — world orientation
  • Sequence numbers: instance_sequence, server_control_sequence, teleport_sequence, force_position_sequence (all u16)
  • contact_long_jump: u8 — ground contact flag

AutonomousPosition (GameAction, opcode 0xF753)

Periodic heartbeat sent every ~200ms while the player is moving. Carries the current WorldPosition + sequence numbers + ground contact flag. Does NOT carry motion commands — the server already knows those from MoveToState.

Wrapping in GameAction

Both messages are sent as GameAction (game message opcode 0xF7B1):

u32 game_message_opcode = 0xF7B1
u32 action_sequence     = next game action sequence
u32 action_type_opcode  = 0xF61C (MoveToState) or 0xF753 (AutonomousPosition)
... payload ...

This matches how GameActionLoginComplete (0x00A1) is already sent in Phase 4.10. The difference is that MoveToState/AutonomousPosition carry a structured payload after the action type opcode.

Note on game action sequence: holtburger increments a game_action_sequence counter per outbound game action. WorldSession already has _fragmentSequence for fragment-level sequencing; a new _gameActionSequence counter is needed for the action-level sequence field.

Third-Person Chase Camera

New ChaseCamera class implementing ICamera:

  • Position: behind + above the player at a configurable offset. In player-local space: (-distance * cos(pitch), 0, distance * sin(pitch)) rotated by the player's yaw. Default distance ~8 units, default pitch ~20 degrees above horizontal.
  • LookAt: the player's position + a small vertical offset (eye height, ~1.5 units).
  • Update per frame: receives the player's current world position + yaw + mouse-Y-driven pitch delta. Recomputes the camera's world position from the offset math.
  • Projection: same parameters as FlyCamera (FovY = PI/3, near = 1, far = 5000).

Camera does NOT collide with terrain/buildings — if the camera clips through a hill behind the player, that's acceptable for the MVP. Camera collision is Phase C polish.

Walk Animation (Local)

When player mode is active:

  • Detect which motion command the current input maps to:
    • W held → WalkForward (0x45000005), or RunForward (0x44000007) if Shift held
    • S held → WalkForward with negative speed (backward)
    • Z held → SideStepLeft (0x65000010)
    • X held → SideStepRight (0x6500000F)
    • A held (no mouse) → TurnLeft (0x6500000E)
    • D held (no mouse) → TurnRight (0x6500000D)
    • Nothing held → Ready (0x41000003, idle breathing)
  • Use MotionResolver.GetIdleCycle with the command as commandOverride to resolve the animation cycle from the player's motion table.
  • Register/update the player entity in _animatedEntities so TickAnimations plays the resolved cycle with slerp interpolation (existing infrastructure from Phase 6.4/6.5).
  • Only update the cycle when the motion command changes (idle→walk, walk→run, etc.) — don't re-resolve every frame.

Priority when multiple keys are held: forward/backward takes priority over sidestep; sidestep takes priority over turn. Matches retail client behavior.

Architecture

New Files

File Responsibility
src/AcDream.App/Input/PlayerMovementController.cs Per-frame input → physics → position update + outbound message decision logic
src/AcDream.App/Rendering/ChaseCamera.cs Third-person camera implementing ICamera
src/AcDream.Core.Net/Messages/MoveToState.cs Outbound MoveToState message builder
src/AcDream.Core.Net/Messages/AutonomousPosition.cs Outbound AutonomousPosition heartbeat builder

Modified Files

File Changes
src/AcDream.App/Rendering/GameWindow.cs Tab toggle, wire PlayerMovementController + ChaseCamera into OnUpdate/OnRender, local animation cycle switching, identify player entity by guid
src/AcDream.App/Rendering/CameraController.cs Add chase camera as a third mode (orbit / fly / chase)
src/AcDream.Core.Net/WorldSession.cs Add _gameActionSequence counter, add SendGameAction(byte[]) method that wraps payload in the 0xF7B1 GameAction envelope with incrementing sequence

Data Flow (Per Frame, Player Mode)

OnUpdate:
  1. Read keyboard (WASD/ZX/Shift/AD) + mouse delta
  2. PlayerMovementController.Update(dt, inputState):
     a. Compute yaw delta from mouse X + A/D keyboard turn
     b. Update player yaw
     c. Compute movement delta from W/S + Z/X in the player's
        facing direction (yaw-rotated), scaled by walk/run speed * dt
     d. PhysicsEngine.Resolve(currentPos, currentCellId, delta, stepUpHeight)
        → resolvedPos, resolvedCellId, isOnGround
     e. Update playerEntity.Position = resolvedPos
     f. Update playerEntity.Rotation = Quaternion.CreateFromAxisAngle(UnitZ, yaw)
     g. Detect motion state change (compare current vs previous command)
     h. If changed: build MoveToState → WorldSession.SendGameAction
     i. Every ~200ms: build AutonomousPosition → WorldSession.SendGameAction
  3. Update walk animation on playerEntity:
     - If motion command changed: re-resolve cycle via MotionResolver
     - Update _animatedEntities entry
  4. ChaseCamera.Update(playerEntity.Position, yaw, cameraPitch)

OnRender:
  - _cameraController.Active returns ChaseCamera when in player mode
  - Frustum culling + terrain + static mesh draw as usual

Player Entity Identification

The player's own character is identified by matching the EntitySpawn.Guid from the CreateObject received at login against the character's GUID from CharacterList. GameWindow already stores _entitiesByServerGuid — the player entity is _entitiesByServerGuid[chosenCharacterGuid]. Store the player's server guid as a field on GameWindow when EnterWorld is called.

Coordinate Space for Server Messages

AC positions on the wire use (landblock cell ID, local XY, Z, rotation). The cell ID is the full 32-bit value: (landblockX << 24) | (landblockY << 16) | cellIndex. PhysicsEngine.Resolve currently returns a cell index (0x00010x0040 outdoor, 0x0100+ indoor) without the landblock prefix. The message builder needs to compose the full cell ID from the entity's world position (to get the landblock coordinates) + the engine's cell index.

acdream's internal world coordinates are center-landblock-relative. Converting back to AC wire coordinates requires:

  • landblockX = _liveCenterX + floor(worldPos.X / 192)
  • landblockY = _liveCenterY + floor(worldPos.Y / 192)
  • localX = worldPos.X - (landblockX - _liveCenterX) * 192
  • localY = worldPos.Y - (landblockY - _liveCenterY) * 192
  • localZ = worldPos.Z
  • cellId = (landblockX << 24) | (landblockY << 16) | resolvedCellIndex

This is the inverse of the OnLiveEntitySpawned coordinate translation.

Testing Strategy

Unit Tests

  • PlayerMovementControllerTests — with a fake PhysicsEngine:

    • Forward input → position advances in facing direction
    • Shift+forward → position advances at run speed (faster)
    • Turn input → yaw changes
    • Motion state change detection: idle→walk→idle transitions
    • Heartbeat timing: AutonomousPosition sent every ~200ms during movement
  • MoveToStateTests — message builder:

    • Build with a known RawMotionState + WorldPosition → assert byte layout
    • Verify GameAction envelope (0xF7B1 + sequence + 0xF61C)
  • AutonomousPositionTests — message builder:

    • Build with a known WorldPosition → assert byte layout
    • Verify GameAction envelope (0xF7B1 + sequence + 0xF753)
  • ChaseCameraTests — offset math:

    • Player at origin, yaw=0 → camera at (-8, 0, ~3)
    • Player at origin, yaw=PI/2 → camera at (0, -8, ~3)
    • Pitch adjustment changes camera height

Integration / Visual Verification

  • Log in at Holtburg.
  • Press Tab to enter player mode.
  • Walk with W → character walks forward on terrain, walk animation plays.
  • Shift+W → run animation, faster movement.
  • A/D → character turns, camera follows.
  • Release all keys → character returns to idle breathing.
  • Open original AC client with different account → see acdream's character at the correct position, visually moving.
  • ACE console shows no movement rejections or errors.
  • Close acdream → graceful logout (already working).

Acceptance Criteria

Phase B.2 is done when:

  1. Tab toggles between fly mode and player mode.
  2. WASD/ZX walks/strafes the character on collision-resolved terrain.
  3. A/D + mouse turns the character.
  4. Shift toggles walk/run speed.
  5. Walk/run/turn/idle animations play correctly on the player's character.
  6. The server accepts the movement (no ACE-side rejections in the console log).
  7. Other clients see the character at the correct position.
  8. Third-person chase camera follows the character smoothly.
  9. All new unit tests pass. Total test count increases by ~15-20.

Open Questions (Resolved During Implementation)

  • Exact walk/run speed values: check ACE's MovementHandler for the server-side validation thresholds. Use holtburger's default speeds as a starting point.
  • RawMotionState flag encoding: the exact bitmask layout comes from holtburger's RawMotionFlags. Implementation will read references/holtburger/crates/holtburger-protocol/src/messages/movement/types.rs for the canonical encoding.
  • Sequence number management: the four sequence counters (instance, server_control, teleport, force_position) need to be tracked per-session. Start at the values ACE sends in the initial handshake and increment as holtburger does. If exact behavior is unclear, start at 0 and adjust if ACE rejects.
  • WorldPosition rotation encoding: AC wire quaternion order is (W, X, Y, Z). Our internal System.Numerics.Quaternion is (X, Y, Z, W). The message builder must swap the order — same transform already used in OnLiveEntitySpawned.