diff --git a/docs/superpowers/specs/2026-04-12-physics-collision-engine-design.md b/docs/superpowers/specs/2026-04-12-physics-collision-engine-design.md
new file mode 100644
index 0000000..5c298fe
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-12-physics-collision-engine-design.md
@@ -0,0 +1,259 @@
+# Phase B.3 — Physics Collision Engine Design
+
+**Status:** Spec, 2026-04-12, brainstormed with user.
+**Scope:** A pure-computation collision engine that resolves entity positions against terrain heightmaps, EnvCell floor polygons, and cell transitions. No rendering, no networking — those live in Phase B.2 (player movement mode, separate spec). This module is the foundation for all future ground-based movement in acdream.
+**Parent:** `docs/plans/2026-04-11-roadmap.md` — Phase B (Gameplay), sub-piece B.3.
+
+## Goals
+
+1. Given a position + movement delta, resolve where the entity ends up on the ground.
+2. Handle outdoor terrain (hills, slopes via heightmap bilinear interpolation).
+3. Handle indoor floors (buildings, cellars via EnvCell CellStruct floor polygons).
+4. Handle outdoor↔indoor transitions via CellPortal boundary detection.
+5. Enforce step-height limits (stairs OK, walls blocked).
+6. Apply gravity (entity falls when not on a surface).
+7. All logic is unit-testable without dat files, rendering, or networking.
+
+## Non-goals
+
+- Full rigid-body physics (no bouncing, wall-sliding, momentum).
+- Projectile collision (server-side in AC).
+- NPC pathing (server-driven).
+- Water depth / swimming (visual only).
+- BSP tree traversal for polygon lookup. Floor polygon counts per cell are small (< 20); brute-force polygon iteration is sufficient for the MVP. If profiling shows this is a bottleneck later, BSP acceleration can be added behind the same API.
+
+## Architecture
+
+Three components in `src/AcDream.Core/Physics/`:
+
+### 1. `TerrainSurface`
+
+Outdoor ground resolver for a single landblock.
+
+**Construction:** takes a `LandBlock.Height[]` (81 bytes) + `float[] heightTable` (256 entries, region-global).
+
+**API:**
+```csharp
+public sealed class TerrainSurface
+{
+ public TerrainSurface(byte[] heights, float[] heightTable);
+
+ ///
+ /// Bilinear-interpolated terrain Z at (localX, localY) in
+ /// landblock-local coordinates (0..192 range). Clamps to
+ /// the landblock bounds if the position is outside.
+ ///
+ public float SampleZ(float localX, float localY);
+
+ ///
+ /// Compute the outdoor cell ID for the given local position.
+ /// Outdoor cells are a flat 8×8 grid of 24×24 unit cells
+ /// numbered 0x0001..0x0040. The cell at (0,0) is 0x0001.
+ ///
+ public uint ComputeOutdoorCellId(float localX, float localY);
+}
+```
+
+The `SampleZ` algorithm is the same bilinear interpolation currently in `GameWindow.SampleTerrainZ` — extract it into this reusable class. Heights are indexed x-major: `heights[x * 9 + y]` where x and y are vertex indices (0..8), each vertex covers 24 world units.
+
+### 2. `CellSurface`
+
+Indoor floor resolver for a single EnvCell.
+
+**Construction:** takes an `EnvCell` (for position, orientation, portal list) + `CellStruct` (for floor polygons + vertex array).
+
+**API:**
+```csharp
+public sealed class CellSurface
+{
+ /// The EnvCell's dat id (e.g., 0xA9B40100).
+ public uint CellId { get; }
+
+ /// The cell's world-space origin (from EnvCell.Position.Origin).
+ public Vector3 Origin { get; }
+
+ /// Portal connections to adjacent cells.
+ public IReadOnlyList Portals { get; }
+
+ public CellSurface(uint cellId, EnvCell envCell, CellStruct cellStruct);
+
+ ///
+ /// Project (worldX, worldY) onto this cell's floor polygons and
+ /// return the Z height at that point. Returns null if the XY
+ /// doesn't fall within any floor polygon (the entity isn't over
+ /// this cell's floor).
+ ///
+ ///
+ /// Uses PhysicsPolygons (not rendering Polygons) — these define
+ /// the walkable surfaces. For each polygon, does a point-in-
+ /// triangle test (after fan triangulation) + barycentric Z
+ /// interpolation. Brute-force iteration over all polygons;
+ /// cell polygon counts are small (typically < 20) so BSP
+ /// acceleration is deferred.
+ ///
+ ///
+ public float? SampleFloorZ(float worldX, float worldY);
+}
+
+///
+/// Minimal portal-connection info extracted from EnvCell.CellPortals.
+///
+public readonly record struct CellPortalInfo(
+ uint TargetCellId,
+ Vector3 PortalOrigin,
+ Vector3 PortalNormal);
+```
+
+Floor polygon coordinate space: `CellStruct.VertexArray` positions are in cell-local space. `EnvCell.Position` (origin + orientation) transforms them to landblock-local space. The `SampleFloorZ` method transforms the query point into cell-local space, tests against the `PhysicsPolygons`, then returns the result in world space.
+
+### 3. `PhysicsEngine`
+
+Top-level resolver that owns the per-landblock terrain surfaces and per-cell indoor surfaces.
+
+**API:**
+```csharp
+public sealed class PhysicsEngine
+{
+ ///
+ /// Register terrain + EnvCell surfaces for a loaded landblock.
+ /// Called from the render thread in ApplyLoadedTerrain.
+ ///
+ public void AddLandblock(
+ uint landblockId,
+ TerrainSurface terrain,
+ IReadOnlyList cells);
+
+ /// Remove all surfaces for an unloaded landblock.
+ public void RemoveLandblock(uint landblockId);
+
+ ///
+ /// Resolve a movement step. Given the entity's current world
+ /// position + cell id + a movement delta + a step-up height limit,
+ /// return the valid resolved position and the cell id the entity
+ /// ends up in.
+ ///
+ public ResolveResult Resolve(
+ Vector3 currentPos,
+ uint currentCellId,
+ Vector3 delta,
+ float stepUpHeight);
+}
+
+public readonly record struct ResolveResult(
+ Vector3 Position,
+ uint CellId,
+ bool IsOnGround);
+```
+
+**Resolution algorithm:**
+
+```
+1. Compute candidatePos = currentPos + delta (horizontal only; Z handled below)
+
+2. Determine which landblock the candidate is in:
+ landblockX = floor((candidatePos.X + centerOffsetX) / 192)
+ landblockY = floor((candidatePos.Y + centerOffsetY) / 192)
+ (The offset accounts for the world origin being the center landblock.)
+
+3. If currently in an OUTDOOR cell (cellId < 0x0100):
+ a. Sample terrain Z at candidatePos via TerrainSurface.SampleZ
+ b. Compute candidateZ = terrainZ
+ c. Step-height check: if |candidateZ - currentPos.Z| > stepUpHeight
+ AND candidateZ > currentPos.Z (going UP), reject horizontal
+ movement → keep currentPos.XY, keep currentPos.Z
+ d. Check if candidatePos crosses into any EnvCell:
+ for each CellSurface in this landblock:
+ if CellSurface.SampleFloorZ(candidateX, candidateY) is not null:
+ → transition to indoor: use cell's floor Z, set cellId to cell's id
+ e. If no transition: set Z = candidateZ, compute outdoor cellId
+
+4. If currently in an INDOOR cell (cellId >= 0x0100):
+ a. Sample floor Z from the current CellSurface
+ b. If SampleFloorZ returns null (walked off the edge of this cell's floor):
+ - Check CellPortals for a transition to an adjacent cell
+ - If a portal connects to another EnvCell: transition to that cell
+ - If a portal connects to outdoor: transition to outdoor (sample terrain Z)
+ - If no portal matches: reject movement (can't walk through walls)
+ c. If SampleFloorZ returns a value:
+ - Step-height check same as outdoor
+ - Set Z = floorZ
+
+5. Gravity: if !isOnGround (no surface found under the entity):
+ candidatePos.Z -= gravity * dt
+ (Gravity is applied by the caller in Phase B.2; the engine just
+ reports isOnGround = false when no surface is found.)
+
+6. Return ResolveResult(resolvedPos, resolvedCellId, isOnGround)
+```
+
+**Note on gravity:** the engine itself is stateless per call — it doesn't track falling velocity. It reports `isOnGround = false` when no surface is under the entity, and the caller (Phase B.2's movement mode) applies a gravity velocity accumulator between frames. This keeps the engine pure-functional and testable.
+
+**Note on world coordinates:** the engine works in acdream's world space (center-landblock-relative). `TerrainSurface` works in landblock-local space (0..192); the engine translates between the two using the landblock's world offset (same math as `ApplyLoadedTerrain`).
+
+## Integration with existing code
+
+### Population (streaming → physics)
+
+In `GameWindow.ApplyLoadedTerrainLocked`, after terrain mesh upload:
+
+```csharp
+// Create physics surfaces for this landblock.
+var terrainSurf = new TerrainSurface(lb.Heightmap.Height, _heightTable);
+
+var cellSurfaces = new List();
+// Walk the same EnvCells we walk for rendering (Phase 7.1 / Task 8).
+// For each cell with an EnvironmentId: create CellSurface from
+// envCell + cellStruct.
+// ... (reuse the interior-walker pattern) ...
+
+_physicsEngine.AddLandblock(lb.LandblockId, terrainSurf, cellSurfaces);
+```
+
+In the unload path (when `RemoveLandblock` fires), also call `_physicsEngine.RemoveLandblock(lbId)`.
+
+### Consumption (Phase B.2 — future, not this spec)
+
+The player movement mode will call `_physicsEngine.Resolve(...)` once per frame with the player's WASD-derived delta, then update the player entity's position and send the result to the server.
+
+## Testing strategy
+
+All tests in `tests/AcDream.Core.Tests/Physics/`. No dat files needed — tests construct minimal fake data.
+
+### `TerrainSurfaceTests`
+- Construct a `TerrainSurface` with a known 81-byte height array + a known 256-float height table.
+- **Flat terrain:** all heights = same value → `SampleZ` returns that value everywhere.
+- **Slope:** heights increase linearly across X → `SampleZ` at midpoints returns interpolated values.
+- **Corner clamping:** positions outside 0..192 are clamped to the boundary.
+- **Cell ID computation:** `(0, 0)` → 0x0001, `(23, 0)` → 0x0001, `(24, 0)` → 0x0009 (second column), `(191, 191)` → 0x0040 (last cell).
+
+### `CellSurfaceTests`
+- Construct a minimal `CellStruct` with one floor polygon (a flat square at Z=10, vertices at ±5 in X/Y).
+- **Inside polygon:** `SampleFloorZ(0, 0)` → 10.0.
+- **Outside polygon:** `SampleFloorZ(100, 100)` → null.
+- **Sloped floor:** polygon with varying Z → barycentric interpolation returns correct Z at midpoints.
+
+### `PhysicsEngineTests`
+- **Walk on flat terrain:** delta=(1,0,0), terrain flat at Z=50 → resolved position has Z=50.
+- **Walk up a hill:** terrain slopes from Z=50 to Z=60 → resolved Z follows the slope.
+- **Step up stairs (within height):** small Z increase (< stepUpHeight) → accepted.
+- **Blocked by wall (exceeds height):** large Z increase (> stepUpHeight) → horizontal movement rejected.
+- **Outdoor→indoor transition:** walk into an EnvCell's floor area → cellId changes to indoor.
+- **Indoor→outdoor transition:** walk out of an EnvCell → cellId changes to outdoor.
+- **Not on ground:** position over empty space (between floors) → isOnGround = false.
+
+## Open questions (resolved during implementation)
+
+- **Portal boundary detection:** the exact algorithm for "did the entity cross a portal" depends on the `CellPortal` dat structure. Implementation will check `EnvCell.CellPortals` for portal geometry and test the movement vector against it. If the portal geometry is just a reference (not spatial), the fallback is "if SampleFloorZ returns null on the current cell but non-null on an adjacent cell, transition."
+- **Multi-story buildings:** if two EnvCells overlap in XY (one above the other), the engine picks the one whose floor Z is closest to the entity's current Z. This handles the Holtburg foundry's upper floor vs ground floor.
+- **Landblock boundary crossing:** when the entity walks from one landblock to another, the engine switches to the neighboring landblock's TerrainSurface. This requires the neighbor to be loaded (it should be — the streaming window keeps a radius around the observer).
+
+## Acceptance criteria
+
+Phase B.3 is done when:
+
+1. `TerrainSurface.SampleZ` matches `SampleTerrainZ`'s output for the same inputs.
+2. `CellSurface.SampleFloorZ` correctly projects XY onto floor polygons and returns the interpolated Z.
+3. `PhysicsEngine.Resolve` handles all test cases listed above (flat, slope, step, transition, gravity).
+4. All tests pass with fake data — no dat files or rendering needed.
+5. Integration hook exists in `GameWindow.ApplyLoadedTerrainLocked` to populate the engine from streamed landblocks.
+6. Total test count increases by ~15-20.