How to program an isometric (2.5D) game

Table of Contents

TL;DR

Here’s the JavaScript function to convert a three-dimensional point into a two-dimensional one:

function to_isometric(point) {
  const angle = Math.PI / 6; // 30°
  const cos = Math.cos(angle); // ≈ 0.866
  const sin = Math.sin(angle); // = 0.5

  return {
    x: (point.x - point.y) * cos,
    y: (point.x + point.y) * sin - point.z
  };
}

If you can’t convert this into the programming language you’re using, you don’t deserve to have an isometric design in your game.

More detailed explanation

Isometric graphics were and still are common in games. This style offers a kind of blend between 2D and 3D that creates a unique look.

The game SimCity is an example of one that uses the isometric art style.

The game SimCity is an example of one that uses the isometric art style.

Isometric drawing works through three lines separated by 60º from each other, where each represents a dimension. These lines allow you to convert a three-dimensional point into a two-dimensional one using orthographic projection:

The three lines of isometric drawing

The math behind it

The three lines

In isometric drawing, as mentioned, we have three lines separated by 60º from each other. However, since the Z axis is completely vertical, let’s focus, for now, only on X and Y:

X and Y axes

As you can see, the X and Y axes are at an angle of 30º relative to the real X axis (in the previous case they were at angles of 60º from each other). So the first thing to do is to find the points on each line according to the distance from the origin — which can be done with sine and cosine. For this, let’s suppose a three-dimensional point T = (3, 5, 0) (the Z dimension won’t be considered for now), and we want to find a two-dimensional point A (on the X line) and B (on the Y line):

T=(350) \vec{T} = \begin{pmatrix} 3 \\ 5 \\ 0 \end{pmatrix}

Calculating A and B:

A=T1(cos30sin30)A=3(cos30sin30)3(0.870.5)=(2.611.5) \vec{A} = T_1 \begin{pmatrix} \cos 30 ^ \circ \\ \sin 30 ^ \circ \end{pmatrix} \therefore \vec{A} = 3 \cdot \begin{pmatrix} \cos 30 ^ \circ \\ \sin 30 ^ \circ \end{pmatrix} \approx 3 \cdot \begin{pmatrix} 0.87 \\ 0.5 \end{pmatrix} = \boxed{ \begin{pmatrix} 2.61 \\ 1.5 \end{pmatrix} } B=T2(cos30sin30)B=5(cos30sin30)5(0.870.5)=(4.352.5) \vec{B} = T_2 \begin{pmatrix} -\cos 30 ^ \circ \\ \sin 30 ^ \circ \end{pmatrix} \therefore \vec{B} = 5 \cdot \begin{pmatrix} -\cos 30 ^ \circ \\ \sin 30 ^ \circ \end{pmatrix} \approx 5 \cdot \begin{pmatrix} -0.87 \\ 0.5 \end{pmatrix} = \boxed{ \begin{pmatrix} -4.35 \\ 2.5 \end{pmatrix} }

Plotting the points on the graph:

Points A and B on the graph

Now, to find the real point R — the one that will be drawn on the screen — we need to add the two vectors:

R=A+BR=(2.611.5)+(4.352.5)=(1.744) \vec{R} = \vec{A} + \vec{B} \therefore \vec{R} = \begin{pmatrix} 2.61 \\ 1.5 \end{pmatrix} + \begin{pmatrix} -4.35 \\ 2.5 \end{pmatrix} = \boxed{ \begin{pmatrix} -1.74 \\ 4 \end{pmatrix} }

Plotting it on the graph:

Point R on the graph

To add the Z axis, just add this value to the real Y value plotted — since the Z axis is completely vertical —, so if the Z value of point T were 3, the position vector to be drawn would be $\begin{pmatrix} -1.74 \ 7 \end{pmatrix}$.

Generic function

A generic function would look like this:

f(x)=x1(cos30sin30)+x2(cos30sin30)+(0x3) \vec{f}(\vec{x}) = x_1 \begin{pmatrix} \cos 30 ^ \circ \\ \sin 30 ^ \circ \end{pmatrix} + x_2 \begin{pmatrix} -\cos 30 ^ \circ \\ \sin 30 ^ \circ \end{pmatrix} + \begin{pmatrix} 0 \\ x_3 \end{pmatrix}
Why is Z subtracted in the JavaScript function instead of added?

If you’re very observant, you may have noticed this. The reason is that in most graphics libraries (SDL, HTML Canvas, Raylib, p5.js, etc.), the smaller the Y value, the higher the point appears — which is the opposite of most Cartesian planes, like GeoGebra’s, which I used to demonstrate how it works. However, if you’re using a game engine like Unity or Godot, you should change it to add instead of subtract.

Implementing in p5.js

By opening editor.p5js.org, there’s an online editor where you can program in JavaScript using the p5.js graphics library — it’s not one of the best for creating games, but it’s very easy and widely used for learning programming.

Initially, a code like this will appear:

function setup() {
  createCanvas(400, 400);
}

function draw() {
  background(220);
}

The setup function runs only once, at the start of the game, while draw runs every loop. Running it, we only see this:

Blank screen

What we want to do is create a grid that moves like waves, and for that, we’ll use the function from the TL;DR. Then, we’ll draw equally spaced points using that function:

[...]
function draw() {
  background(220);
  
  // Put the camera in the center
  translate(width/2, height/2)
  
  const grid_size=5
  const grid_distance=30
  for (let y=-grid_size; y<grid_size; y++) {
    for (let x=-grid_size; x<grid_size; x++) {
      // Transform 3D point into 2D
      let drawn_point = to_isometric(
        {x: x*grid_distance, y: y*grid_distance, z: 0}
      )
      
      // Draw the point
      stroke(255, 0, 0)
      strokeWeight(3)
      point(drawn_point.x, drawn_point.y)
    }
  }
}

Thus, the result is this:

Grid of points

Animating the points

For this, we’ll use the sine function, which, on a graph, looks like this:

Sine graph

Passing the time since the start of the program into the function, which can be done with p5.js’s millis() function — it returns how many milliseconds since the start of execution — we can animate them:

let drawn_point = to_isometric(
  {x: x*grid_distance, y: y*grid_distance, z: Math.sin(millis()/1000)*20}
)

Result:

Initial animation

However, as we can see, all the points move equally. To change this, let’s add an offset value for each point, defined by their Y position:

let drawn_point = to_isometric(
  {x: x*grid_distance, y: y*grid_distance, z: Math.sin(millis()/1000+y)*20}
)

Result:

Offset animation

Adding sprites

For this, I’ll use these sprites from opengameart.org. Then, I cropped and reduced the resolution in GIMP so it’s just a cube:

Isometric pixel-art cube

Download this image and place it in the editor. To do this, create an account and then click the arrow on the left side of the editor, then click the “plus” symbol and “Upload file,” and select the cube file.

To draw the cube, define a global variable called cube:

let cube

Then, inside the setup() function, assign the variable as the cube texture:

cube = loadImage('cube.png')

And instead of drawing the point, draw the texture, remembering to keep its size as grid_distance times two:

/*stroke(255, 0, 0)
strokeWeight(3)
point(drawn_point.x, drawn_point.y)*/
image(cube, drawn_point.x, drawn_point.y, grid_distance*2, grid_distance*2)

Result:

Animation with textures

#Tutorial   #Programming   #Gamedev   #Mathematics