acdream/docs/superpowers/specs/2026-04-12-b3-complete-movement-design.md
Erik cc5ab683ea docs(specs): Phase B.3 Complete — movement and world navigation design
Comprehensive spec for completing the physics/movement system:
CellPortal-based indoor/outdoor/room-to-room transitions via
sphere-plane intersection, multi-landblock boundary crossing,
momentum-preserving jump with gravity arc, portal-space state
machine for teleports, Setup.StepUpHeight hookup, and
scenery-on-road exclusion fix.

Replaces the current "outdoor heightmap sampler with disabled
indoor transitions" with the full world-navigation system AC's
client uses. 12 acceptance criteria, ~20 new unit tests planned.

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

13 KiB

Phase B.3 Complete — Movement and World Navigation Design

Status: Spec, 2026-04-12, brainstormed with user. Scope: Make the physics collision engine and movement system fully functional: CellPortal-based indoor/outdoor transitions, multi-landblock boundary crossing, jump with momentum-preserving gravity arc, portal-space state machine for teleports, Setup.StepUpHeight, and scenery-on-road fix. Replaces the current "outdoor heightmap sampler with disabled indoor transitions" with a complete world-navigation system. Parent: docs/plans/2026-04-11-roadmap.md — Phase B (Gameplay), sub-piece B.3 completion. Depends on: Phase B.2 (player movement mode) — already shipped. Phase B.3 partial (TerrainSurface, CellSurface, PhysicsEngine skeleton) — already shipped.

Goals

  1. Walk through a doorway into a building (outdoor→indoor via CellPortal plane crossing).
  2. Walk between rooms inside a building (indoor→indoor via CellPortal).
  3. Walk back out of a building (indoor→outdoor via CellPortal with OtherCellId=0xFFFF).
  4. Walk from one landblock to the next seamlessly.
  5. Jump while walking with momentum-preserving arc.
  6. Jump off a ledge and land on terrain below (gravity when not on ground).
  7. Enter a game portal → portal-space state → arrive at destination.
  8. Step-height from Setup.StepUpHeight (not hardcoded).
  9. No trees/stones on roads.
  10. ACE console shows no movement rejection errors.

Non-goals

  • Charge jump (hold Space for higher jump) — deferred.
  • Wall sliding (deflecting off walls at oblique angles) — deferred.
  • Swimming / water depth detection — deferred.
  • NPC/entity collision (only terrain + cell surfaces) — deferred.
  • BSP tree acceleration for polygon lookups — brute-force polygon iteration is sufficient at cell-level polygon counts (< 20 per cell).

1. Cell Transition System

Data model

Each loaded EnvCell's portals are precomputed into portal planes at landblock-load time:

public readonly record struct PortalPlane(
    Vector3 Normal,           // plane normal (from portal polygon vertices)
    float D,                  // plane distance (dot(normal, v0))
    uint TargetCellId,        // OtherCellId (0xFFFF = outdoor)
    uint OwnerCellId,         // the EnvCell that owns this portal
    PortalFlags Flags);       // PortalSide flag for disambiguation

Stored in PhysicsEngine per landblock alongside the existing TerrainSurface + CellSurface entries:

sealed record LandblockPhysics(
    TerrainSurface Terrain,
    IReadOnlyList<CellSurface> Cells,
    IReadOnlyList<PortalPlane> Portals,  // NEW: all portals across all cells
    float WorldOffsetX,
    float WorldOffsetY);

Portal planes are computed at load time in GameWindow.ApplyLoadedTerrainLocked from EnvCell.CellPortals + CellStruct.Polygons:

  1. For each EnvCell in the landblock
  2. For each CellPortal in the cell
  3. Look up CellStruct.Polygons[portal.PolygonId]
  4. Extract the first 3 vertex positions (from CellStruct.VertexArray)
  5. Transform to world space using EnvCell.Position (orientation + origin + landblock offset)
  6. Compute plane: normal = normalize(cross(v1-v0, v2-v0)), d = -dot(normal, v0)
  7. Store as PortalPlane with the portal's OtherCellId, OwnerCellId, and Flags

Three transition types

Outdoor → Indoor (entering a building):

  • Player is in an outdoor cell (cellId & 0xFFFF < 0x0100).
  • Each frame, test the player's movement vector against all portal planes in the current landblock that belong to cells with OtherCellId = 0xFFFF (outside-facing portals — these are the building entry points viewed from outside).
  • Crossing test: sign(dot(normal, oldPos) + d) != sign(dot(normal, newPos) + d).
  • On crossing: transition to the OwnerCellId of the portal (the EnvCell the doorway belongs to). Switch to that cell's CellSurface for Z resolution.

