Building a Classic Arcade Game with JavaScript and HTML5 Canvas

Learn how to build a version of Tron Light Cycles in 240 lines of JavaScript

Published on
Sep 6, 2019

Read time
9 min read

Introduction

I discovered the Tron Light Cycles game at school. The school IT department banned most gaming websites, but we were always finding sites that fell outside of the blacklist: one of them had a Flash version of Tron Light Cycles. Though it’s very simple by modern standards, this 1982 game was still really addictive — and it got pretty competitive!

Now, Flash is soon-to-be deprecated, while HTML and JavaScript are as powerful as they’ve ever been. So, in this article, we’ll recreate a lightweight, multiplayer version of the Tron Light Cycles game using an HTML canvas and JavaScript.

What We’ll Create

Before diving into the code, let’s have a look at what we’re going to create.

Tron is a two-player game, where the objective is to outlast your opponent. You can’t touch the walls, your own trail or your opponent’s trail!

Player 1 uses the arrow keys, and Player 2 uses WASD:

See the Pen Tron Light Cycles by Bret Cameron (@BretCameron) on CodePen.

This tutorial’s version of Tron Light Cycles, but a bit smaller (so it fits inside the CodePen!)

We’ll now take a deep dive into the code that went into this. If you find yourself getting lost during the tutorial, take a look at the final repo here.

An original Tron arcade game

Step 1: The HTML and CSS

index.html

The HTML we’ll use is mostly boilerplate. The only tag necessary for our game is canvas — plus we need to make sure to link to our style.css and tron.js files:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Tron Light Cycles</title>
    <link rel="stylesheet" href="style.css" />
    <link
      href="https://fonts.googleapis.com/css?family=Bungee&display=swap"
      rel="stylesheet"
    />
  </head>
  <body>
    <canvas id="tron" width="750" height="750"></canvas>
    <script src="tron.js"></script>
  </body>
</html>

style.css

In homage to the original, we’ll make our version of Tron Light Cycles on a dark background. Here’s some initial styling so we can see what we’re doing:

body {
  background: #000;
  text-align: center;
  font-family: "Bungee", cursive;
}

#tron {
  border: 1px solid #777;
  outline: 1px solid #333;
  outline-offset: 5px;
}

From now on, pretty much everything we’ll do will happen in our tron.js file.

Step 2: Set Up Canvas and Context

When working with an HTML5 canvas element, we need to choose the context we’ll use to draw out elements. Our game will be 2D, so open up tron.js and type in the following code:

const canvas = document.getElementById("tron");
const context = canvas.getContext("2d");
const unit = 15;

Above, I also added a unit variable, as this will be helpful at multiple points in our code. Our grid will be made up of 15px squares, and we also want our light cycles to move 15px at a time.

Step 3: Define Players and Controls

Players

We’ll start with two players, but in the end, we may want as many as four. So, to make it easy to create players, we’ll use a class:

class Player {
  constructor(x, y, color) {
    this.color = color || "#fff";
    this.dead = false;
    this.direction = "";
    this.key = "";
    this.x = x;
    this.y = y;
    this.startX = x;
    this.startY = y;
    this.constructor.counter = (this.constructor.counter || 0) + 1;
    this.id = this.constructor.counter;

    Player.allInstances.push(this);
  }
}

Player.allInstances = [];

Our Player class has quite a few properties:

  • color tells us each player’s colour,
  • dead is a boolean, telling us whether the player is still alive or not,
  • direction tells us the direction the player is actually going in,
  • key tells us the last direction the player has tried to go in,
  • x and y give us the player’s coordinates at any one time,
  • startX and startY keep a record of the player’s coordinates at the start, so we can easily reset out game, and
  • _id gives each player a number, based on when they are initiated.

Below the class, we’ve also created an array containing all our player instances. Every time the constructor is called, it will add the new player to our array. This will be helpful later on when we need to determine how many players there are and apply actions to every player instance.

Now we’re ready to create our first two players:

let p1 = new Player(unit * 6, unit * 6, "#75A4FF");
let p2 = new Player(unit * 43, unit * 43, "#FF5050");

Setting the Players’ Direction

We need a generic function to control our players’ movement. They can move any direction in the 2D plane, but they can’t go immediately back on themselves: for example, if you’re going left, you can’t immediately go right without first going up or down.

We’ll place these rules in a generic function. This means that we can easily choose a player and assign key codes for up, right, down and left:

function setKey(key, player, up, right, down, left) {
  switch (key) {
    case up:
      if (player.direction !== "DOWN") {
        player.key = "UP";
      }
      break;
    case right:
      if (player.direction !== "LEFT") {
        player.key = "RIGHT";
      }
      break;
    case down:
      if (player.direction !== "UP") {
        player.key = "DOWN";
      }
      break;
    case left:
      if (player.direction !== "RIGHT") {
        player.key = "LEFT";
      }
      break;
    default:
      break;
  }
}

