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:
- Transforming grid coordinates to screen coordinates using rotation and scaling
- Adding perspective distortion for depth perception
- Rendering objects back-to-front (painter’s algorithm)
- 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 * prBasis 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
perspectiveStrengthcontrols 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 - gridCenterYThis 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) // HiddenBlock 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 (
#4ade80top,#22c55eright,#16a34aleft) - Body: Medium green (
#22c55etop,#16a34aright,#15803dleft)
Food
- Red: (
#ef4444top,#dc2626right,#b91c1cleft)
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
| Key | Effect |
|---|---|
| Q | Rotate clockwise (decrease isoAngle by 5°) |
| E | Rotate 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