Jakub Arnold's Blog


Let's Write a 2D Platformer From Scratch Using HTML5 and JavaScript, Part 2: Rendering

This article is a part 3 of the Let’s Write a 2D Platformer From Scratch Using HTML5 and JavaScript series.

Now that we have the game loop, we can build a small wrapper around the HTML5 Canvas API. We’ll start with a simple tile based map where each tile is rendered as a colored rectangle.

The map will be specified as an array of numbers where 0 means empty and 1 means wall. JavaScript doesn’t have direct support for multi-dimensional arrays, which leaves us with two options. We can either store all of the values in a 1D array and calculate the index based on 2D coordinates, or we can use an Array of Arrays. The second approach is simpler in terms of indexing, but works conceptually very differently. For example, there is nothing enforcing each row to have the same length as the other rows.

Using 1D arrays to store a 2D matrix is actually a very common pattern in lower level programming, which is why we’ll pick it here mainly for the educational purpose. The core idea is that if we have want to access an element at i-th row and j-th column, we’ll have to skip i * ROW_LENGTH elements to get to a subset of the array where the i-th row begins. After that, we just add the offset j within the i-th row to access the element. Since we’re specifying map dimensions as MAP_W and MAP_H (for map width and height) we simply do i * MAP_W + j.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");

var BOX_SIZE = 20;
var MAP_W = 10;
var MAP_H = 10;

function drawBox(color, x, y, w, h) {
    ctx.fillStyle = color;
    ctx.fillRect(x, y, w || BOX_SIZE, h || BOX_SIZE);
}

var map = [
  0,0,0,0,0,0,0,0,0,0,
  0,0,0,0,0,0,0,0,0,0,
  0,0,0,0,0,0,0,0,0,0,
  0,0,0,0,0,0,0,0,0,0,
  0,0,0,1,1,1,0,0,0,0,
  0,0,0,0,0,0,0,0,1,1,  
  1,0,0,0,0,0,0,0,0,0,
  1,0,0,0,0,0,0,0,0,0,
  1,1,1,1,1,0,1,1,1,1,
  1,1,1,1,1,0,1,1,1,1,  
];

function drawMap() {
    for (var i = 0; i < MAP_H; i++) {
        for (var j = 0; j < MAP_W; j++) {
            // Calculating the color for a tile on corrdinates [j, i].
            var color = map[i * MAP_W + j] ? "#5d995d" : "lightblue";
            // And draw it at the appropriate offset.
            drawBox(color, BOX_SIZE * j, BOX_SIZE * i);
        }
    }
}

// Draw the player.
drawBox("#612b2e", 0, 5 * BOX_SIZE);

Later on when we put things together, each iteration of the gameLoop will call drawMap to render the background.

function gameLoop(timestamp) {
    // ... rest of the game loop

    drawMap();
    // Draw the player.
    drawBox("#612b2e", 0, 5 * BOX_SIZE);
}

Now all that is left to do is implement player movement.

Input handling and simple movement

There is also no way to check if a key is being pressed in JavaScript, so we’ll create a small global handler that stores the keypress values in a global map. Later on we can add the ability to detect key press just in the frame in which it occurred.

var keys = {};
window.onkeyup = function(e) { keys[e.keyCode] = false; }
window.onkeydown = function(e) { keys[e.keyCode] = true; }

With these, we can write an updatePlayer function which takes a dt and moves the player based on a key being pressed. We’ll also need a drawPlayer function to draw the player at their position.

var player = { x: 0, y: 0 };

function updatePlayer(dt) {
    // Key codes for player hotkeys.
    var A = 65;
    var W = 87;
    var D = 68;
    
    // The player moves at 80px per second.
    var SPEED = 80;
    
    if (keys[A]) { player.x -= SPEED * dt; }
    if (keys[D]) { player.x += SPEED * dt; }
}

function drawPlayer() {
    drawBox("#612b2e", player.x, player.y);
}

Basic collision handling

Collision handling is a complicated subject, especially if there can be arbitrary geometry present in the physics world. Luckily for us, we only have boxes of constant dimensions, and all of the walls are aligned to the tile map. The player is also the only object moving in the world, which means we only calculate collisions against the environment. If we had a multi-agent environment, we’d need a more general concept of colliders and raycasting. But for now, we can implement the raycast by simply checking the adjacent tiles on each side.

Since the player can move just a single pixel, we need to actually calculate its position within a tile. We can do this by using the modulo operation % which returns the remainder after integer division. player.x % BOX_SIZE returns a value from 0 to BOX_SIZE, which is exactly the x offset of the top left corner within its containing tile. We’ll store the tile coordinates in variables tileX and tileY.

We’ll also check if the player stands next to a wall on both sides. The right side is a tiny bit trickier, because we’re measuring the position from the top-left corner, which means we actually have to look two tiles to the right. We’ll improve this later on when we write a more general collision handling logic.

Lastly, we introduce a new concept, the player’s velocity. This can be thought of as the number of pixels the player will move within the frame (per-frame velocity). The velocity is initially based on the player’s inputs. We then check if the player is moving in a direction of a wall, and check if the velocity is greater than the distance to the wall. If it is, the player would skip into the wall on the frame update, which is why we use Math.min/Math.max to make sure the player moves at most the distance he needs to reach the wall.

function updatePlayer(dt) {
    // Key codes for player hotkeys.
    var A = 65;
    var W = 87;
    var D = 68;
    
    // The player moves at 80px per second.
    var SPEED = 80;
    
    // We calculate the tile where the player is.
    var tileX = Math.floor(player.x / BOX_SIZE);
    var tileY = Math.floor(player.y / BOX_SIZE);

    // Player collides on the left either with the leftmost edge of the screen,
    // or with a tile which is adjacent to the left.
    var possibleCollisionLeft = tileX == 0 || map[tileY * MAP_W + (tileX - 1)];
    // Same for the right side.
    var possibleCollisionRight = tileX == (MAP_W - 1) || map[tileY * MAP_W + (tileX + 2)];
    
    // Vertical velocity of the player.
    var vx = 0;

    if (keys[A]) { vx = -SPEED * dt; }
    if (keys[D]) { vx = SPEED * dt; }

    if (vx < 0 && possibleCollisionLeft) {
        // If the player is near a left wall, either move him closer to the wall
        // based on his velocity, or based on his offset within the tile if the velocity
        // would cause him to run through the wall.
        vx = Math.max(vx, -(player.x % BOX_SIZE));
    }
            
    if (vx > 0 && possibleCollisionRight) {
        // Same as for moving left, but here we have to account for the fact
        // that we use the left corner as the player's position, hence the distance
        // to the wall is computed differently.
        vx = Math.min(vx, BOX_SIZE - (player.x % BOX_SIZE));
    }
    
    // Lastly, we have to check if the player is already standing next to a wall,
    // and nullify the vertical velocity in that case.
    if (vx > 0 && map[tileY * MAP_W + (tileX + 1)]) {
        vx = 0;
    }
    
    player.x += vx;
}

And here’s how it looks inside the game: (move player with A and D keys)

Conclusion

We’ve implemented basic input and collision handling with player movement. What we have so far serves more as a demonstration than what we’ll end up with in the next article, as the collision handling is not flexible enough to handle more complicated player movement ansuch as gravity.

This article is a part 3 of the Let’s Write a 2D Platformer From Scratch Using HTML5 and JavaScript series.

References

Related
Gamedev