Isometric Art: How to Create Stunning 2.5D 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 — objects in the distance are the same size as objects up close. This gives isometric art its distinctive, toylike charm that has captivated game designers, illustrators, and generative artists for decades. From classic games like SimCity and Civilization to modern indie hits like Monument Valley, isometric projection turns flat pixels into convincing miniature worlds.
In this guide, we will build 8 different isometric art systems from scratch in JavaScript and Canvas. Each example demonstrates a different technique — from basic grid rendering to full procedural city generation. Every line of code runs in the browser with no dependencies.
How isometric projection works
Isometric projection maps 3D coordinates (x, y, z) onto 2D screen coordinates using a fixed rotation. The standard isometric angle tilts the view 30 degrees from horizontal, which produces a 2:1 width-to-height ratio for tiles. The math is straightforward:
// Convert 3D world coordinates to 2D screen coordinates
function toScreen(x, y, z) {
var screenX = (x - y) * TILE_W / 2;
var screenY = (x + y) * TILE_H / 2 - z * TILE_H;
return { x: screenX, y: screenY };
}
// TILE_W = tile width, TILE_H = tile height (usually TILE_W / 2)
// z lifts objects vertically (elevation)
This projection has a key property: moving +1 in x goes right and down on screen, moving +1 in y goes left and down, and moving +1 in z goes straight up. This makes depth sorting predictable — objects with higher (x + y) values are drawn later, back to front.
1. Isometric grid
The foundation of all isometric art is the diamond grid. Each tile is a rhombus (diamond shape) that represents one unit of floor space. Drawing the grid back-to-front ensures correct layering.
var c = document.createElement('canvas');
c.width = 580; c.height = 400;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var TW = 56, TH = 28; // tile width and height (2:1 ratio)
var COLS = 8, ROWS = 8;
var ox = c.width / 2, oy = 60; // origin offset
function toIso(col, row) {
return {
x: ox + (col - row) * TW / 2,
y: oy + (col + row) * TH / 2
};
}
function drawTile(col, row, fill) {
var p = toIso(col, row);
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 = '#556';
ctx.lineWidth = 1;
ctx.stroke();
}
// Draw grid back to front
for (var row = 0; row < ROWS; row++) {
for (var col = 0; col < COLS; col++) {
var hue = (col * 15 + row * 25) % 360;
drawTile(col, row, 'hsl(' + hue + ',40%,65%)');
}
}
The 2:1 ratio (tile width = 2 × tile height) is standard in isometric art. It produces clean pixel lines on screen and tiles that fit together perfectly without subpixel gaps.
2. Isometric cube renderer
A single isometric cube has three visible faces: top, left, and right. By shading each face differently, the illusion of depth is immediate and convincing. The top face gets the lightest shade, the right face is medium, and the left face is darkest — simulating a light source from the upper right.
var c = document.createElement('canvas');
c.width = 580; c.height = 440;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var TW = 48, TH = 24, DEPTH = 40;
var ox = c.width / 2, oy = 100;
function toIso(col, row) {
return {
x: ox + (col - row) * TW / 2,
y: oy + (col + row) * TH / 2
};
}
function drawCube(col, row, h, baseHue) {
var p = toIso(col, row);
var lift = h * DEPTH;
// Top face
ctx.beginPath();
ctx.moveTo(p.x, p.y - lift);
ctx.lineTo(p.x + TW / 2, p.y + TH / 2 - lift);
ctx.lineTo(p.x, p.y + TH - lift);
ctx.lineTo(p.x - TW / 2, p.y + TH / 2 - lift);
ctx.closePath();
ctx.fillStyle = 'hsl(' + baseHue + ',55%,70%)';
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.15)';
ctx.stroke();
// Left face
ctx.beginPath();
ctx.moveTo(p.x - TW / 2, p.y + TH / 2 - lift);
ctx.lineTo(p.x, p.y + TH - lift);
ctx.lineTo(p.x, p.y + TH);
ctx.lineTo(p.x - TW / 2, p.y + TH / 2);
ctx.closePath();
ctx.fillStyle = 'hsl(' + baseHue + ',50%,40%)';
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.15)';
ctx.stroke();
// Right face
ctx.beginPath();
ctx.moveTo(p.x + TW / 2, p.y + TH / 2 - lift);
ctx.lineTo(p.x, p.y + TH - lift);
ctx.lineTo(p.x, p.y + TH);
ctx.lineTo(p.x + TW / 2, p.y + TH / 2);
ctx.closePath();
ctx.fillStyle = 'hsl(' + baseHue + ',50%,52%)';
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.15)';
ctx.stroke();
}
// Draw a field of cubes with varying heights
var heights = [];
for (var i = 0; i < 64; i++) heights.push(Math.random() * 2 + 0.5);
for (var row = 0; row < 8; row++) {
for (var col = 0; col < 8; col++) {
var idx = row * 8 + col;
var hue = 200 + heights[idx] * 30;
drawCube(col, row, heights[idx], hue);
}
}
The three-face shading trick is the entire secret behind convincing isometric art. Light top, medium right, dark left — memorize this and every cube you draw will look solid.
3. Voxel terrain with height map
Voxel terrain stacks cubes to create landscapes. Using a simple noise function to generate heights, we can build rolling hills, valleys, and cliffs. The key insight is to draw columns from back to front and bottom to top so that nearer, taller columns correctly occlude farther ones.
var c = document.createElement('canvas');
c.width = 620; c.height = 500;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var TW = 32, TH = 16, VH = 12;
var COLS = 16, ROWS = 16;
var ox = c.width / 2, oy = 40;
function toIso(col, row, z) {
return {
x: ox + (col - row) * TW / 2,
y: oy + (col + row) * TH / 2 - z * VH
};
}
// Simple value noise
var perm = [];
for (var i = 0; i < 256; i++) perm.push(i);
for (var i = 255; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = perm[i]; perm[i] = perm[j]; perm[j] = tmp;
}
function noise2d(x, y) {
var xi = Math.floor(x) & 255, yi = Math.floor(y) & 255;
var xf = x - Math.floor(x), yf = y - Math.floor(y);
var u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
var a = perm[xi] + yi, b = perm[xi + 1] + yi;
var aa = perm[a & 255] / 255, ba = perm[b & 255] / 255;
var ab = perm[(a + 1) & 255] / 255, bb = perm[(b + 1) & 255] / 255;
return aa + u * (ba - aa) + v * (ab - aa) + u * v * (aa - ba - ab + bb);
}
function drawVoxel(col, row, z, hue, sat, lit) {
var p = toIso(col, row, z);
// Top
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 = 'hsl(' + hue + ',' + sat + '%,' + lit + '%)';
ctx.fill();
// Left
ctx.beginPath();
ctx.moveTo(p.x - TW / 2, p.y + TH / 2);
ctx.lineTo(p.x, p.y + TH);
ctx.lineTo(p.x, p.y + TH + VH);
ctx.lineTo(p.x - TW / 2, p.y + TH / 2 + VH);
ctx.closePath();
ctx.fillStyle = 'hsl(' + hue + ',' + sat + '%,' + (lit - 18) + '%)';
ctx.fill();
// Right
ctx.beginPath();
ctx.moveTo(p.x + TW / 2, p.y + TH / 2);
ctx.lineTo(p.x, p.y + TH);
ctx.lineTo(p.x, p.y + TH + VH);
ctx.lineTo(p.x + TW / 2, p.y + TH / 2 + VH);
ctx.closePath();
ctx.fillStyle = 'hsl(' + hue + ',' + sat + '%,' + (lit - 10) + '%)';
ctx.fill();
}
// Generate terrain
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, c.width, c.height);
for (var row = 0; row < ROWS; row++) {
for (var col = 0; col < COLS; col++) {
var h = noise2d(col * 0.2, row * 0.2) * 5 + noise2d(col * 0.5, row * 0.5) * 2;
var maxH = Math.floor(h) + 1;
for (var z = 0; z < maxH; z++) {
var isTop = z === maxH - 1;
var hue = isTop ? 120 : (z < 2 ? 30 : 95);
var sat = isTop ? 50 : 35;
var lit = isTop ? 55 + z * 3 : 35 + z * 5;
drawVoxel(col, row, z, hue, sat, lit);
}
}
}
The terrain uses two octaves of noise at different frequencies — one for broad hills and one for surface detail. Green tops represent grass, brown lower layers represent earth and stone. This layered noise approach is the same technique used in Minecraft's terrain generation.
4. Isometric city builder
Cities are the classic use case for isometric art. This example procedurally generates a city block with buildings of varying heights, roads, and parks. The building style uses flat-colored faces with window patterns drawn as small rectangles.
var c = document.createElement('canvas');
c.width = 620; c.height = 520;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var TW = 40, TH = 20, FH = 10;
var GRID = 12;
var ox = c.width / 2, oy = 30;
function toIso(col, row, z) {
return {
x: ox + (col - row) * TW / 2,
y: oy + (col + row) * TH / 2 - z * FH
};
}
function drawBlock(col, row, floors, hue) {
var h = floors * FH;
var p = toIso(col, row, 0);
// Left wall
ctx.fillStyle = 'hsl(' + hue + ',30%,35%)';
ctx.beginPath();
ctx.moveTo(p.x - TW / 2, p.y + TH / 2);
ctx.lineTo(p.x, p.y + TH);
ctx.lineTo(p.x, p.y + TH - h);
ctx.lineTo(p.x - TW / 2, p.y + TH / 2 - h);
ctx.closePath();
ctx.fill();
// Right wall
ctx.fillStyle = 'hsl(' + hue + ',30%,45%)';
ctx.beginPath();
ctx.moveTo(p.x + TW / 2, p.y + TH / 2);
ctx.lineTo(p.x, p.y + TH);
ctx.lineTo(p.x, p.y + TH - h);
ctx.lineTo(p.x + TW / 2, p.y + TH / 2 - h);
ctx.closePath();
ctx.fill();
// Roof
ctx.fillStyle = 'hsl(' + hue + ',25%,55%)';
ctx.beginPath();
ctx.moveTo(p.x, p.y - h);
ctx.lineTo(p.x + TW / 2, p.y + TH / 2 - h);
ctx.lineTo(p.x, p.y + TH - h);
ctx.lineTo(p.x - TW / 2, p.y + TH / 2 - h);
ctx.closePath();
ctx.fill();
// Windows on left wall
ctx.fillStyle = 'rgba(255,240,180,0.6)';
for (var f = 0; f < floors; f++) {
var wy = p.y + TH / 2 - f * FH - FH * 0.7;
var wx = p.x - TW / 4;
ctx.fillRect(wx - 2, wy, 3, 3);
ctx.fillRect(wx + 3, wy, 3, 3);
}
}
function drawRoad(col, row) {
var p = toIso(col, row, 0);
ctx.fillStyle = '#3a3a4a';
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.fill();
// Center line
ctx.fillStyle = '#666';
ctx.fillRect(p.x - 1, p.y + TH / 2 - 1, 2, 2);
}
function drawPark(col, row) {
var p = toIso(col, row, 0);
ctx.fillStyle = '#2d5a2d';
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.fill();
// Tree
ctx.fillStyle = '#4a8a4a';
ctx.beginPath();
ctx.arc(p.x, p.y + TH / 2 - 8, 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#5a3a2a';
ctx.fillRect(p.x - 1, p.y + TH / 2 - 3, 2, 5);
}
ctx.fillStyle = '#0f0f1a';
ctx.fillRect(0, 0, c.width, c.height);
// Generate city
for (var row = 0; row < GRID; row++) {
for (var col = 0; col < GRID; col++) {
var isRoad = col % 4 === 0 || row % 4 === 0;
var isPark = !isRoad && Math.random() < 0.1;
if (isRoad) {
drawRoad(col, row);
} else if (isPark) {
drawPark(col, row);
} else {
var floors = Math.floor(Math.random() * 8) + 2;
var hue = 200 + Math.floor(Math.random() * 40);
drawBlock(col, row, floors, hue);
}
}
}
The city uses a simple rule: every 4th column and row is a road. Remaining tiles are randomly assigned as buildings (90%) or parks (10%). Buildings get a random number of floors between 2 and 10. The back-to-front drawing order automatically handles occlusion — taller buildings in the back peek over shorter ones in front.
5. Pixel isometric tiles
Pixel art and isometric projection are a natural combination. Classic games like Final Fantasy Tactics and Tactics Ogre used hand-placed pixel isometric tiles. Here we generate them procedurally — each tile type (grass, water, stone, sand) has its own pixel pattern drawn at the sub-tile level.
var c = document.createElement('canvas');
c.width = 580; c.height = 420;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var TW = 32, TH = 16;
var COLS = 12, ROWS = 12;
var ox = c.width / 2, oy = 20;
function toIso(col, row) {
return {
x: ox + (col - row) * TW / 2,
y: oy + (col + row) * TH / 2
};
}
// Check if point is inside isometric diamond
function inDiamond(px, py, cx, cy) {
var dx = Math.abs(px - cx) / (TW / 2);
var dy = Math.abs(py - cy) / (TH / 2);
return dx + dy <= 1;
}
function drawPixelTile(col, row, type) {
var p = toIso(col, row);
var cx = p.x, cy = p.y + TH / 2;
var colors;
if (type === 0) { // grass
colors = ['#3a7a3a', '#4a8a4a', '#2d6a2d', '#5a9a5a'];
} else if (type === 1) { // water
colors = ['#2a4a8a', '#3a5a9a', '#1a3a7a', '#4a6aaa'];
} else if (type === 2) { // stone
colors = ['#6a6a6a', '#7a7a7a', '#5a5a5a', '#8a8a8a'];
} else { // sand
colors = ['#c4a44a', '#d4b45a', '#b4943a', '#e4c46a'];
}
// Fill diamond pixel by pixel (2px resolution)
for (var py = p.y; py < p.y + TH; py += 2) {
for (var px = p.x - TW / 2; px < p.x + TW / 2; px += 2) {
if (inDiamond(px + 1, py + 1, cx, cy)) {
var ci = (Math.floor(px * 7.3 + py * 13.7) & 3);
ctx.fillStyle = colors[ci];
ctx.fillRect(px, py, 2, 2);
}
}
}
// Tile border
ctx.strokeStyle = 'rgba(0,0,0,0.1)';
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.stroke();
}
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, c.width, c.height);
// Simple island: water around edges, sand border, grass center, stone mountains
for (var row = 0; row < ROWS; row++) {
for (var col = 0; col < COLS; col++) {
var dx = col - COLS / 2 + 0.5;
var dy = row - ROWS / 2 + 0.5;
var dist = Math.sqrt(dx * dx + dy * dy);
var type;
if (dist > 5) type = 1; // water
else if (dist > 4) type = 3; // sand
else if (dist < 1.5) type = 2; // stone (mountain)
else type = 0; // grass
drawPixelTile(col, row, type);
}
}
The pixel dithering uses a hash function to select from 4 color variants per tile type, creating the characteristic pixel-art texture without needing actual image assets. The island is generated using distance from center — a simple but effective technique for procedural landmasses.
6. Animated isometric waterfall
Animation brings isometric scenes to life. This example creates a cascading waterfall flowing down stepped terrain. Water particles are spawned at the top and follow gravity, splashing when they hit surfaces. The isometric projection makes the animation feel spatial.
var c = document.createElement('canvas');
c.width = 580; c.height = 460;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var TW = 48, TH = 24, SH = 20;
var ox = c.width / 2 - 40, oy = 40;
function toIso(x, y, z) {
return {
x: ox + (x - y) * TW / 2,
y: oy + (x + y) * TH / 2 - z * SH
};
}
// Terrain steps
var steps = [
{ col: 3, row: 1, z: 5 },
{ col: 3, row: 2, z: 4 },
{ col: 3, row: 3, z: 3 },
{ col: 4, row: 3, z: 2 },
{ col: 5, row: 3, z: 1 },
{ col: 5, row: 4, z: 0 },
{ col: 5, row: 5, z: 0 },
{ col: 6, row: 5, z: 0 }
];
// Cliff tiles (background)
var cliffs = [];
for (var r = 0; r < 7; r++) {
for (var cc = 0; cc < 8; cc++) {
if (!(cc === 3 && r < 4) && !(cc > 3 && r === 3) && !(cc >= 5 && r > 3)) {
var cz = Math.max(0, 5 - r - Math.abs(cc - 3) * 0.5);
cliffs.push({ col: cc, row: r, z: Math.floor(cz) });
}
}
}
function drawStep(col, row, z, hue) {
var p = toIso(col, row, z);
// Top
ctx.fillStyle = 'hsl(' + hue + ',35%,50%)';
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.fill();
// Left
var wallH = z * SH + SH;
ctx.fillStyle = 'hsl(' + hue + ',30%,32%)';
ctx.beginPath();
ctx.moveTo(p.x - TW / 2, p.y + TH / 2); ctx.lineTo(p.x, p.y + TH);
ctx.lineTo(p.x, p.y + TH + wallH); ctx.lineTo(p.x - TW / 2, p.y + TH / 2 + wallH);
ctx.closePath(); ctx.fill();
// Right
ctx.fillStyle = 'hsl(' + hue + ',30%,40%)';
ctx.beginPath();
ctx.moveTo(p.x + TW / 2, p.y + TH / 2); ctx.lineTo(p.x, p.y + TH);
ctx.lineTo(p.x, p.y + TH + wallH); ctx.lineTo(p.x + TW / 2, p.y + TH / 2 + wallH);
ctx.closePath(); ctx.fill();
}
// Water particles
var drops = [];
function spawnDrop() {
drops.push({
x: 3 + Math.random() * 0.6 - 0.3,
y: 0.5 + Math.random() * 0.4,
z: 5.5,
vx: 0, vy: 0.02, vz: 0,
life: 1
});
}
function updateDrops() {
for (var i = drops.length - 1; i >= 0; i--) {
var d = drops[i];
d.vz -= 0.008; // gravity
d.x += d.vx;
d.y += d.vy;
d.z += d.vz;
d.life -= 0.005;
// Check step collisions
for (var s = 0; s < steps.length; s++) {
var st = steps[s];
if (Math.abs(d.x - st.col) < 0.6 && Math.abs(d.y - st.row) < 0.6 && d.z <= st.z + 0.3 && d.z > st.z - 0.2) {
d.z = st.z + 0.3;
d.vz = 0.03 + Math.random() * 0.02;
d.vy += 0.01;
d.vx += (Math.random() - 0.4) * 0.02;
}
}
if (d.life <= 0 || d.z < -2) drops.splice(i, 1);
}
}
function draw() {
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, c.width, c.height);
// Draw terrain
cliffs.forEach(function(cl) { drawStep(cl.col, cl.row, cl.z, 25); });
steps.forEach(function(st) { drawStep(st.col, st.row, st.z, 155); });
// Spawn drops
if (Math.random() < 0.4) spawnDrop();
updateDrops();
// Draw water drops
drops.forEach(function(d) {
var p = toIso(d.x, d.y, d.z);
var alpha = d.life * 0.8;
ctx.fillStyle = 'rgba(100,180,255,' + alpha + ')';
ctx.beginPath();
ctx.arc(p.x, p.y, 2 + d.life * 2, 0, Math.PI * 2);
ctx.fill();
});
// Pool at bottom
ctx.fillStyle = 'rgba(60,140,220,0.3)';
var pp = toIso(5, 5, 0);
ctx.beginPath();
ctx.ellipse(pp.x, pp.y + TH / 2, TW, TH * 0.7, 0, 0, Math.PI * 2);
ctx.fill();
requestAnimationFrame(draw);
}
draw();
The waterfall uses simple Euler integration for physics — gravity pulls drops down, and collision detection against the step surfaces makes them bounce and flow. The transparent pool at the bottom is drawn as an ellipse in isometric space. Real water simulation (like fluid simulation) could replace the particles for more realistic results.
7. Depth-sorted isometric scene
When objects overlap in isometric space, depth sorting determines which appears in front. The painter's algorithm draws back-to-front: sort all objects by their (row + col) sum (the isometric depth), then draw in order. Objects with higher depth values are closer to the viewer.
var c = document.createElement('canvas');
c.width = 580; c.height = 440;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var TW = 44, TH = 22, BH = 14;
var ox = c.width / 2, oy = 50;
function toIso(col, row, z) {
return {
x: ox + (col - row) * TW / 2,
y: oy + (col + row) * TH / 2 - z * BH
};
}
// Scene objects: trees, rocks, houses
var objects = [];
// Generate a forest village
for (var i = 0; i < 40; i++) {
var col = Math.floor(Math.random() * 10);
var row = Math.floor(Math.random() * 10);
var type = Math.random();
if (type < 0.15) {
objects.push({ col: col, row: row, type: 'house', floors: Math.floor(Math.random() * 3) + 1 });
} else if (type < 0.4) {
objects.push({ col: col, row: row, type: 'rock', size: Math.random() * 0.5 + 0.3 });
} else {
objects.push({ col: col, row: row, type: 'tree', height: Math.random() * 20 + 15 });
}
}
// Sort by depth: back to front
objects.sort(function(a, b) {
return (a.col + a.row) - (b.col + b.row);
});
function drawTree(col, row) {
var p = toIso(col, row, 0);
var bx = p.x, by = p.y + TH / 2;
// Trunk
ctx.fillStyle = '#6b4226';
ctx.fillRect(bx - 2, by - 25, 4, 20);
// Canopy layers
var greens = ['#2d6b2d', '#3a8a3a', '#4a9a4a'];
for (var i = 0; i < 3; i++) {
ctx.fillStyle = greens[i];
ctx.beginPath();
ctx.ellipse(bx, by - 28 - i * 8, 12 - i * 2, 8 - i * 1.5, 0, 0, Math.PI * 2);
ctx.fill();
}
}
function drawRock(col, row, size) {
var p = toIso(col, row, 0);
var bx = p.x, by = p.y + TH / 2;
var r = size * 12;
ctx.fillStyle = '#707070';
ctx.beginPath();
ctx.ellipse(bx, by - r * 0.5, r * 1.2, r * 0.8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#888';
ctx.beginPath();
ctx.ellipse(bx - r * 0.2, by - r * 0.7, r * 0.6, r * 0.4, -0.3, 0, Math.PI * 2);
ctx.fill();
}
function drawHouse(col, row, floors) {
var p = toIso(col, row, 0);
var h = floors * BH + 10;
// Walls
ctx.fillStyle = '#c4956a';
ctx.fillRect(p.x - 10, p.y + TH / 2 - h, 20, h);
// Roof
ctx.fillStyle = '#8b3a3a';
ctx.beginPath();
ctx.moveTo(p.x - 14, p.y + TH / 2 - h);
ctx.lineTo(p.x, p.y + TH / 2 - h - 12);
ctx.lineTo(p.x + 14, p.y + TH / 2 - h);
ctx.closePath();
ctx.fill();
// Door
ctx.fillStyle = '#5a3a1a';
ctx.fillRect(p.x - 3, p.y + TH / 2 - 10, 6, 10);
// Windows
ctx.fillStyle = '#ffeebb';
for (var f = 0; f < floors; f++) {
ctx.fillRect(p.x - 7, p.y + TH / 2 - 16 - f * BH, 4, 4);
ctx.fillRect(p.x + 3, p.y + TH / 2 - 16 - f * BH, 4, 4);
}
}
// Draw ground
ctx.fillStyle = '#1a2a1a';
ctx.fillRect(0, 0, c.width, c.height);
for (var row = 0; row < 10; row++) {
for (var col = 0; col < 10; col++) {
var p = toIso(col, row, 0);
ctx.fillStyle = 'hsl(120,' + (20 + Math.random() * 15) + '%,' + (25 + Math.random() * 10) + '%)';
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.fill();
}
}
// Draw sorted objects
objects.forEach(function(obj) {
if (obj.type === 'tree') drawTree(obj.col, obj.row);
else if (obj.type === 'rock') drawRock(obj.col, obj.row, obj.size);
else if (obj.type === 'house') drawHouse(obj.col, obj.row, obj.floors);
});
Depth sorting is what makes complex isometric scenes possible. Without it, a tree in the back could accidentally draw over a house in the front. The sort key (col + row) works because in isometric projection, objects with larger (col + row) sums are visually closer to the viewer. For objects at the same depth, you can add z-height as a tiebreaker.
8. Generative isometric art
Our final example combines everything into a generative art piece: an animated isometric composition where cubes grow, pulse, and shift colors. Each frame, the heights oscillate using sine waves with different phases, creating a mesmerizing wave effect across the grid.
var c = document.createElement('canvas');
c.width = 620; c.height = 500;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var TW = 36, TH = 18, BH = 8;
var GRID = 12;
var ox = c.width / 2, oy = 60;
var time = 0;
function toIso(col, row, z) {
return {
x: ox + (col - row) * TW / 2,
y: oy + (col + row) * TH / 2 - z * BH
};
}
function drawBar(col, row, height, hue, sat, lit) {
var p = toIso(col, row, 0);
var h = height * BH;
// Top
ctx.fillStyle = 'hsl(' + hue + ',' + sat + '%,' + lit + '%)';
ctx.beginPath();
ctx.moveTo(p.x, p.y - h);
ctx.lineTo(p.x + TW / 2, p.y + TH / 2 - h);
ctx.lineTo(p.x, p.y + TH - h);
ctx.lineTo(p.x - TW / 2, p.y + TH / 2 - h);
ctx.closePath();
ctx.fill();
// Left face
ctx.fillStyle = 'hsl(' + hue + ',' + sat + '%,' + (lit - 20) + '%)';
ctx.beginPath();
ctx.moveTo(p.x - TW / 2, p.y + TH / 2 - h);
ctx.lineTo(p.x, p.y + TH - h);
ctx.lineTo(p.x, p.y + TH);
ctx.lineTo(p.x - TW / 2, p.y + TH / 2);
ctx.closePath();
ctx.fill();
// Right face
ctx.fillStyle = 'hsl(' + hue + ',' + sat + '%,' + (lit - 10) + '%)';
ctx.beginPath();
ctx.moveTo(p.x + TW / 2, p.y + TH / 2 - h);
ctx.lineTo(p.x, p.y + TH - h);
ctx.lineTo(p.x, p.y + TH);
ctx.lineTo(p.x + TW / 2, p.y + TH / 2);
ctx.closePath();
ctx.fill();
}
function animate() {
time += 0.02;
ctx.fillStyle = '#0a0a14';
ctx.fillRect(0, 0, c.width, c.height);
for (var row = 0; row < GRID; row++) {
for (var col = 0; col < GRID; col++) {
// Layered sine waves create organic pulsing
var wave1 = Math.sin(col * 0.5 + time * 1.2) * 2;
var wave2 = Math.cos(row * 0.4 + time * 0.8) * 1.5;
var wave3 = Math.sin((col + row) * 0.3 + time * 0.6) * 1;
var wave4 = Math.cos(Math.sqrt(
(col - GRID / 2) * (col - GRID / 2) +
(row - GRID / 2) * (row - GRID / 2)
) * 0.8 - time * 1.5) * 2;
var height = Math.max(0.3, wave1 + wave2 + wave3 + wave4 + 4);
var hue = (col * 20 + row * 15 + time * 30) % 360;
var sat = 60 + Math.sin(time + col * 0.3) * 15;
var lit = 45 + height * 3;
drawBar(col, row, height, Math.floor(hue), Math.floor(sat), Math.floor(lit));
}
}
requestAnimationFrame(animate);
}
animate();
The height of each bar is driven by four overlapping sine waves — two linear (column and row based), one diagonal (col + row), and one radial (distance from center). This creates complex, organic-looking patterns that never repeat. The hue shifts over time, creating a constantly evolving color palette that flows across the grid like aurora borealis viewed from above.
Isometric art techniques and tips
- The 2:1 rule: always use tile width = 2 × tile height. This produces the cleanest pixel lines and makes tiles fit without gaps.
- Three-face shading: top = light, right = medium, left = dark. This simple rule makes any cube look three-dimensional instantly.
- Draw back-to-front: iterate rows and columns in order so that objects closer to the viewer are drawn last, naturally occluding those behind.
- Use integer coordinates: subpixel rendering causes blurry edges in isometric art. Round your screen coordinates to avoid gaps between tiles.
- Depth sorting key: sort objects by (col + row), then by z-height for tiebreaking. This handles 99% of occlusion cases correctly.
- Elevation: subtract z × tileHeight from the y screen coordinate to lift objects. Height is always straight up on screen.
- Outlined edges: a 1px darker stroke around tiles adds definition. Use rgba(0,0,0,0.15) for subtle edges that don't overpower the fill colors.
- Shadow projection: draw a dark translucent diamond on the ground below elevated objects. Offset it by the object's height to simulate a directional light.
The math behind isometric projection
Isometric projection is a special case of axonometric projection where the three coordinate axes are equally foreshortened. In true isometric, the angle between any two axes is 120°. The projection matrix for standard isometric is:
// True isometric angles:
// x-axis: 30° from horizontal (going right-down)
// y-axis: 30° from horizontal (going left-down)
// z-axis: straight up
// Simplified 2D mapping:
// screenX = (worldX - worldY) * cos(30°) = (worldX - worldY) * 0.866
// screenY = (worldX + worldY) * sin(30°) - worldZ = (worldX + worldY) * 0.5 - worldZ
// In pixel art, we approximate cos(30°) as 0.5 * tileWidth
// and sin(30°) as 0.5 * tileHeight, giving the 2:1 ratio
This approximation (2:1 ratio instead of true cos(30°)/sin(30°)) is called dimetric projection technically, but the game industry universally calls it "isometric." The slight angle difference is invisible in practice and produces cleaner pixel grids.
Where to go from here
- Combine isometric rendering with procedural generation algorithms to create infinite worlds — caves, dungeons, and landscapes that generate as the player explores
- Apply pixel art techniques to your isometric tiles for a retro aesthetic with limited color palettes and deliberate dithering patterns
- Use Perlin noise to generate natural-looking terrain elevation maps instead of random heights — smooth hills flow better than jagged random cubes
- Add color theory to your isometric palette — try analogous color schemes for natural scenes and complementary schemes for abstract art
- Explore mathematical patterns in isometric space — Fibonacci spirals, golden ratio subdivisions, and fractal patterns all look stunning when projected isometrically
- Animate with particle systems — add rain, snow, fire, or magic effects that interact with your isometric terrain and buildings
- On Lumitree, isometric micro-worlds create miniature ecosystems and cities that grow from a single seed — each one a tiny 2.5D universe living inside the infinite tree