Nimbin[12]?Games / Entropie / commits / 5b9ce5d

Entropie git · main

Entropie festival game; first steps

sdl c game entropie cmake · first commit 2026-10-10 · last commit 2026-11-02 (17 hours ago) · synced 8 hours ago

C 88.5% Markdown 9.1% GLSL 2.3%
git clone https://git.christianimmanuel.de/games/Entropie.gitwget https://git.christianimmanuel.de/games/Entropie/archive/Entropie.tar.gz

entropie 1.96.0: people find the basement, legs under the table

Christian Immanuel · 2026-10-19 16:42 · 5b9ce5d94e2bb859a9bd8061a002f986ddd29704

- The walking map has two levels: people find their way down the stairs to
  the basement and back up
- Narrow doorways and the landing at the top of the basement stairs no
  longer stop people; the floor over the stairs doesn't count as a wall
  on the way down
- Wandering somewhere the long way round no longer gives up halfway
- Sitting at a table, people's legs go under it (they lay on the table top)
 entropie/Makefile               |   2 +-
 entropie/README.md              |   3 +-
 entropie/STATE.md               |  10 ++-
 entropie/commit_message.txt     |  21 ++---
 entropie/src/people/ai.c        |  11 ++-
 entropie/src/people/body_pose.c |   2 +
 entropie/src/world/nav.c        | 174 +++++++++++++++++++++++++---------------
 7 files changed, 138 insertions(+), 85 deletions(-)

diff --git a/entropie/Makefile b/entropie/Makefile
index 2633686..c46d9a3 100644
--- a/entropie/Makefile
+++ b/entropie/Makefile
@@ -1,5 +1,5 @@
 APP       := entropie
-VERSION   := 1.95.0
+VERSION   := 1.96.0
 
 PREFIX    ?= /usr
 BINDIR    ?= $(PREFIX)/bin
diff --git a/entropie/README.md b/entropie/README.md
index 5d18828..e30e9e0 100644
--- a/entropie/README.md
+++ b/entropie/README.md
@@ -104,7 +104,8 @@ and satellite pictures (about 340 x 340 m).
 - People come over to a burning fire, sit down round it and look into the flames
 - Now and then they sit down at a table (the meeting table, the kitchens) for
   a while and have a word; food they pick up they mostly eat there, a beer
-  sometimes too. They find their way in: up the steps, through the doors
+  sometimes too. They find their way in: up the steps, through the doors,
+  down the stairs to the basement
 - C: sit down yourself (and rest faster): on a bench or chair (right on
   the seat; a chair is pulled out first, shuffled in to the table once you sit,
   and pushed back in after you leave) if one is next
