diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
new file mode 100644
index 0000000..3ddf887
--- /dev/null
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -0,0 +1,148 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Top-level physics resolver that combines and
+/// to resolve entity movement with step-height
+/// enforcement and outdoor/indoor cell transitions.
+///
+///
+/// Landblocks are registered via with their
+/// terrain, indoor cells, and world-space offsets.
+/// takes a current position, the entity's current cell ID, a movement delta,
+/// and a step-up height limit; it returns the validated new position, the
+/// updated cell ID, and whether the entity is standing on a surface.
+///
+///
+public sealed class PhysicsEngine
+{
+ private readonly Dictionary _landblocks = new();
+
+ private sealed record LandblockPhysics(
+ TerrainSurface Terrain,
+ IReadOnlyList Cells,
+ float WorldOffsetX,
+ float WorldOffsetY);
+
+ ///
+ /// Register a landblock with its terrain surface, indoor cells, and
+ /// world-space origin offset.
+ ///
+ public void AddLandblock(uint landblockId, TerrainSurface terrain,
+ IReadOnlyList cells, float worldOffsetX, float worldOffsetY)
+ {
+ _landblocks[landblockId] = new LandblockPhysics(terrain, cells, worldOffsetX, worldOffsetY);
+ }
+
+ ///
+ /// Remove a previously registered landblock.
+ ///
+ public void RemoveLandblock(uint landblockId) => _landblocks.Remove(landblockId);
+
+ ///
+ /// Resolve an entity's movement from by
+ /// applying (XY only) and computing the correct Z
+ /// from the terrain or indoor cell floor beneath the candidate position.
+ ///
+ ///
+ /// Step-height enforcement rejects horizontal movement when the upward Z
+ /// change exceeds . Downhill movement is
+ /// always accepted. Returns false
+ /// when no loaded landblock covers the candidate position.
+ ///
+ ///
+ 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;
+ 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;
+ 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);
+ }
+
+ return new ResolveResult(
+ new Vector3(candidatePos.X, candidatePos.Y, targetZ),
+ targetCellId,
+ IsOnGround: true);
+ }
+}
diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs
new file mode 100644
index 0000000..cc7fef8
--- /dev/null
+++ b/src/AcDream.Core/Physics/ResolveResult.cs
@@ -0,0 +1,13 @@
+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);
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
new file mode 100644
index 0000000..10b92b8
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
@@ -0,0 +1,166 @@
+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);
+ }
+}