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.
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
andy
give us the player’s coordinates at any one time,startX
andstartY
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.
Related articles
You might also enjoy...
How to Automate Merge Requests with Node.js and Jira
A quick guide to speed up your MR or PR workflow with a simple Node.js script
7 min read
Automate Your Release Notes with AI
How to save time every week using GitLab, OpenAI, and Node.js
11 min read
How to Create a Super Minimal MDX Writing Experience
Learn to create a custom MDX experience so you can focus on writing without worrying about boilerplate or repetition
12 min read