0 Spicy Yoghurt | Last updated: 4 November 2026 | HTML5 game tutorial
Manage game states with a simple state machine
Learn how to add game states to your JavaScript game, from the start screen to game over. Build a simple state machine without a game engine, pause the game and restart it cleanly. By the end of this tutorial, you'll have a real game with a score, lives and a high score to beat.
Part of the HTML5 game tutorial series
Here, the bee you learned to steer in the part about game controls gets a real game to play.
Show all partsHide all parts
In the game you'll make, you fly your bee around and stay away from the other bees. The longer you stay alive, the more points you score, and every bee that hits you costs a life. The swarm keeps growing, so sooner or later, it's game over. Then you can play again and try to beat your high score.
First, you'll learn what game states are and keep track of them in one variable. Then you'll use them for a start screen, a pause menu and a game over screen, and make sure every new game really starts from scratch. Finally, you'll give each state its own object, so new screens are easy to add and manage.
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 you to start playing. 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 to start or pressing P to pause. 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:
A game that works like this is called a state machine. It has a fixed set of states, and only one of them is active at a time. On this page, you'll build one in plain JavaScript, without a library or a game engine.
Not everything that changes during a game is a state. Your score and your lives change all the time, while the game stays in the playing state. Those are just numbers the game keeps track of. The state is the mode the game is in, and it decides what happens to those values.
You might have seen the term game state 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';You built your game loop over the previous parts of this series, starting with the JavaScript game loop tutorial. In this tutorial, you'll build on it again. Right now, gameLoop() does all the work itself: it calls update() on every game object, checks for collisions, clears the canvas and then calls draw() on every game object. Move that work into two new functions for the whole game, also called 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;
}
}
function draw() {
// Clear the canvas and draw all game objects
context.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < gameObjects.length; i++) {
gameObjects[i].draw();
}
// Draw the screen for the current state on top
switch (gameState) {
case 'start':
drawStartScreen();
break;
case 'playing':
drawHud();
break;
case 'paused':
drawHud();
drawPauseScreen();
break;
case 'gameover':
drawHud();
drawGameOverScreen();
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 anything that depends on time, like text that fades in and out or a short wait on the game over screen.
Try the four states below. Click the canvas to start, and press P to pause. You steer your bee just like in the game controls tutorial: with the arrow keys or WASD, or by holding down the mouse button or keeping your finger on the screen. In this demo, getting hit by one bee is enough to end the game, so fly into one to see the state change to 'gameover'. The state screens don't exist yet, so the demo shows the name of the current state instead. The code under the demo shows the last few calls to changeState(). The comment after each call says how long the game spent in the state it was leaving:
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. It's waiting for the player. 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('Pollen Rush', 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 line under the title fades in and out with stateTime and Math.sin(), a trick from the smooth canvas animation tutorial.
A click, a tap, the space bar or Enter starts the game. 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');
}
}Here's the start screen. Click or tap it to start playing:
In this demo, nothing can hurt you yet. And when you click or tap outside the canvas, the demo goes back to the start screen.
Keep score and lose lives only while playing
Now the game needs something to play for. You'll add a score that goes up, bees that can hurt you and new bees that join the swarm. But all of that should only happen in the playing state, not on the start screen or the game over screen.
The score and the new bees are easy. Update them in the playing case of update(), and they stop by themselves in every other state:
let score = 0;
// ...
case 'playing':
updateObjects(secondsPassed);
updateSpawning(secondsPassed);
// 10 points for every second you stay alive
score += 10 * secondsPassed;
break;The lives need a check of their own. Your game checks every pair of game objects for a collision in detectCollisions(), and it keeps doing that on the start screen, because the bees fly around there too. So when a bee touches yours, check the state before it costs you a life:
let lives = 3;
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;
}Call hitPlayer() in detectCollisions() when one of the two bees is player, your own bee. After a hit, invincibleTime gives you two seconds to get away, and your bee blinks until the time runs out. Without that, the swarm would take all your lives in a split second, because every frame in which you touch a bee counts as a new hit. The Player class counts invincibleTime down in its update(), and makes your bee blink in its draw(). You'll find both in the complete code.
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(), right after the check for the space bar. Their codes are 'KeyP' and 'Escape'.
You don't have to stop anything else. Everything that counts down during the game, like invincibleTime and the timer for the next bee, counts down in updateObjects() or updateSpawning(). The paused case calls neither of them, so those timers stop too, and when you resume, they pick up right where they left off.
Notice that the game loop keeps running while the game is paused. That's on purpose: the loop still has to draw the pause screen. Because the loop keeps running, oldTimeStamp stays up to date, so the first frame after a pause is just a normal frame, not one big jump forward in time. For a pause screen, it's simpler to keep the loop running and let the state decide what moves.
Add a pause menu
A pause screen usually has a menu. This one has three buttons: Resume to continue, Restart to start over and Quit to go back to the start screen. On a phone, there's no P key, so there's also a pause button in the top-right corner while you play. All of these buttons are rectangles on the canvas, just like the on-screen touch buttons in the game controls tutorial. This time, each button in the menu also stores the function it calls:
// 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
}
];
function resumeGame() {
changeState('playing');
}
function quitGame() {
changeState('start');
}A click now means something different in every state. On the start screen and the game over screen, it starts a game. While you play, it steers your bee, unless it hits the pause button. And on the pause screen, it presses one of the menu buttons. So let a switch on the state decide what the click does. Here's the whole onPointerDown():
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;
}
}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.
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 browser stops running your game loop, so the game freezes. It picks up again the moment they come back, before they've even found their bee on the screen.
To fix this, pause the game when the canvas loses focus or the tab is hidden. You've already used both events: blur in the game controls tutorial, and visibilitychange in the JavaScript game loop tutorial. Add both listeners in init(), the function that sets up your game when the page loads:
// 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.
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 pausing the game 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 also tells you why the game paused. Only this demo does that, so you can follow what's going on. The buttons work just like they do in your own game:
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. It also calls startGame() now, instead of changeState('playing'). You'll write startGame() in the next section. Here's the new pressStart():
function pressStart() {
if (gameState == 'start') {
startGame();
}
// Wait a second, so you don't skip the game over screen 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. Here's the whole onKeyDown(), with the check for repeats right before the checks for the keys that start and pause the game:
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();
}
}A high score gives the player a reason to play just one more time. Check it when the game is over, and save it in local storage, so it's still there on the next visit. Do that once, at the moment the game ends, in a function that then switches to the game over state:
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');
}Why not check the high score in update() or drawGameOverScreen()? Those run every frame. The first frame would save your score as the new high score. From the second frame on, your score would no longer be higher than the high score, because it would already be the high score, so New best! would disappear right away. Code that should run only once belongs in the place where the state changes.
When the page loads, read the high score back in init():
// The best score from an earlier visit, or 0
highScore = Number(localStorage.getItem('beeHighScore'));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');
}If you only reset the score and the lives, your bee starts the new game right where the last one ended, with the swarm bee that hit you still right next to it. And the bees that joined the swarm during the game are all still there. 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 with the bee images has loaded, so the first game is built in exactly the same way as every game after it. If resetGame() forgets to build something, like the swarm, you'll notice it right away in the first game, instead of only after a restart.
There's one more thing that startGame() shouldn't do: set up the game a second time. Add the event listeners and start the game loop once, in init(), and never in startGame(). If every new game adds its listeners again, one press of P after a restart calls togglePause() twice. The game pauses and resumes right away, so P seems to stop working. And if startGame() starts the game loop too, every restart adds another one, so every frame your game does all of its work twice, then three times, and so on.
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(), and more checks like gameState == 'playing'. Picture a game with a level select, a settings screen and a shop. The code for a single screen ends up spread across the whole file.
So give each state its own object instead. Every state gets the same four functions: update() and draw(), and onKeyDown() and onPointerDown() for the controls. Here are the start state and the playing state:
const states = {
start: {
update(secondsPassed) {
// The bees already fly around behind the title
updateObjects(secondsPassed);
},
draw() {
drawStartScreen();
},
onKeyDown(code) {
if (isStartKey(code)) {
startGame();
}
},
onPointerDown(event) {
startGame();
}
},
playing: {
update(secondsPassed) {
updateObjects(secondsPassed);
updateSpawning(secondsPassed);
// 10 points for every second you stay alive
score += 10 * secondsPassed;
},
draw() {
drawHud();
drawPauseButton();
},
onKeyDown(code) {
if (isPauseKey(code)) {
pauseGame();
}
},
onPointerDown(event) {
if (isOnButton(pauseButton)) {
pauseGame();
} else {
// Fly toward the mouse or finger while it's down
pointer.isDown = true;
canvas.setPointerCapture(event.pointerId);
}
}
}
// ... and the same for paused and gameover
};
// The state the game is in right now
let state = states.start;isStartKey() and isPauseKey() are two small helpers. They check for Space or Enter, and for P or Escape. The game loop and the event listeners don't need a switch anymore. They pass everything on to the current state:
function gameLoop(timeStamp) {
// ...
state.update(secondsPassed);
draw();
window.requestAnimationFrame(gameLoop);
}
function onKeyDown(event) {
// ...
// The current state decides what the key does
state.onKeyDown(event.code);
}
function onPointerDown(event) {
updatePointer(event);
// The current state decides what the click or tap does
state.onPointerDown(event);
}At the end of draw(), state.draw() takes the place of the switch. To switch states, you now pass the object itself: changeState(states.paused) instead of changeState('paused'). And a check like gameState == 'playing' becomes state == states.playing, for example in hitPlayer() and pauseGame().
The paused state and the game over state work the same way, each with its own keys and clicks. To see what the game does on the pause screen, you only have to look at states.paused. A new screen is a new object, and all of its code is in one place. You'll find all four states in the complete code.
Run code when a state begins or ends
Some code has to run only once, at the moment the game switches from one state to another. You've already seen an example: gameOver() saves the high score right before the game switches to the game over state. With state objects, that code can move into the state itself. Give a state an enter() function, and the game calls it when it enters that state. An exit() function runs when the game leaves it:
function changeState(newState) {
// Let the old state finish, and the new state begin
if (state.exit) {
state.exit();
}
state = newState;
stateTime = 0;
if (state.enter) {
state.enter();
}
}Not every state needs them, so changeState() only calls them when the state has them. The game over state saves the high score in its enter(). Now hitPlayer() can call changeState(states.gameover), just like every other change, and you don't need gameOver() anymore:
gameover: {
enter() {
// Keep the best score, and save it for next time
isNewHighScore = Math.floor(score) > highScore;
if (isNewHighScore) {
highScore = Math.floor(score);
localStorage.setItem('beeHighScore', highScore);
}
},
// ... update(), draw() and the controls
}enter() and exit() often come in pairs: what one turns on, the other turns off. In the game controls tutorial, you turned off scrolling on the canvas with touch-action: none, so dragging your finger steers the bee instead of scrolling the page. But on the start screen and the game over screen, there's nothing to steer, and on a phone, the player might just want to scroll past the game. So only turn scrolling off while the game is playing:
playing: {
enter() {
// Your finger steers the bee instead of scrolling
canvas.style.touchAction = 'none';
},
exit() {
// On every other screen, the page scrolls again
canvas.style.touchAction = 'auto';
},
// ... update(), draw() and the controls
}One thing doesn't belong in the playing state's enter(): resetGame(). The game also enters the playing state when you continue after a pause, and that shouldn't start a new game. That's why startGame() calls resetGame() itself.
See enter() and exit() in action below. Start the game, pause it, resume it and then lose. In this demo, getting hit by one bee is enough to end the game. The code under the demo shows each call to enter() and exit() as it happens:
let state = states.start;This is still the state machine from the start of this page, only now with an object for every state. Its full name is 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. Many game engines work in a similar way: in Phaser, for example, you'd make each state a scene.
So when should you switch from strings to objects? For a small game with a few simple screens, a switch is fine. Once a screen gets its own buttons, timers or things to clean up, objects keep each screen's code in one place, so your game stays easy to change and extend.
Play the finished game
That's everything you need. Your game now has a start screen, a score, lives, a pause menu and a game over screen, and your bee dodges a swarm that keeps growing. The complete code is further down. Click or tap the canvas below to start, and see how long you can stay alive:
What's your best score? Share it in the comments below. And if you change the game, for example by adding faster bees or power-ups, let us know how it went.
Want the bees to hunt you down, instead of just bouncing around? In the tutorial on flocking with boids, a hunter chases the nearest bird in the flock, and the swarm can chase you the same way. And the tutorial on pathfinding shows how an enemy can find its way to you, even around walls.
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 states.countdown, and let startGame() switch to it instead of states.playing. The countdown's draw() shows Math.ceil(3 - stateTime) in the middle of the canvas, so you see 3, 2, 1. When stateTime reaches 3, its update() switches to states.playing. The same countdown works after a pause: let resumeGame() switch to it too, 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 with stack.push(states.paused), and when you continue, you take it off again with stack.pop(). The game loop only updates the top state, stack[stack.length - 1], but it draws 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.
Can a game object have states too?
Yes. Your bee could have its own states, like flying, hurt and invincible, each with its own animation and its own rules. It works just like the game states on this page, only for one object. In this game, invincibleTime is a simple version of that: while it's above 0, your bee is invincible and blinks.
Do I need a library for a state machine?
Not for a game like this. The state machine in this game is just an object for every state and a function that switches between them, so a few lines of plain JavaScript are enough. Libraries like XState add extras, such as states inside states and a fixed list of allowed changes. They help in a big app with a lot of states, but in a small game, it's easier to see what's going on when you write it yourself.
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, with an object for every state. 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;
}
</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};
// 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
}
];
// Every state has its own update(), draw() and controls.
// enter() and exit() run when the game switches states.
const states = {
start: {
update(secondsPassed) {
// The bees already fly around behind the title
updateObjects(secondsPassed);
},
draw() {
drawStartScreen();
},
onKeyDown(code) {
if (isStartKey(code)) {
startGame();
}
},
onPointerDown(event) {
startGame();
}
},
playing: {
enter() {
// Your finger steers the bee instead of scrolling
canvas.style.touchAction = 'none';
},
exit() {
// On every other screen, the page scrolls again
canvas.style.touchAction = 'auto';
},
update(secondsPassed) {
updateObjects(secondsPassed);
updateSpawning(secondsPassed);
// 10 points for every second you stay alive
score += 10 * secondsPassed;
},
draw() {
drawHud();
drawPauseButton();
},
onKeyDown(code) {
if (isPauseKey(code)) {
pauseGame();
}
},
onPointerDown(event) {
if (isOnButton(pauseButton)) {
pauseGame();
} else {
// Fly toward the mouse or finger while it's down
pointer.isDown = true;
canvas.setPointerCapture(event.pointerId);
}
}
},
paused: {
update(secondsPassed) {
// Everything stands still
},
draw() {
drawHud();
drawPauseScreen();
},
onKeyDown(code) {
if (isPauseKey(code)) {
resumeGame();
}
},
onPointerDown(event) {
// Click a button on the pause screen
for (let i = 0; i < menuButtons.length; i++) {
if (isOnButton(menuButtons[i])) {
menuButtons[i].onClick();
}
}
}
},
gameover: {
enter() {
// Keep the best score, and save it for next time
isNewHighScore = Math.floor(score) > highScore;
if (isNewHighScore) {
highScore = Math.floor(score);
localStorage.setItem('beeHighScore', highScore);
}
},
update(secondsPassed) {
// Everything stands still
},
draw() {
drawHud();
drawGameOverScreen();
},
onKeyDown(code) {
// Wait a second, so you don't miss your score
if (stateTime > 1 && isStartKey(code)) {
startGame();
}
},
onPointerDown(event) {
if (stateTime > 1) {
startGame();
}
}
}
};
// The state the game is in right now
let state = states.start;
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;
// How far the dashes of the ring have moved
this.dashOffset = 0;
// Seconds until a bee can hurt you again
this.invincibleTime = 0;
}
update(secondsPassed) {
this.invincibleTime -= secondsPassed;
// Move the dashes 20 pixels per second, so the ring turns
this.dashOffset -= 20 * 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 dashed ring that turns,
// so you can spot your own bee in the swarm
this.context.strokeStyle = '#0099b0';
this.context.lineWidth = 3;
this.context.setLineDash([8, 7]);
this.context.lineDashOffset = this.dashOffset;
this.context.beginPath();
this.context.arc(this.x, this.y, 34, 0, 2 * Math.PI);
this.context.stroke();
this.context.setLineDash([]);
// 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) {
// Let the old state finish, and the new state begin
if (state.exit) {
state.exit();
}
state = newState;
stateTime = 0;
if (state.enter) {
state.enter();
}
}
function startGame() {
resetGame();
changeState(states.playing);
}
function pauseGame() {
// Only a running game can be paused
if (state == states.playing) {
changeState(states.paused);
}
}
function resumeGame() {
changeState(states.playing);
}
function quitGame() {
changeState(states.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;
}
// The current state decides what the key does
state.onKeyDown(event.code);
}
// Space or Enter starts the game
function isStartKey(code) {
return code == 'Space' || code == 'Enter';
}
// P or Escape pauses or resumes the game
function isPauseKey(code) {
return code == 'KeyP' || code == 'Escape';
}
function onKeyUp(event) {
keys[event.code] = false;
}
function releaseAllKeys() {
// The game can't see keys being released anymore
keys = {};
}
function onPointerDown(event) {
updatePointer(event);
// The current state decides what the click or tap does
state.onPointerDown(event);
}
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;
state.update(secondsPassed);
draw();
window.requestAnimationFrame(gameLoop);
}
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 (state != states.playing || player.invincibleTime > 0) {
return;
}
lives--;
if (lives == 0) {
changeState(states.gameover);
return;
}
// Two seconds to get away
player.invincibleTime = 2;
}
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
state.draw();
}
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('Pollen Rush', 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, switch between states in one place, and reset everything when the game starts over. Every state is now an object of its own, so adding a new screen is easy. 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.
Up next: Add sound effects and music
Your bee game gets sound: effects that play right away, music that loops and a mute button that remembers your choice.
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!