The setKey function is designed to be re-usable. Instead of getting confused by key codes, the function will allow us to refer to movements using easily comprehensible strings: 'UP', 'RIGHT', 'DOWN' and 'LEFT'.

But for our setKey function to have an effect, we need to specify which keys will affect which players, and create an event listener to listen out for those keys being pressed:

function handleKeyPress(event) {
  let key = event.keyCode;

  if (key === 37 || key === 38 || key === 39 || key === 40) {
    event.preventDefault();
  }

  setKey(key, p1, 38, 39, 40, 37); // arrow keys
  setKey(key, p2, 87, 68, 83, 65); // WASD
}

document.addEventListener("keydown", handleKeyPress);

In the code above, we’ve assigned the arrow keys to control player 1 and WASD to control player 2. We’ve also prevented the arrow keys’ default behaviour, to stop unwanted scrolling.

Step 4: Set Up the Board

Determining the Playable Cells

The more calculations we can do before the game actually begins, the better for performance. One strategy is creating a Set containing all playable cells at the beginning.

As each player traverses the board, we’ll remove a cell from this list. Then, instead of relying on complex if or switch statements to determine if a player has died, we can simply see whether they are on a playable cell or not!

function getPlayableCells(canvas, unit) {
  let playableCells = new Set();

  for (let i = 0; i < canvas.width / unit; i++) {
    for (let j = 0; j < canvas.height / unit; j++) {
      playableCells.add(`${i * unit}x${j * unit}y`);
    }
  }

  return playableCells;
}

let playableCells = getPlayableCells(canvas, unit);

Making the Background

We can finally get into putting visual elements on the screen! Using this code creates a subtle grid pattern:

function drawBackground() {
  context.strokeStyle = "#001900";

  for (let i = 0; i <= canvas.width / unit + 2; i += 2) {
    for (let j = 0; j <= canvas.height / unit + 2; j += 2) {
      context.strokeRect(0, 0, unit * i, unit * j);
    }
  }

  context.strokeStyle = "#000000";
  context.lineWidth = 2;

  for (let i = 1; i <= canvas.width / unit; i += 2) {
    for (let j = 1; j <= canvas.height / unit; j += 2) {
      context.strokeRect(0, 0, unit * i, unit * j);
    }
  }

  context.lineWidth = 1;
}

drawBackground();

This is also a good point to draw in our players’ starting positions:

function drawStartingPositions(players) {
  players.forEach((p) => {
    context.fillStyle = p.color;
    context.fillRect(p.x, p.y, unit, unit);
    context.strokeStyle = "black";
    context.strokeRect(p.x, p.y, unit, unit);
  });
}

drawStartingPositions(Player.allInstances);

You’ve probably noticed that we’re wrapping a lot of processes inside their own functions. Aside from being good practice, this allows us to re-use the code later in our resetGame function.

Step 5: In-Game Logic

We’re now ready to get on with the dynamic parts of our game. First, we’ll add three new variables to the global namespace:

let outcome,
  winnerColor,
  playerCount = Player.allInstances.length;

Next, we’ll create a draw function which triggers repeatedly, at a set interval of 100ms:

function draw() {
  if (Player.allInstances.filter((p) => !p.key).length === 0) {
    // in-game logic...
  }
}

const game = setInterval(draw, 100);

Here, the if statement requires that the game will only begin once every player has selected a starting key.

From the remainder of this section, all the code we’ll be discussing goes inside the draw function (and inside the if statement). We’re ready to start moving our players!

Adding Movement

Inside the draw function, add the following code:

Player.allInstances.forEach((p) => {
  if (p.key) {
    p.direction = p.key;

    context.fillStyle = p.color;
    context.fillRect(p.x, p.y, unit, unit);
    context.strokeStyle = "black";
    context.strokeRect(p.x, p.y, unit, unit);

    if (!playableCells.has(`${p.x}x${p.y}y`) && p.dead === false) {
      p.dead = true;
      p.direction = "";
      playerCount -= 1;
    }

    playableCells.delete(`${p.x}x${p.y}y`);

    if (!p.dead) {
      if (p.direction == "LEFT") p.x -= unit;
      if (p.direction == "UP") p.y -= unit;
      if (p.direction == "RIGHT") p.x += unit;
      if (p.direction == "DOWN") p.y += unit;
    }
  }
});

Try pressing the arrow keys or WASD. Our light cycles now move!

If we break down the code above, we notice that every time the draw function runs:

  • We draw a new square for each player, 1 unit in their selected direction.
  • If a player moves onto an unplayable cell, we mark it as dead.
  • We remove the cell that has just been traversed from the Set of playableCells.

This function also runs only if each player has selected a key (preventing players from killing themselves by staying still).

We’re nearly done, but there’s still no way for the game to end!

