7-task plan: PortalPlane math, PhysicsEngine portal transitions, populate portals from streaming, jump+gravity+falling, portal-space state machine, Setup.StepUpHeight + scenery road fix, and visual verification with 12 acceptance criteria. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14 KiB
Phase B.3 Complete — Movement and World Navigation 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: Complete the physics collision engine with CellPortal-based indoor transitions, jump with gravity, portal-space teleport handling, and scenery/step-height fixes.
Architecture: Extend PhysicsEngine.Resolve with portal-plane crossing detection for indoor/outdoor transitions. Add vertical velocity + gravity to PlayerMovementController. Add a portal-space state machine triggered by PlayerTeleport server messages. Fix scenery road exclusion and step-height from Setup dat.
Tech Stack: .NET 10, System.Numerics, DatReaderWriter, xUnit.
Spec: docs/superpowers/specs/2026-04-12-b3-complete-movement-design.md
File structure
src/AcDream.Core/Physics/
PortalPlane.cs [new] portal plane record + crossing test
PhysicsEngine.cs [modify] add portal planes to LandblockPhysics,
replace disabled indoor branch with
portal-plane crossing logic
CellSurface.cs [no change — portals live on PhysicsEngine]
src/AcDream.App/Input/
PlayerMovementController.cs [modify] add jump/gravity/airborne state,
Space input, ledge-fall detection
src/AcDream.Core.Net/
WorldSession.cs [modify] add PlayerTeleport handler + event
src/AcDream.App/Rendering/
GameWindow.cs [modify] portal-space state machine, populate
portal planes at load time, pass
Setup.StepUpHeight, wire Space input
src/AcDream.Core/World/
SceneryGenerator.cs [modify] add road terrain-type exclusion
tests/AcDream.Core.Tests/Physics/
PortalPlaneTests.cs [new]
PhysicsEngineTests.cs [modify] add portal transition + landblock
boundary tests
tests/AcDream.Core.Tests/Input/
PlayerMovementControllerTests.cs [modify] add jump/gravity/fall tests
tests/AcDream.Core.Tests/World/
SceneryGeneratorTests.cs [new] road exclusion test
Task 1: PortalPlane (record + plane math + crossing test)
Files:
- Create:
src/AcDream.Core/Physics/PortalPlane.cs - Test:
tests/AcDream.Core.Tests/Physics/PortalPlaneTests.cs
Pure math — no dependencies on the rest of the physics engine. Build the portal plane record with a static crossing-detection method.
- Step 1: Write failing tests
Create tests covering: plane construction from 3 vertices, crossing detection (positions on opposite sides), no crossing (positions on same side), crossing at exact plane.
// PortalPlaneTests.cs
[Fact] FromVertices_ComputesCorrectNormal()
[Fact] IsCrossing_PositionsOnOppositeSides_ReturnsTrue()
[Fact] IsCrossing_PositionsOnSameSide_ReturnsFalse()
[Fact] IsCrossing_StartOnPlane_ReturnsFalse()
- Step 2: Implement PortalPlane
Create src/AcDream.Core/Physics/PortalPlane.cs:
namespace AcDream.Core.Physics;
public readonly record struct PortalPlane(
Vector3 Normal,
float D,
uint TargetCellId, // OtherCellId (0xFFFF = outdoor)
uint OwnerCellId, // the EnvCell that owns this portal
ushort Flags)
{
public static PortalPlane FromVertices(
Vector3 v0, Vector3 v1, Vector3 v2,
uint targetCellId, uint ownerCellId, ushort flags)
{
var edge1 = v1 - v0;
var edge2 = v2 - v0;
var normal = Vector3.Normalize(Vector3.Cross(edge1, edge2));
float d = -Vector3.Dot(normal, v0);
return new PortalPlane(normal, d, targetCellId, ownerCellId, flags);
}
/// <summary>
/// Test whether the movement from oldPos to newPos crosses this plane.
/// Returns true if the two positions are on opposite sides.
/// </summary>
public bool IsCrossing(Vector3 oldPos, Vector3 newPos)
{
float oldDist = Vector3.Dot(Normal, oldPos) + D;
float newDist = Vector3.Dot(Normal, newPos) + D;
// Different signs = crossed the plane. Ignore near-zero (on the plane).
return oldDist * newDist < 0f;
}
}
- Step 3: Run tests, commit
git commit -m "feat(core): Phase B.3 — PortalPlane (plane math + crossing detection)"
Task 2: PhysicsEngine portal-based indoor transitions
Files:
- Modify:
src/AcDream.Core/Physics/PhysicsEngine.cs - Modify:
tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
Replace the if (false) disabled branch with real CellPortal plane-crossing logic.
- Step 1: Extend LandblockPhysics record
Add IReadOnlyList<PortalPlane> Portals to the record and AddLandblock signature.
- Step 2: Write failing tests
Add to PhysicsEngineTests.cs:
[Fact] Resolve_OutdoorThroughPortal_TransitionsToIndoor()
[Fact] Resolve_IndoorThroughPortal_TransitionsToOtherCell()
[Fact] Resolve_IndoorThroughExitPortal_TransitionsToOutdoor()
[Fact] Resolve_LandblockBoundary_PicksAdjacentTerrain()
Each test constructs a fake portal plane at a known position and asserts cell ID changes when the movement crosses it.
- Step 3: Implement portal crossing in Resolve
Replace the if (false) block. The logic:
For outdoor players: test all portals in the current landblock where TargetCellId != 0xFFFF (outside-facing portals have OwnerCellId = building entry; TargetCellId = 0xFFFF means the portal FACES outside, so from outside you enter the OwnerCellId). Actually, the outside-facing portals should be tested from the outdoor side — when IsCrossing is true, the player enters the OwnerCellId.
For indoor players: test only portals belonging to the current cell. Get them by filtering on OwnerCellId == currentCellId. On crossing, transition to TargetCellId (or outdoor if TargetCellId == 0xFFFF).
IMPORTANT: Read the spec section 1 carefully. Also read references/ACViewer/ACViewer/Physics/Common/EnvCell.cs for the canonical transition logic. The PortalFlags.PortalSide flag may affect which direction the crossing triggers. Implementation should follow ACViewer's pattern.
- Step 4: Update the existing indoor-transition test
Resolve_EnterIndoorCell_StaysOutdoor_BecauseTransitionDisabled should be renamed to Resolve_OutdoorThroughPortal_TransitionsToIndoor and updated to use a portal plane instead.
- Step 5: Run tests, commit
git commit -m "feat(core): Phase B.3 — CellPortal-based indoor/outdoor transitions in PhysicsEngine"
Task 3: Populate portal planes from streaming
Files:
-
Modify:
src/AcDream.App/Rendering/GameWindow.cs(ApplyLoadedTerrainLocked) -
Step 1: Extract portal planes at landblock load time
In ApplyLoadedTerrainLocked, after the existing CellSurface construction loop, add a second loop that extracts portal planes from each EnvCell:
For each EnvCell:
For each CellPortal in envCell.CellPortals:
Look up the polygon via cellStruct.PhysicsPolygons[portal.PolygonId] (note: the key type is ushort; cast PolygonId)
Get the first 3 vertex positions from the polygon's VertexIds
Transform to world space (same transform already used for CellSurface vertices)
Create a PortalPlane.FromVertices(v0, v1, v2, portal.OtherCellId, envCellId & 0xFFFF, (ushort)portal.Flags)
Add to a List<PortalPlane>
Pass the portal list to _physicsEngine.AddLandblock.
IMPORTANT: The portal's PolygonId indexes CellStruct.Polygons (rendering polygons), NOT CellStruct.PhysicsPolygons. Verify by reading the CellPortal definition — the PolygonId is used in ACViewer's CellStructure.Polygons[portal.PolygonId]. Check which dictionary it actually indexes. If it's the rendering Polygons dict, use that instead.
- Step 2: Build + test (no new unit tests — verified via Task 2's integration tests + live run)
git commit -m "feat(app): Phase B.3 — populate portal planes from streaming pipeline"
Task 4: Jump + gravity + falling
Files:
-
Modify:
src/AcDream.App/Input/PlayerMovementController.cs -
Modify:
tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs -
Modify:
src/AcDream.App/Rendering/GameWindow.cs(wire Space key) -
Step 1: Add MovementInput.Jump field
Add bool Jump = false to the MovementInput record.
- Step 2: Add airborne state to PlayerMovementController
Add fields:
public float VerticalVelocity { get; private set; }
public bool IsAirborne { get; private set; }
public float JumpImpulse { get; set; } = 10f;
public float GravityAccel { get; set; } = 20f;
public float AirControlFactor { get; set; } = 0.2f;
- Step 3: Implement jump/gravity in Update
After horizontal movement resolution, add:
-
On Space + onGround + !IsAirborne: set VerticalVelocity = JumpImpulse, IsAirborne = true
-
If IsAirborne: apply gravity, check if landed (candidateZ <= groundZ)
-
If !IsAirborne and resolved Z is significantly below current Z: enter falling state
-
While airborne: reduce horizontal input by AirControlFactor
-
Step 4: Write failing tests
[Fact] Update_JumpOnFlatTerrain_BecomesAirborne()
[Fact] Update_AirborneFrame_ZIncreasesThenDecreases()
[Fact] Update_Landing_ReturnsToGround()
[Fact] Update_WalkOffLedge_BecomesFalling()
- Step 5: Wire Space key in GameWindow
In the player-mode OnUpdate block, add Jump: kb.IsKeyPressed(Key.Space) to the MovementInput constructor.
- Step 6: Run tests, commit
git commit -m "feat(app+core): Phase B.3 — jump with momentum-preserving gravity arc"
Task 5: Portal space state machine
Files:
-
Modify:
src/AcDream.Core.Net/WorldSession.cs -
Modify:
src/AcDream.App/Rendering/GameWindow.cs -
Modify:
src/AcDream.App/Input/PlayerMovementController.cs -
Step 1: Add PlayerTeleport handler to WorldSession
In ProcessDatagram, add a handler for the PlayerTeleport opcode. Check holtburger for the exact opcode — it may be a GameEvent (0xF7B0) with inner type. Add a TeleportStarted event on WorldSession.
- Step 2: Add PlayerState enum to PlayerMovementController
public enum PlayerState { InWorld, PortalSpace }
public PlayerState State { get; private set; } = PlayerState.InWorld;
In Update: if State == PortalSpace, return immediately (no movement processed).
- Step 3: Wire in GameWindow
Subscribe to TeleportStarted → set controller State to PortalSpace.
In OnLivePositionUpdated: if currently in PortalSpace and position changed significantly, recenter streaming, snap position, set State to InWorld, send LoginComplete.
Reset _loginCompleteSent latch to allow re-sending after teleport.
- Step 4: Run tests, commit
git commit -m "feat(net+app): Phase B.3 — portal-space state machine for teleports"
Task 6: Setup.StepUpHeight + scenery road fix
Files:
-
Modify:
src/AcDream.App/Rendering/GameWindow.cs -
Modify:
src/AcDream.Core/World/SceneryGenerator.cs -
Test:
tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs(new) -
Step 1: Read Setup.StepUpHeight when entering player mode
In the Tab handler, after loading the Setup, set:
_playerController.StepUpHeight = setup.StepUpHeight > 0f ? setup.StepUpHeight : 2f;
- Step 2: Add road exclusion to SceneryGenerator
In SceneryGenerator.Generate, after extracting terrainType at line 68, add a check to skip road terrain types. The exact road value needs verification from the dat — check Region.TerrainInfo.TerrainTypes for which indices are roads, or check if the raw terrain value has a road bit.
Read references/ACViewer/ or references/WorldBuilder/ for how they detect road terrain. The simplest approach: check terrainType against known road indices from the Region dat.
- Step 3: Write a scenery road exclusion test
Create tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs with a test that verifies no scenery is generated at a road vertex.
- Step 4: Run all tests, commit
git commit -m "fix(app+core): Phase B.3 — Setup.StepUpHeight + scenery road exclusion"
Task 7: Visual verification + roadmap update
- Step 1: Live verification
Launch the app, log in, press Tab, and verify all 12 acceptance criteria from the spec:
- Walk outdoors ✓ (already works)
- Walk to next landblock
- Walk into a building through a door
- Walk inside a building
- Walk back out
- Walk between rooms
- Jump while walking
- Jump off a ledge
- Enter a portal → arrive at destination
- Step-height feels right
- No trees on roads
- ACE console clean
- Step 2: Update roadmap
Mark B.3 Complete as shipped in docs/plans/2026-04-11-roadmap.md.
- Step 3: Commit
git commit -m "docs: mark Phase B.3 Complete (movement + world navigation) as shipped"
Self-review
Spec coverage:
- CellPortal indoor transitions (outdoor→indoor, indoor→indoor, indoor→outdoor) → Tasks 1, 2, 3 ✓
- Multi-landblock boundary crossing → Task 2 (test) ✓
- Jump + gravity + falling → Task 4 ✓
- Portal space state machine → Task 5 ✓
- Setup.StepUpHeight → Task 6 ✓
- Scenery road fix → Task 6 ✓
- All 12 acceptance criteria → Task 7 ✓
Placeholder scan: Tasks 2 and 3 have "read the reference code" instructions instead of exact code — this is intentional because the portal-plane crossing logic depends on ACViewer's exact convention for PortalFlags.PortalSide and polygon winding, which must be verified at implementation time. The subagent is instructed to read specific reference files.
Type consistency: PortalPlane record used in Tasks 1, 2, 3. PlayerState enum in Task 5. MovementInput.Jump in Task 4. All consistent.