back to home

Adding a Z-Axis to a 2D Isometric World.

March 16, 2026

Isometric games look like they have depth, but most of that depth is a very convincing lie. Everything still lives on a flat plane. Walking behind a building changes your Y coordinate. There is no real “above” or “below.” The .5 in 2.5D is doing a lot of work.

So I wanted to add actual height: raised terrain, cliffs, jumping, and gravity. My first thought was that this meant adding a Z coordinate to an entity. That part took almost no time. Then Z wandered into collision, rendering, pathfinding, networking, input, persistence, and the map editor.

This is what I ended up with, including the choices that kept the system manageable and the parts that were less obvious than I expected. Clicking on a tile, for example, gets surprisingly weird once several different heights can occupy the same screen position.

A player character standing on a raised grass platform made from several stacked elevation levels in an isometric game
The first terrain test where Z was doing something visible: stacked tiles, cliff faces, and a character standing above the ground plane.

Where I put Z

Players, NPCs, and anything else that moves need a height coordinate. The first decision was what type that coordinate should be. An integer made collision easy, but looked terrible if I rendered it directly. A float looked smooth, but I did not want fractional values leaking into the simulation.

The useful answer was both. The simulation keeps Z as an integer, so physics and collision stay as grid math. The client keeps floating-point values and interpolates between updates. Once I stopped trying to make one representation do both jobs, the rest became much easier to reason about.

Z alone is not enough. Each entity also needs a grounded flag, a jump timer, and a fall timer. Together, those values describe whether the entity is standing, rising, or falling.

// Authoritative simulation state
struct Position {
    x: i32,
    y: i32,
    z: i16,
}

struct VerticalState {
    grounded: bool,
    jump_ticks: u8,
    fall_ticks: u8,
}

// Client-only rendering state
struct RenderHeight {
    server_z: f32,
    target_z: f32,
    render_z: f32,
}

Only the integer Z is authoritative and crosses the network with X and Y. RenderHeight belongs to the client and can be as smooth and imprecise as it needs to be without affecting collision.

Storing terrain height

My tile map is split into chunks, so height fits naturally into a per-chunk heightmap. One byte per tile is enough for 16 elevation levels, from 0 to 15, which is plenty here.

That only describes the top of the terrain, though. As soon as one tile is taller than its neighbor, the exposed side needs a sprite too. I store those southwest and southeast cliff faces in two additional arrays.

ArrayTypePer ChunkPurpose
heightmapbyte[]1 per tilePer-tile elevation level (0–15)
faceSpritesSWuint16[]1 per tileSprite ID for the +Y (south-west) cliff face
faceSpritesSEuint16[]1 per tileSprite ID for the +X (south-east) cliff face

The jump that was good enough

I did not need continuous physics to make jumping work in a grid-based world. A symmetric, tick-based arc was simpler and matched the rest of the simulation.

At 20 Hz, I use a six-tick jump: three ticks up and three ticks down, with Z changing by one each tick. The whole jump lasts 300ms and peaks three blocks above the starting point. It is quick enough to feel responsive and tall enough to clear something useful.

Jump Arc — Z Over Time (6 Ticks / 300ms)
Z+0Z+1Z+2Z+3T0T1T2T3T4T5T6PEAK▲ RISING (+1/tick)▼ FALLING (-1/tick)

The implementation is just a countdown. Jump input sets the timer to its maximum. Each simulation tick decrements it. The first half adds one to Z, and the second half subtracts one.

const JUMP_TICKS: u8 = 6;

fn start_jump(body: &mut VerticalState) {
    if body.grounded {
        body.grounded = false;
        body.jump_ticks = JUMP_TICKS;
    }
}

fn tick_jump(position: &mut Position, body: &mut VerticalState) {
    if body.jump_ticks == 0 {
        return;
    }

    let rising = body.jump_ticks > JUMP_TICKS / 2;
    position.z += if rising { 1 } else { -1 };
    body.jump_ticks -= 1;
}

Landing is a separate check. Once the entity reaches the terrain height or goes below it, I snap Z to the ground and set grounded.

One choice mattered more than I expected: XY movement stays separate from the jump. A player can still walk, sprint, or change direction in the air. Z sits on top of the existing 2D movement instead of replacing it. It is simpler, and locking horizontal movement during a jump made a tile-based game feel unnecessarily sluggish.

Then I walked off an edge

The jump arc handled one way of leaving the ground. It did nothing for walking off a cliff.

