Skip to Content
Rendering

Last Updated: 3/6/2026


Isometric Rendering System

The Snake Game uses a custom isometric projection system to render a 2D grid-based game in pseudo-3D. This document explains how the rendering pipeline works.

Overview

Isometric projection creates the illusion of 3D by:

  1. Transforming grid coordinates to screen coordinates using rotation and scaling
  2. Adding perspective distortion for depth perception
  3. Rendering objects back-to-front (painter’s algorithm)
  4. Drawing 3D blocks with shaded faces

Coordinate Systems

Grid Space

  • Origin: Top-left corner (0, 0)
  • Axes: X increases right, Y increases down
  • Units: Grid cells (integers)

Screen Space

  • Origin: Canvas top-left (0, 0)
  • Axes: X increases right, Y increases down
  • Units: Pixels

Transformation Pipeline

1. Basis Vector Calculation

The grid is rotated and scaled using basis vectors:

const cosA = Math.cos(this.isoAngle) // Default: 60° rotation const sinA = Math.sin(this.isoAngle) const pr = this.perspectiveRatio // 0.5 = vertical squash this.basisXx = cosA * scale this.basisXy = sinA * scale * pr this.basisYx = -sinA * scale this.basisYy = cosA * scale * pr

Basis vectors:

  • (basisXx, basisXy): Grid X-axis direction in screen space
  • (basisYx, basisYy): Grid Y-axis direction in screen space

2. Scale Calculation

The scale factor ensures the grid fits within the viewport:

const isoWidth = getIsoWidth() // Viewport-based width const scale = isoWidth / (this.gridSize * Math.SQRT2)

This makes the grid size rotation-invariant - it stays the same visual size when rotated with Q/E keys.

3. Perspective Projection

Perspective is applied to create depth:

const d = this.rawMaxY - rawY // Distance from camera const scale = this.focalLength / (this.focalLength + d)
  • Objects farther from the “camera” (higher rawY) appear smaller
  • perspectiveStrength controls intensity (0 = no perspective, 0.8 = strong)
  • Adjusted with [ and ] keys

4. Origin Offset

The grid is centered in the canvas:

this.isoOriginX = this.canvas.width / 2 - gridCenterX this.isoOriginY = this.canvas.height / 2 - gridCenterY

This ensures rotation pivots around the grid center, not the canvas corner.

Rendering Pipeline

Step 1: Draw Ground Plane

private drawGroundPlane() { // Draw checkerboard pattern for (let gy = 0; gy < this.gridSize; gy++) { for (let gx = 0; gx < this.gridSize; gx++) { const color = (gx + gy) % 2 === 0 ? '#2a2a2a' : '#222222' // Draw quad using cached iso coordinates } } }

Optimization: Uses Path2D batching to draw all light tiles in one fill, then all dark tiles.

Step 2: Draw Grid Lines

private drawGridLines() { // Skip if tiles are too small if (tileScreenWidth < 5) return // Draw lines along each axis for (let gy = 0; gy <= this.gridSize; gy++) { // Line from (0, gy) to (gridSize, gy) } }

Optimization: Conditionally skipped when grid is zoomed out.

Step 3: Sort Objects (Painter’s Algorithm)

objects.sort((a, b) => (a.x * this.basisXy + a.y * this.basisYy) - (b.x * this.basisXy + b.y * this.basisYy) )

Objects are sorted by their projected Y coordinate (depth):

  • Lower Y = closer to camera = drawn first
  • Higher Y = farther from camera = drawn last (on top)

Step 4: Draw Shadows

Shadows are drawn as semi-transparent quads on the ground:

private drawBlockShadow(gx: number, gy: number) { const inset = 0.05 // Slightly smaller than tile // Draw quad with rgba(0, 0, 0, 0.3) }

Step 5: Draw 3D Blocks

Each block is drawn as a cube with visible faces:

private drawBlock(gx, gy, topColor, rightColor, leftColor) { // 1. Draw back faces (occluded by front) // 2. Draw front faces (visible sides) // 3. Draw top face }

Face culling: Only draws faces facing the camera using surface normal calculation:

const normalY = a.x - b.x // Outward normal Y component if (normalY > 0) frontFaces.push(i) // Visible else if (normalY < 0) backFaces.push(i) // Hidden

Block Height Calculation

Block height varies with perspective:

private getBlockHeight(gx: number, gy: number): number { const centerRawY = (gx + 0.5) * this.basisXy + (gy + 0.5) * this.basisYy const d = this.rawMaxY - centerRawY const scale = this.focalLength / (this.focalLength + d) return this.baseBlockHeight * scale }

Blocks farther away are shorter, enhancing depth perception.

Isometric Cache

Grid intersection points are pre-computed for performance:

this.isoCache = [] for (let gy = 0; gy <= this.gridSize; gy++) { this.isoCache[gy] = [] for (let gx = 0; gx <= this.gridSize; gx++) { this.isoCache[gy][gx] = this.toIso(gx, gy) } }

Accessed as this.isoCache[gy][gx] to avoid recalculating transformations every frame.

Color Scheme

Snake

  • Head: Bright green (#4ade80 top, #22c55e right, #16a34a left)
  • Body: Medium green (#22c55e top, #16a34a right, #15803d left)

Food

  • Red: (#ef4444 top, #dc2626 right, #b91c1c left)

Ground

  • Light tiles: #2a2a2a
  • Dark tiles: #222222
  • Grid lines: rgba(255, 255, 255, 0.06)
  • Border: rgba(255, 255, 255, 0.15)

Shading creates a light source effect from the top-right.

Interactive Controls

KeyEffect
QRotate clockwise (decrease isoAngle by 5°)
ERotate counter-clockwise (increase isoAngle by 5°)
[Decrease perspective strength
]Increase perspective strength
+/-Adjust grid size (before game starts)

All changes trigger updateCanvasSize() and draw() to refresh the view.

Performance Characteristics

  • O(n²) ground drawing (n = gridSize)
  • O(m log m) object sorting (m = snake length + 1 food)
  • O(m) shadow and block rendering
  • Cached transformations reduce per-frame computation

Typical performance:

  • 60 FPS on modern hardware up to 50×50 grid
  • Canvas size fixed to prevent layout reflows
  • Conditional rendering (grid lines) improves zoomed-out performance

Future Enhancements

  • WebGL rendering: Hardware-accelerated 3D
  • Lighting effects: Dynamic shadows, ambient occlusion
  • Texture mapping: Patterned snake skin, food sprites
  • Particle effects: Trail behind snake, food sparkle
  • Camera controls: Zoom, pan, free rotation