The runtime math of an isometric world: converting between the logical tile grid
(cartesian, integer x/y) and the screen (pixels), picking the tile under the
cursor, and drawing everything back-to-front so near objects occlude far ones. This is
the code that runs every frame — get it right once and the whole map behaves; get the
anchor or the sort key wrong and sprites clip through each other for the rest of the
project.
Scope. This file owns the coordinate transforms and depth-sorting doctrine for tile-based isometric worlds (the 2:1 dimetric family — commonly called isometric in games — and true-iso grids). The projection geometry (why 2:1, the exact angles, the plane matrices) lives in
projection-math.md; the asset spec that pins tile W×H, anchor, and elevation-per-step lives intile-spec.md; engine-native tilemap nodes (GodotTileMapLayerY-Sort, UnitysortingOrder) live inengine-integration.md. Everything here is the raw math those wrappers hide.
All formulas below are parametrized by tileW and tileH (the on-screen width and
height of a single ground diamond in pixels). For 2:1 dimetric tileW = 2·tileH
(e.g. 64×32, 128×64), which is what almost every tiled game uses. The formulas are
general — they hold for any tileW:tileH ratio, including true-iso grids where the
ratio follows tan 30° = 0.57735 rather than exactly 0.5.
Terminology note. Throughout, "2:1 dimetric (commonly called isometric in games)"
is the 26.565° projection at a 2px:1px pixel step. After this first mention it is
"2:1 dimetric". See projection-math.md for why the game-standard
projection is dimetric, not isometric.
Three coordinate frames are in play. Confusing them is the single most common source of "my tiles are off by a diamond" bugs.
| Frame | Symbols | Units | Origin | Description |
|---|---|---|---|---|
| Tile / grid (cartesian) | x, y |
tiles (integer or fractional) | tile (0,0) |
The logical world. x increases toward the lower-right screen edge, y toward the lower-left. This is where game logic, pathfinding (A*), and the map array live. |
| Screen | screenX, screenY |
pixels | a chosen anchor pixel | Where you actually drawImage. y-down, matching every 2D canvas / DOM / engine viewport. |
| Elevation | z |
z-steps (integer) | ground plane z=0 |
Height above the ground diamond. Lifts a sprite straight up the screen and is a tie-breaker in the sort key — never a horizontal offset. |
Fixed conventions used in this file (state yours explicitly in your tile-spec):
screenY grows downward. This is universal for canvas/DOM/
engine viewports; the formulas below assume it. (Three.js/WebGL use y-up world space —
that world is handled in threejs-orthographic.md, not here.)(0,0)'s top vertex sits at the screen origin;
+x runs down-right, +y runs down-left. This matches Godot's Diamond Down layout
and the canonical (x−y, x+y) transform.tileW and tileH by 2, precompute
halfW = tileW/2, halfH = tileH/2 once. For 64×32 that's halfW=32, halfH=16.The canonical transform. A unit step in tile x moves half a tile right and half a
tile down; a unit step in tile y moves half a tile left and half a tile down. That is
exactly the diamond lattice.
screenX = (x − y) · tileW / 2
screenY = (x + y) · tileH / 2
Equivalently, with halfW = tileW/2 and halfH = tileH/2:
screenX = (x − y) · halfW
screenY = (x + y) · halfH
Ground-truth check. Substitute the basis tiles at 64×32 (halfW=32, halfH=16):
Tile (x,y) |
screenX = (x−y)·32 |
screenY = (x+y)·16 |
Meaning |
|---|---|---|---|
(0,0) |
0 |
0 |
origin (top vertex of tile 0,0) |
(1,0) |
+32 |
+16 |
one step down-right ✓ |
(0,1) |
−32 |
+16 |
one step down-left ✓ |
(1,1) |
0 |
+32 |
straight down one full tile ✓ |
The (1,0) and (0,1) rows are the two ground-axis basis vectors: (+halfW, +halfH)
and (−halfW, +halfH). Their slope magnitude is halfH/halfW = tileH/tileW. For a 2:1
tile that is 16/32 = 0.5 — the defining 2px-across : 1px-up pixel step. For a true-iso
grid it is tan 30° = 0.57735. That single ratio is the projection fingerprint; a grid
SVG's line slope must match it (see the assets/grids/ verification in the skill).
This transform positions the tile's origin vertex. To draw a diamond sprite whose
image is tileW × tileH, blit its top-left corner at (screenX − halfW, screenY) so the
diamond's top vertex lands on (screenX, screenY) — or, more robustly, anchor by the
sprite's declared anchor point (§8) rather than a corner.
Inverting the 2×2 system above (used for mouse picking, drag-drop, click-to-move):
x = ( screenX / (tileW/2) + screenY / (tileH/2) ) / 2
y = ( screenY / (tileH/2) − screenX / (tileW/2) ) / 2
With half-dimensions:
x = ( screenX/halfW + screenY/halfH ) / 2
y = ( screenY/halfH − screenX/halfW ) / 2
Derivation (so you can re-derive it, not memorise it). The forward map is a linear system:
screenX = halfW·x − halfW·y
screenY = halfH·x + halfH·y
Divide the first by halfW and the second by halfH:
u = screenX/halfW = x − y
v = screenY/halfH = x + y
Then x = (u + v)/2 and y = (v − u)/2. Substituting u and v back gives the
boxed formulas. The determinant of the forward matrix is halfW·halfH − (−halfW·halfH)
= 2·halfW·halfH ≠ 0, so the inverse always exists for a non-degenerate tile.
The result is fractional x, y. To get the integer tile you must floor — but a
naive floor of a fractional tile coordinate is not the same as the diamond the cursor
is inside near the diamond edges. §6 gives the correct within-diamond picking test.
Numeric sanity check (must round-trip): feed any (x,y) through §2 then §3 and you
must recover (x,y) exactly. Verified below.
tileW=64, tileH=32 → halfW=32, halfH=16. Forward then inverse; the inverse recovers the
original tile exactly (this is the round-trip the iso-math.py to-screen / to-tile
subcommands assert, and what the JS/Python snippets in §12 reproduce):
| Tile in | to-screen |
to-tile back |
Round-trip |
|---|---|---|---|
(0,0) |
(0, 0) |
(0, 0) |
✓ |
(1,0) |
(32, 16) |
(1, 0) |
✓ |
(0,1) |
(−32, 16) |
(0, 1) |
✓ |
(3,2) |
(32, 80) |
(3, 2) |
✓ |
(5,5) |
(0, 160) |
(5, 5) |
✓ |
(10,7) |
(96, 272) |
(10, 7) |
✓ |
A non-integer pick. Screen point (48, 8) at 64×32:
x = (48/32 + 8/16)/2 = (1.5 + 0.5)/2 = 1.0
y = (8/16 − 48/32)/2 = (0.5 − 1.5)/2 = −0.5
→ tile (1.0, −0.5). The integer x = 1.0 is the boundary signal here: in the
unit-square (fx,fy) space of §6 an integer coordinate lands exactly on a diamond
edge. The y = −0.5 is the opposite — a mid-diamond value (its remainder 0.5 is the
diamond center on that axis, as far from an edge as you can get). So the cursor sits on
the shared edge between tiles (0,−1) and (1,−1) (both diamonds meet along x = 1.0),
and floor(1.0, −0.5) resolves it to (1,−1) — a correct, unambiguous pick.
The subtlety §6 warns about is not this clean case but the float-noise one: when a
computed coordinate should be an exact integer edge value but arrives as 0.9999997 or
1.0000004, floor can flip it to the wrong side of the seam — the off-by-one-diamond
picking bug. Snap near-integer results (or use §6's remainder test) before flooring at
the seams; don't trust raw floating-point equality on a boundary.
CLI equivalent (see scripts/iso-math.py):
iso-math.py to-screen 3 2 --tile-w 64 --tile-h 32 # → 32 80
iso-math.py to-tile 32 80 --tile-w 64 --tile-h 32 # → 3 2
Elevation lifts a sprite straight up the screen — a pure vertical (−screenY)
offset. It is never a horizontal shift, because moving up in the world does not change
which ground tile you stand on.
Let unitZ be the pixels-per-z-step your tile-spec pins (a per-set constant — e.g. a
64×32 dungeon commonly uses unitZ = 16, i.e. one z-step = the tile's half-height, so a
wall block reads as one tile tall). Then:
screenX = (x − y) · halfW
screenY = (x + y) · halfH − z · unitZ // subtract: up is −y in y-down space
unitZ belongs in the spec, not the code — it defines how tall a "floor" reads and
must be identical across every asset in a set or stacked blocks won't line up. Pin it in
tile-spec.md.
Elevation and sorting. z does not enter the primary (x+y) sort key — two tiles
at the same (x+y) but different height still occupy the same screen column band, and
the higher one must draw after (on top of) the lower. So z is the second sort key
(§7). A common mistake is to fold z into the depth key as x + y + z; that makes a
tall object on a near tile incorrectly sort behind a short object on a far tile.
Reverse-projecting a screen point (§3) gives fractional tile coordinates. Turning those
into "which diamond is the cursor in" needs care at the diamond edges, where a plain
floor picks the wrong tile ~half the time along the seams. Two robust methods:
Work in the tile's local diamond space. For screen point (sx, sy):
// fractional tile coords
fx = ( sx/halfW + sy/halfH ) / 2
fy = ( sy/halfH − sx/halfW ) / 2
// candidate integer tile
tx = floor(fx)
ty = floor(fy)
Because the inverse transform maps the diamond onto the unit square in (fx,fy) space,
floor on both axes lands you in the correct diamond directly — the diamond's four
edges become the unit-square's four sides. This is the elegance of doing the test after
the inverse transform rather than in raw screen space: no per-corner edge test is
needed. The remainders fx − tx and fy − ty further tell you where inside the
diamond the cursor sits (useful for sub-tile snapping: < 0.5 / ≥ 0.5 quadrants).
This is why the inverse transform is written as the full linear solve and not an ad-hoc "divide screenX by tileW" — only the correct
/2averaging step makesfloorland in the right diamond.
For irregularly shaped or overlapping tiles where the math test is ambiguous, render an
off-screen buffer where each tile is filled with a unique color encoding its (x,y)
(or a monotonic tile ID). Read back the single pixel under the cursor and decode. This
sidesteps all edge math and handles non-diamond footprints, at the cost of a second
render target. Standard technique for editors and complex maps.
Elevation-aware picking. If tiles have height, the topmost visible surface under
the cursor may be an elevated tile, not the ground tile the math returns. Resolve by
casting from the cursor and testing candidate tiles from high z down, or use Method B
against the composited (post-elevation) frame.
Isometric rendering is painter's algorithm: draw far things first, near things last, so near occludes far. The whole game hinges on the sort key.
Draw order key, in priority:
(x + y) ascending — the primary depth. Larger x+y is nearer the camera
(lower on screen) and draws later (on top). This is the diagonal "wavefront" that
sweeps from the back corner to the front corner.z ascending — tie-breaker for same-column stacks; the higher object
draws after (over) the lower.explicit layer then zBias — a manual override for authored exceptions
(a decal that must sit over its tile, a bridge deck over the river beneath it). Keep
layer coarse (ground < props < overlay) and zBias a small integer nudge.
sortKey = (x + y, z, layer, zBias)
Sort ascending on this tuple; render in that order.
When every sprite occupies exactly one tile and nothing overlaps tile boundaries, you do
not need to sort at all — a back-to-front nested loop emits tiles in (x+y)-ascending
order for free:
for sum in 0 .. (W-1)+(H-1): // each diagonal wavefront
for x in max(0, sum-(H-1)) .. min(sum, W-1):
y = sum - x
draw(tile[x][y])
or the simpler double loop that also produces correct order for a full rectangular map:
for y in 0 .. H-1:
for x in 0 .. W-1:
draw(tile[x][y]) // each tile precedes both its front neighbours
The double loop works by a local invariant, not a global one. Its (x+y) sequence is
not monotone: a 3×3 map visits sums [0,1,2, 1,2,3, 2,3,4], which drops at every row
boundary (2→1, 3→2). What makes it a correct painter's order is that every tile is drawn
before both of its front (nearer-camera) neighbours — the tile at (x,y) is emitted
before (x+1,y) (later in the same row) and before (x,y+1) (the next row), and those
two are the only tiles a static, non-overlapping single-tile grid can occlude it. Because
each tile precedes everything that can draw over it, the order is correct even though the
row-major traversal is not a global (x+y)-ascending sort. It is the cheapest correct
order for a static single-tile grid, and only for that case. The moment you add
free-moving actors, multi-tile props, or elevation, this neighbour-adjacency guarantee
no longer holds — fall back to an explicit (x+y, z, layer, zBias) sort on the tuple
above. (yal.cc, Packt.)
Use a stable sort so equal-key sprites keep a deterministic order (insertion order, or add the entity id as a final tuple element). An unstable sort makes coplanar sprites flicker their z-order frame to frame.
Rule: the sprite's anchor (its (x,y) reference point and its screen blit origin) is
the bottom of its ground contact — its visual feet — never its center.
This is not a stylistic choice; it is what makes the depth sort correct. The sort key uses the tile the object stands on. Depth is determined by where an object touches the ground, because that is what decides whether it is in front of or behind another object.
Why center anchors break it. Consider a tall lamppost and a short crate, both drawn
with center anchors. The lamppost's center is high on screen (its bulk is above the
ground), so a center-based screenY sort places it behind the crate even when the
lamppost stands on a nearer tile. Result: the crate incorrectly draws over the base of
the lamppost, or the character walks and their head-height center makes them pop in front
of / behind objects at the wrong moment. Anchoring at the feet ties the sort to ground
contact, which is the only surface that determines occlusion.
Consequences to encode in the tile-spec (see tile-spec.md):
anchor = {x: halfW, y: spriteHeight −
footOffset}), pinned per asset, consistent across a set.blitX = screenX − anchor.x; blitY = screenY − anchor.y.If a sprite genuinely has no single ground-contact point (a floating platform), give it
an explicit layer/zBias and treat its logical footprint tile as the anchor
(§9–§10).
A single (x+y) scalar sorts point objects correctly. It fails for objects that cover
more than one tile (a 2×2 house, a 3×1 bridge, a long train car), because such an
object has no single (x+y) — it spans a range, and it can be simultaneously in front of
one neighbour and behind another.
Doctrine: sort large sprites by their axis-aligned bounding box (AABB) in iso/tile space, comparing overlaps, not by a single point. (jwopitz "Absolute Isometric Depth Sorting"; mazebert forum.)
Two workable strategies:
Treat a W×H-footprint object as its W·H component tiles for sorting purposes; draw
the whole sprite when its frontmost cell (max x+y, i.e. the ground cell nearest the
camera) comes up in the sort. This keeps the single-key sort but pins the object's draw
slot to its nearest ground contact. Cheap and correct for convex, axis-aligned
footprints — the common case (buildings, crates, floors).
When footprints interleave (an L-shaped wall partly in front of and partly behind a column), a total order on a scalar key does not exist. Build a directed graph: for each pair of sprites whose iso-space AABBs overlap in screen projection, add an edge "A must draw before B" using the standard behind/in-front test:
A is behind B if A.maxTileX ≤ B.minTileX
or A.maxTileY ≤ B.minTileY
or A.maxZ ≤ B.minZ
(each condition means A is entirely on the far side of B along one axis). Then topological-sort the graph to get a valid draw order. This is the general, always-correct method (used by Unity/Unreal iso plugins and Tiled-based renderers) and degrades to the scalar sort when no footprints overlap. It is O(n²) in the pair test — restrict it to sprites whose screen AABBs actually intersect (broad-phase first), or cluster by grid region, to keep it cheap.
Choosing: use Strategy 1 by default (nearly all game props are convex axis-aligned footprints); reach for Strategy 2 only where sprites genuinely interlock and Strategy 1 shows visible z-errors.
Moving and stacked-floor elements are where naive (x+y) sorting visibly fails, because
a moving actor's z/floor changes its correct draw slot mid-motion. (Envato Tuts+
"Isometric Depth Sorting for Moving Platforms" is the definitive treatment.)
z = floorIndex · floorHeightSteps + localZ. The z tie-breaker (§7) then keeps upper
floors over lower ones. For a cutaway building (walls removed to see inside), authored
layer/zBias overrides handle the "wall is in front but must be drawn transparent or
omitted" cases — a rendering decision, not a sort decision (see cutaway conventions in
style-guide.md).z; actors on the deck inherit the deck's z, actors beneath keep z=0. The
standard sort then resolves both without special cases.Y-Sort on a TileMapLayer/Node2D does the (x+y)
ordering automatically off the node's y position (anchor at feet still required); in
Unity it's sortingOrder. See engine-integration.md — this
file is the math those features implement, for when you render manually or need to
understand/override the engine's choice.Small maps can brute-force everything. Past a size threshold, three techniques become non-negotiable:
| Threshold | Technique | Why |
|---|---|---|
| > ~50×50 tiles | Viewport culling | Never transform/sort/draw tiles outside the visible frustum. Compute the visible tile-coordinate window from the camera's screen rect via the inverse transform (§3) on the four viewport corners, then iterate only that (x,y) range. Turns O(map) per frame into O(screen). |
| > ~50×50 tiles | Chunking | Partition the map into fixed chunks (e.g. 16×16 tiles). Cull, sort, and (optionally) pre-render each chunk to its own canvas/texture; redraw a chunk only when its contents change. Static ground chunks then blit as a single image. |
| Many small sprites | Batching / texture atlas | Every distinct source image is a draw-call/state-change; thousands of individual tile images tank the framerate. Pack all tiles into one texture atlas (a single sheet + a {name → x,y,w,h} map — see scripts/sheet-pack.py) so the whole visible set draws from one texture with minimal state changes. This is the single biggest win for tile-heavy scenes. |
Atlas rationale in one line: the GPU is fast at drawing many quads from one
texture and slow at switching textures — an atlas converts N texture binds into 1.
Pack with scripts/sheet-pack.py; it emits the atlas PNG +
JSON the renderer indexes by frame name.
Depth-sort cost at scale. Sorting every sprite every frame is O(n log n); at 50×50+ that's wasteful when most tiles don't move. Sort only the dynamic layer (actors, moving props) against a pre-sorted static ground, or maintain the sorted order incrementally (only re-insert sprites that moved this frame). Combined with culling, you sort dozens of movers, not thousands of tiles.
All three implementations below produce identical results and agree with the
canonical transforms in §2–§3 and with scripts/iso-math.py (to-screen / to-tile).
Copy the one matching your stack.
halfW = tileW / 2
halfH = tileH / 2
function tileToScreen(x, y, z):
screenX = (x - y) * halfW
screenY = (x + y) * halfH - z * unitZ // z: up is -y (y-down screen)
return (screenX, screenY)
function screenToTile(screenX, screenY): // ground plane (z = 0)
x = (screenX / halfW + screenY / halfH) / 2
y = (screenY / halfH - screenX / halfW) / 2
return (x, y) // fractional; floor for the diamond
function pickTile(screenX, screenY):
(fx, fy) = screenToTile(screenX, screenY)
return (floor(fx), floor(fy)) // floor lands in the correct diamond (§6)
function depthKey(sprite): // ascending sort; stable
return (sprite.x + sprite.y, sprite.z, sprite.layer, sprite.zBias)
// Isometric coordinate + depth utilities.
// Parametrized by tile pixel dimensions; 2:1 dimetric uses tileW = 2*tileH.
// Screen space is y-down (canvas/DOM). Agrees with scripts/iso-math.py.
function makeIso(tileW, tileH, unitZ = tileH / 2) {
const halfW = tileW / 2;
const halfH = tileH / 2;
return {
// tile (x,y,z) -> screen pixels; anchor subtraction is the caller's job (§8)
tileToScreen(x, y, z = 0) {
return {
x: (x - y) * halfW,
y: (x + y) * halfH - z * unitZ, // up is -y in y-down space
};
},
// screen pixels -> fractional tile on the ground plane (z = 0)
screenToTile(sx, sy) {
return {
x: (sx / halfW + sy / halfH) / 2,
y: (sy / halfH - sx / halfW) / 2,
};
},
// screen pixels -> integer tile under the point (within-diamond, §6 Method A)
pickTile(sx, sy) {
const t = this.screenToTile(sx, sy);
return { x: Math.floor(t.x), y: Math.floor(t.y) };
},
};
}
// Depth sort: (x+y) asc, then z, then layer, then zBias. Stable via id fallback.
function sortByDepth(sprites) {
return sprites
.map((s, i) => [s, i])
.sort((a, b) => {
const [A, ia] = a, [B, ib] = b;
return (A.x + A.y) - (B.x + B.y)
|| (A.z || 0) - (B.z || 0)
|| (A.layer || 0) - (B.layer || 0)
|| (A.zBias || 0) - (B.zBias || 0)
|| ia - ib; // stable tie-break
})
.map(([s]) => s);
}
// --- round-trip check (matches §4 table) ---
// const iso = makeIso(64, 32);
// iso.tileToScreen(3, 2); // { x: 32, y: 80 }
// iso.screenToTile(32, 80); // { x: 3, y: 2 }
# Isometric coordinate + depth utilities (pure stdlib).
# Parametrized by tile pixel dimensions; 2:1 dimetric uses tile_w = 2*tile_h.
# Screen space is y-down. Agrees with scripts/iso-math.py to-screen / to-tile.
import math
def tile_to_screen(x, y, tile_w, tile_h, z=0, unit_z=None):
"""Tile (x, y, z) -> (screen_x, screen_y) in pixels. Up is -y (y-down)."""
half_w, half_h = tile_w / 2, tile_h / 2
if unit_z is None:
unit_z = half_h
screen_x = (x - y) * half_w
screen_y = (x + y) * half_h - z * unit_z
return screen_x, screen_y
def screen_to_tile(screen_x, screen_y, tile_w, tile_h):
"""Screen pixels -> fractional tile on the ground plane (z = 0)."""
half_w, half_h = tile_w / 2, tile_h / 2
x = (screen_x / half_w + screen_y / half_h) / 2
y = (screen_y / half_h - screen_x / half_w) / 2
return x, y
def pick_tile(screen_x, screen_y, tile_w, tile_h):
"""Screen pixels -> integer tile under the point (within-diamond, §6)."""
fx, fy = screen_to_tile(screen_x, screen_y, tile_w, tile_h)
return math.floor(fx), math.floor(fy)
def depth_key(sprite):
"""Ascending sort key: (x+y, z, layer, zBias). Use with a stable sort."""
return (
sprite["x"] + sprite["y"],
sprite.get("z", 0),
sprite.get("layer", 0),
sprite.get("zBias", 0),
)
# sprites.sort(key=depth_key) # list.sort is stable -> coplanar order preserved
# --- round-trip check (matches §4 table) ---
# tile_to_screen(3, 2, 64, 32) -> (32.0, 80.0)
# screen_to_tile(32, 80, 64, 32) -> (3.0, 2.0)
Python's list.sort / sorted are guaranteed stable, so depth_key needs no explicit
id tie-break; the JS Array.prototype.sort is stable in modern engines (ES2019+) but the
snippet adds the index tie-break belt-and-braces so coplanar sprites never flicker.
/2) and the cart↔iso transform.
https://pikuma.com/blog/isometric-projection-in-gamesiso_to_screen(x,y,tw,th) = ((x−y)·tw/2, (x+y)·th/2), layered draw order, y-sort.x_iso = x_cart − y_cart; y_iso =
(x_cart + y_cart)/2 and its inverse, with an IsoHelper class.sortingOrder).projection-math.md
(angles, plane matrices, the projection decision), tile-spec.md
(anchor / unitZ / footprint discipline), engine-integration.md
(Godot Y-Sort, Unity sortingOrder), threejs-orthographic.md
(y-up world space, scripts/sheet-pack.py atlas consumption),
style-guide.md (cutaway conventions), and scripts/iso-math.py
(the to-screen / to-tile CLI these snippets mirror).Terminology reminder for citing files: this doc's transforms are dimensionally identical for true-iso and 2:1 dimetric grids — only the
tileW:tileHratio differs (0.57735 vs 0.5). The projection choice is made first, inprojection-math.md; the math here is downstream of it.