Isometric Art: How to Create Stunning 3D-Looking Worlds With Code
Isometric art creates the illusion of three dimensions on a flat screen without perspective distortion. Unlike true 3D rendering with vanishing points, isometric projection keeps parallel lines parallel — every cube is the same size whether it’s in the foreground or background. This gives isometric art its distinctive, clean, almost architectural quality that has defined entire genres of video games, from SimCity and Civilization to Monument Valley and Crossy Road.
The mathematics behind isometric projection are surprisingly elegant. You take 3D coordinates (x, y, z) and project them onto a 2D screen using a fixed rotation — typically 30° from horizontal for each axis. This guide covers 8 working JavaScript implementations, from a basic isometric grid to a full interactive voxel editor. Every example runs in a single HTML file under 50KB.
The isometric coordinate system
In true isometric projection, the three coordinate axes are equally foreshortened, meeting at 120° angles. On a pixel grid, the standard convention tilts the x-axis 30° right and the y-axis 30° left, while the z-axis points straight up. The conversion formulas from 3D world coordinates to 2D screen coordinates are:
screenX = (worldX - worldY) * cos(30°) = (worldX - worldY) * TILE_W / 2
screenY = (worldX + worldY) * sin(30°) - worldZ = (worldX + worldY) * TILE_H / 2 - worldZ
Where TILE_W is the tile width and TILE_H is half that (the standard 2:1 ratio). This simple pair of equations is the foundation of everything in this guide. Let’s start building.
1. Isometric grid
The foundation of any isometric scene is the grid — a diamond-shaped lattice of tiles. Each tile is a rhombus (diamond shape) drawn with four points. The trick is iterating in the right order: back-to-front, so tiles in front correctly overlap tiles behind them.
<!DOCTYPE html>
<html><head><title>Isometric Grid</title></head>
<body style="margin:0;background:#1a1a2e;display:flex;justify-content:center;align-items:center;height:100vh">
<canvas id="c"></canvas>
<script>
var c = document.getElementById('c'), ctx = c.getContext('2d');
c.width = 800; c.height = 600;
var COLS = 10, ROWS = 10;
var TW = 64, TH = 32; // tile width, tile height (2:1 ratio)
var originX = c.width / 2, originY = 120;
function toScreen(gx, gy) {
return {
x: originX + (gx - gy) * TW / 2,
y: originY + (gx + gy) * TH / 2
};
}
function drawTile(gx, gy, fill, stroke) {
var p = toScreen(gx, gy);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x + TW / 2, p.y + TH / 2);
ctx.lineTo(p.x, p.y + TH);
ctx.lineTo(p.x - TW / 2, p.y + TH / 2);
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
ctx.strokeStyle = stroke;
ctx.lineWidth = 1;
ctx.stroke();
}
function render() {
ctx.clearRect(0, 0, c.width, c.height);
for (var gy = 0; gy < ROWS; gy++) {
for (var gx = 0; gx < COLS; gx++) {
var shade = ((gx + gy) % 2 === 0) ? '#2a6e4e' : '#34875e';
drawTile(gx, gy, shade, '#1a4e36');
}
}
}
render();
// Hover detection
var hoverX = -1, hoverY = -1;
c.addEventListener('mousemove', function(e) {
var rect = c.getBoundingClientRect();
var mx = e.clientX - rect.left - originX;
var my = e.clientY - rect.top - originY - TH / 2;
// Reverse the isometric transform
var gx = Math.floor((mx / (TW / 2) + my / (TH / 2)) / 2);
var gy = Math.floor((my / (TH / 2) - mx / (TW / 2)) / 2);
if (gx !== hoverX || gy !== hoverY) {
hoverX = gx; hoverY = gy;
render();
if (gx >= 0 && gx < COLS && gy >= 0 && gy < ROWS) {
drawTile(gx, gy, 'rgba(255,255,100,0.4)', '#ff0');
}
}
});
</script>
</body>
</html>
The key insight is the toScreen function: it converts grid coordinates to pixel positions using the 2:1 diamond ratio. The reverse transform in the mouse handler converts pixel positions back to grid coordinates, enabling hover detection on the diamond-shaped tiles. This reverse mapping — dividing by the tile dimensions and solving the simultaneous equations — is essential for any interactive isometric application.
2. Isometric cube renderer
A single isometric cube is drawn as three visible faces: top (brightest), left (medium), and right (darkest). This lighting convention — light coming from the upper-left — is the standard in isometric art and creates a convincing sense of volume with minimal effort.
<!DOCTYPE html>
<html><head><title>Isometric Cubes</title></head>
<body style="margin:0;background:#0f0f23;display:flex;justify-content:center;align-items:center;height:100vh">
<canvas id="c"></canvas>
<script>
var c = document.getElementById('c'), ctx = c.getContext('2d');
c.width = 800; c.height = 600;
var TW = 48, TH = 24, DEPTH = 40;
var originX = c.width / 2, originY = 80;
function toScreen(gx, gy, gz) {
return {
x: originX + (gx - gy) * TW / 2,
y: originY + (gx + gy) * TH / 2 - gz * DEPTH
};
}
function drawCube(gx, gy, gz, baseHue) {
var top = toScreen(gx, gy, gz + 1);
var s = TW / 2, h = TH / 2;
// Top face (brightest)
ctx.beginPath();
ctx.moveTo(top.x, top.y);
ctx.lineTo(top.x + s, top.y + h);
ctx.lineTo(top.x, top.y + TH);
ctx.lineTo(top.x - s, top.y + h);
ctx.closePath();
ctx.fillStyle = 'hsl(' + baseHue + ',60%,55%)';
ctx.fill();
// Left face (medium)
ctx.beginPath();
ctx.moveTo(top.x - s, top.y + h);
ctx.lineTo(top.x, top.y + TH);
ctx.lineTo(top.x, top.y + TH + DEPTH);
ctx.lineTo(top.x - s, top.y + h + DEPTH);
ctx.closePath();
ctx.fillStyle = 'hsl(' + baseHue + ',55%,38%)';
ctx.fill();
// Right face (darkest)
ctx.beginPath();
ctx.moveTo(top.x + s, top.y + h);
ctx.lineTo(top.x, top.y + TH);
ctx.lineTo(top.x, top.y + TH + DEPTH);
ctx.lineTo(top.x + s, top.y + h + DEPTH);
ctx.closePath();
ctx.fillStyle = 'hsl(' + baseHue + ',50%,28%)';
ctx.fill();
}
// Draw a cluster of cubes — back to front, bottom to top
var cubes = [
[0,0,0,210],[1,0,0,210],[2,0,0,210],[3,0,0,210],
[0,1,0,210],[1,1,0,210],[2,1,0,210],[3,1,0,210],
[0,2,0,210],[1,2,0,210],[2,2,0,210],[3,2,0,210],
[0,3,0,210],[1,3,0,210],[2,3,0,210],[3,3,0,210],
[1,1,1,140],[2,1,1,140],[1,2,1,140],[2,2,1,140],
[1,1,2,50],[2,1,2,50],[1,2,2,50],[2,2,2,50],
[1,2,3,0],[2,1,3,0]
];
// Sort: back to front (gy desc, gx desc), bottom to top (gz asc)
cubes.sort(function(a, b) {
var sa = a[0] + a[1]; var sb = b[0] + b[1];
if (sa !== sb) return sa - sb;
return a[2] - b[2];
});
ctx.clearRect(0, 0, c.width, c.height);
for (var i = 0; i < cubes.length; i++) {
drawCube(cubes[i][0], cubes[i][1], cubes[i][2], cubes[i][3]);
}
</script>
</body>
</html>
The three-face cube is the atomic unit of isometric art. By varying the baseHue parameter, you create different materials: blue for water, green for grass, brown for wood, grey for stone. The sorting order is critical — cubes must be drawn back-to-front (ascending x+y) and bottom-to-top (ascending z) to ensure correct occlusion. This is the painter’s algorithm applied to isometric space.
3. Voxel city generator
With the cube renderer in place, we can generate an entire city. Each building is a column of cubes with a random height. Streets form a grid pattern, and color varies by building height to create a sense of density and zoning.
<!DOCTYPE html>
<html><head><title>Isometric Voxel City</title></head>
<body style="margin:0;background:#0a0a1a;display:flex;justify-content:center;align-items:center;height:100vh">
<canvas id="c"></canvas>
<script>
var c = document.getElementById('c'), ctx = c.getContext('2d');
c.width = 900; c.height = 700;
var TW = 20, TH = 10, DEPTH = 14;
var originX = c.width / 2, originY = 60;
var GRID = 20;
function seed(s) { return function() { s = (s * 16807 + 0) % 2147483647; return (s - 1) / 2147483646; }; }
var rand = seed(42);
function toScreen(gx, gy, gz) {
return { x: originX + (gx - gy) * TW / 2, y: originY + (gx + gy) * TH / 2 - gz * DEPTH };
}
function drawCube(gx, gy, gz, hue, sat, lit) {
var top = toScreen(gx, gy, gz + 1);
var s = TW / 2, h = TH / 2;
// Top
ctx.beginPath();
ctx.moveTo(top.x, top.y);
ctx.lineTo(top.x + s, top.y + h);
ctx.lineTo(top.x, top.y + TH);
ctx.lineTo(top.x - s, top.y + h);
ctx.closePath();
ctx.fillStyle = 'hsl(' + hue + ',' + sat + '%,' + lit + '%)';
ctx.fill();
// Left
ctx.beginPath();
ctx.moveTo(top.x - s, top.y + h);
ctx.lineTo(top.x, top.y + TH);
ctx.lineTo(top.x, top.y + TH + DEPTH);
ctx.lineTo(top.x - s, top.y + h + DEPTH);
ctx.closePath();
ctx.fillStyle = 'hsl(' + hue + ',' + sat + '%,' + (lit * 0.65) + '%)';
ctx.fill();
// Right
ctx.beginPath();
ctx.moveTo(top.x + s, top.y + h);
ctx.lineTo(top.x, top.y + TH);
ctx.lineTo(top.x, top.y + TH + DEPTH);
ctx.lineTo(top.x + s, top.y + h + DEPTH);
ctx.closePath();
ctx.fillStyle = 'hsl(' + hue + ',' + sat + '%,' + (lit * 0.45) + '%)';
ctx.fill();
}
function isStreet(x, y) {
return (x % 5 === 0) || (y % 5 === 0);
}
// Build heightmap
var heights = [];
for (var y = 0; y < GRID; y++) {
heights[y] = [];
for (var x = 0; x < GRID; x++) {
if (isStreet(x, y)) { heights[y][x] = 0; }
else { heights[y][x] = Math.floor(rand() * 8) + 1; }
}
}
// Collect and sort all cubes
var allCubes = [];
for (var gy = 0; gy < GRID; gy++) {
for (var gx = 0; gx < GRID; gx++) {
var h = heights[gy][gx];
if (h === 0) {
allCubes.push([gx, gy, 0, 0, 0, 15]); // dark street
} else {
for (var gz = 0; gz < h; gz++) {
var hue = h > 5 ? 220 : h > 3 ? 180 : 40;
var sat = 50 + gz * 5;
var lit = 30 + gz * 4;
allCubes.push([gx, gy, gz, hue, sat, lit]);
}
}
}
}
allCubes.sort(function(a, b) {
var sa = a[0] + a[1], sb = b[0] + b[1];
if (sa !== sb) return sa - sb;
return a[2] - b[2];
});
ctx.clearRect(0, 0, c.width, c.height);
for (var i = 0; i < allCubes.length; i++) {
var cb = allCubes[i];
drawCube(cb[0], cb[1], cb[2], cb[3], cb[4], cb[5]);
}
// Click to regenerate
c.addEventListener('click', function() {
rand = seed(Math.floor(Math.random() * 99999));
for (var y = 0; y < GRID; y++) for (var x = 0; x < GRID; x++) {
heights[y][x] = isStreet(x, y) ? 0 : Math.floor(rand() * 8) + 1;
}
allCubes = [];
for (var gy2 = 0; gy2 < GRID; gy2++) for (var gx2 = 0; gx2 < GRID; gx2++) {
var h2 = heights[gy2][gx2];
if (h2 === 0) { allCubes.push([gx2, gy2, 0, 0, 0, 15]); }
else { for (var gz2 = 0; gz2 < h2; gz2++) {
var hue2 = h2 > 5 ? 220 : h2 > 3 ? 180 : 40;
allCubes.push([gx2, gy2, gz2, hue2, 50 + gz2 * 5, 30 + gz2 * 4]);
}}
}
allCubes.sort(function(a, b) { var sa = a[0]+a[1], sb = b[0]+b[1]; return sa !== sb ? sa - sb : a[2] - b[2]; });
ctx.clearRect(0, 0, c.width, c.height);
for (var j = 0; j < allCubes.length; j++) { var d = allCubes[j]; drawCube(d[0],d[1],d[2],d[3],d[4],d[5]); }
});
</script>
</body>
</html>
Click to generate a new city. The street grid is created with a modulo pattern (every 5th row and column), and building heights are random within blocks. Taller buildings use cooler hues (blue for skyscrapers, teal for mid-rise, warm yellow for low buildings), mimicking the visual language of urban zoning. The vertical color gradient on each building — darker at the base, lighter at the top — enhances the sense of height and atmospheric perspective.
4. Terrain heightmap
Isometric terrain uses the same projection but varies the z-coordinate smoothly across the grid. By sampling a noise function at each grid position and using the result as elevation, we get rolling hills and valleys with natural-looking contours.
<!DOCTYPE html>
<html><head><title>Isometric Terrain</title></head>
<body style="margin:0;background:#0d1117;display:flex;justify-content:center;align-items:center;height:100vh">
<canvas id="c"></canvas>
<script>
var c = document.getElementById('c'), ctx = c.getContext('2d');
c.width = 900; c.height = 650;
var TW = 32, TH = 16, ELEV = 80;
var originX = c.width / 2, originY = 50;
var GRID = 24;
// Simple value noise
var perm = [];
for (var i = 0; i < 256; i++) perm[i] = i;
for (var i2 = 255; i2 > 0; i2--) { var j = Math.floor(Math.random() * (i2 + 1)); var t = perm[i2]; perm[i2] = perm[j]; perm[j] = t; }
for (var i3 = 0; i3 < 256; i3++) perm[256 + i3] = perm[i3];
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + t * (b - a); }
function grad(hash, x, y) {
var h = hash & 3;
return (h === 0 ? x + y : h === 1 ? -x + y : h === 2 ? x - y : -x - y);
}
function noise(x, y) {
var X = Math.floor(x) & 255, Y = Math.floor(y) & 255;
x -= Math.floor(x); y -= Math.floor(y);
var u = fade(x), v = fade(y);
var a = perm[X] + Y, b = perm[X + 1] + Y;
return lerp(lerp(grad(perm[a], x, y), grad(perm[b], x - 1, y), u),
lerp(grad(perm[a + 1], x, y - 1), grad(perm[b + 1], x - 1, y - 1), u), v);
}
function fbm(x, y) {
var v = 0, amp = 1, freq = 1;
for (var o = 0; o < 5; o++) { v += noise(x * freq, y * freq) * amp; amp *= 0.5; freq *= 2; }
return v;
}
function toScreen(gx, gy, gz) {
return { x: originX + (gx - gy) * TW / 2, y: originY + (gx + gy) * TH / 2 - gz * ELEV };
}
function biomeColor(h) {
if (h < 0.1) return [40, 80, 140]; // deep water
if (h < 0.2) return [60, 120, 180]; // shallow water
if (h < 0.25) return [210, 200, 160]; // sand
if (h < 0.5) return [60, 150, 60]; // grass
if (h < 0.7) return [40, 120, 40]; // forest
if (h < 0.85) return [120, 100, 80]; // mountain
return [220, 225, 230]; // snow
}
function drawColumn(gx, gy, height) {
var col = biomeColor(height);
var p = toScreen(gx, gy, height);
var s = TW / 2, h = TH / 2;
var baseY = originY + (gx + gy) * TH / 2;
var topY = p.y;
// Top face
ctx.beginPath();
ctx.moveTo(p.x, topY);
ctx.lineTo(p.x + s, topY + h);
ctx.lineTo(p.x, topY + TH);
ctx.lineTo(p.x - s, topY + h);
ctx.closePath();
ctx.fillStyle = 'rgb(' + col[0] + ',' + col[1] + ',' + col[2] + ')';
ctx.fill();
// Left face
if (topY + TH < baseY + TH) {
ctx.beginPath();
ctx.moveTo(p.x - s, topY + h);
ctx.lineTo(p.x, topY + TH);
ctx.lineTo(p.x, baseY + TH);
ctx.lineTo(p.x - s, baseY + h);
ctx.closePath();
ctx.fillStyle = 'rgb(' + Math.floor(col[0]*0.6) + ',' + Math.floor(col[1]*0.6) + ',' + Math.floor(col[2]*0.6) + ')';
ctx.fill();
}
// Right face
if (topY + TH < baseY + TH) {
ctx.beginPath();
ctx.moveTo(p.x + s, topY + h);
ctx.lineTo(p.x, topY + TH);
ctx.lineTo(p.x, baseY + TH);
ctx.lineTo(p.x + s, baseY + h);
ctx.closePath();
ctx.fillStyle = 'rgb(' + Math.floor(col[0]*0.4) + ',' + Math.floor(col[1]*0.4) + ',' + Math.floor(col[2]*0.4) + ')';
ctx.fill();
}
}
ctx.clearRect(0, 0, c.width, c.height);
for (var gy = 0; gy < GRID; gy++) {
for (var gx = 0; gx < GRID; gx++) {
var h = (fbm(gx * 0.08, gy * 0.08) + 1) / 2;
drawColumn(gx, gy, h);
}
}
c.addEventListener('click', function() {
for (var k = 0; k < 512; k++) { perm[k] = perm[k % 256]; }
for (var k2 = 255; k2 > 0; k2--) { var j2 = Math.floor(Math.random()*(k2+1)); var t2=perm[k2]; perm[k2]=perm[j2]; perm[j2]=t2; }
for (var k3 = 0; k3 < 256; k3++) perm[256+k3] = perm[k3];
ctx.clearRect(0, 0, c.width, c.height);
for (var gy2 = 0; gy2 < GRID; gy2++) for (var gx2 = 0; gx2 < GRID; gx2++) {
drawColumn(gx2, gy2, (fbm(gx2*0.08, gy2*0.08)+1)/2);
}
});
</script>
</body>
</html>
Click to generate a new terrain. The heightmap is generated from fractal Brownian motion (fBm) — five octaves of Perlin noise layered together. Biome colors are assigned based on elevation: deep water at the lowest elevations, then sand beaches, grasslands, forests, rocky mountains, and snow-capped peaks. Each column is drawn as an extruded tile, with the side faces showing the cliff walls. This technique is used extensively in strategy games like Civilization and tactical RPGs.
5. Animated isometric water
Static terrain is beautiful, but animation brings isometric scenes to life. This example adds animated water tiles that shimmer with a sine-wave displacement, creating a gentle lapping effect on the shoreline.
<!DOCTYPE html>
<html><head><title>Isometric Water</title></head>
<body style="margin:0;background:#0a0e1a;display:flex;justify-content:center;align-items:center;height:100vh">
<canvas id="c"></canvas>
<script>
var c = document.getElementById('c'), ctx = c.getContext('2d');
c.width = 800; c.height = 600;
var TW = 40, TH = 20;
var originX = c.width / 2, originY = 80;
var GRID = 16;
// Simple island heightmap
var map = [];
var cx = GRID / 2, cy = GRID / 2;
for (var y = 0; y < GRID; y++) {
map[y] = [];
for (var x = 0; x < GRID; x++) {
var dx = x - cx, dy = y - cy;
var dist = Math.sqrt(dx * dx + dy * dy);
var h = Math.max(0, 1 - dist / (GRID * 0.4));
h = h * h * 3;
h += (Math.sin(x * 1.2) * Math.cos(y * 0.9)) * 0.2;
map[y][x] = Math.max(0, Math.min(2.5, h));
}
}
var WATER_LEVEL = 0.3;
function toScreen(gx, gy, gz) {
return { x: originX + (gx - gy) * TW / 2, y: originY + (gx + gy) * TH / 2 - gz * 40 };
}
function drawTile(gx, gy, gz, r, g, b, alpha) {
var p = toScreen(gx, gy, gz);
var s = TW / 2, h = TH / 2;
ctx.globalAlpha = alpha || 1;
// Top
ctx.beginPath();
ctx.moveTo(p.x, p.y); ctx.lineTo(p.x+s, p.y+h); ctx.lineTo(p.x, p.y+TH); ctx.lineTo(p.x-s, p.y+h);
ctx.closePath();
ctx.fillStyle = 'rgb('+r+','+g+','+b+')';
ctx.fill();
// Left side
var base = toScreen(gx, gy, 0);
if (p.y + TH < base.y + TH) {
ctx.beginPath();
ctx.moveTo(p.x-s, p.y+h); ctx.lineTo(p.x, p.y+TH); ctx.lineTo(p.x, base.y+TH); ctx.lineTo(p.x-s, base.y+h);
ctx.closePath();
ctx.fillStyle = 'rgb('+Math.floor(r*0.6)+','+Math.floor(g*0.6)+','+Math.floor(b*0.6)+')';
ctx.fill();
}
// Right side
if (p.y + TH < base.y + TH) {
ctx.beginPath();
ctx.moveTo(p.x+s, p.y+h); ctx.lineTo(p.x, p.y+TH); ctx.lineTo(p.x, base.y+TH); ctx.lineTo(p.x+s, base.y+h);
ctx.closePath();
ctx.fillStyle = 'rgb('+Math.floor(r*0.4)+','+Math.floor(g*0.4)+','+Math.floor(b*0.4)+')';
ctx.fill();
}
ctx.globalAlpha = 1;
}
function render(time) {
ctx.clearRect(0, 0, c.width, c.height);
var t = time * 0.001;
for (var gy = 0; gy < GRID; gy++) {
for (var gx = 0; gx < GRID; gx++) {
var h = map[gy][gx];
if (h > WATER_LEVEL) {
// Land
var col = h > 1.8 ? [220,225,230] : h > 1.2 ? [100,90,70] : h > 0.6 ? [50,140,50] : [180,170,120];
drawTile(gx, gy, h, col[0], col[1], col[2], 1);
} else {
// Animated water
var wave = Math.sin(t * 2 + gx * 0.7 + gy * 0.5) * 0.05;
var wh = WATER_LEVEL + wave;
var brightness = 120 + Math.sin(t * 3 + gx * 1.1 - gy * 0.8) * 30;
drawTile(gx, gy, wh, 30, Math.floor(brightness * 0.6), Math.floor(brightness), 0.8);
}
}
}
requestAnimationFrame(render);
}
requestAnimationFrame(render);
</script>
</body>
</html>
The water animation works by varying two things each frame: the water surface height (a sine wave that creates gentle bobbing) and the surface brightness (a second sine wave at a different frequency that creates shimmering highlights). The semi-transparent water tiles (alpha 0.8) let the dark base show through, adding depth. The phase offset based on grid position (gx * 0.7 + gy * 0.5) ensures the wave travels across the surface rather than pulsing uniformly.
6. Isometric character sprites
Isometric games need characters. This example draws simple pixel-art characters at isometric positions using a minimal sprite system. Each character is defined as a small grid of colored pixels, scaled up and placed at their isometric world position.
<!DOCTYPE html>
<html><head><title>Isometric Sprites</title></head>
<body style="margin:0;background:#1a1a2e;display:flex;justify-content:center;align-items:center;height:100vh">
<canvas id="c"></canvas>
<script>
var c = document.getElementById('c'), ctx = c.getContext('2d');
c.width = 800; c.height = 600;
var TW = 64, TH = 32;
var originX = c.width / 2, originY = 100;
function toScreen(gx, gy, gz) {
return { x: originX + (gx - gy) * TW / 2, y: originY + (gx + gy) * TH / 2 - gz * 32 };
}
function drawTile(gx, gy, fill) {
var p = toScreen(gx, gy, 0);
ctx.beginPath();
ctx.moveTo(p.x, p.y); ctx.lineTo(p.x+TW/2, p.y+TH/2); ctx.lineTo(p.x, p.y+TH); ctx.lineTo(p.x-TW/2, p.y+TH/2);
ctx.closePath();
ctx.fillStyle = fill; ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.2)'; ctx.lineWidth = 1; ctx.stroke();
}
// Character sprite: 7x10 pixel art (0=transparent)
var sprites = {
warrior: {
pixels: [
[0,0,0,'#aaa',0,0,0],
[0,0,'#ddd','#ccc','#ddd',0,0],
[0,0,'#fdb','#fdb','#fdb',0,0],
[0,0,'#fdb','#fdb','#fdb',0,0],
[0,'#c33','#c33','#c33','#c33','#c33',0],
[0,0,'#c33','#c33','#c33',0,0],
['#fdb',0,'#c33','#c33','#c33',0,'#fdb'],
[0,0,'#c33','#c33','#c33',0,0],
[0,0,'#44a',0,'#44a',0,0],
[0,0,'#333',0,'#333',0,0]
], color: '#c33'
},
mage: {
pixels: [
[0,0,'#63c','#63c','#63c',0,0],
[0,'#63c','#63c','#63c','#63c','#63c',0],
[0,0,'#fdb','#fdb','#fdb',0,0],
[0,0,'#fdb','#fdb','#fdb',0,0],
[0,'#63c','#63c','#63c','#63c','#63c',0],
[0,'#63c','#63c','#63c','#63c','#63c',0],
[0,'#63c','#63c','#63c','#63c','#63c',0],
[0,0,'#63c','#63c','#63c',0,0],
[0,0,'#fdb',0,'#fdb',0,0],
[0,0,'#333',0,'#333',0,0]
], color: '#63c'
},
archer: {
pixels: [
[0,0,0,'#5a3',0,0,0],
[0,0,'#fdb','#fdb','#fdb',0,0],
[0,0,'#fdb','#fdb','#fdb',0,0],
[0,0,'#5a3','#5a3','#5a3',0,0],
['#960',0,'#5a3','#5a3','#5a3',0,0],
['#960',0,'#5a3','#5a3','#5a3',0,0],
['#960','#fdb','#5a3','#5a3','#5a3','#fdb',0],
[0,0,'#5a3','#5a3','#5a3',0,0],
[0,0,'#543',0,'#543',0,0],
[0,0,'#333',0,'#333',0,0]
], color: '#5a3'
}
};
function drawSprite(sprite, gx, gy, scale) {
var pos = toScreen(gx, gy, 0);
var px = sprite.pixels;
var sw = px[0].length * scale;
var sh = px.length * scale;
var sx = pos.x - sw / 2;
var sy = pos.y - sh + TH / 2;
// Shadow
ctx.beginPath();
ctx.ellipse(pos.x, pos.y + TH / 4, 12, 5, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fill();
for (var row = 0; row < px.length; row++) {
for (var col = 0; col < px[row].length; col++) {
if (px[row][col] !== 0) {
ctx.fillStyle = px[row][col];
ctx.fillRect(sx + col * scale, sy + row * scale, scale, scale);
}
}
}
}
// Draw scene
var GRID_SIZE = 8;
ctx.clearRect(0, 0, c.width, c.height);
// Ground tiles
for (var gy = 0; gy < GRID_SIZE; gy++) {
for (var gx = 0; gx < GRID_SIZE; gx++) {
var shade = ((gx + gy) % 2 === 0) ? '#3a7a4a' : '#2e6e3e';
drawTile(gx, gy, shade);
}
}
// Characters (drawn in sorted order)
var chars = [
{sprite: sprites.warrior, gx: 2, gy: 3},
{sprite: sprites.mage, gx: 5, gy: 2},
{sprite: sprites.archer, gx: 4, gy: 5},
{sprite: sprites.warrior, gx: 6, gy: 6},
{sprite: sprites.mage, gx: 1, gy: 1}
];
chars.sort(function(a, b) { return (a.gx + a.gy) - (b.gx + b.gy); });
for (var i = 0; i < chars.length; i++) {
drawSprite(chars[i].sprite, chars[i].gx, chars[i].gy, 3);
}
</script>
</body>
</html>
Characters in isometric games are typically pre-rendered sprites drawn at 8 directions (N, NE, E, SE, S, SW, W, NW). This simplified version uses a single facing direction but demonstrates the core principles: sprites are positioned using the same toScreen transform as tiles, sorted by depth (ascending x+y) so characters in front correctly overlap those behind, and each character casts an elliptical shadow on the ground plane. The pixel-art sprites are defined as 2D arrays of color values, scaled up at render time — a technique that gives you crisp, resolution-independent characters.
7. Interactive isometric block editor
This example combines everything into an interactive voxel editor. Click tiles to place blocks, right-click to remove them. Hold shift to place blocks on top of existing ones. It’s a miniature version of the world-building tools used in games like Minecraft and Townscaper.
<!DOCTYPE html>
<html><head><title>Isometric Block Editor</title></head>
<body style="margin:0;background:#16213e;display:flex;justify-content:center;align-items:center;height:100vh">
<canvas id="c"></canvas>
<script>
var c = document.getElementById('c'), ctx = c.getContext('2d');
c.width = 800; c.height = 600;
var TW = 48, TH = 24, DEPTH = 32;
var originX = c.width / 2, originY = 100;
var GRID = 10, MAX_H = 8;
var currentHue = 200;
// 3D grid: grid[y][x] = height (number of stacked cubes)
var grid = [];
for (var y = 0; y < GRID; y++) { grid[y] = []; for (var x = 0; x < GRID; x++) grid[y][x] = 0; }
function toScreen(gx, gy, gz) {
return { x: originX + (gx - gy) * TW / 2, y: originY + (gx + gy) * TH / 2 - gz * DEPTH };
}
function drawCube(gx, gy, gz, hue) {
var top = toScreen(gx, gy, gz + 1);
var s = TW/2, h = TH/2;
ctx.beginPath();
ctx.moveTo(top.x, top.y); ctx.lineTo(top.x+s, top.y+h); ctx.lineTo(top.x, top.y+TH); ctx.lineTo(top.x-s, top.y+h);
ctx.closePath();
ctx.fillStyle = 'hsl('+hue+',60%,55%)'; ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.15)'; ctx.stroke();
ctx.beginPath();
ctx.moveTo(top.x-s, top.y+h); ctx.lineTo(top.x, top.y+TH); ctx.lineTo(top.x, top.y+TH+DEPTH); ctx.lineTo(top.x-s, top.y+h+DEPTH);
ctx.closePath();
ctx.fillStyle = 'hsl('+hue+',55%,38%)'; ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.15)'; ctx.stroke();
ctx.beginPath();
ctx.moveTo(top.x+s, top.y+h); ctx.lineTo(top.x, top.y+TH); ctx.lineTo(top.x, top.y+TH+DEPTH); ctx.lineTo(top.x+s, top.y+h+DEPTH);
ctx.closePath();
ctx.fillStyle = 'hsl('+hue+',50%,28%)'; ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.15)'; ctx.stroke();
}
function drawFloorTile(gx, gy) {
var p = toScreen(gx, gy, 0);
ctx.beginPath();
ctx.moveTo(p.x, p.y); ctx.lineTo(p.x+TW/2, p.y+TH/2); ctx.lineTo(p.x, p.y+TH); ctx.lineTo(p.x-TW/2, p.y+TH/2);
ctx.closePath();
ctx.fillStyle = ((gx+gy)%2===0) ? '#1e3a5f' : '#1a3050';
ctx.fill();
ctx.strokeStyle = 'rgba(100,150,200,0.2)'; ctx.lineWidth = 1; ctx.stroke();
}
function render() {
ctx.clearRect(0, 0, c.width, c.height);
// Floor
for (var gy2 = 0; gy2 < GRID; gy2++) for (var gx2 = 0; gx2 < GRID; gx2++) drawFloorTile(gx2, gy2);
// Cubes sorted
for (var sum = 0; sum < GRID * 2; sum++) {
for (var gx3 = 0; gx3 < GRID; gx3++) {
var gy3 = sum - gx3;
if (gy3 < 0 || gy3 >= GRID) continue;
for (var gz = 0; gz < grid[gy3][gx3]; gz++) {
var hue = (200 + gz * 30) % 360;
drawCube(gx3, gy3, gz, hue);
}
}
}
// HUD
ctx.fillStyle = '#fff'; ctx.font = '14px monospace';
ctx.fillText('Left click: place block | Right click: remove | Scroll: change color', 10, c.height - 15);
ctx.fillStyle = 'hsl('+currentHue+',60%,55%)';
ctx.fillRect(c.width - 40, c.height - 30, 25, 25);
ctx.strokeStyle = '#fff'; ctx.strokeRect(c.width - 40, c.height - 30, 25, 25);
}
function screenToGrid(mx, my) {
// Try each cell from front to back, top to bottom
var best = null;
for (var gy4 = GRID - 1; gy4 >= 0; gy4--) {
for (var gx4 = GRID - 1; gx4 >= 0; gx4--) {
var h = grid[gy4][gx4];
// Check top face of highest cube (or floor)
var gz = h;
var p = toScreen(gx4, gy4, gz);
var rx = mx - p.x, ry = my - p.y;
// Diamond hit test
if (Math.abs(rx / (TW/2)) + Math.abs((ry - TH/2) / (TH/2)) <= 1) {
if (!best || (gx4 + gy4 > best.gx + best.gy) || (gx4 + gy4 === best.gx + best.gy && gz > best.gz)) {
best = {gx: gx4, gy: gy4, gz: gz};
}
}
}
}
return best;
}
c.addEventListener('click', function(e) {
var rect = c.getBoundingClientRect();
var hit = screenToGrid(e.clientX - rect.left, e.clientY - rect.top);
if (hit && grid[hit.gy][hit.gx] < MAX_H) {
grid[hit.gy][hit.gx]++;
render();
}
});
c.addEventListener('contextmenu', function(e) {
e.preventDefault();
var rect = c.getBoundingClientRect();
var hit = screenToGrid(e.clientX - rect.left, e.clientY - rect.top);
if (hit && grid[hit.gy][hit.gx] > 0) {
grid[hit.gy][hit.gx]--;
render();
}
});
c.addEventListener('wheel', function(e) {
e.preventDefault();
currentHue = (currentHue + (e.deltaY > 0 ? 15 : -15) + 360) % 360;
render();
});
render();
</script>
</body>
</html>
The editor demonstrates the complete interaction loop for an isometric application: rendering the scene with correct depth ordering, picking (converting screen coordinates back to world coordinates), and modifying the world state. The picking algorithm tests each tile from front to back, checking if the mouse position falls within the diamond-shaped hit area of the top face. This reverse mapping is the trickiest part of isometric interaction — because tiles overlap, you must test in the correct order and take the frontmost hit.
8. Generative isometric landscape
The final example combines all the techniques — terrain, water, vegetation, buildings, and atmospheric effects — into a complete procedural isometric landscape. Click to generate new worlds with different seeds.
<!DOCTYPE html>
<html><head><title>Generative Isometric Landscape</title></head>
<body style="margin:0;background:#0b0e17;display:flex;justify-content:center;align-items:center;height:100vh">
<canvas id="c"></canvas>
<script>
var c = document.getElementById('c'), ctx = c.getContext('2d');
c.width = 900; c.height = 700;
var TW = 28, TH = 14, ELEV = 50;
var originX = c.width / 2, originY = 30;
var G = 22;
function seedRng(s) { return function(){ s=(s*16807)%2147483647; return(s-1)/2147483646; }; }
var rng = seedRng(12345);
var perm = [];
function initNoise() {
for(var i=0;i<256;i++) perm[i]=i;
for(var i2=255;i2>0;i2--){var j=Math.floor(rng()*(i2+1));var t=perm[i2];perm[i2]=perm[j];perm[j]=t;}
for(var i3=0;i3<256;i3++) perm[256+i3]=perm[i3];
}
function fade(t){return t*t*t*(t*(t*6-15)+10);}
function lerp(a,b,t){return a+t*(b-a);}
function grad(h,x,y){var v=h&3;return(v===0?x+y:v===1?-x+y:v===2?x-y:-x-y);}
function noise(x,y){
var X=Math.floor(x)&255,Y=Math.floor(y)&255;x-=Math.floor(x);y-=Math.floor(y);
var u=fade(x),v=fade(y),a=perm[X]+Y,b=perm[X+1]+Y;
return lerp(lerp(grad(perm[a],x,y),grad(perm[b],x-1,y),u),lerp(grad(perm[a+1],x,y-1),grad(perm[b+1],x-1,y-1),u),v);
}
function fbm(x,y){var v=0,a=1,f=1;for(var o=0;o<4;o++){v+=noise(x*f,y*f)*a;a*=0.5;f*=2;}return v;}
function toScreen(gx,gy,gz){return{x:originX+(gx-gy)*TW/2,y:originY+(gx+gy)*TH/2-gz*ELEV};}
function drawBlock(gx,gy,gz,r,g,b){
var top=toScreen(gx,gy,gz+1),s=TW/2,h=TH/2;
var bot=toScreen(gx,gy,gz);
ctx.beginPath();ctx.moveTo(top.x,top.y);ctx.lineTo(top.x+s,top.y+h);ctx.lineTo(top.x,top.y+TH);ctx.lineTo(top.x-s,top.y+h);ctx.closePath();
ctx.fillStyle='rgb('+r+','+g+','+b+')';ctx.fill();
ctx.beginPath();ctx.moveTo(top.x-s,top.y+h);ctx.lineTo(top.x,top.y+TH);ctx.lineTo(bot.x,bot.y+TH);ctx.lineTo(bot.x-s,bot.y+h);ctx.closePath();
ctx.fillStyle='rgb('+Math.floor(r*0.6)+','+Math.floor(g*0.6)+','+Math.floor(b*0.6)+')';ctx.fill();
ctx.beginPath();ctx.moveTo(top.x+s,top.y+h);ctx.lineTo(top.x,top.y+TH);ctx.lineTo(bot.x,bot.y+TH);ctx.lineTo(bot.x+s,bot.y+h);ctx.closePath();
ctx.fillStyle='rgb('+Math.floor(r*0.4)+','+Math.floor(g*0.4)+','+Math.floor(b*0.4)+')';ctx.fill();
}
function drawTree(gx,gy,gz){
var p=toScreen(gx,gy,gz);
// Trunk
ctx.fillStyle='#654';
ctx.fillRect(p.x-2,p.y-20,4,20);
// Canopy (3 layered circles)
var greens=['#2a7','#3b8','#2a6'];
for(var i=0;i<3;i++){
ctx.beginPath();
ctx.arc(p.x+(i-1)*3, p.y-24-i*5, 8-i, 0, Math.PI*2);
ctx.fillStyle=greens[i];
ctx.fill();
}
}
function drawHouse(gx,gy,gz){
var p=toScreen(gx,gy,gz);
var s=TW/2-2,h=TH/2-1;
// Walls
ctx.beginPath();ctx.moveTo(p.x-s,p.y+h);ctx.lineTo(p.x-s,p.y+h-22);ctx.lineTo(p.x,p.y-22);ctx.lineTo(p.x,p.y+TH);ctx.closePath();
ctx.fillStyle='#d4a';ctx.fill();
ctx.beginPath();ctx.moveTo(p.x+s,p.y+h);ctx.lineTo(p.x+s,p.y+h-22);ctx.lineTo(p.x,p.y-22);ctx.lineTo(p.x,p.y+TH);ctx.closePath();
ctx.fillStyle='#b38';ctx.fill();
// Roof
ctx.beginPath();ctx.moveTo(p.x,p.y-34);ctx.lineTo(p.x+s+2,p.y+h-22);ctx.lineTo(p.x,p.y-16);ctx.lineTo(p.x-s-2,p.y+h-22);ctx.closePath();
ctx.fillStyle='#c44';ctx.fill();
// Window
ctx.fillStyle='#ff8';
ctx.fillRect(p.x-3,p.y-12,6,6);
}
function generate(){
initNoise();
ctx.clearRect(0,0,c.width,c.height);
var WATER=0.25;
var heights=[], features=[];
for(var y=0;y<G;y++){heights[y]=[];features[y]=[];for(var x=0;x<G;x++){
var h=(fbm(x*0.1,y*0.1)+1)/2;heights[y][x]=h;
features[y][x]=null;
if(h>0.4&&h<0.7&&rng()<0.15)features[y][x]='tree';
if(h>0.35&&h<0.5&&rng()<0.03)features[y][x]='house';
}}
// Draw back to front
for(var sum=0;sum<G*2;sum++){
for(var gx=0;gx<G;gx++){
var gy=sum-gx;
if(gy<0||gy>=G)continue;
var hh=heights[gy][gx];
if(hh<=WATER){
// Water
drawBlock(gx,gy,0, 30,80+Math.floor(rng()*30),160+Math.floor(rng()*40));
} else {
// Terrain
var col=hh>0.8?[220,225,230]:hh>0.65?[130,110,90]:hh>0.4?[50,140+Math.floor(rng()*20),50]:[190,180,130];
drawBlock(gx,gy,hh, col[0],col[1],col[2]);
if(features[gy][gx]==='tree') drawTree(gx,gy,hh+1);
if(features[gy][gx]==='house') drawHouse(gx,gy,hh+1);
}
}
}
// Clouds
ctx.globalAlpha=0.08;
for(var ci=0;ci<5;ci++){
var cx2=rng()*c.width,cy2=rng()*120+20;
for(var cj=0;cj<4;cj++){
ctx.beginPath();
ctx.ellipse(cx2+cj*25-35, cy2+Math.sin(cj)*8, 30+rng()*15, 12+rng()*6, 0, 0, Math.PI*2);
ctx.fillStyle='#fff';ctx.fill();
}
}
ctx.globalAlpha=1;
}
generate();
c.addEventListener('click', function(){ rng=seedRng(Math.floor(Math.random()*99999)); generate(); });
</script>
</body>
</html>
Click to generate a new landscape. Each world features a unique terrain profile from Perlin noise, scattered trees in grassland zones, occasional houses in low-lying areas, biome-colored terrain (water, sand, grass, forest, mountain, snow), and semi-transparent clouds drifting overhead. The depth sorting iterates by the sum of x+y coordinates, ensuring everything renders in the correct back-to-front order regardless of elevation. This is the same technique used in classic isometric games to handle complex multi-layer scenes.
The mathematics of isometric projection
Isometric projection is one of several axonometric projections. In true isometric, the viewing direction is symmetric with respect to all three coordinate axes — the angle between any two projected axes is exactly 120°. Mathematically, this means the view direction vector is (1, 1, 1) / √3, and each axis is foreshortened by the same factor of √(2/3) ≈ 0.8165.
In pixel art, however, we typically use dimetric projection with a 2:1 pixel ratio. The horizontal displacement per tile is twice the vertical displacement, which gives clean pixel lines at approximately 26.57° (arctan(1/2)) rather than the true isometric 30°. This “pixel-perfect” isometric is so widely used that it has become the de facto standard, even though purists would call it dimetric.
The projection matrix for our screen transform is:
| screenX | | 0.5 -0.5 0 | | worldX |
| screenY | = | 0.25 0.25 -1 | | worldY |
| worldZ |
Scaled by tile dimensions. The inverse (screen-to-world) requires knowing Z, which is why isometric picking is harder than orthographic — you must test at each possible Z level or use other heuristics.
Depth sorting: the painter’s algorithm
The most common source of visual bugs in isometric rendering is incorrect depth sorting. For a flat grid, sorting by (x + y) ascending gives correct back-to-front order. When you add a Z axis (stacked cubes), you also sort by Z ascending within each (x + y) group. This works because:
- Objects with lower (x + y) are further from the camera.
- Objects at the same (x + y) but lower Z are below and behind higher ones.
- Within the same (x + y) and Z, objects with lower (y - x) are further right, which doesn’t affect occlusion in standard isometric.
For complex scenes with objects spanning multiple tiles (like the houses above), you may need more sophisticated approaches: BSP trees, tile-based layers, or even per-pixel depth buffers. But for most isometric art and games, the simple (x + y, z) sort is sufficient and fast.
Performance tips
- Only redraw what changed. Keep a dirty-tile list and only re-render affected tiles plus their neighbors (to handle overlap).
- Pre-render tiles to off-screen canvases. Draw each tile type once to a small canvas, then use
drawImageinstead of path operations. This is dramatically faster for static tiles. - Use integer coordinates. Align tile positions to whole pixels to avoid sub-pixel anti-aliasing blur. This is especially important for pixel-art style isometric games.
- Chunk your world. Divide the map into chunks and only process visible chunks. For a scrolling isometric map, calculate which chunk boundaries are visible and skip everything outside.
- Layer your rendering. Draw all ground tiles first, then all objects, then all overlays. This lets you batch similar draw calls and potentially use WebGL instancing for large scenes.
Going further with isometric art
The eight examples in this guide cover the foundational techniques, but isometric art is a rich field with many extensions:
- Smooth terrain: Instead of flat-topped columns, interpolate vertex heights between neighboring tiles to create smooth slopes and hillsides.
- Lighting and shadows: Cast directional shadows by projecting each object’s silhouette onto the ground plane. Ambient occlusion in tile seams adds subtle depth.
- Animated sprites: Create walk cycles for characters in 8 directions. Each direction needs its own sprite sheet to look correct in isometric view.
- Procedural buildings: Generate buildings from grammar rules — floor plans, window placement, roof types — for infinite architectural variety.
- WebGL isometric: Move the projection to a vertex shader for hardware-accelerated rendering of thousands of tiles. Use a texture atlas for all tile types.
Explore more generative art on Lumitree, where every branch grows into a unique procedural world. For related techniques, see the pixel art guide for retro-style rendering, the low poly art tutorial for triangulated 3D landscapes, or the terrain generation deep-dive for advanced heightmap techniques.