diff --git a/entropie/STATE.md b/entropie/STATE.md
index d29d011..9f96501 100644
--- a/entropie/STATE.md
+++ b/entropie/STATE.md
@@ -129,6 +129,11 @@ Round 1.95:
 - Sitting: no half-over-something falls (`world.c` halfOver clears `crossing` while sitting; feet under the table started a step over).
 - Felling cut (`tools.c`): looking lower than hip height, right where you look; between that and AIM_UP_FROM 0.3 m over the eyes, hip height; higher, the cut goes up as much (clamped to the tool's range: axe 0.45-1.35 m).
 
+Round 1.96:
+- Walking map in two levels (`nav.c`): level 0 where one stands (the ground or the top floor / step), level 1 under it where there's BELOW_ROOM 2 m or more (a basement: the pit floor, its stairs' lower steps). Nodes are level x N2 + cell; A* steps to whichever level of the next cell is within NAV_RISE (`stepTo`): the stairs join them. Start and goal take the level nearest their height (`levelFor`); waypoints carry the floor height. Diagonals only blocked between two blocked corners (a turned stair runs diagonally). NAV_MARGIN 0.25 -> 0.18 (an 0.8 m landing or doorway keeps a row of free cells).
+- Steering (`ai.c` ai_blockedBy): a building's walls kept WALL_RADIUS 0.24 m off (BODY_RADIUS 0.35 left no way through a 0.9 m doorway); a building part over the head (bottom 0.3 m+ over the feet) more than 0.6 m off doesn't block (a floor over a stair going down). Wandering counts progress along the way round (`stillToGo`), not straight to the goal.
+- Sitting at a table (`body_pose.c` keepBodyOut): the legs aren't lifted onto anything higher than the hips (they lay on the table top).
+
 Unfinished building (Shell_build, at SHELL_AT in the frame's frame): brick shell 6.4 x 4.7, walls 2.9, open front (2 m) to the frame (now only the front half: pillars at z -13.8 / -9.2), window holes, bare floor, tiled roof with slabs. Barn: brick courses on BUILDING_BRICK walls (`addBrickCourses`: mortar a shade lighter, so the ink doesn't trace it), ivy (`Shapes_ivy`, OBJ_STRUCTUREs) on both long sides; the woodpile (OBJ_WOODPILE "Woodpile", WOODPILE_A/B) left of the toilet, everybody's woodpile from the start (`scene.c` Scene_finish). Fireplace tripod: wooden poles, feet TRIPOD_OUT 0.9 m outside the stones, TRIPOD_PAST 0.4 m past the top, a lashing; each pole an OBJ_WALL along it ("Tripod pole"). Gates (FURN_GATE, green boards): in your garages' doorways (GATES: hinge, end), `gate_open` / `gate_t`, `hinge`, `gate_shut` / `gate_out`; Object_swingGate (1.2 s), driven by showProps; a click within 1.2 m opens / shuts both leaves.
 
 Props that show (1.89, `world.c` showProps every frame): the tin's mesh is built again when its butts change (`Shapes_ashtray`: up to 12 show, the ash rises, a heap from 14; `shown` = what's built); the coat rack (FURN_COATRACK, in your kitchen by the doors, garage coords 20.6 / 2.4) shows the jackets hanging on it (`Shapes_coatRack`, up to 6, a bitmask of whose in `shown`). `jacket_hung` (characters, true at the start): cold (chill 0.6+) and the jacket with them: on it goes (as before); hanging: NPCs `ai.want_jacket` -> `ai.c` fetchJacket (idle / wander: goTo the rack, on with it, "Ah, better."; gives up after 90 s or warm again); you are told once ("your jacket hangs on the rack in your garage") and click the rack (`game_actions.c` coatRack: on, or back onto it). Warm again: it comes off and stays with them.