Indoor → Indoor (room to room):

  • Player is in an indoor cell (cellId & 0xFFFF >= 0x0100).
  • Test only the current cell's portals (not all portals in the landblock).
  • OtherCellId != 0xFFFF — points to the adjacent room.
  • On crossing: transition to the target cell's CellSurface.

Indoor → Outdoor (exiting a building):

  • Same as indoor→indoor test, but OtherCellId = 0xFFFF.
  • On crossing: switch back to TerrainSurface.SampleZ for Z resolution. Compute the outdoor cell ID from coordinates.

Outdoor → Outdoor (landblock crossing)

No portals involved. When the candidate position falls outside the current landblock's [0, 192) local coordinate range, the existing PhysicsEngine.Resolve landblock-lookup loop picks up the adjacent landblock's TerrainSurface. This should work with the existing code — needs verification testing.

2. Jump + Gravity

New state on PlayerMovementController

float VerticalVelocity;          // current vertical speed (positive = up)
bool IsAirborne;                  // true from jump/fall until landing
const float JumpImpulse = 10f;   // tunable initial upward velocity
const float Gravity = 20f;        // downward acceleration per second squared
const float AirControlFactor = 0.2f;  // horizontal steering while airborne

Per-frame logic

1. If on ground AND Space pressed AND NOT airborne:
     verticalVelocity = jumpImpulse
     isAirborne = true

2. If airborne:
     verticalVelocity -= gravity * dt
     candidateZ = position.Z + verticalVelocity * dt
     
     // Resolve ground Z at the candidate XY
     groundZ = resolve terrain/floor Z at (candidateX, candidateY)
     
     if candidateZ <= groundZ:
         // Landed
         position.Z = groundZ
         verticalVelocity = 0
         isAirborne = false
     else:
         position.Z = candidateZ
         // Skip terrain Z snap — still in the air
         
     // Horizontal input at reduced speed
     horizontal delta *= airControlFactor

3. If NOT airborne AND resolved terrain Z is significantly below
   current Z (> stepUpHeight gap):
     // Walked off a ledge — enter falling state
     isAirborne = true
     verticalVelocity = 0  // start falling, not jumping

Server interaction

While airborne, still send AutonomousPosition heartbeats with the actual position. The server tracks the player's "in-air" state separately. MoveToState is sent at the start of the jump (motion command changes).

3. Portal Space State Machine

States

enum PlayerState { InWorld, PortalSpace }