That is a separate state: the entity is airborne, but it is not jumping. A constant fall speed looked like an elevator going down, so I used three gravity tiers instead. The fall starts slowly, speeds up, and reaches terminal velocity after a few ticks.

The three speeds are easier to see as the tiny lookup table they actually are. The phase boundaries are tuning values, so I can change how long each stage lasts without touching the fall logic.

const FALL_EVERY: [u8; 3] = [3, 2, 1];

fn tick_fall(position: &mut Position, body: &mut VerticalState) {
    body.fall_ticks = body.fall_ticks.saturating_add(1);

    // 0 = slow, 1 = medium, 2 = terminal velocity
    let phase = gravity_phase(body.fall_ticks);
    let interval = FALL_EVERY[phase];

    if body.fall_ticks % interval == 0 {
        position.z -= 1;
    }
}

fn gravity_phase(fall_ticks: u8) -> usize {
    match fall_ticks {
        0..=3 => 0,
        4..=7 => 1,
        _ => 2,
    }
}

That works out to roughly 7 blocks per second, then 10, then 20 at a 20 Hz simulation rate. Stepping off a one-block ledge is barely noticeable, while a long fall quickly reaches terminal velocity. The whole thing costs one counter and an array lookup.


Walking into cliffs

Once tiles had different heights, the old horizontal movement rules were incomplete. A grounded entity can step onto an adjacent tile when the height difference is at most one block. I treat that as a curb and step up automatically. Anything taller is a wall.

Airborne entities get a different rule. They can move over gaps freely, then fall when there is no ground beneath them.

The actual rule fits in one function, which is a better explanation than another picture of two blocks:

const MAX_STEP: i16 = 1;

fn can_step(from_z: i16, to_z: i16, airborne: bool) -> bool {
    airborne || (to_z - from_z).abs() <= MAX_STEP
}

The one-block auto-step made a big difference. Pressing jump for every curb would get old immediately. With the threshold, small slopes are easy to cross and cliffs still behave like obstacles. Most of the time, the player does not need to think about the rule at all.

Pathfinding calls the same function for every neighboring tile. When an NPC moves, its simulation Z snaps straight to the destination tile’s height. There is no reason to interpolate on the server. Instanced maps, such as dungeons and interiors, also need to look up height from their own heightmaps rather than the main world map.


Keeping the server in charge

Because this is multiplayer, Z has to be server-authoritative. The client only asks to jump. The server checks whether the entity is grounded, alive, and allowed to jump, then runs the physics and broadcasts the result through the existing state sync. The client never gets to declare its own height, so bypassing the rules requires a server-side exploit rather than a client hack.

Fortunately, Z does almost nothing to the network budget. It is one integer beside the existing position fields. At typical tick rates with dozens of entities, that overhead is negligible. The jump request does not need a payload either. The server already has everything it needs.


Making flat sprites look tall

The simulation could now understand height, but the screen was still flat. For a standard 64 by 32 pixel isometric tile, half the tile height is a natural Z offset. That gives me 16 pixels per elevation level. Every sprite moves upward by z × half_tile_h × zoom pixels.

That calculation was the easy part. Deciding what gets drawn in front of what was not.

Depth sorting

The renderer uses the painter’s algorithm and draws from back to front. Once height exists, the depth formula becomes layer × 10000 + x + y + z.

Z has the same weight as X or Y here. An entity on a height-five plateau sorts the same as an entity five tiles farther southeast at ground level. Small offsets handle ties without turning the sort order into a pile of special cases.

enum DrawKind {
    CliffFace,
    Tile,
    Wall,
    Entity,
}

fn depth(layer: i32, position: Position, kind: DrawKind) -> f32 {
    let base = (layer * 10_000 + position.x + position.y
        + i32::from(position.z)) as f32;

    base + match kind {
        DrawKind::CliffFace => -0.2,
        DrawKind::Tile      =>  0.0,
        DrawKind::Wall      =>  0.1,
        DrawKind::Entity    =>  0.2,
    }
}

Cliff walls and cheap lighting

Raising a tile exposes two edges in this isometric view: the +X face on the southeast and the +Y face on the southwest. The renderer has to fill both or the raised tile looks like it is floating. I can tile each face with a repeating wall sprite, then fall back to a solid polygon when there is no sprite.