@@ -260,9 +265,6 @@ Most important first.
 **Bugs**
 - Joint jumps left in the audit: jog+stop 171, sprint 297 (worst 26 cm) - running into things: a hurried catch step swings through in a few frames (step 0.07 -> 0.34, the ankle up 20 cm). Capping the swing speed helped sprint (278) but made jumps, drunk walk and the NPCs worse - tried and reverted in 1.83; ladder 45: toes on the sloping roof (a landing step's toes snap onto the slope, 16 cm; blending them in made all walking worse), toes turning onto the rungs getting on from the top (14 cm)
 
-**From the 15th of October (to do)**
-- NPC pathfinding: buildings, steps and squeezes fixed in 1.95; the basement (under a floor) isn't on the walking map yet
-
 **Body and moving**
 - Obstacle approach, still to do: NPCs stepping up onto higher things (now only what they'd stride over anyway); thin round logs (under 0.2 m) are still stepped over; a step in between on top of something unstable (if clumsy); NPCs: pull up on their own for their own reasons (now only following a leader), jumping onto things
 - Climbing rework: hands first (one reaches first, shoulder ahead), a knee up, the other foot next to it; or sit on it and lift the legs; or belly first and roll; by height, surface and person
@@ -284,4 +286,4 @@ Most important first.
 - Drawing many figures on a real graphics card: not measured (software GL: 300 people cost 3x the rest of the scene)
 
 ---
-State of `entropie 1.95.0 (build f8a9b47)`
+State of `entropie 1.96.0 (build d8ab965)`
diff --git a/entropie/commit_message.txt b/entropie/commit_message.txt
index 76f77ad..75f0ca3 100644
--- a/entropie/commit_message.txt
+++ b/entropie/commit_message.txt
@@ -1,14 +1,9 @@
-entropie 1.95.0: people sit at the tables, find their way into buildings
+entropie 1.96.0: people find the basement, legs under the table
 
-- People now and then sit down at a table (the meeting table, the kitchen
-  tables) for a while and have a word with whoever sits there; food they
-  pick up they mostly eat there, a beer sometimes too
-- They find their way into the buildings: the walking map knows floors and
-  steps (before, every floor counted as a wall), and they walk up steps
-  instead of round them
-- Nobody stands stuck under a small tree's crown or squeezed against a box
-  any more: they step out the way that's free
-- Sitting at a table no longer ends in a fall (the feet under the table
-  counted as stepping over something)
-- Felling: look down at the trunk to cut right there, look up the trunk
-  above your head to cut higher (up to the chest); straight ahead stays hip high
+- The walking map has two levels: people find their way down the stairs to
+  the basement and back up
+- Narrow doorways and the landing at the top of the basement stairs no
+  longer stop people; the floor over the stairs doesn't count as a wall
+  on the way down
+- Wandering somewhere the long way round no longer gives up halfway
+- Sitting at a table, people's legs go under it (they lay on the table top)
diff --git a/entropie/src/people/ai.c b/entropie/src/people/ai.c
index 481ede4..fc34ef8 100644
--- a/entropie/src/people/ai.c
+++ b/entropie/src/people/ai.c
@@ -110,6 +110,7 @@ static bool longToStepOnto(const Object *self, const Object *o) {
     return longest >= ONTO_LONG && top - self->position.y <= ONTO_HIGH;
 }
 
+#define WALL_RADIUS 0.24f /* m kept from a building's walls (a doorway 0.9 m wide leaves 0.4 m to aim through) */
 #define STAIR_FIRST 0.3f  /* m: a step up right here ... */
 #define STAIR_SLOPE 0.65f /* ... and this much more per m further on: stairs, not a wall */
 
@@ -151,14 +152,18 @@ bool ai_blockedBy(const Object *self, const Object *ignore, const Object *ignore
             float top = Object_colliderCenter(o, c).y + fabsf(box->axes[0].y) * box->half_extents.x +
                         fabsf(box->axes[1].y) * box->half_extents.y + fabsf(box->axes[2].y) * box->half_extents.z;
             if (top < self->position.y + 0.25f) continue;
+            /* Over our heads here and a way off (a floor above a stair going down): there we'll be lower */
+            float bottom = 2.0f * Object_colliderCenter(o, c).y - top;
+            if (o->type == OBJ_WALL && bottom > self->position.y + 0.3f && flatDistance(self->position, p) > 0.6f) continue;
             /* A building's floor or steps: walked up (a stair rises about STAIR_SLOPE) */
             if (o->type == OBJ_WALL && o->variant != 3 && box->axes[1].y > 0.99f && box->half_extents.x >= 0.1f && box->half_extents.z >= 0.1f &&
                 top <= self->position.y + STAIR_FIRST + STAIR_SLOPE * flatDistance(self->position, p))
                 continue;
-            /* Knee, waist and head height above the feet */
+            /* Knee, waist and head height above the feet (a building's walls: closer, or no doorway is wide enough) */
+            float clear = o->type == OBJ_WALL ? WALL_RADIUS : BODY_RADIUS;
             for (int k = 0; k < 3; k++) {
                 Vec3 q = vec3(p.x, self->position.y + 0.4f + 0.6f * (float)k, p.z);
-                if (insideCollider(o, c, q, BODY_RADIUS)) return true;
+                if (insideCollider(o, c, q, clear)) return true;
             }
         }
     }
@@ -707,7 +712,7 @@ static void updateIdle(Object *self, Object *const *objects, int count, float dt
 static void updateWander(Object *self, Object *const *objects, int count, float dt) {
     Ai *ai = &self->ai;
     float dist = flatDistance(ai->goal, self->position);
-    if (dist < ARRIVE_DISTANCE * 2.0f || isStuck(ai, dist, dt)) {
+    if (dist < ARRIVE_DISTANCE * 2.0f || isStuck(ai, stillToGo(self, ai->goal), dt)) { /* (along the way round, if any) */
         rest(ai, PAUSE_MIN, PAUSE_MAX);
         stand(self);
         return;
diff --git a/entropie/src/people/body_pose.c b/entropie/src/people/body_pose.c
index 0c7661e..5dd5725 100644
--- a/entropie/src/people/body_pose.c
+++ b/entropie/src/people/body_pose.c
@@ -1396,6 +1396,7 @@ static void keepBodyOut(Body *b, Vec3 position, Vec3 facing, float s, float dt,
     Vec3 *j = out->joints;
     float lift[JOINT_COUNT] = { 0.0f };
     bool any = false;
+    const float hips_y = Figure_toWorld(position, facing, j[JOINT_PELVIS]).y; /* (sitting: a table top up there goes over the legs) */
     for (unsigned n = 0; n < sizeof BONES / sizeof BONES[0]; n++) {
         int a = BONES[n][0], c = BONES[n][1];
         bool trunk = a <= JOINT_HEAD && c <= JOINT_HEAD;
@@ -1407,6 +1408,7 @@ static void keepBodyOut(Body *b, Vec3 position, Vec3 facing, float s, float dt,
             float from = w.y + 0.55f * s; /* (what a body could come to lie on: about knee high) */
             float top = b->ground(b->clutter_ctx, w.x, w.z, from);
             if (top >= from - 0.01f || top + r <= w.y) continue; /* (clear, or inside something tall: left alone) */
+            if (legs_only && top > hips_y - 0.05f * s) continue;   /* (under a table: the legs go under it, not up on it) */
             float need = top + r - w.y;
             /* a joint is lifted by what's under it, a bone's middle lifts both ends */
             if (t < 0.99f) lift[a] = fmaxf(lift[a], need * (1.0f - t) + (k ? need * t : 0.0f));
diff --git a/entropie/src/world/nav.c b/entropie/src/world/nav.c
index 85ba462..b1e89ac 100644
--- a/entropie/src/world/nav.c
+++ b/entropie/src/world/nav.c
@@ -1,9 +1,11 @@
 /*
- * Walking map: a grid over the site, a cell blocked where something fixed
- * stands between knee and head height over where one stands there (the
- * ground, or a building's floor or step). Ways are found with A* (8
+ * Walking map: a grid over the site in two levels - where one stands (the
+ * ground, or a building's floor or step) and, under a floor, the level below
+ * it (a basement). A cell of a level is blocked where something fixed stands
+ * between knee and head height over it. Ways are found with A* (8
  * directions, no cutting corners, no more up or down from cell to cell than a
- * step or two) and then straightened: only the turns are kept.
+ * step or two, from one level to the other where they meet: stairs) and then
+ * straightened: only the turns are kept.
  */
 #include "world/nav.h"
 
@@ -12,16 +14,19 @@
 #include <string.h>
 
 #define NAV_N         ((int)(TERRAIN_SIZE / NAV_CELL))
-#define NAV_MARGIN    0.25f  /* clearance from walls (sideways) */
+#define NAV_MARGIN    0.18f  /* clearance from walls (sideways): an 0.8 m landing or doorway still has a row of free cells */
 #define STEP_OVER     0.25f  /* lower than this above the ground: stepped over */
 #define HEADROOM      1.8f   /* higher than this: walked under */
 #define MOST_STEPS    90000  /* cells looked at for one way, at most */
 #define FREE_SEARCH   6      /* cells round a blocked start or end to find a free one */
 #define WALK_ON_MOST  1.2f   /* building floors and steps (OBJ_WALL) up to this over the natural ground are walked on ... */
 #define NAV_RISE      0.38f  /* ... from one cell to the next at most this much up or down (a stair: 1-2 steps) */
+#define BELOW_ROOM    2.0f   /* a level this far under the top one (or more): a second one there (a basement) */
+#define NO_FLOOR      -1e9f
+#define N2            ((size_t)NAV_N * NAV_N) /* cells in one level; node = level * N2 + cell */
 
-static unsigned char *g_blocked;
-static float         *g_floor; /* where one stands in each cell: the ground, or a floor / step of a building */
+static unsigned char *g_blocked; /* [2 * N2] */
+static float         *g_floor;   /* [2 * N2] where one stands: the ground, a floor or step of a building; NO_FLOOR: no lower level */
 static bool           g_ready;
 static double         g_signature = -1.0;
 
@@ -37,9 +42,9 @@ static int       g_heap_n, g_heap_cap;
 static int cellOf(float v) { return (int)floorf((v + TERRAIN_SIZE * 0.5f) / NAV_CELL); }
 static float centerOf(int i) { return ((float)i + 0.5f) * NAV_CELL - TERRAIN_SIZE * 0.5f; }
 static bool inside(int x, int z) { return x >= 0 && z >= 0 && x < NAV_N && z < NAV_N; }
-static bool open(int x, int z) { return inside(x, z) && !g_blocked[z * NAV_N + x]; }
-/* From one cell to the next: no more up or down than a step or two. */
-static bool canStep(int a, int b, bool diagonal) { return fabsf(g_floor[a] - g_floor[b]) <= (diagonal ? NAV_RISE * 1.42f : NAV_RISE); }
+static int  node(int level, int x, int z) { return (int)((size_t)level * N2) + z * NAV_N + x; }
+static bool openAt(int level, int x, int z) { return inside(x, z) && !g_blocked[node(level, x, z)]; }
+static bool open(int x, int z) { return openAt(0, x, z) || openAt(1, x, z); }
 
 /* Point in the collider, with clearance sideways only (not up and down). */
 static bool inCollider(const Object *o, int i, Vec3 p) {
@@ -93,7 +98,7 @@ static bool walkOn(const Object *o, int i, const Terrain *terrain, float *top) {
     return *top <= Terrain_naturalHeight(terrain, center.x, center.z) + WALK_ON_MOST;
 }
 
-static void raiseFloor(const Object *o, int i, const Terrain *terrain, CellRect clip) {
+static void raiseFloor(const Object *o, int i, const Terrain *terrain, CellRect clip, int level) {
     float top;
     if (!walkOn(o, i, terrain, &top)) return;
     const Collider *c = &o->colliders[i];
@@ -109,7 +114,8 @@ static void raiseFloor(const Object *o, int i, const Terrain *terrain, CellRect
         for (int x = x0; x <= x1; x++) {
             Vec3 rel = vec3_sub(vec3(centerOf(x), center.y, centerOf(z)), center);
             if (fabsf(vec3_dot(rel, c->axes[0])) > c->half_extents.x || fabsf(vec3_dot(rel, c->axes[2])) > c->half_extents.z) continue;
-            float *f = &g_floor[z * NAV_N + x];
+            float *f = &g_floor[node(level, x, z)];
+            if (level == 1 && top > g_floor[node(0, x, z)] - BELOW_ROOM) continue; /* (the level below: under the top one) */
             if (top > *f) *f = top;
         }
 }
@@ -130,18 +136,20 @@ static void blockCollider(const Object *o, int i, CellRect clip) {
     if (z0 < clip.z0) z0 = clip.z0;
     if (x1 > clip.x1) x1 = clip.x1;
     if (z1 > clip.z1) z1 = clip.z1;
-    for (int z = z0; z <= z1; z++)
-        for (int x = x0; x <= x1; x++) {
-            if (g_blocked[z * NAV_N + x]) continue;
-            float px = centerOf(x), pz = centerOf(z);
-            float ground = g_floor[z * NAV_N + x];
-            if (center.y + ey < ground + STEP_OVER || center.y - ey > ground + HEADROOM) continue;
-            for (int k = 0; k < 3; k++)
-                if (inCollider(o, i, vec3(px, ground + 0.4f + 0.6f * (float)k, pz))) {
-                    g_blocked[z * NAV_N + x] = 1;
-                    break;
-                }
-        }
+    for (int level = 0; level < 2; level++)
+        for (int z = z0; z <= z1; z++)
+            for (int x = x0; x <= x1; x++) {
+                int n = node(level, x, z);
+                if (g_blocked[n]) continue;
+                float px = centerOf(x), pz = centerOf(z);
+                float ground = g_floor[n];
+                if (center.y + ey < ground + STEP_OVER || center.y - ey > ground + HEADROOM) continue;
+                for (int k = 0; k < 3; k++)
+                    if (inCollider(o, i, vec3(px, ground + 0.4f + 0.6f * (float)k, pz))) {
+                        g_blocked[n] = 1;
+                        break;
+                    }
+            }
 }
 
 /* What each fixed thing was at the last build (by pointer, open addressing):
@@ -165,14 +173,29 @@ static NavEntry *slotFor(NavEntry *table, const Object *o, bool *found) {
 /* Clears the cells in r and blocks them again from every fixed thing over them. */
 static void redo(Object *const *objects, int count, const Terrain *terrain, CellRect r) {
     for (int z = r.z0; z <= r.z1; z++) {
-        memset(&g_blocked[z * NAV_N + r.x0], 0, (size_t)(r.x1 - r.x0 + 1));
-        for (int x = r.x0; x <= r.x1; x++) g_floor[z * NAV_N + x] = Terrain_height(terrain, centerOf(x), centerOf(z));
+        memset(&g_blocked[node(0, r.x0, z)], 0, (size_t)(r.x1 - r.x0 + 1));
+        memset(&g_blocked[node(1, r.x0, z)], 0, (size_t)(r.x1 - r.x0 + 1));
+        for (int x = r.x0; x <= r.x1; x++) g_floor[node(0, x, z)] = Terrain_height(terrain, centerOf(x), centerOf(z));
     }
     for (int i = 0; i < count; i++) { /* floors and steps first: what's in the way is from there up */
         const Object *o = objects[i];
         if (!o->is_static || o->type != OBJ_WALL || !overlaps(boundsOf(o), r)) continue;
-        for (int c = 0; c < o->collider_count; c++) raiseFloor(o, c, terrain, r);
+        for (int c = 0; c < o->collider_count; c++) raiseFloor(o, c, terrain, r, 0);
     }
+    /* The level below (a basement under a floor): the ground there, or a step, well under the top one */
+    for (int z = r.z0; z <= r.z1; z++)
+        for (int x = r.x0; x <= r.x1; x++) {
+            float ground = Terrain_height(terrain, centerOf(x), centerOf(z));
+            g_floor[node(1, x, z)] = ground <= g_floor[node(0, x, z)] - BELOW_ROOM ? ground : NO_FLOOR;
+        }
+    for (int i = 0; i < count; i++) {
+        const Object *o = objects[i];
+        if (!o->is_static || o->type != OBJ_WALL || !overlaps(boundsOf(o), r)) continue;
+        for (int c = 0; c < o->collider_count; c++) raiseFloor(o, c, terrain, r, 1);
+    }
+    for (int z = r.z0; z <= r.z1; z++)
+        for (int x = r.x0; x <= r.x1; x++)
+            if (g_floor[node(1, x, z)] == NO_FLOOR) g_blocked[node(1, x, z)] = 1;
     for (int i = 0; i < count; i++) {
         const Object *o = objects[i];
         if (!o->is_static || o->collider_count == 0 || !overlaps(boundsOf(o), r)) continue;
@@ -191,7 +214,7 @@ void Nav_update(Object *const *objects, int count, const Terrain *terrain) {
     }
     if (g_ready && signature == g_signature) return;
     if (!g_blocked) {
-        size_t n = (size_t)NAV_N * NAV_N;
+        size_t n = 2 * N2;
         g_blocked = calloc(n, 1);
         g_floor = calloc(n, sizeof *g_floor);
         g_cost = malloc(n * sizeof *g_cost);
@@ -251,9 +274,16 @@ bool Nav_free(Vec3 p) {
     return !g_ready || open(cellOf(p.x), cellOf(p.z));
 }
 
-/* Nearest open cell to (x, z) within a few cells; false if none. */
-static bool nearestOpen(int *x, int *z) {
-    if (open(*x, *z)) return true;
+/* The level at (x, z) nearest the height y (the lower one only if it's there and nearer). */
+static int levelFor(int x, int z, float y) {
+    if (!inside(x, z)) return 0;
+    float f0 = g_floor[node(0, x, z)], f1 = g_floor[node(1, x, z)];
+    return f1 != NO_FLOOR && fabsf(y - f1) < fabsf(y - f0) ? 1 : 0;
+}
+
+/* Nearest open cell of that level to (x, z) within a few cells; false if none. */
+static bool nearestOpen(int level, int *x, int *z) {
+    if (openAt(level, *x, *z)) return true;
     for (int r = 1; r <= FREE_SEARCH; r++) {
         int best = -1;
         float best_d = 1e9f;
@@ -261,7 +291,7 @@ static bool nearestOpen(int *x, int *z) {
             for (int dx = -r; dx <= r; dx++) {
                 if (abs(dx) != r && abs(dz) != r) continue;
                 float d = (float)(dx * dx + dz * dz);
-                if (open(*x + dx, *z + dz) && d < best_d) {
+                if (openAt(level, *x + dx, *z + dz) && d < best_d) {
                     best_d = d;
                     best = (*z + dz) * NAV_N + (*x + dx);
                 }
@@ -307,34 +337,52 @@ static float guess(int x, int z, int gx, int gz) {
     return (dx + dz) + (1.41421356f - 2.0f) * fminf(dx, dz);
 }
 
-/* Straight line between two cells without touching a blocked one. */
-static bool clearLine(int ax, int az, int bx, int bz) {
+/* The open node at (x, z) a step from height `from` (either level), or -1. */
+static int stepTo(int x, int z, float from, bool diagonal) {
+    for (int level = 0; level < 2; level++) {
+        if (!openAt(level, x, z)) continue;
+        int n = node(level, x, z);
+        if (fabsf(g_floor[n] - from) <= (diagonal ? NAV_RISE * 1.42f : NAV_RISE)) return n;
+    }
+    return -1;
+}
+
+/* Straight line between two nodes over open cells, a step at a time up or down. */
+static bool clearLine(int a, int b) {
+    int ac = (int)((size_t)a % N2), bc = (int)((size_t)b % N2);
+    int ax = ac % NAV_N, az = ac / NAV_N, bx = bc % NAV_N, bz = bc / NAV_N;
     float dx = (float)(bx - ax), dz = (float)(bz - az);
-    int steps = (int)(fmaxf(fabsf(dx), fabsf(dz)) * 3.0f) + 1, last = az * NAV_N + ax;
+    int steps = (int)(fmaxf(fabsf(dx), fabsf(dz)) * 3.0f) + 1, last = ac;
+    float floor_y = g_floor[a];
     for (int s = 1; s <= steps; s++) {
         float t = (float)s / (float)steps;
         float fx = (float)ax + 0.5f + dx * t, fz = (float)az + 0.5f + dz * t;
-        if (!open((int)floorf(fx), (int)floorf(fz))) return false;
-        int here = (int)floorf(fz) * NAV_N + (int)floorf(fx);
-        if (here != last && !canStep(last, here, true)) return false; /* (not off the edge of a floor) */
-        last = here;
+        int cx = (int)floorf(fx), cz = (int)floorf(fz), here = cz * NAV_N + cx;
+        if (here != last) {
+            int n = stepTo(cx, cz, floor_y, true); /* (not off the edge of a floor) */
+            if (n < 0) return false;
+            floor_y = g_floor[n];
+            last = here;
+        }
         /* (a little to each side too: no grazing corners) */
-        if (!open((int)floorf(fx + 0.3f), (int)floorf(fz + 0.3f)) || !open((int)floorf(fx - 0.3f), (int)floorf(fz - 0.3f)))
+        if (stepTo((int)floorf(fx + 0.3f), (int)floorf(fz + 0.3f), floor_y, true) < 0 ||
+            stepTo((int)floorf(fx - 0.3f), (int)floorf(fz - 0.3f), floor_y, true) < 0)
             return false;
     }
-    return true;
+    return fabsf(floor_y - g_floor[b]) <= NAV_RISE * 1.42f;
 }
 
 int Nav_findPath(Vec3 from, Vec3 to, Vec3 *out, int max) {
     if (!g_ready || max < 1) return 0;
     int sx = cellOf(from.x), sz = cellOf(from.z), gx = cellOf(to.x), gz = cellOf(to.z);
-    bool goal_open = open(gx, gz);
-    if (!nearestOpen(&sx, &sz) || !nearestOpen(&gx, &gz)) return 0;
-    int start = sz * NAV_N + sx, goal = gz * NAV_N + gx;
+    int s_level = levelFor(sx, sz, from.y), g_level = levelFor(gx, gz, to.y);
+    bool goal_open = openAt(g_level, gx, gz);
+    if (!nearestOpen(s_level, &sx, &sz) || !nearestOpen(g_level, &gx, &gz)) return 0;
+    int start = node(s_level, sx, sz), goal = node(g_level, gx, gz);
 
     if (++g_stamp == 0) { /* wrapped: clear the stamps once */
-        memset(g_seen, 0, (size_t)NAV_N * NAV_N * sizeof *g_seen);
-        memset(g_done, 0, (size_t)NAV_N * NAV_N * sizeof *g_done);
+        memset(g_seen, 0, 2 * N2 * sizeof *g_seen);
+        memset(g_done, 0, 2 * N2 * sizeof *g_done);
         g_stamp = 1;
     }
     g_heap_n = 0;
@@ -346,29 +394,29 @@ int Nav_findPath(Vec3 from, Vec3 to, Vec3 *out, int max) {
     bool found = false;
     for (int steps = 0; g_heap_n > 0 && steps < MOST_STEPS; steps++) {
         HeapItem it = heapPop();
-        int cell = it.cell;
-        if (g_done[cell] == g_stamp) continue;
-        g_done[cell] = g_stamp;
-        if (cell == goal) { found = true; break; }
-        int x = cell % NAV_N, z = cell / NAV_N;
+        int cur = it.cell;
+        if (g_done[cur] == g_stamp) continue;
+        g_done[cur] = g_stamp;
+        if (cur == goal) { found = true; break; }
+        int cell = (int)((size_t)cur % N2), x = cell % NAV_N, z = cell / NAV_N;
+        float here = g_floor[cur];
         for (int k = 0; k < 8; k++) {
             int nx = x + DX[k], nz = z + DZ[k];
-            if (!open(nx, nz)) continue;
-            if (k >= 4 && (!open(x + DX[k], z) || !open(x, z + DZ[k]))) continue; /* no cutting corners */
-            int n = nz * NAV_N + nx;
-            if (!canStep(cell, n, k >= 4)) continue; /* (up or down a floor's edge: no) */
+            int n = stepTo(nx, nz, here, k >= 4); /* (up or down a floor's edge: no) */
+            if (n < 0) continue;
+            if (k >= 4 && stepTo(x + DX[k], z, here, false) < 0 && stepTo(x, z + DZ[k], here, false) < 0) continue; /* no squeezing between two corners (past one: the margin's room enough - a turned stair runs diagonally) */
             if (g_done[n] == g_stamp) continue;
-            float cost = g_cost[cell] + (k < 4 ? 1.0f : 1.41421356f);
+            float cost = g_cost[cur] + (k < 4 ? 1.0f : 1.41421356f);
             if (g_seen[n] == g_stamp && cost >= g_cost[n]) continue;
             g_seen[n] = g_stamp;
             g_cost[n] = cost;
-            g_from[n] = cell;
+            g_from[n] = cur;
             heapPush(cost + guess(nx, nz, gx, gz), n);
         }
     }
     if (!found) return 0;
 
-    /* The cells back from the goal, then only the turns that matter */
+    /* The nodes back from the goal, then only the turns that matter */
     int length = 0;
     for (int c = goal; c >= 0; c = g_from[c]) length++;
     int *chain = malloc((size_t)length * sizeof *chain);
@@ -378,13 +426,13 @@ int Nav_findPath(Vec3 from, Vec3 to, Vec3 *out, int max) {
     int n = 0, at = 0;
     while (at < length - 1 && n < max) {
         int next = at + 1; /* as far along as can be seen straight */
-        while (next + 1 < length && clearLine(chain[at] % NAV_N, chain[at] / NAV_N, chain[next + 1] % NAV_N, chain[next + 1] / NAV_N))
-            next++;
+        while (next + 1 < length && clearLine(chain[at], chain[next + 1])) next++;
         at = next;
-        out[n++] = vec3(centerOf(chain[at] % NAV_N), 0.0f, centerOf(chain[at] / NAV_N));
+        int cell = (int)((size_t)chain[at] % N2);
+        out[n++] = vec3(centerOf(cell % NAV_N), g_floor[chain[at]], centerOf(cell / NAV_N));
     }
     free(chain);
-    if (n == 0) out[n++] = vec3(centerOf(gx), 0.0f, centerOf(gz));
-    if (goal_open && at == length - 1) out[n - 1] = vec3(to.x, 0.0f, to.z); /* right to it */
+    if (n == 0) out[n++] = vec3(centerOf(gx), g_floor[goal], centerOf(gz));
+    if (goal_open && at == length - 1) out[n - 1] = vec3(to.x, g_floor[goal], to.z); /* right to it */
     return n;
 }