Ending the Game

We’ll now check to see whether the game has finished or not. At the top of the draw function, add the following code:

if (playerCount === 1) {
  const alivePlayers = Player.allInstances.filter((p) => p.dead === false);
  outcome = `Player ${alivePlayers[0]._id} wins!`;
} else if (playerCount === 0) {
  outcome = "Draw!";
}

if (outcome) {
  console.log(outcome);
  clearInterval(game);
}

As soon as the draw function is called, it checks whether the player count has dropped to either 1 or 0. At 1, it declares the last living player as the winner. If the player count drops to 0, it means the remaining players died in the same frame.

And that’s it! Your outcome should now be logged to the console, but of course, you can send it wherever you like. You now have a functioning game!

Step 6: Results and Reset

Finally, instead of simply logging to the console, let’s create a visual means of telling our players who’s won and a way for them to reset the game.

We could create our results page in HTML, but I said earlier that we’d be sticking to JavaScript, and so I’ve gone through the longwinded JavaScript route…

function createResultsScreen(color) {
  const resultNode = document.createElement("div");

  resultNode.id = "result";
  resultNode.style.color = color || "#fff";
  resultNode.style.position = "fixed";
  resultNode.style.top = 0;
  resultNode.style.display = "grid";
  resultNode.style.gridTemplateColumns = "1fr";
  resultNode.style.width = "100%";
  resultNode.style.height = "100vh";
  resultNode.style.justifyContent = "center";
  resultNode.style.alignItems = "center";
  resultNode.style.background = "#00000088";

  const resultText = document.createElement("h1");

  resultText.innerText = outcome;
  resultText.style.fontFamily = "Bungee, cursive";
  resultText.style.textTransform = "uppercase";

  const replayButton = document.createElement("button");

  replayButton.innerText = "Replay (Enter)";
  replayButton.style.fontFamily = "Bungee, cursive";
  replayButton.style.textTransform = "uppercase";
  replayButton.style.padding = "10px 30px";
  replayButton.style.fontSize = "1.2rem";
  replayButton.style.margin = "0 auto";
  replayButton.style.cursor = "pointer";
  replayButton.onclick = resetGame;

  resultNode.appendChild(resultText);
  resultNode.appendChild(replayButton);

  document.querySelector("body").appendChild(resultNode);

  document.addEventListener("keydown", (e) => {
    let key = event.keyCode;
    if (key == 13)
      // 'Enter'
      resetGame();
  });
}

To make the reset button more user-friendly, I added an event listener so it can be triggered by pressing the Enter key.

We’ll now need a resetGame function. Because we’ve broken up our code into reusable functions, we’ve saved ourselves a lot of work:

function resetGame() {
  // Remove the results node
  const result = document.getElementById("result");

  if (result) result.remove();

  // Remove background then re-draw it
  context.clearRect(0, 0, canvas.width, canvas.height);

  drawBackground();

  // Reset playableCells
  playableCells = getPlayableCells(canvas, unit);

  // Reset players
  Player.allInstances.forEach((p) => {
    p.x = p.startX;
    p.y = p.startY;
    p.dead = false;
    p.direction = "";
    p.key = "";
  });

  playerCount = Player.allInstances.length;

  drawStartingPositions(Player.allInstances);

  // Reset outcome
  outcome = "";
  winnerColor = "";
  // Ensure draw() has stopped, then re-trigger it

  clearInterval(game);

  game = setInterval(draw, 100);
}

Lastly, remember to call createResultsScreen, back where we were calling console.log(outcome).

if (outcome) {
  createResultsScreen(winnerColor);
  clearInterval(game);
}

And that’s a wrap! You now have a functional two-player game! To view a working version of this code, see my GitHub repository.

What Next?

There are lots of ways you could extend the code we’ve already created.

For example, for a four-player game, you could create the following players:

const p1 = new Player(unit * 6, unit * 6, "blue");
const p2 = new Player(unit * 43, unit * 43, "red");
const p3 = new Player(unit * 43, unit * 6, "green");
const p4 = new Player(unit * 6, unit * 43, "orange");

And you could edit our handleKeyPress function to provide our four players with the following controls:

function handleKeyPress(event) {
  let key = event.keyCode;
  setKey(key, p1, 38, 39, 40, 37); // arrow keys
  setKey(key, p2, 87, 68, 83, 65); // WASD
  setKey(key, p3, 73, 76, 75, 74); // IJKL
  setKey(key, p4, 104, 102, 101, 100); // numpad 8456
}

Another option would be to add art and sound by calling new Image() and new Audio().

And something I’m keen to add — but haven’t yet mastered — are computer-controlled players. My attempts so far have had a habit of running into themselves very quickly!

I hope you enjoyed this article. In particular, if you’re new to HTML5 canvas or coding in-browser games, I hope this helped show you what’s possible.

© 2024 Bret Cameron