5-task TDD plan: TerrainSurface (outdoor heightmap Z + cell ID), CellSurface (indoor floor polygon projection via barycentric interp), PhysicsEngine (top-level resolver with step-height + cell transitions), GameWindow integration (populate from streaming), and roadmap update. ~16 new unit tests with fake data. No dat files or rendering needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
38 KiB
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:
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:
using System;
namespace AcDream.Core.Physics;
/// <summary>
/// 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.
///
/// <para>
/// Algorithm ported from GameWindow.SampleTerrainZ (which was inlined
/// and not reusable). The heightmap is indexed x-major:
/// <c>heights[x * 9 + y]</c>; each byte is a lookup into
/// <paramref name="heightTable"/> (256-entry float array from
/// Region.LandDefs.LandHeightTable).
/// </para>
/// </summary>
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;
}
/// <summary>
/// Bilinear-interpolated terrain Z at (localX, localY) in
/// landblock-local coordinates (0..192 range).
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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
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) <noreply@anthropic.com>
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:
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class CellSurfaceTests
{
/// <summary>
/// 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.
/// </summary>
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<ushort, Vector3>
{
[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<List<short>>
{
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<ushort, Vector3>
{
[0] = new(0f, 0f, 0f),
[1] = new(20f, 0f, 0f),
[2] = new(10f, 20f, 20f),
};
var polygons = new List<List<short>> { 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:
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Indoor floor resolver for a single EnvCell. Projects an XY point
/// onto the cell's floor polygons and returns the Z at that point.
///
/// <para>
/// 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.
/// </para>
///
/// <para>
/// 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.
/// </para>
/// </summary>
public sealed class CellSurface
{
public uint CellId { get; }
private readonly List<(Vector3 A, Vector3 B, Vector3 C)> _triangles;
/// <summary>
/// Construct a CellSurface from pre-transformed vertex positions
/// and polygon definitions.
/// </summary>
/// <param name="cellId">The EnvCell dat id (e.g., 0xA9B40100).</param>
/// <param name="vertices">Vertex id → world-space position map.</param>
/// <param name="polygonVertexIds">
/// List of polygons, each a list of vertex IDs. Polygons with fewer
/// than 3 vertices are skipped. Quads and larger are fan-triangulated.
/// </param>
public CellSurface(
uint cellId,
Dictionary<ushort, Vector3> vertices,
List<List<short>> 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<Vector3>(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]));
}
}
}
/// <summary>
/// Project (worldX, worldY) onto this cell's floor polygons and
/// return the Z. Returns null if outside all floor polygons.
/// </summary>
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;
}
/// <summary>
/// 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 <paramref name="z"/>.
/// </summary>
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
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) <noreply@anthropic.com>
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:
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Result of <see cref="PhysicsEngine.Resolve"/>: the validated
/// position after collision, the cell the entity ended up in,
/// and whether they're standing on a surface.
/// </summary>
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:
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<CellSurface>(),
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<CellSurface>(),
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<CellSurface>(),
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<ushort, Vector3>
{
[0] = new(40f, 40f, 55f),
[1] = new(60f, 40f, 55f),
[2] = new(60f, 60f, 55f),
[3] = new(40f, 60f, 55f),
};
var cellPolys = new List<List<short>> { 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<ushort, Vector3>
{
[0] = new(40f, 40f, 55f),
[1] = new(60f, 40f, 55f),
[2] = new(60f, 60f, 55f),
[3] = new(40f, 60f, 55f),
};
var cellPolys = new List<List<short>> { 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<uint, (TerrainSurface Terrain, IReadOnlyList<CellSurface> Cells, float WorldOffsetX, float WorldOffsetY)> AddLandblocktakes the canonical landblock id + TerrainSurface + cells + world offsetRemoveLandblockremoves the entryResolveimplements 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
// 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<uint, LandblockPhysics> _landblocks = new();
private sealed record LandblockPhysics(
TerrainSurface Terrain,
IReadOnlyList<CellSurface> Cells,
float WorldOffsetX,
float WorldOffsetY);
public void AddLandblock(uint landblockId, TerrainSurface terrain,
IReadOnlyList<CellSurface> 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
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) <noreply@anthropic.com>
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
_physicsEnginefield to GameWindow
Near the other streaming fields (_streamer, _worldState, etc.), add:
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
- Step 2: Populate in
ApplyLoadedTerrainLocked
After the existing _worldState.SetLandblockAabb(...) call, add:
// 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<AcDream.Core.Physics.CellSurface>();
var lbInfoPhys = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>((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<DatReaderWriter.DBObjs.EnvCell>(envCellId);
if (envCell is null || envCell.EnvironmentId == 0) continue;
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(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<ushort, System.Numerics.Vector3>();
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<List<short>>();
foreach (var poly in cellStruct.PhysicsPolygons.Values)
{
if (poly.VertexIds.Count >= 3)
polyVertIds.Add(new List<short>(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<uint> 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
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) <noreply@anthropic.com>
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
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) <noreply@anthropic.com>"
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<ushort, Vector3>, List<List<short>>), PhysicsEngine.AddLandblock(uint, TerrainSurface, IReadOnlyList<CellSurface>, 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?