# 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);

    /// <summary>
    /// Bilinear-interpolated terrain Z at (localX, localY) in
    /// landblock-local coordinates (0..192 range). Clamps to
    /// the landblock bounds if the position is outside.
    /// </summary>
    public float SampleZ(float localX, float localY);

    /// <summary>
    /// 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.
    /// </summary>
    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
{
    /// <summary>The EnvCell's dat id (e.g., 0xA9B40100).</summary>
    public uint CellId { get; }

    /// <summary>The cell's world-space origin (from EnvCell.Position.Origin).</summary>
    public Vector3 Origin { get; }

    /// <summary>Portal connections to adjacent cells.</summary>
    public IReadOnlyList<CellPortalInfo> Portals { get; }

    public CellSurface(uint cellId, EnvCell envCell, CellStruct cellStruct);

    /// <summary>
    /// 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).
    ///
    /// <para>
    /// 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.
    /// </para>
    /// </summary>
    public float? SampleFloorZ(float worldX, float worldY);
}

/// <summary>
/// Minimal portal-connection info extracted from EnvCell.CellPortals.
/// </summary>
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
{
    /// <summary>
    /// Register terrain + EnvCell surfaces for a loaded landblock.
    /// Called from the render thread in ApplyLoadedTerrain.
    /// </summary>
    public void AddLandblock(
        uint landblockId,
        TerrainSurface terrain,
        IReadOnlyList<CellSurface> cells);

    /// <summary>Remove all surfaces for an unloaded landblock.</summary>
    public void RemoveLandblock(uint landblockId);

    /// <summary>
    /// 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.
    /// </summary>
    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<CellSurface>();
// 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.