Flow

  1. Server sends PlayerTeleport(sequence) game message.
  2. WorldSession.ProcessDatagram fires a new TeleportStarted(uint sequence) event.
  3. GameWindow handler: set PlayerState = PortalSpace.
  4. In PortalSpace:
    • All WASD/mouse movement input is ignored.
    • No MoveToState / AutonomousPosition messages are sent.
    • The player entity is optionally hidden (set a flag to skip rendering it).
  5. Server sends UpdatePosition with the new landblock + position.
  6. OnLivePositionUpdated handler detects the position is far from the current location (different landblock) and:
    • Recenters the streaming controller on the new landblock.
    • Resolves the new position through PhysicsEngine.
    • Snaps the player entity to the resolved position.
    • Sets PlayerState = InWorld.
  7. Send LoginComplete (GameAction 0x00A1) to tell the server "I've finished loading" — same message as initial login, reused for teleport completion (per holtburger's PlayerTeleport handler). Reset the _loginCompleteSent latch to allow this.

TeleportStarted event on WorldSession

New opcode handler in ProcessDatagram:

else if (op == 0xF7B0u)  // PlayerTeleport (GameEvent container)
{
    // Parse the teleport sequence from the event body.
    // Fire TeleportStarted event.
}

Note: PlayerTeleport may arrive as a GameEvent (opcode 0xF7B0) with an inner event type, not as a standalone game message. Check holtburger's GameEvent enum for the exact inner opcode. Implementation will read the reference code to determine the wire layout.

4. Remaining Fixes

Setup.StepUpHeight

  • Setup dat has a float StepUpHeight field.
  • When entering player mode (Tab), read setup.StepUpHeight and set it on PlayerMovementController.StepUpHeight.
  • If zero or missing, default to 2.0 (standard human height).

Scenery-on-road fix

In src/AcDream.Core/World/SceneryGenerator.cs, the terrain type is extracted per vertex:

uint terrainType = (uint)((raw >> 2) & 0x1F);

Road type in the raw terrain value has a specific bit pattern. Check if the vertex is a road terrain type and skip scenery generation:

// Road check: terrain types with road flag set should not spawn scenery.
// The exact road type value needs to be verified against the
// Region.TerrainInfo dat data, but the road bit is typically
// indicated by specific terrain type indices that the original
// client excludes from scenery placement.

Implementation will verify the exact road terrain type value from the dat and add the exclusion check.

PhysicsEngine refactoring

  • Remove the if (false) disabled indoor-transition branch.
  • Replace with real CellPortal plane-crossing logic from Section 1.
  • Add IReadOnlyList<PortalPlane> Portals to LandblockPhysics.
  • Resolve gains a Vector3 oldPos parameter (or derives it from currentPos) to test the movement vector against portal planes.
  • Cell tracking: the engine needs to know which cell the player is currently in to test only that cell's portals (for indoor→indoor transitions). The cellId parameter already serves this purpose.

Testing Strategy

Unit tests (~20 new)

Portal plane extraction:

  • Given 3 known vertices, assert correct plane normal and distance.
  • Test crossing detection: position on each side of the plane → sign differs.
  • Test no crossing: positions on the same side → sign matches.

Indoor transition integration:

  • Construct a fake two-cell building (cell A with portal to cell B at a known plane).
  • Start in cell A, move through the portal → assert cell ID changes to B.
  • Move back through the portal → assert cell ID returns to A.

Outdoor→indoor transition:

  • Construct a fake EnvCell with an outside-facing portal (OtherCellId=0xFFFF).
  • Start outdoors, move through the portal → assert cell ID changes to the EnvCell.

Indoor→outdoor transition:

  • Start inside the cell, move through the outside-facing portal → assert cell ID changes to outdoor.

Landblock boundary crossing:

  • Two adjacent landblocks registered in the engine.
  • Walk from X=190 to X=194 → assert the engine picks up the second landblock's terrain Z.

Jump arc:

  • Start at Z=50 on flat terrain, jump → assert Z increases above 50.
  • Continue frames → assert Z peaks then decreases.
  • Assert landing: Z returns to 50, IsAirborne = false.

Falling off ledge:

  • Terrain drops from Z=50 to Z=30 at a step. Walk off the edge.
  • Assert: IsAirborne becomes true, Z decreases toward 30, lands at Z=30.

Portal space state:

  • Simulate TeleportStarted → assert PlayerState = PortalSpace.
  • Simulate UpdatePosition with new location → assert PlayerState = InWorld, position updated.

Scenery road exclusion:

  • Construct terrain data with a road-type vertex.
  • Assert SceneryGenerator produces no scenery at that vertex.

Acceptance Criteria

Phase B.3 Complete is done when ALL of the following pass:

  1. Walk outdoors on terrain following heightmap Z.
  2. Walk from one landblock to the next seamlessly.
  3. Walk through a doorway into a building (outdoor→indoor via CellPortal).
  4. Walk around inside a building on the correct floor.
  5. Walk back out of a building (indoor→outdoor via CellPortal).
  6. Walk between rooms inside a building (indoor→indoor via CellPortal).
  7. Jump while walking (momentum-preserving arc).
  8. Jump off a ledge and land on terrain below.
  9. Enter a game portal → portal-space state → arrive at destination.
  10. Step-height read from Setup.StepUpHeight.
  11. No trees/stones on roads.
  12. ACE console shows no movement rejection errors.
  13. All new unit tests pass (~20). Total test count increases.

Open Questions (resolved during implementation)

  • Portal polygon vertex winding: the plane normal direction determines which side is "inside." The PortalFlags.PortalSide flag disambiguates. Implementation will read ACViewer's EnvCell.cs to confirm the convention.
  • Outdoor→indoor portal detection radius: testing every portal in the landblock each frame could be expensive with many buildings. If profiling shows this, add a spatial hash or AABB pre-filter. Expected to be fine for Holtburg (< 50 portals per landblock).
  • PlayerTeleport wire format: may be a GameEvent (0xF7B0) with inner type, or a standalone message. Check holtburger's opcode table during implementation.
  • Exact road terrain type value: verify from the Region.TerrainInfo dat during implementation.
  • Jump impulse / gravity tuning: start with JumpImpulse=10, Gravity=20. Tune to match retail feel during visual verification.