The faces looked too similar at full brightness, so I faked directional light. The southeast face renders at about 82% brightness and the southwest face at about 65%, as if the light comes from the upper right. Tile surfaces also get a small brightness bonus for each Z level and an ambient occlusion penalty when a taller neighbor blocks them. There is no real light source in the engine, but the difference is enough to make the terrain read as depth.

Directional Lighting — Face Brightness
65%+Y (SW)82%+X (SE)100% base+ bonus per Z level

Smoothing Z on the client

Rendering the server’s integer Z directly produced visible steps. The client instead keeps three floating-point values for every entity: the last authoritative Z, a target Z, and the current rendered Z that chases the target.

Rising uses a constant interpolation speed because the jump arc looks clean at a fixed rate. Falling uses a speed based on distance, so short drops stay gentle and longer falls accelerate. It is the visual version of the server’s gravity curve, without putting floats into the authoritative simulation.

fn update_render_height(height: &mut RenderHeight, dt: f32) {
    let distance = (height.target_z - height.render_z).abs();
    let speed = if height.target_z > height.render_z {
        RISE_SPEED
    } else {
        FALL_SPEED * distance.max(1.0)
    };

    height.render_z = move_towards(
        height.render_z,
        height.target_z,
        speed * dt,
    );
}

The click that could mean three tiles

Tile picking was the part I underestimated. On a flat isometric map, a screen coordinate reverse-projects to one tile. With elevation, that same point can refer to several tiles at different heights. A surface at Z=3 can occupy the same screen pixel as a Z=0 tile three rows farther south.

I solve that ambiguity with a top-down probe. It starts at the maximum Z level and works downward, reverse-projecting the cursor at each height. The first valid match wins, so the highest visible surface gets picked.

One Cursor — Two World Tiles
(x, y, Z=3)(x, y+3, Z=0)cursorBoth tiles reverse-project to the same screen diamond

The probe itself is a short loop. For each possible height, I undo that height’s screen offset, convert the cursor back to world coordinates, and check whether the map agrees.

const MAX_Z: i16 = 15;
const HALF_TILE_HEIGHT: f32 = 16.0;

fn pick_tile(cursor: Vec2, map: &Map) -> Option<TilePos> {
    for z in (0..=MAX_Z).rev() {
        let projected = Vec2 {
            x: cursor.x,
            y: cursor.y + f32::from(z) * HALF_TILE_HEIGHT,
        };
        let (x, y) = screen_to_world(projected);

        if map.height(x, y) == z
            && cursor_is_on_diamond(cursor, x, y, z)
        {
            return Some(TilePos { x, y, z });
        }
    }

    ground_tile_at(cursor)
}

There is one extra check that is easy to miss. The cursor must land on the tile’s diamond surface, not the cliff face below it. Without that test, a click near the bottom of a tall block selects its top surface even though the cursor is visibly on the wall. If the probe finds nothing, I fall back to the ground plane at Z=0.


Making height editable

At this point height worked, but building a map with it meant editing raw data. That was never going to last. The editor needed a height brush so elevation could be painted directly onto tiles like any other map data.

The brush itself is straightforward. It reads the current chunk’s heightmap, changes the value at the clicked tile index, and writes it back. The useful bit happens when the chunk is serialized. After every edit, I check whether the heightmap is all zeros and remove it if so. Most chunks are flat, and flat chunks should not carry height data.

// Pseudocode: height painting with sparse storage
let heights = chunk.heightmap ?? new ByteArray(TILES_PER_CHUNK);
heights[tileIndex] = newHeight;

// Drop all-zero arrays to save space
if (heights.every(h => h === 0)) {
  chunk.heightmap = null;
}

I use the same zero-drop check for both cliff face sprite arrays. The three arrays add a few KB to a chunk that uses height. A flat chunk adds nothing.


What I would keep

The integer and float split did most of the architectural heavy lifting. The simulation never has to understand fractional height, while the renderer can still move smoothly between server updates.

Keeping Z separate from XY movement also contained the damage. The jump system sits on top of movement. Pathfinding only adds a height check. Networking gets one field instead of a new family of messages. If adding Z requires rewriting all of XY movement, the axes are probably too tightly coupled.

I would also keep the one-block auto-step. Small elevation changes should be boring to cross. Cliffs are where the player should have to make a decision.

None of this required a 3D rendering pipeline. The cliff faces are repeating 2D sprites stacked vertically, and two brightness values create enough directional light to sell the shape. No meshes needed.

Finally, the editor is part of the feature. If painting height is awkward, the maps will stay flat no matter how good the runtime code is.

© 2026 andrew rubenstein