0 Spicy Yoghurt | Last updated: 25 October 2026 | HTML5 game tutorial
Add a start screen, a score and a game over screen
Learn how to add game states to your JavaScript game, from the start screen to game over. Keep score, give the player three lives, pause the game and save the high score. By the end of this tutorial, your bee will be dodging a swarm in a real game.
Part of a series - In this series, you make an HTML5 game step by step. Here, the bee you learned to steer in the part about game controls gets a real game to play.
Here's what you'll build. Click or tap the canvas to start, and dodge the swarm for as long as you can:
What is a game state?
Right now, your game starts the moment the page loads, and it never ends. The bees just keep on flying. A real game has a beginning and an end, and a few screens in between.
Each of those screens is a game state. The game is always in exactly one of them, and the state decides what the game draws and how it reacts to the player. Your game gets these four states:
- Start - Shows the name of the game and waits for a click, a tap, the space bar or Enter. The bees already fly around in the background.
- Playing - The game itself. Everything moves, the score goes up and the bees can hurt you.
- Paused - Everything stands still, with a menu on top.
- Game over - Shows your score and waits for you to play again.
The game moves from one state to another when the player does something, like clicking or pressing P. Some changes happen by themselves, like game over when you run out of lives. This diagram shows how the game gets from one state to another:
You might have seen the term in the tutorial about local storage. There, the game state is the data you save, like the upgrades the player bought. On this page, it's the screen the game is on.
Keep track of the state in one variable
You could use a boolean for every state, like isPaused and isGameOver. That works for a while, until something sets both of them to true. Press P on the game over screen, and your game is paused and over at the same time. Which screen should it draw then?
One variable can't have two values at once. Store the state as a string, and the game can only ever be in one state:
// The state of the game:
// 'start', 'playing', 'paused' or 'gameover'
let gameState = 'start';Right now, your game loop updates and draws all game objects. Move that into two functions, update() and draw(), and let a switch statement decide what happens in each state:
function gameLoop(timeStamp) {
// Calculate how much time has passed, capped at a maximum
let secondsPassed = (timeStamp - oldTimeStamp) / 1000;
oldTimeStamp = timeStamp;
secondsPassed = Math.min(secondsPassed, 0.1);
stateTime += secondsPassed;
update(secondsPassed);
draw();
window.requestAnimationFrame(gameLoop);
}
function update(secondsPassed) {
switch (gameState) {
case 'start':
// The bees already fly around behind the title
updateObjects(secondsPassed);
break;
case 'playing':
updateObjects(secondsPassed);
break;
case 'paused':
case 'gameover':
// Everything stands still
break;
}
}update() calls updateObjects(), which holds the code that used to be in your game loop. It updates all game objects and checks for collisions. draw() works the same way: it draws all game objects, and then uses a switch to draw the screen for the current state on top. You'll write those screens in the next sections.
To switch to another state, don't change the variable directly. Call a function instead, so every change happens in one place:
// How many seconds the game has been in this state
let stateTime = 0;
function changeState(newState) {
gameState = newState;
stateTime = 0;
}The game loop adds secondsPassed to stateTime every frame, so you always know how long the game has been in the current state. You'll need that for blinking text and for a short wait on the game over screen. And when your game grows, changeState() is the place for everything else that has to happen when the state changes, like starting or stopping the music.
Try it below. Start the game and press P to pause it. In this demo, one bee is enough to end the game, so fly into one to see the game over screen. The code under the demo shows the last few calls to changeState(), and how long each state lasted:
let gameState = 'start';Create a start screen
As soon as the page loads, your game is in the start state. The bees fly around, but the game hasn't really begun yet. Draw the name of the game on top, and a line that tells the player what to do:
function drawStartScreen() {
// A light layer, so the text stands out from the bees
context.fillStyle = 'rgba(255, 255, 255, 0.6)';
context.fillRect(0, 0, canvasWidth, canvasHeight);
context.fillStyle = '#303030';
drawText('Dodge the Swarm', 375, 130, 'bold 48px Arial');
// Fade the text in and out, once every second
let pulse = Math.sin(stateTime * 2 * Math.PI);
context.globalAlpha = 0.6 + 0.4 * pulse;
drawText('Click to start', 375, 270, '24px Arial');
context.globalAlpha = 1;
}
function drawText(text, x, y, font) {
context.font = font;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(text, x, y);
}drawText() is a small helper that draws a line of text, centered on the point you give it. You'll use it for every screen in this game. Need a refresher on fonts and text alignment? It's all in the tutorial about drawing on the canvas.
The text fades in and out because Math.sin() goes smoothly from -1 to 1 and back. Multiply stateTime by 2π, and one full wave takes exactly one second. You can read more about this trick in "Make things float, pulse and orbit with Math.sin()" in the smooth canvas animation tutorial.
A click, a tap, the space bar or Enter starts the game. Add the space bar to gameKeys, so it doesn't scroll the page, and check for Space and Enter in onKeyDown():
function onKeyDown(event) {
// ...
// Space or Enter to start
if (event.code == 'Space' || event.code == 'Enter') {
pressStart();
}
}
function pressStart() {
if (gameState == 'start') {
changeState('playing');
}
}In onPointerDown(), call pressStart() on the start screen, instead of flying toward the mouse or finger. You'll see the whole function further down this page.
The start screen also solves a problem from the game controls tutorial. There, the keyboard only worked after the player clicked the canvas. Now the first click starts the game and gives the canvas focus at the same time. Players who use the keyboard can also tab to the canvas and press the space bar. Later in this series, the same click will also turn on the sound, because browsers only play sound after the player does something on the page.
Here's the start screen. Click or tap it to start playing:
In this demo, nothing can hurt you yet. Click or tap outside the canvas, and it goes back to the start screen.
Draw the score on the canvas
In this game, you score points by staying alive: 10 points for every second. Add a variable for the score, and increase it in the playing state:
let score = 0;
// ...
case 'playing':
updateObjects(secondsPassed);
// 10 points for every second you stay alive
score += 10 * secondsPassed;
break;secondsPassed is a fraction of a second, so the score becomes a number like 123.4567. Round it down with Math.floor() when you draw it:
function drawHud() {
// The score in the top-left corner
context.font = 'bold 24px Arial';
context.textAlign = 'left';
context.textBaseline = 'middle';
context.fillStyle = '#303030';
context.fillText('Score: ' + Math.floor(score), 20, 30);
}The score is part of the HUD, short for heads-up display. That's everything that stays on the screen while you play, like the score and the lives. Draw it after the game objects, so the bees fly under it and not over it.
Give the player three lives
Next, give the bees a way to hurt you. Your game already checks every pair of game objects for a collision in detectCollisions(). When it finds one, check if your bee is one of the two:
// A bee that touches you costs a life
if (obj1 == player || obj2 == player) {
hitPlayer();
}hitPlayer() takes a life, and ends the game when the last one is gone:
let lives = 3;
function hitPlayer() {
// Bees can only hurt you while you're playing
if (gameState != 'playing') {
return;
}
lives--;
if (lives == 0) {
changeState('gameover');
}
}The check for the state matters. On the start screen, the bees fly around and bump into your bee too, and that shouldn't cost you a life.
Show the lives in the HUD as small bees, under the score. Draw the first frame of the sprite sheet, just like in "Draw only part of an image" in the tutorial about sprite animations. Add this to drawHud():
// A small bee for every life you have left
for (let i = 0; i < lives; i++) {
context.drawImage(
Bee.sprite,
0, 0, Bee.frameWidth, Bee.frameHeight,
8 + i * 40, 42, 52, 52
);
}Make the player invincible for a moment
Fly into the swarm with the code above, and your lives disappear a lot faster than you'd expect. After a hit, your bee bounces off, but in a swarm, the next bee is often right there. And as long as you keep flying into a bee, every frame counts as a new hit. Try it in the demo below, with Invincibility off:
Most games fix this with a brief period of invincibility after a hit. For two seconds, nothing can hurt you, so you have time to get away. Give the Player class a timer, and count it down in its update():
// In the constructor of Player:
// seconds until a bee can hurt you again
this.invincibleTime = 0;
// At the start of update():
this.invincibleTime -= secondsPassed;In hitPlayer(), ignore every hit while the timer is still running, and start it after a hit:
function hitPlayer() {
// Only while playing, and not right after a hit
if (gameState != 'playing' || player.invincibleTime > 0) {
return;
}
lives--;
if (lives == 0) {
changeState('gameover');
return;
}
// Two seconds to get away
player.invincibleTime = 2;
}The player needs to know when the bee is invincible, so make it blink. Math.floor(this.invincibleTime * 8) % 2 switches between 0 and 1 eight times per second. Every time it's 1, the bee is almost see-through. Add this to the start of draw() in the Player class:
// Blink after a hit, while nothing can hurt you
let blink = Math.floor(this.invincibleTime * 8) % 2;
if (this.invincibleTime > 0 && blink == 1) {
this.context.globalAlpha = 0.3;
}At the end of draw(), set globalAlpha back to 1, or the rest of the game will blink too. Now turn Invincibility on in the demo and fly into the swarm again.
Pause the game
With states, pausing is easy: switch to the paused state. In update(), the paused case does nothing, so nothing moves. draw() keeps drawing, so the game stays on the screen, frozen, with the pause screen on top:
function togglePause() {
if (gameState == 'playing') {
changeState('paused');
} else if (gameState == 'paused') {
changeState('playing');
}
}
function drawPauseScreen() {
// Darken the game behind the menu
context.fillStyle = 'rgba(0, 0, 0, 0.5)';
context.fillRect(0, 0, canvasWidth, canvasHeight);
context.fillStyle = 'white';
drawText('Paused', 375, 100, 'bold 40px Arial');
}P and Escape are the usual keys for pausing a game. Call togglePause() for both of them in onKeyDown(), next to the check for the space bar.
Notice that the game loop keeps running while the game is paused. That's on purpose: the loop still draws the pause screen. The JavaScript game loop tutorial shows how to stop the loop itself with cancelAnimationFrame(). For a pause screen, it's simpler to keep the loop running and let the state decide what moves.
On a phone, there's no P key. So draw a pause button in the top-right corner of the canvas:
// The pause button in the top-right corner
const pauseButton = {x: 696, y: 14, width: 40, height: 40};
function drawPauseButton() {
// Two bars in the top-right corner
let x = pauseButton.x;
let y = pauseButton.y;
context.fillStyle = '#303030';
context.fillRect(x + 10, y + 8, 7, 24);
context.fillRect(x + 23, y + 8, 7, 24);
}Only draw it while the game is running: call drawPauseButton() in the playing state of draw(), after drawHud().
A tap on the button pauses the game. To find out if the tap hit the button, you check if a point is inside a rectangle. You'll do the same for the buttons on the pause screen, a little further down.
Pause the game automatically
What happens when the player clicks somewhere else on the page in the middle of a game? The canvas loses focus, so the keys stop working, but the bees keep flying. By the time the player comes back to the game, they've probably lost a life or two. And when the player switches to another tab, the game freezes, only to continue the moment they come back, before they're ready.
So pause the game when the canvas loses focus or the page gets hidden:
// Pause when you click somewhere else or switch tabs
canvas.addEventListener('blur', pauseGame);
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
pauseGame();
}
});
function pauseGame() {
if (gameState == 'playing') {
changeState('paused');
}
}pauseGame() only pauses a game that's running. On the start screen and the game over screen, there's nothing to pause. The tutorial about the JavaScript game loop explains visibilitychange in "Pause the game when the tab is hidden".
Don't resume the game automatically when the player comes back. They may not be ready yet, or they may just be checking something. Let them continue when they want to.
Try it below. Start the game, and then press P, tap the pause button, click outside the canvas or switch to another tab. On this page, the demos also pause when you scroll them out of view. The pause screen tells you why it paused:
Show a game over screen
When your last life is gone, hitPlayer() switches to the game over state. Just like on the pause screen, the game stands still, with your score on top:
function drawGameOverScreen() {
// Darken the game behind the text
context.fillStyle = 'rgba(0, 0, 0, 0.5)';
context.fillRect(0, 0, canvasWidth, canvasHeight);
context.fillStyle = 'white';
drawText('Game over', 375, 120, 'bold 48px Arial');
drawText(
'Score: ' + Math.floor(score), 375, 185, '28px Arial'
);
// A click only starts a new game after a second
if (stateTime > 1) {
drawText('Click to play again', 375, 300, '24px Arial');
}
}Why wait a second? When a bee hits you, you're probably still pressing keys or tapping like crazy to get away. The game over screen appears, your next tap starts a new game right away, and you never even see your score. So pressStart() ignores the player for one second:
function pressStart() {
if (gameState == 'start') {
startGame();
}
// Wait a second, so you don't skip game over by accident
if (gameState == 'gameover' && stateTime > 1) {
startGame();
}
}The same goes for a key that the player holds down. As long as you hold a key down, the browser keeps sending keydown events, with event.repeat set to true. Holding the space bar would start one game after another. Ignore those repeats for actions like starting and pausing:
// In onKeyDown(): a key that you hold down only counts once
if (event.repeat) {
return;
}Reset everything when the game restarts
So what does startGame() do? The score goes back to 0 and the lives go back to 3, of course. Here's a first try:
function startGame() {
score = 0;
lives = 3;
changeState('playing');
}The demo below has only one life, so the game is over quickly. Play until it's over, and then start again. What's going wrong here?
Your bee starts the new game right where the old one ended, with the bee that hit you still next to it. This demo also adds bees during the game, and they're all still there. You'll add that yourself in the next section. Every variable that changes during a game has to go back to how it was at the start, and it's easy to forget one.
The fix is to put all of it in one function, resetGame(), and call it at the start of every game, including the first one. It builds a new world, with a new swarm and your bee in the middle:
function resetGame() {
// Your own bee, in the middle of the canvas
player = new Player(context, 375, 200);
// A small swarm, not too close to you
gameObjects = [];
for (let i = 0; i < 6; i++) {
let spot = findSpawnSpot();
gameObjects.push(createBee(spot.x, spot.y));
}
gameObjects.push(player);
// Everything else back to how it was at the start
score = 0;
lives = 3;
isNewHighScore = false;
spawnInterval = 5;
spawnTimer = spawnInterval;
nextBee = null;
}
function startGame() {
resetGame();
changeState('playing');
}resetGame() replaces createWorld() from the previous parts. init() calls it when the sprite sheet has loaded, so the first game is built in exactly the same way as every game after it. If you forget to reset something, you'll notice it in the first game, and not only after a restart. You'll find out about findSpawnSpot() and the other new variables in the next section.
Switch the demo to Everything and try again.
Make the game harder over time
With six bees, surviving gets a little boring after a while. So let a new bee join the swarm every few seconds, a little sooner each time. Count down a timer in the playing state, just like in "Use random values in your game" in the tutorial about random numbers in JavaScript:
// A new bee joins the swarm every few seconds
const maxBees = 25;
let spawnInterval = 5;
let spawnTimer = 5;
// The spot where the next bee will appear
let nextBee = null;
function updateSpawning(secondsPassed) {
// Stop when the swarm is big enough
if (gameObjects.length > maxBees) {
return;
}
// Pick a spot one second early, to warn the player
spawnTimer -= secondsPassed;
if (spawnTimer < 1 && !nextBee) {
nextBee = findSpawnSpot();
}
if (spawnTimer <= 0) {
// Add it first, so it's drawn under your bee
gameObjects.unshift(createBee(nextBee.x, nextBee.y));
nextBee = null;
// A little sooner every time, but not too fast
spawnInterval = Math.max(spawnInterval - 0.25, 2);
spawnTimer = spawnInterval;
}
}Call it in the playing state of update(), right after updateObjects(). The first bee comes after 5 seconds, and every bee after that comes a quarter of a second sooner. Math.max() makes sure there are always at least 2 seconds between new bees. At 25 bees, the swarm stops growing, or the canvas would fill up with bees.
Two more things keep the game fair. A new bee never appears right on top of you. findSpawnSpot() tries random spots until it finds one that's at least 200 pixels away:
function findSpawnSpot() {
let x;
let y;
let dx;
let dy;
// Try random spots until one is far enough away from you
do {
x = 50 + Math.random() * 650;
y = 50 + Math.random() * 300;
dx = x - player.x;
dy = y - player.y;
} while (dx * dx + dy * dy < 200 * 200);
return {x: x, y: y};
}And one second before a bee appears, a red ring shows you where. It shrinks as spawnTimer counts down to 0. Call this in draw(), before you draw the game objects:
function drawWarning() {
if (!nextBee) {
return;
}
// A red ring that shrinks until the bee appears
context.strokeStyle = '#ff8080';
context.lineWidth = 3;
context.beginPath();
context.arc(
nextBee.x, nextBee.y, 15 + spawnTimer * 30, 0, 2 * Math.PI
);
context.stroke();
}Watch the swarm grow in the demo below. Nothing can hurt you here, so you can take your time:
Save the high score
A high score gives the player a reason to play just one more time. Keep it in a variable, and check it when the game is over:
let highScore = 0;
let isNewHighScore = false;
function gameOver() {
// Keep the best score, and save it for your next visit
isNewHighScore = Math.floor(score) > highScore;
if (isNewHighScore) {
highScore = Math.floor(score);
localStorage.setItem('beeHighScore', highScore);
}
changeState('gameover');
}In hitPlayer(), call gameOver() instead of changeState('gameover'). localStorage keeps the high score in the browser, even after the player closes the page. When the page loads, read it back in init():
// The best score from an earlier visit, or 0
highScore = Number(localStorage.getItem('beeHighScore'));Local storage only stores strings, so Number() turns the saved string back into a number. On the first visit, there's nothing saved yet. getItem() returns null, and Number(null) is 0. That's all you need for one number. The tutorial about HTML5 local storage explains the rest, like how to save a whole list of high scores.
Show the high score on the start screen, and on the game over screen. When the player beats it, they deserve a little more than a number:
// In drawStartScreen():
drawText('Best: ' + highScore, 375, 320, '18px Arial');
// In drawGameOverScreen():
if (isNewHighScore) {
drawText('New best!', 375, 225, 'bold 22px Arial');
} else {
drawText('Best: ' + highScore, 375, 225, '22px Arial');
}The game at the top of this page saves your high score too. Play it once, and your best score will be waiting for you next time.
Draw buttons on the canvas
The pause screen needs a few buttons: one to continue, one to start over and one to go back to the start screen. On the canvas, a button is just a rectangle with text on it. Give each button a position, a size and the function it calls:
// The buttons on the pause screen
const menuButtons = [
{
text: 'Resume',
x: 285, y: 150, width: 180, height: 46,
onClick: resumeGame
},
{
text: 'Restart',
x: 285, y: 212, width: 180, height: 46,
onClick: startGame
},
{
text: 'Quit',
x: 285, y: 274, width: 180, height: 46,
onClick: quitGame
}
];
function resumeGame() {
changeState('playing');
}
function quitGame() {
changeState('start');
}onClick gets the function itself, without parentheses. With parentheses, you would call the function right away and store what it returns. In drawPauseScreen(), loop over menuButtons and draw each of them with drawButton():
function drawButton(button) {
// Lighter when the mouse is over it
context.fillStyle = isOnButton(button)
? '#33b5c7'
: '#0099b0';
context.fillRect(
button.x, button.y, button.width, button.height
);
context.fillStyle = 'white';
drawText(
button.text,
button.x + button.width / 2, button.y + button.height / 2,
'22px Arial'
);
}
function isOnButton(button) {
return pointInRect(
pointer.x, pointer.y,
button.x, button.y, button.width, button.height
);
}
function pointInRect(px, py, x, y, w, h) {
return px >= x && px <= x + w && py >= y && py <= y + h;
}pointInRect() is the same function as in "Check if a point is inside a shape" in the collision detection tutorial. The pointer position changes every time the mouse moves over the canvas, so a button lights up as soon as the mouse is over it. What's left is the click. Here's onPointerDown(), with a switch that has a case for every state:
function onPointerDown(event) {
updatePointer(event);
switch (gameState) {
case 'start':
case 'gameover':
pressStart();
break;
case 'playing':
if (isOnButton(pauseButton)) {
pauseGame();
} else {
// Fly toward the mouse or finger while it's down
pointer.isDown = true;
canvas.setPointerCapture(event.pointerId);
}
break;
case 'paused':
// Click a button on the pause screen
for (let i = 0; i < menuButtons.length; i++) {
if (isOnButton(menuButtons[i])) {
menuButtons[i].onClick();
}
}
break;
}
}Try the buttons below. Start the game, pause it and pick one:
Buttons that you draw on the canvas have one downside: the keyboard and screen readers can't find them. If that matters for your game, put HTML buttons on top of the canvas instead. A real <button> works with Tab and Enter right away, and a screen reader can read it out loud. In this game, P and Escape already do the most important thing: they continue the game.
Give each state its own object
With four states, the switch statements are still easy to read. But every new screen adds a case to update(), draw() and onPointerDown(). Picture a game with a level select, a settings screen and a shop. The code of one screen ends up spread over the whole file.
For a bigger game, give each state its own object, with its own update() and draw(). An enter() function can do what has to happen when the game switches to that state:
const states = {
start: {
update(secondsPassed) {
updateObjects(secondsPassed);
},
draw() {
drawStartScreen();
}
},
playing: {
update(secondsPassed) {
updateObjects(secondsPassed);
updateSpawning(secondsPassed);
score += 10 * secondsPassed;
},
draw() {
drawHud();
}
},
gameover: {
enter() {
// Keep the best score, and save it for your next visit
if (Math.floor(score) > highScore) {
highScore = Math.floor(score);
localStorage.setItem('beeHighScore', highScore);
}
},
update(secondsPassed) {
// Everything stands still
},
draw() {
drawHud();
drawGameOverScreen();
}
}
// ...
};
let state = states.start;
function changeState(newState) {
state = newState;
stateTime = 0;
if (state.enter) {
state.enter();
}
}The game loop calls state.update() and state.draw(), and the switch statements are gone. To switch to the playing state, for example, you call changeState(states.playing). A new screen is a new object, and all of its code is in one place.
This is called a finite state machine: a fixed number of states, only one of them active at a time, and clear moments when the game switches from one to another. The string version was one too. Many game engines work in a similar way: in Phaser, for example, you'd make each state a scene.
Play the finished game
That's everything you need. Your game now has a start screen, a score, three lives, a pause menu and a game over screen, and your bee dodges a swarm that keeps growing. Here's the game from the top of this page again. The complete code is further down:
What's your best score? Share it in the comments below. And if you change the game, for example with faster bees or more lives, let us know how it went.
Common questions about game states
How do I add a countdown before the game starts?
Add a state between the start screen and the game, like 'countdown'. In that state, draw Math.ceil(3 - stateTime) in the middle of the canvas, so it shows 3, 2 and 1. When stateTime reaches 3, switch to 'playing'. The same countdown works after a pause, so the player has a moment to get ready.
What's a state stack?
A state stack is a list of states, with the active one on top. When you pause, you put the pause screen on top of the game, and when you continue, you take it off again. Only the top state gets updated, but you can draw all of them, from the bottom up. That's handy when screens sit on top of each other, like a settings screen that you open from the pause menu.
How do I add a level select or a settings screen?
Add a state for each screen, with its own buttons. For a screen with a lot of settings, HTML is often easier than the canvas. Sliders, checkboxes and text fields come with the browser, and they work with the keyboard and screen readers. Show them on top of the canvas when the game switches to that state, and hide them when it leaves.
How do I save the game when the player closes the tab?
Listen for visibilitychange, and save the game in local storage when document.hidden is true. That event also fires when the player closes the tab or switches to another app on their phone. It's more reliable than beforeunload, which mobile browsers often skip.
How do game states work in Phaser or PixiJS?
Phaser has scenes. Each scene has its own create() and update(), and you switch between them with this.scene.start(). Several scenes can run at the same time, like a HUD on top of the game. PixiJS only draws, so you build the states yourself, just like on this page. A container for each screen makes it easy to show and hide all of its objects at once.
The complete code
Here's the complete code of the game. It picks up where the game controls tutorial left off, and it loads the sprite sheet from spicyyoghurt.com. Paste it into an HTML file and open it in your browser:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Game States Tutorial | Spicy Yoghurt</title>
<style>
#canvas {
width: 750px;
height: 400px;
border: 1px solid lightgray;
touch-action: none;
}
</style>
</head>
<body>
<canvas
id="canvas"
width="750"
height="400"
tabindex="0"
>
Your browser does not support the HTML5 canvas tag.
</canvas>
<script>
'use strict';
// Define the edges of the canvas
const canvasWidth = 750;
const canvasHeight = 400;
// The sprite sheet of the bee
const spriteUrl =
'https://spicyyoghurt.com/img/tutorials/' +
'develop_a_html5_game/bee-sprite-sheet.png';
let canvas;
let context;
let oldTimeStamp = 0;
let gameObjects = [];
let player;
// The game keys, by event.code
const gameKeys = [
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
'KeyW', 'KeyA', 'KeyS', 'KeyD', 'Space'
];
// The keys that are down right now
let keys = {};
// The mouse or finger on the canvas
let pointer = {x: 0, y: 0, isDown: false};
// The state of the game:
// 'start', 'playing', 'paused' or 'gameover'
let gameState = 'start';
// How many seconds the game has been in this state
let stateTime = 0;
let score = 0;
let lives = 3;
let highScore = 0;
let isNewHighScore = false;
// A new bee joins the swarm every few seconds
const maxBees = 25;
let spawnInterval = 5;
let spawnTimer = 5;
// The spot where the next bee will appear
let nextBee = null;
// The pause button in the top-right corner
const pauseButton = {x: 696, y: 14, width: 40, height: 40};
// The buttons on the pause screen
const menuButtons = [
{
text: 'Resume',
x: 285, y: 150, width: 180, height: 46,
onClick: resumeGame
},
{
text: 'Restart',
x: 285, y: 212, width: 180, height: 46,
onClick: startGame
},
{
text: 'Quit',
x: 285, y: 274, width: 180, height: 46,
onClick: quitGame
}
];
class GameObject {
constructor(context, x, y, vx, vy, mass) {
this.context = context;
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
this.mass = mass;
this.restitution = 0.9;
this.isColliding = false;
}
}
class Bee extends GameObject {
// One sprite sheet for all bees, with 4 columns and 2 rows
static sprite;
static numColumns = 4;
static numRows = 2;
static frameWidth = 128;
static frameHeight = 128;
// One animation for every row of the sprite sheet
static animations = {
fly: {row: 0, fps: 20, loop: true},
bump: {row: 1, fps: 10, loop: false}
};
constructor(context, x, y, vx, vy) {
super(context, x, y, vx, vy, 1);
// Keep flying at the same speed after a bump
this.restitution = 1;
// The hitbox is the round body of the bee
this.radius = 15;
// Face the direction of the velocity
this.angle = Math.atan2(vy, vx);
// Start at a random frame, so the bees don't flap in sync
this.animation = Bee.animations.fly;
this.currentFrame =
Math.floor(Math.random() * Bee.numColumns);
this.frameTimer = 0;
}
play(name) {
this.animation = Bee.animations[name];
this.currentFrame = 0;
this.frameTimer = 0;
}
bump() {
// Don't start over while the bee is still dizzy
if (this.animation != Bee.animations.bump) {
this.play('bump');
}
}
update(secondsPassed) {
// Move at the current velocity
this.x += this.vx * secondsPassed;
this.y += this.vy * secondsPassed;
// Calculate the angle, in radians
this.angle = Math.atan2(this.vy, this.vx);
// Pick the next frame when it's due
this.frameTimer += secondsPassed;
while (this.frameTimer >= 1 / this.animation.fps) {
this.frameTimer -= 1 / this.animation.fps;
this.currentFrame++;
if (this.currentFrame == Bee.numColumns) {
if (this.animation.loop) {
this.currentFrame = 0;
} else {
this.play('fly');
}
}
}
}
draw() {
// Save the context, move it to the bee and rotate it
this.context.save();
this.context.translate(this.x, this.y);
this.context.rotate(this.angle);
// Draw the image around (0, 0), the middle of the body
this.context.drawImage(
Bee.sprite,
this.currentFrame * Bee.frameWidth,
this.animation.row * Bee.frameHeight,
Bee.frameWidth, Bee.frameHeight,
-28, -32, 64, 64
);
// Put the context back the way it was
this.context.restore();
}
}
class Player extends Bee {
constructor(context, x, y) {
super(context, x, y, 0, 0);
// Heavier, so you push the other bees away
this.mass = 3;
// A bit bigger than the other bees, and so is the hitbox
this.scale = 1.25;
this.radius = 15 * this.scale;
// Pixels per second
this.speed = 200;
// Seconds until a bee can hurt you again
this.invincibleTime = 0;
}
update(secondsPassed) {
this.invincibleTime -= secondsPassed;
// Fly in the direction of the controls
let direction = getDirection(this);
this.vx = direction.x * this.speed;
this.vy = direction.y * this.speed;
// Keep facing the same way when you stop
let angle = this.angle;
super.update(secondsPassed);
if (direction.x == 0 && direction.y == 0) {
this.angle = angle;
}
}
draw() {
// Blink after a hit, while nothing can hurt you
let blink = Math.floor(this.invincibleTime * 8) % 2;
if (this.invincibleTime > 0 && blink == 1) {
this.context.globalAlpha = 0.3;
}
// A circle, so you can spot your own bee in the swarm
this.context.fillStyle = 'rgba(0, 153, 176, 0.3)';
this.context.beginPath();
this.context.arc(this.x, this.y, 30, 0, 2 * Math.PI);
this.context.fill();
// Draw the bee bigger, scaled around its middle
this.context.save();
this.context.translate(this.x, this.y);
this.context.scale(this.scale, this.scale);
this.context.translate(-this.x, -this.y);
super.draw();
this.context.restore();
this.context.globalAlpha = 1;
}
}
window.onload = init;
function init() {
canvas = document.getElementById('canvas');
context = canvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// The keyboard, only when the canvas has focus
canvas.addEventListener('keydown', onKeyDown);
canvas.addEventListener('keyup', onKeyUp);
canvas.addEventListener('blur', releaseAllKeys);
canvas.focus();
// The mouse, a finger or a pen
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', updatePointer);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
// Pause when you click somewhere else or switch tabs
canvas.addEventListener('blur', pauseGame);
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
pauseGame();
}
});
// The best score from an earlier visit, or 0
highScore = Number(localStorage.getItem('beeHighScore'));
// Show the start screen when the sprite sheet has loaded
loadImage(spriteUrl).then(function (img) {
Bee.sprite = img;
resetGame();
window.requestAnimationFrame(gameLoop);
});
}
function loadImage(src) {
return new Promise(function (resolve, reject) {
let img = new Image();
img.onload = function () {
resolve(img);
};
img.onerror = reject;
img.src = src;
});
}
function resizeCanvas() {
// One canvas pixel for every pixel of the screen
let ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(canvas.clientWidth * ratio);
canvas.height = Math.round(canvas.clientHeight * ratio);
// Keep drawing in game coordinates (750 x 400)
context.setTransform(
canvas.width / canvasWidth, 0,
0, canvas.height / canvasHeight,
0, 0
);
}
function resetGame() {
// Your own bee, in the middle of the canvas
player = new Player(context, 375, 200);
// A small swarm, not too close to you
gameObjects = [];
for (let i = 0; i < 6; i++) {
let spot = findSpawnSpot();
gameObjects.push(createBee(spot.x, spot.y));
}
gameObjects.push(player);
// Everything else back to how it was at the start
score = 0;
lives = 3;
isNewHighScore = false;
spawnInterval = 5;
spawnTimer = spawnInterval;
nextBee = null;
}
function findSpawnSpot() {
let x;
let y;
let dx;
let dy;
// Try random spots until one is far enough away from you
do {
x = 50 + Math.random() * 650;
y = 50 + Math.random() * 300;
dx = x - player.x;
dy = y - player.y;
} while (dx * dx + dy * dy < 200 * 200);
return {x: x, y: y};
}
function createBee(x, y) {
// Every bee flies at 100 pixels per second
let angle = Math.random() * 2 * Math.PI;
return new Bee(
context, x, y,
Math.cos(angle) * 100, Math.sin(angle) * 100
);
}
function changeState(newState) {
gameState = newState;
stateTime = 0;
}
function startGame() {
resetGame();
changeState('playing');
}
// Space, Enter, a click or a tap
function pressStart() {
if (gameState == 'start') {
startGame();
}
// Wait a second, so you don't skip game over by accident
if (gameState == 'gameover' && stateTime > 1) {
startGame();
}
}
function pauseGame() {
if (gameState == 'playing') {
changeState('paused');
}
}
function resumeGame() {
changeState('playing');
}
function togglePause() {
if (gameState == 'playing') {
changeState('paused');
} else if (gameState == 'paused') {
changeState('playing');
}
}
function quitGame() {
changeState('start');
}
function onKeyDown(event) {
keys[event.code] = true;
// Don't let the game keys scroll the page
if (gameKeys.includes(event.code)) {
event.preventDefault();
}
// A key that you hold down only counts once
if (event.repeat) {
return;
}
// Space or Enter to start, P or Escape to pause
if (event.code == 'Space' || event.code == 'Enter') {
pressStart();
} else if (event.code == 'KeyP' || event.code == 'Escape') {
togglePause();
}
}
function onKeyUp(event) {
keys[event.code] = false;
}
function releaseAllKeys() {
// The game can't see keys being released anymore
keys = {};
}
function onPointerDown(event) {
updatePointer(event);
switch (gameState) {
case 'start':
case 'gameover':
pressStart();
break;
case 'playing':
if (isOnButton(pauseButton)) {
pauseGame();
} else {
// Fly toward the mouse or finger while it's down
pointer.isDown = true;
canvas.setPointerCapture(event.pointerId);
}
break;
case 'paused':
// Click a button on the pause screen
for (let i = 0; i < menuButtons.length; i++) {
if (isOnButton(menuButtons[i])) {
menuButtons[i].onClick();
}
}
break;
}
}
function onPointerUp() {
pointer.isDown = false;
}
function updatePointer(event) {
// The position on the canvas, in screen pixels
let rect = canvas.getBoundingClientRect();
let screenX = event.clientX - rect.left;
let screenY = event.clientY - rect.top;
// Convert it to game coordinates
pointer.x = screenX * canvasWidth / rect.width;
pointer.y = screenY * canvasHeight / rect.height;
}
function getDirection(obj) {
let x = 0;
let y = 0;
// The arrow keys or WASD
if (keys.ArrowLeft || keys.KeyA) {
x -= 1;
}
if (keys.ArrowRight || keys.KeyD) {
x += 1;
}
if (keys.ArrowUp || keys.KeyW) {
y -= 1;
}
if (keys.ArrowDown || keys.KeyS) {
y += 1;
}
// Hold the mouse or a finger down to fly toward it
if (pointer.isDown) {
x = pointer.x - obj.x;
y = pointer.y - obj.y;
// Stop when you get there
if (x * x + y * y < 10 * 10) {
x = 0;
y = 0;
}
}
// Give the direction a length of 1, so you fly
// just as fast in every direction
let length = Math.sqrt(x * x + y * y);
if (length > 0) {
x /= length;
y /= length;
}
return {x: x, y: y};
}
function isOnButton(button) {
return pointInRect(
pointer.x, pointer.y,
button.x, button.y, button.width, button.height
);
}
function pointInRect(px, py, x, y, w, h) {
return px >= x && px <= x + w && py >= y && py <= y + h;
}
function gameLoop(timeStamp) {
// Calculate how much time has passed, capped at a maximum
let secondsPassed = (timeStamp - oldTimeStamp) / 1000;
oldTimeStamp = timeStamp;
secondsPassed = Math.min(secondsPassed, 0.1);
stateTime += secondsPassed;
update(secondsPassed);
draw();
window.requestAnimationFrame(gameLoop);
}
function update(secondsPassed) {
switch (gameState) {
case 'start':
// The bees already fly around behind the title
updateObjects(secondsPassed);
break;
case 'playing':
updateObjects(secondsPassed);
updateSpawning(secondsPassed);
// 10 points for every second you stay alive
score += 10 * secondsPassed;
break;
case 'paused':
case 'gameover':
// Everything stands still
break;
}
}
function updateObjects(secondsPassed) {
// Loop over all game objects
for (let i = 0; i < gameObjects.length; i++) {
gameObjects[i].update(secondsPassed);
}
detectCollisions();
detectEdgeCollisions();
}
function updateSpawning(secondsPassed) {
// Stop when the swarm is big enough
if (gameObjects.length > maxBees) {
return;
}
// Pick a spot one second early, to warn the player
spawnTimer -= secondsPassed;
if (spawnTimer < 1 && !nextBee) {
nextBee = findSpawnSpot();
}
if (spawnTimer <= 0) {
// Add it first, so it's drawn under your bee
gameObjects.unshift(createBee(nextBee.x, nextBee.y));
nextBee = null;
// A little sooner every time, but not too fast
spawnInterval = Math.max(spawnInterval - 0.25, 2);
spawnTimer = spawnInterval;
}
}
function hitPlayer() {
// Only while playing, and not right after a hit
if (gameState != 'playing' || player.invincibleTime > 0) {
return;
}
lives--;
if (lives == 0) {
gameOver();
return;
}
// Two seconds to get away
player.invincibleTime = 2;
}
function gameOver() {
// Keep the best score, and save it for your next visit
isNewHighScore = Math.floor(score) > highScore;
if (isNewHighScore) {
highScore = Math.floor(score);
localStorage.setItem('beeHighScore', highScore);
}
changeState('gameover');
}
function draw() {
// Clear the canvas
context.clearRect(0, 0, canvas.width, canvas.height);
// Draw the warning for the next bee and all game objects
drawWarning();
for (let i = 0; i < gameObjects.length; i++) {
gameObjects[i].draw();
}
// Draw the screen of the current state on top
switch (gameState) {
case 'start':
drawStartScreen();
break;
case 'playing':
drawHud();
drawPauseButton();
break;
case 'paused':
drawHud();
drawPauseScreen();
break;
case 'gameover':
drawHud();
drawGameOverScreen();
break;
}
}
function drawWarning() {
if (!nextBee) {
return;
}
// A red ring that shrinks until the bee appears
context.strokeStyle = '#ff8080';
context.lineWidth = 3;
context.beginPath();
context.arc(
nextBee.x, nextBee.y, 15 + spawnTimer * 30, 0, 2 * Math.PI
);
context.stroke();
}
function drawHud() {
// The score in the top-left corner
context.font = 'bold 24px Arial';
context.textAlign = 'left';
context.textBaseline = 'middle';
context.fillStyle = '#303030';
context.fillText('Score: ' + Math.floor(score), 20, 30);
// A small bee for every life you have left
for (let i = 0; i < lives; i++) {
context.drawImage(
Bee.sprite,
0, 0, Bee.frameWidth, Bee.frameHeight,
8 + i * 40, 42, 52, 52
);
}
}
function drawPauseButton() {
// Two bars in the top-right corner
let x = pauseButton.x;
let y = pauseButton.y;
context.fillStyle = '#303030';
context.fillRect(x + 10, y + 8, 7, 24);
context.fillRect(x + 23, y + 8, 7, 24);
}
function drawText(text, x, y, font) {
context.font = font;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(text, x, y);
}
function drawStartScreen() {
// A light layer, so the text stands out from the bees
context.fillStyle = 'rgba(255, 255, 255, 0.6)';
context.fillRect(0, 0, canvasWidth, canvasHeight);
context.fillStyle = '#303030';
drawText('Dodge the Swarm', 375, 130, 'bold 48px Arial');
drawText('Best: ' + highScore, 375, 320, '18px Arial');
// Fade the text in and out, once every second
let pulse = Math.sin(stateTime * 2 * Math.PI);
context.globalAlpha = 0.6 + 0.4 * pulse;
drawText('Click to start', 375, 270, '24px Arial');
context.globalAlpha = 1;
}
function drawPauseScreen() {
// Darken the game behind the menu
context.fillStyle = 'rgba(0, 0, 0, 0.5)';
context.fillRect(0, 0, canvasWidth, canvasHeight);
context.fillStyle = 'white';
drawText('Paused', 375, 100, 'bold 40px Arial');
for (let i = 0; i < menuButtons.length; i++) {
drawButton(menuButtons[i]);
}
}
function drawButton(button) {
// Lighter when the mouse is over it
context.fillStyle = isOnButton(button)
? '#33b5c7'
: '#0099b0';
context.fillRect(
button.x, button.y, button.width, button.height
);
context.fillStyle = 'white';
drawText(
button.text,
button.x + button.width / 2, button.y + button.height / 2,
'22px Arial'
);
}
function drawGameOverScreen() {
// Darken the game behind the text
context.fillStyle = 'rgba(0, 0, 0, 0.5)';
context.fillRect(0, 0, canvasWidth, canvasHeight);
context.fillStyle = 'white';
drawText('Game over', 375, 120, 'bold 48px Arial');
drawText(
'Score: ' + Math.floor(score), 375, 185, '28px Arial'
);
if (isNewHighScore) {
drawText('New best!', 375, 225, 'bold 22px Arial');
} else {
drawText('Best: ' + highScore, 375, 225, '22px Arial');
}
// A click only starts a new game after a second
if (stateTime > 1) {
drawText('Click to play again', 375, 300, '24px Arial');
}
}
function detectCollisions() {
let obj1;
let obj2;
// Reset the collision state of all objects
for (let i = 0; i < gameObjects.length; i++) {
gameObjects[i].isColliding = false;
}
// Start checking for collisions
for (let i = 0; i < gameObjects.length; i++) {
obj1 = gameObjects[i];
for (let j = i + 1; j < gameObjects.length; j++) {
obj2 = gameObjects[j];
// Compare object1 with object2
if (!circleIntersect(
obj1.x, obj1.y, obj1.radius,
obj2.x, obj2.y, obj2.radius
)) {
continue;
}
obj1.isColliding = true;
obj2.isColliding = true;
// A bee that touches you costs a life
if (obj1 == player || obj2 == player) {
hitPlayer();
}
// Find the direction of the collision
let vCollision = {
x: obj2.x - obj1.x,
y: obj2.y - obj1.y
};
let distance = Math.sqrt(
(obj2.x - obj1.x) * (obj2.x - obj1.x) +
(obj2.y - obj1.y) * (obj2.y - obj1.y)
);
// Two objects in the same spot have no direction
if (distance == 0) {
continue;
}
let vCollisionNorm = {
x: vCollision.x / distance,
y: vCollision.y / distance
};
// Push them apart, so they don't sink into each other
separate(obj1, obj2, vCollisionNorm, distance);
// Calculate the speed of the detected collision
let vRelativeVelocity = {
x: obj1.vx - obj2.vx,
y: obj1.vy - obj2.vy
};
let speed =
vRelativeVelocity.x * vCollisionNorm.x +
vRelativeVelocity.y * vCollisionNorm.y;
// The objects are already moving apart
if (speed < 0) {
continue;
}
// Use the lower restitution of the two objects
let restitution = Math.min(
obj1.restitution, obj2.restitution
);
// Change the velocity of both objects
let totalMass = obj1.mass + obj2.mass;
let impulse = (1 + restitution) * speed / totalMass;
obj1.vx -= (impulse * obj2.mass * vCollisionNorm.x);
obj1.vy -= (impulse * obj2.mass * vCollisionNorm.y);
obj2.vx += (impulse * obj1.mass * vCollisionNorm.x);
obj2.vy += (impulse * obj1.mass * vCollisionNorm.y);
// Both bees feel the bump
obj1.bump();
obj2.bump();
}
}
}
function circleIntersect(x1, y1, r1, x2, y2, r2) {
// Calculate the squared distance between the two centers
let squareDistance =
(x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2);
// When the distance is less than or equal to the sum
// of the two radii, the circles touch or overlap
return squareDistance <= ((r1 + r2) * (r1 + r2));
}
function separate(obj1, obj2, vCollisionNorm, distance) {
// How far the circles overlap
let overlap = obj1.radius + obj2.radius - distance;
// Move them apart along the collision normal
let totalMass = obj1.mass + obj2.mass;
obj1.x -= vCollisionNorm.x * overlap * obj2.mass / totalMass;
obj1.y -= vCollisionNorm.y * overlap * obj2.mass / totalMass;
obj2.x += vCollisionNorm.x * overlap * obj1.mass / totalMass;
obj2.y += vCollisionNorm.y * overlap * obj1.mass / totalMass;
}
function detectEdgeCollisions() {
let obj;
for (let i = 0; i < gameObjects.length; i++) {
obj = gameObjects[i];
// Check for left and right
if (obj.x < obj.radius) {
obj.vx = Math.abs(obj.vx) * obj.restitution;
obj.x = obj.radius;
obj.bump();
} else if (obj.x > canvasWidth - obj.radius) {
obj.vx = -Math.abs(obj.vx) * obj.restitution;
obj.x = canvasWidth - obj.radius;
obj.bump();
}
// Check for top and bottom
if (obj.y < obj.radius) {
obj.vy = Math.abs(obj.vy) * obj.restitution;
obj.y = obj.radius;
obj.bump();
} else if (obj.y > canvasHeight - obj.radius) {
obj.vy = -Math.abs(obj.vy) * obj.restitution;
obj.y = canvasHeight - obj.radius;
obj.bump();
}
}
}
</script>
</body>
</html>What's next?
Your bee now has a real game to play, with a start screen, a score, lives, a pause menu and a game over screen. You've learned how to keep track of the state of your game in one variable, switch between states in one place, and reset everything when the game starts over. And your high score is still there when you come back.
If you have any questions, feel free to ask them in the comment section. You can download the example code here.
You've reached the final tutorial in this series. At least for now, that is. We're working hard to create the next one, so check back every now and then!
If you'd like to support Spicy Yoghurt, please share our articles or follow us on social media.
Comments
Thanks! We've received your comment. We check every comment by hand before it appears, so this can take a few days.
No comments yet. Be the first to leave one!