# Phase B.3 — Physics Collision Engine 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:** Build a pure-computation collision engine that resolves entity positions against terrain heightmaps and EnvCell floor polygons, handling outdoor/indoor cell transitions, step-height enforcement, and gravity reporting. **Architecture:** Three components in `src/AcDream.Core/Physics/`: `TerrainSurface` (outdoor heightmap Z interpolation), `CellSurface` (indoor floor polygon projection), and `PhysicsEngine` (top-level resolver combining both with step-height + cell transitions). Populated by the streaming system; consumed by Phase B.2 (player movement, separate spec). **Tech Stack:** .NET 10, System.Numerics, DatReaderWriter types, xUnit. **Spec:** `docs/superpowers/specs/2026-04-12-physics-collision-engine-design.md` --- ## File structure ``` src/AcDream.Core/ Physics/ [new folder] TerrainSurface.cs [new] outdoor heightmap Z interpolation + cell ID CellSurface.cs [new] indoor floor polygon projection PhysicsEngine.cs [new] top-level resolver ResolveResult.cs [new] result record src/AcDream.App/ Rendering/GameWindow.cs [modify] populate physics engine from streaming tests/AcDream.Core.Tests/ Physics/ [new folder] TerrainSurfaceTests.cs [new] CellSurfaceTests.cs [new] PhysicsEngineTests.cs [new] ``` --- ## Task 1: TerrainSurface (outdoor heightmap Z + cell ID) **Files:** - Create: `src/AcDream.Core/Physics/TerrainSurface.cs` - Test: `tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs` This extracts the existing `SampleTerrainZ` algorithm from GameWindow into a reusable class and adds outdoor cell ID computation. - [ ] **Step 1: Write failing tests** Create `tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs`: ```csharp using System.Numerics; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; public class TerrainSurfaceTests { // A height table where index N maps to N * 1.0f (linear). // Makes test assertions predictable: height byte 10 → Z = 10.0. private static float[] LinearHeightTable() { var table = new float[256]; for (int i = 0; i < 256; i++) table[i] = i * 1.0f; return table; } // A flat heightmap where every vertex is height byte 50. private static byte[] FlatHeightmap(byte value = 50) { var heights = new byte[81]; Array.Fill(heights, value); return heights; } [Fact] public void SampleZ_FlatTerrain_ReturnsSameValueEverywhere() { var surface = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); Assert.Equal(50f, surface.SampleZ(0f, 0f)); Assert.Equal(50f, surface.SampleZ(96f, 96f)); Assert.Equal(50f, surface.SampleZ(191f, 191f)); } [Fact] public void SampleZ_SlopeAlongX_InterpolatesLinearly() { // Heights increase along X: column 0 = byte 10, column 8 = byte 90. // Each column step is (90-10)/8 = 10 bytes. var heights = new byte[81]; for (int x = 0; x < 9; x++) for (int y = 0; y < 9; y++) heights[x * 9 + y] = (byte)(10 + x * 10); var surface = new TerrainSurface(heights, LinearHeightTable()); // At x=0 (vertex 0): Z = 10 Assert.Equal(10f, surface.SampleZ(0f, 96f), precision: 1); // At x=96 (midpoint, vertex 4): Z = 50 Assert.Equal(50f, surface.SampleZ(96f, 96f), precision: 1); // At x=192 (vertex 8): Z = 90 Assert.Equal(90f, surface.SampleZ(192f, 96f), precision: 1); // At x=48 (between vertex 2 and 3): Z = 30 + 0.5 * 10 = 35 // vertex 2 = byte 30, vertex 3 = byte 40, midpoint = 35 Assert.Equal(35f, surface.SampleZ(60f, 96f), precision: 1); } [Fact] public void SampleZ_ClampsOutOfBounds() { var surface = new TerrainSurface(FlatHeightmap(42), LinearHeightTable()); // Negative coordinates clamp to 0 Assert.Equal(42f, surface.SampleZ(-10f, -10f)); // Beyond 192 clamps to boundary Assert.Equal(42f, surface.SampleZ(300f, 300f)); } [Fact] public void ComputeOutdoorCellId_Origin_ReturnsFirst() { var surface = new TerrainSurface(FlatHeightmap(), LinearHeightTable()); // Cell (0,0) at position (0,0) → cell ID 0x0001 Assert.Equal(0x0001u, surface.ComputeOutdoorCellId(0f, 0f)); } [Fact] public void ComputeOutdoorCellId_SecondColumn_ReturnsCorrect() { var surface = new TerrainSurface(FlatHeightmap(), LinearHeightTable()); // 24 units in X = cell (1, 0) → cell ID 0x0001 + 1*8 = 0x0009 Assert.Equal(0x0009u, surface.ComputeOutdoorCellId(24f, 0f)); } [Fact] public void ComputeOutdoorCellId_LastCell_Returns0x0040() { var surface = new TerrainSurface(FlatHeightmap(), LinearHeightTable()); // Cell (7,7) at position (191,191) → 0x0001 + 7*8 + 7 = 0x0040 Assert.Equal(0x0040u, surface.ComputeOutdoorCellId(191f, 191f)); } } ``` - [ ] **Step 2: Run tests — verify they fail** Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TerrainSurfaceTests" --nologo` Expected: FAIL with build error `TerrainSurface not found`. - [ ] **Step 3: Implement TerrainSurface** Create `src/AcDream.Core/Physics/TerrainSurface.cs`: ```csharp using System; namespace AcDream.Core.Physics; /// /// Outdoor terrain height resolver for a single landblock. Performs /// bilinear interpolation of the 9×9 heightmap grid to produce the /// ground Z at any (localX, localY) within the 192×192 landblock /// footprint. Also computes the outdoor cell ID for AC's position /// encoding. /// /// /// Algorithm ported from GameWindow.SampleTerrainZ (which was inlined /// and not reusable). The heightmap is indexed x-major: /// heights[x * 9 + y]; each byte is a lookup into /// (256-entry float array from /// Region.LandDefs.LandHeightTable). /// /// public sealed class TerrainSurface { private const int HeightmapSide = 9; private const float CellSize = 24f; private const int CellsPerSide = 8; // 192 / 24 private readonly byte[] _heights; private readonly float[] _heightTable; public TerrainSurface(byte[] heights, float[] heightTable) { ArgumentNullException.ThrowIfNull(heights); ArgumentNullException.ThrowIfNull(heightTable); if (heights.Length < 81) throw new ArgumentException("heights must have 81 entries", nameof(heights)); if (heightTable.Length < 256) throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable)); _heights = heights; _heightTable = heightTable; } /// /// Bilinear-interpolated terrain Z at (localX, localY) in /// landblock-local coordinates (0..192 range). /// public float SampleZ(float localX, float localY) { float fx = Math.Clamp(localX / CellSize, 0f, HeightmapSide - 1f); float fy = Math.Clamp(localY / CellSize, 0f, HeightmapSide - 1f); int x0 = Math.Min((int)fx, HeightmapSide - 2); int y0 = Math.Min((int)fy, HeightmapSide - 2); int x1 = x0 + 1; int y1 = y0 + 1; float tx = fx - x0; float ty = fy - y0; float h00 = _heightTable[_heights[x0 * HeightmapSide + y0]]; float h10 = _heightTable[_heights[x1 * HeightmapSide + y0]]; float h01 = _heightTable[_heights[x0 * HeightmapSide + y1]]; float h11 = _heightTable[_heights[x1 * HeightmapSide + y1]]; float hx0 = h00 * (1 - tx) + h10 * tx; float hx1 = h01 * (1 - tx) + h11 * tx; return hx0 * (1 - ty) + hx1 * ty; } /// /// Compute the outdoor cell ID for the given landblock-local position. /// Outdoor cells are an 8×8 grid of 24×24-unit cells numbered /// 0x0001..0x0040. Cell (0,0) at position (0,0) is 0x0001. /// public uint ComputeOutdoorCellId(float localX, float localY) { int cx = Math.Clamp((int)(localX / CellSize), 0, CellsPerSide - 1); int cy = Math.Clamp((int)(localY / CellSize), 0, CellsPerSide - 1); return (uint)(1 + cx * CellsPerSide + cy); } } ``` - [ ] **Step 4: Run tests — verify all pass** Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TerrainSurfaceTests" --nologo` Expected: PASS 6/6. - [ ] **Step 5: Run full suite — no regressions** Run: `dotnet test -c Debug --nologo` Expected: 227+ tests green, no regressions. - [ ] **Step 6: Commit** ```bash git add src/AcDream.Core/Physics/TerrainSurface.cs tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs git commit -m "$(cat <<'EOF' feat(core): Phase B.3 — TerrainSurface (outdoor heightmap Z + cell ID) Extracts the bilinear heightmap interpolation from GameWindow's inlined SampleTerrainZ into a reusable class. Also adds outdoor cell ID computation (8×8 grid of 24-unit cells, 0x0001..0x0040). First component of the physics collision engine. 6 new tests, all green. Co-Authored-By: Claude Opus 4.6 (1M context) EOF )" ``` --- ## Task 2: CellSurface (indoor floor polygon projection) **Files:** - Create: `src/AcDream.Core/Physics/CellSurface.cs` - Test: `tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs` This is the most algorithmically complex component. It projects an XY point onto the floor polygons of an EnvCell's CellStruct and returns the Z. - [ ] **Step 1: Write failing tests** Create `tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs`: ```csharp using System; using System.Collections.Generic; using System.Numerics; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; public class CellSurfaceTests { /// /// Build a minimal CellSurface representing a flat square floor /// centered at (originX, originY) with the given half-size and Z. /// The floor polygon is a quad: 4 vertices at the corners. /// private static CellSurface MakeFlatFloor( uint cellId, float originX, float originY, float z, float halfSize = 10f) { // 4 vertices forming a square floor at the given Z, in WORLD space. var vertices = new Dictionary { [0] = new(originX - halfSize, originY - halfSize, z), [1] = new(originX + halfSize, originY - halfSize, z), [2] = new(originX + halfSize, originY + halfSize, z), [3] = new(originX - halfSize, originY + halfSize, z), }; // One quad polygon with 4 vertex IDs. var polygonVertexIds = new List> { new() { 0, 1, 2, 3 }, }; return new CellSurface(cellId, vertices, polygonVertexIds); } [Fact] public void SampleFloorZ_InsideFlat_ReturnsZ() { var surface = MakeFlatFloor(0x0100, originX: 50f, originY: 50f, z: 10f); float? z = surface.SampleFloorZ(50f, 50f); Assert.NotNull(z); Assert.Equal(10f, z!.Value, precision: 2); } [Fact] public void SampleFloorZ_OutsideFloor_ReturnsNull() { var surface = MakeFlatFloor(0x0100, originX: 50f, originY: 50f, z: 10f, halfSize: 5f); float? z = surface.SampleFloorZ(100f, 100f); Assert.Null(z); } [Fact] public void SampleFloorZ_AtEdge_ReturnsZ() { var surface = MakeFlatFloor(0x0100, originX: 50f, originY: 50f, z: 10f, halfSize: 10f); // Right at the edge of the polygon. float? z = surface.SampleFloorZ(60f, 50f); Assert.NotNull(z); Assert.Equal(10f, z!.Value, precision: 2); } [Fact] public void SampleFloorZ_SlopedFloor_InterpolatesZ() { // A triangular floor that slopes from Z=0 to Z=20. var vertices = new Dictionary { [0] = new(0f, 0f, 0f), [1] = new(20f, 0f, 0f), [2] = new(10f, 20f, 20f), }; var polygons = new List> { new() { 0, 1, 2 } }; var surface = new CellSurface(0x0100, vertices, polygons); // At the centroid (10, 6.67): Z should be roughly 6.67 float? z = surface.SampleFloorZ(10f, 6.67f); Assert.NotNull(z); Assert.InRange(z!.Value, 5f, 8f); // approximate } } ``` - [ ] **Step 2: Run tests — verify they fail** Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellSurfaceTests" --nologo` Expected: FAIL with build error `CellSurface not found`. - [ ] **Step 3: Implement CellSurface** Create `src/AcDream.Core/Physics/CellSurface.cs`: ```csharp using System; using System.Collections.Generic; using System.Numerics; namespace AcDream.Core.Physics; /// /// Indoor floor resolver for a single EnvCell. Projects an XY point /// onto the cell's floor polygons and returns the Z at that point. /// /// /// Uses a simplified constructor that takes pre-transformed vertex /// positions (world-space) and polygon vertex-id lists. The caller /// is responsible for transforming CellStruct vertices from cell-local /// space to world space using EnvCell.Position before constructing /// this surface. /// /// /// /// Floor polygon iteration is brute-force (no BSP). Cell polygon /// counts are typically < 20, making this acceptable for the MVP. /// Each polygon is fan-triangulated and tested via point-in-triangle /// + barycentric Z interpolation. /// /// public sealed class CellSurface { public uint CellId { get; } private readonly List<(Vector3 A, Vector3 B, Vector3 C)> _triangles; /// /// Construct a CellSurface from pre-transformed vertex positions /// and polygon definitions. /// /// The EnvCell dat id (e.g., 0xA9B40100). /// Vertex id → world-space position map. /// /// List of polygons, each a list of vertex IDs. Polygons with fewer /// than 3 vertices are skipped. Quads and larger are fan-triangulated. /// public CellSurface( uint cellId, Dictionary vertices, List> polygonVertexIds) { CellId = cellId; _triangles = new List<(Vector3, Vector3, Vector3)>(); foreach (var polyVerts in polygonVertexIds) { if (polyVerts.Count < 3) continue; // Resolve vertex positions. var positions = new List(polyVerts.Count); bool skip = false; foreach (var vid in polyVerts) { if (!vertices.TryGetValue((ushort)vid, out var pos)) { skip = true; break; } positions.Add(pos); } if (skip) continue; // Fan triangulation: (v0, v1, v2), (v0, v2, v3), ... for (int i = 1; i < positions.Count - 1; i++) { _triangles.Add((positions[0], positions[i], positions[i + 1])); } } } /// /// Project (worldX, worldY) onto this cell's floor polygons and /// return the Z. Returns null if outside all floor polygons. /// public float? SampleFloorZ(float worldX, float worldY) { foreach (var (a, b, c) in _triangles) { if (PointInTriangleXY(worldX, worldY, a, b, c, out float z)) return z; } return null; } /// /// Test if (px, py) falls inside triangle (a, b, c) projected onto /// the XY plane. If inside, computes the barycentric Z interpolation /// and returns it via . /// private static bool PointInTriangleXY( float px, float py, Vector3 a, Vector3 b, Vector3 c, out float z) { z = 0; // Barycentric coordinate computation in 2D (XY plane). float v0x = c.X - a.X, v0y = c.Y - a.Y; float v1x = b.X - a.X, v1y = b.Y - a.Y; float v2x = px - a.X, v2y = py - a.Y; float dot00 = v0x * v0x + v0y * v0y; float dot01 = v0x * v1x + v0y * v1y; float dot02 = v0x * v2x + v0y * v2y; float dot11 = v1x * v1x + v1y * v1y; float dot12 = v1x * v2x + v1y * v2y; float denom = dot00 * dot11 - dot01 * dot01; if (MathF.Abs(denom) < 1e-10f) return false; // degenerate triangle float invDenom = 1f / denom; float u = (dot11 * dot02 - dot01 * dot12) * invDenom; float v = (dot00 * dot12 - dot01 * dot02) * invDenom; if (u < -1e-6f || v < -1e-6f || u + v > 1f + 1e-6f) return false; // Barycentric Z interpolation. z = a.Z * (1 - u - v) + b.Z * v + c.Z * u; return true; } } ``` - [ ] **Step 4: Run tests — verify all pass** Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellSurfaceTests" --nologo` Expected: PASS 4/4. - [ ] **Step 5: Commit** ```bash git add src/AcDream.Core/Physics/CellSurface.cs tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs git commit -m "$(cat <<'EOF' feat(core): Phase B.3 — CellSurface (indoor floor polygon projection) Projects an XY point onto a cell's floor polygons via brute-force triangle iteration + barycentric Z interpolation. Fan-triangulates quads and larger polygons. Returns null when outside all floor surfaces. Accepts pre-transformed world-space vertex positions so the caller handles EnvCell coordinate transforms. Second component of the physics collision engine. 4 new tests, all green. Co-Authored-By: Claude Opus 4.6 (1M context) EOF )" ``` --- ## Task 3: PhysicsEngine (top-level resolver + ResolveResult) **Files:** - Create: `src/AcDream.Core/Physics/ResolveResult.cs` - Create: `src/AcDream.Core/Physics/PhysicsEngine.cs` - Test: `tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs` The integration layer that combines terrain + cell surfaces with step-height enforcement and outdoor/indoor transitions. - [ ] **Step 1: Create ResolveResult record** Create `src/AcDream.Core/Physics/ResolveResult.cs`: ```csharp using System.Numerics; namespace AcDream.Core.Physics; /// /// Result of : the validated /// position after collision, the cell the entity ended up in, /// and whether they're standing on a surface. /// public readonly record struct ResolveResult( Vector3 Position, uint CellId, bool IsOnGround); ``` - [ ] **Step 2: Write failing tests for PhysicsEngine** Create `tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs`: ```csharp using System; using System.Collections.Generic; using System.Numerics; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; public class PhysicsEngineTests { private static float[] LinearHeightTable() { var table = new float[256]; for (int i = 0; i < 256; i++) table[i] = i * 1.0f; return table; } private static byte[] FlatHeightmap(byte value = 50) { var heights = new byte[81]; Array.Fill(heights, value); return heights; } private PhysicsEngine MakeFlatEngine(float terrainZ = 50f) { var engine = new PhysicsEngine(); var terrain = new TerrainSurface(FlatHeightmap((byte)terrainZ), LinearHeightTable()); engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); return engine; } [Fact] public void Resolve_FlatTerrain_ZMatchesTerrain() { var engine = MakeFlatEngine(terrainZ: 50f); var result = engine.Resolve( new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(1f, 0f, 0f), stepUpHeight: 2f); Assert.Equal(50f, result.Position.Z, precision: 1); Assert.True(result.IsOnGround); } [Fact] public void Resolve_WalkUpSmallSlope_Accepted() { // Heights slope from 50 to 52 across X — small enough for step height. var heights = new byte[81]; for (int x = 0; x < 9; x++) for (int y = 0; y < 9; y++) heights[x * 9 + y] = (byte)(50 + x / 4); // gentle slope var engine = new PhysicsEngine(); var terrain = new TerrainSurface(heights, LinearHeightTable()); engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); var result = engine.Resolve( new Vector3(48f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f), stepUpHeight: 5f); Assert.True(result.IsOnGround); Assert.True(result.Position.Z >= 50f); // moved uphill } [Fact] public void Resolve_StepUpExceedsHeight_MovementBlocked() { // Heights jump sharply: left half = 50, right half = 100. var heights = new byte[81]; for (int x = 0; x < 9; x++) for (int y = 0; y < 9; y++) heights[x * 9 + y] = (byte)(x < 5 ? 50 : 100); var engine = new PhysicsEngine(); var terrain = new TerrainSurface(heights, LinearHeightTable()); engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); // Try to walk from the low side to the high side. var result = engine.Resolve( new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f), stepUpHeight: 2f); // Movement should be blocked — Z delta (50→100) exceeds step height (2). Assert.Equal(96f, result.Position.X, precision: 1); // didn't move Assert.True(result.IsOnGround); } [Fact] public void Resolve_EnterIndoorCell_TransitionsToCell() { var engine = new PhysicsEngine(); var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); // Indoor cell with a floor at Z=55 covering (40..60, 40..60). var cellVerts = new Dictionary { [0] = new(40f, 40f, 55f), [1] = new(60f, 40f, 55f), [2] = new(60f, 60f, 55f), [3] = new(40f, 60f, 55f), }; var cellPolys = new List> { new() { 0, 1, 2, 3 } }; var cell = new CellSurface(0x0100, cellVerts, cellPolys); engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, worldOffsetX: 0f, worldOffsetY: 0f); // Walk from outdoor (30, 50) into the cell's floor area (50, 50). var result = engine.Resolve( new Vector3(30f, 50f, 50f), cellId: 0x0001, delta: new Vector3(20f, 0f, 0f), stepUpHeight: 10f); // Should transition to the indoor cell and snap to its floor Z. Assert.Equal(0x0100u, result.CellId); Assert.Equal(55f, result.Position.Z, precision: 1); Assert.True(result.IsOnGround); } [Fact] public void Resolve_LeaveIndoorCell_TransitionsToOutdoor() { var engine = new PhysicsEngine(); var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); var cellVerts = new Dictionary { [0] = new(40f, 40f, 55f), [1] = new(60f, 40f, 55f), [2] = new(60f, 60f, 55f), [3] = new(40f, 60f, 55f), }; var cellPolys = new List> { new() { 0, 1, 2, 3 } }; var cell = new CellSurface(0x0100, cellVerts, cellPolys); engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, worldOffsetX: 0f, worldOffsetY: 0f); // Start inside the cell, walk out. var result = engine.Resolve( new Vector3(50f, 50f, 55f), cellId: 0x0100, delta: new Vector3(-20f, 0f, 0f), stepUpHeight: 10f); // Should transition back to outdoor. Assert.True(result.CellId < 0x0100u); Assert.Equal(50f, result.Position.Z, precision: 1); Assert.True(result.IsOnGround); } [Fact] public void Resolve_NoSurfaceUnderEntity_NotOnGround() { var engine = new PhysicsEngine(); // No landblocks loaded — entity is floating in void. var result = engine.Resolve( new Vector3(0f, 0f, 100f), cellId: 0x0001, delta: Vector3.Zero, stepUpHeight: 2f); Assert.False(result.IsOnGround); } } ``` - [ ] **Step 3: Implement PhysicsEngine** Create `src/AcDream.Core/Physics/PhysicsEngine.cs`: The engine stores per-landblock terrain + cells and resolves movement. Implementation should follow the algorithm in the spec (Section 2, resolution algorithm steps 1-6). Key decisions: - Store terrain surfaces in `Dictionary Cells, float WorldOffsetX, float WorldOffsetY)>` - `AddLandblock` takes the canonical landblock id + TerrainSurface + cells + world offset - `RemoveLandblock` removes the entry - `Resolve` implements the outdoor/indoor logic: - Compute candidate position - Find which landblock the candidate is in (from world position → landblock offset) - If outdoor: sample terrain Z, check cells for containment (outdoor→indoor transition), step-height check - If indoor: sample current cell floor Z, if null → transition to outdoor, step-height check - Return ResolveResult The implementation MUST handle: - Converting between world-space and landblock-local coordinates using `worldOffsetX/Y` - The step-height check only blocks upward movement (downhill is always OK) - When no landblock is found for the candidate position, return `isOnGround = false` - Multi-cell overlap (two EnvCells at the same XY, different Z): pick the one closest to current Z ```csharp // Skeleton — implementer fills in the Resolve body per the spec algorithm. using System; using System.Collections.Generic; using System.Numerics; namespace AcDream.Core.Physics; public sealed class PhysicsEngine { private readonly Dictionary _landblocks = new(); private sealed record LandblockPhysics( TerrainSurface Terrain, IReadOnlyList Cells, float WorldOffsetX, float WorldOffsetY); public void AddLandblock(uint landblockId, TerrainSurface terrain, IReadOnlyList cells, float worldOffsetX, float worldOffsetY) { _landblocks[landblockId] = new LandblockPhysics(terrain, cells, worldOffsetX, worldOffsetY); } public void RemoveLandblock(uint landblockId) => _landblocks.Remove(landblockId); public ResolveResult Resolve(Vector3 currentPos, uint cellId, Vector3 delta, float stepUpHeight) { var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f); // Find the landblock this candidate position falls in. LandblockPhysics? physics = null; uint landblockId = 0; foreach (var kvp in _landblocks) { var lb = kvp.Value; float localX = candidatePos.X - lb.WorldOffsetX; float localY = candidatePos.Y - lb.WorldOffsetY; if (localX >= 0 && localX < 192f && localY >= 0 && localY < 192f) { physics = lb; landblockId = kvp.Key; break; } } if (physics is null) return new ResolveResult(candidatePos, cellId, IsOnGround: false); float localCandX = candidatePos.X - physics.WorldOffsetX; float localCandY = candidatePos.Y - physics.WorldOffsetY; // Check if the candidate position falls on any indoor cell floor. // Pick the cell whose floor Z is closest to the entity's current Z. CellSurface? bestCell = null; float? bestCellZ = null; float bestZDist = float.MaxValue; foreach (var cell in physics.Cells) { float? floorZ = cell.SampleFloorZ(candidatePos.X, candidatePos.Y); if (floorZ is not null) { float dist = MathF.Abs(floorZ.Value - currentPos.Z); if (dist < bestZDist) { bestCell = cell; bestCellZ = floorZ; bestZDist = dist; } } } // Determine target surface Z and cell. float terrainZ = physics.Terrain.SampleZ(localCandX, localCandY); float targetZ; uint targetCellId; bool currentlyIndoor = cellId >= 0x0100; if (currentlyIndoor && bestCellZ is not null) { // Stay indoors on the best cell's floor. targetZ = bestCellZ.Value; targetCellId = bestCell!.CellId; } else if (currentlyIndoor && bestCellZ is null) { // Walked out of the current cell — transition to outdoor. targetZ = terrainZ; targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY); } else if (!currentlyIndoor && bestCellZ is not null && MathF.Abs(bestCellZ.Value - currentPos.Z) < stepUpHeight + 2f) { // Walked into an indoor cell from outdoor — transition to indoor. targetZ = bestCellZ.Value; targetCellId = bestCell!.CellId; } else { // Stay outdoors on terrain. targetZ = terrainZ; targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY); } // Step-height enforcement: block upward movement that exceeds the limit. float zDelta = targetZ - currentPos.Z; if (zDelta > stepUpHeight) { // Too steep to step up — reject horizontal movement. return new ResolveResult(currentPos, cellId, IsOnGround: true); } // Encode the landblock ID into the cell ID. uint fullCellId = (landblockId & 0xFFFF0000u) | (targetCellId & 0xFFFFu); return new ResolveResult( new Vector3(candidatePos.X, candidatePos.Y, targetZ), fullCellId, IsOnGround: true); } } ``` - [ ] **Step 4: Run tests — verify all pass** Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PhysicsEngineTests" --nologo` Expected: PASS 6/6. - [ ] **Step 5: Run full suite — no regressions** Run: `dotnet test -c Debug --nologo` Expected: 227 + 16 = 243+ tests green, no regressions. - [ ] **Step 6: Commit** ```bash git add src/AcDream.Core/Physics/ResolveResult.cs src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs git commit -m "$(cat <<'EOF' feat(core): Phase B.3 — PhysicsEngine (top-level collision resolver) Combines TerrainSurface + CellSurface into a single Resolve() API that handles outdoor terrain walking, indoor floor walking, outdoor↔indoor cell transitions, step-height enforcement, and ground detection. Step-height blocks upward Z deltas exceeding the limit (walls, cliffs); downhill movement is always accepted. Indoor transitions pick the cell whose floor Z is closest to the entity's current Z (handles multi-story buildings). Reports IsOnGround=false when no landblock or surface covers the entity's position (gravity applied by the caller). 6 new tests covering flat terrain, slopes, step-height rejection, indoor entry/exit, and void detection. Co-Authored-By: Claude Opus 4.6 (1M context) EOF )" ``` --- ## Task 4: GameWindow integration (populate engine from streaming) **Files:** - Modify: `src/AcDream.App/Rendering/GameWindow.cs` Wire the physics engine into the streaming pipeline so it's populated when landblocks load and cleaned up when they unload. Phase B.2 will consume it. - [ ] **Step 1: Add `_physicsEngine` field to GameWindow** Near the other streaming fields (`_streamer`, `_worldState`, etc.), add: ```csharp private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new(); ``` - [ ] **Step 2: Populate in `ApplyLoadedTerrainLocked`** After the existing `_worldState.SetLandblockAabb(...)` call, add: ```csharp // Phase B.3: populate the physics collision engine for this landblock. var terrainSurface = new AcDream.Core.Physics.TerrainSurface(lb.Heightmap.Height, _heightTable); // Build CellSurfaces for each EnvCell in this landblock (reuse the // same EnvCell walk we do for the interior entity builder). var cellSurfaces = new List(); var lbInfoPhys = _dats.Get((lb.LandblockId & 0xFFFF0000u) | 0xFFFEu); if (lbInfoPhys is not null && lbInfoPhys.NumCells > 0) { uint firstCellId = (lb.LandblockId & 0xFFFF0000u) | 0x0100u; for (uint cellOffset = 0; cellOffset < lbInfoPhys.NumCells; cellOffset++) { uint envCellId = firstCellId + cellOffset; var envCell = _dats.Get(envCellId); if (envCell is null || envCell.EnvironmentId == 0) continue; var environment = _dats.Get(0x0D000000u | envCell.EnvironmentId); if (environment is null || !environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) continue; // Transform CellStruct vertices from cell-local to world space. var worldVerts = new Dictionary(); var cellOrigin = envCell.Position.Origin + new System.Numerics.Vector3(origin.X, origin.Y, 0f); foreach (var kvp in cellStruct.VertexArray.Vertices) { var localPos = kvp.Value.Origin; // Apply cell orientation + cell origin + landblock origin. var rotated = System.Numerics.Vector3.Transform(localPos, envCell.Position.Orientation); worldVerts[kvp.Key] = rotated + cellOrigin; } // Extract physics polygon vertex-id lists. var polyVertIds = new List>(); foreach (var poly in cellStruct.PhysicsPolygons.Values) { if (poly.VertexIds.Count >= 3) polyVertIds.Add(new List(poly.VertexIds)); } if (polyVertIds.Count > 0) { cellSurfaces.Add(new AcDream.Core.Physics.CellSurface( envCellId, worldVerts, polyVertIds)); } } } _physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces, worldOffsetX: origin.X, worldOffsetY: origin.Y); ``` **IMPORTANT:** `origin` is the `Vector3` already computed earlier in `ApplyLoadedTerrainLocked` — it's the landblock's world offset. Verify it's in scope at the point where you're adding this code. - [ ] **Step 3: Clean up in unload path** Find where `_worldState.RemoveLandblock(...)` is called (in `StreamingController.Tick`'s drain of `LandblockStreamResult.Unloaded`). The controller calls `_state.RemoveLandblock(unloaded.LandblockId)`. The physics engine cleanup should happen alongside — but the controller doesn't have a reference to the physics engine. Simplest: add a `removeTerrain` callback to `StreamingController` (same pattern as `applyTerrain`) OR let GameWindow handle it by subscribing to the `RemoveLandblock` action. OR just add a direct call in the controller's unload handler. For the MVP: in `StreamingController.Tick`, add a second callback `Action removeTerrain` that GameWindow passes to call `_physicsEngine.RemoveLandblock(id)` AND `_terrain.RemoveLandblock(id)`. This mirrors the existing `applyTerrain` callback pattern. - [ ] **Step 4: 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, 243+ tests green. - [ ] **Step 5: Commit** ```bash git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/Streaming/StreamingController.cs git commit -m "$(cat <<'EOF' feat(app): Phase B.3 — wire PhysicsEngine into streaming pipeline Populates the collision engine with TerrainSurface + CellSurface entries when landblocks stream in, removes them when they stream out. CellSurface vertices are transformed from cell-local to world space using EnvCell.Position orientation + origin. Phase B.2 (player movement mode) will call PhysicsEngine.Resolve() to get collision-validated positions before sending them to the server. Co-Authored-By: Claude Opus 4.6 (1M context) EOF )" ``` --- ## Task 5: Roadmap update - [ ] **Step 1: Move B.3 into the shipped table** Open `docs/plans/2026-04-11-roadmap.md`, add B.3 to the shipped table, mark B.1 as already shipped (Phase 4.9), and update the quick-lookup. - [ ] **Step 2: Commit** ```bash git add docs/plans/2026-04-11-roadmap.md git commit -m "docs: mark Phase B.3 (physics collision engine) as shipped Co-Authored-By: Claude Opus 4.6 (1M context) " ``` --- ## Self-review **Spec coverage:** - TerrainSurface.SampleZ (bilinear interpolation) — Task 1 ✓ - TerrainSurface.ComputeOutdoorCellId — Task 1 ✓ - CellSurface.SampleFloorZ (polygon projection + barycentric Z) — Task 2 ✓ - PhysicsEngine.Resolve (outdoor/indoor/transition/step-height/gravity) — Task 3 ✓ - Integration with streaming pipeline — Task 4 ✓ - Step-height enforcement — Task 3 tests ✓ - Outdoor→indoor transition — Task 3 tests ✓ - Indoor→outdoor transition — Task 3 tests ✓ - Gravity (isOnGround=false) — Task 3 tests ✓ - Multi-story handling (closest-Z cell) — Task 3 implementation ✓ - Roadmap update — Task 5 ✓ **Placeholder scan:** None found. All code blocks are complete. **Type consistency:** `TerrainSurface(byte[], float[])`, `CellSurface(uint, Dictionary, List>)`, `PhysicsEngine.AddLandblock(uint, TerrainSurface, IReadOnlyList, float, float)`, `PhysicsEngine.Resolve(Vector3, uint, Vector3, float) → ResolveResult` — all consistent across tasks. --- ## Execution Handoff Plan complete and saved to `docs/superpowers/plans/2026-04-12-physics-collision-engine.md`. Two execution options: **1. Subagent-Driven (recommended)** — I dispatch a fresh Sonnet subagent per task, review between tasks, fast iteration. Best for this plan because Tasks 1-3 are independent pure-computation modules with clear test suites. **2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. Which approach?