Make Javascript Games

Ritza

© 2021 - 2022 Ritza

Table of Contents

1. Building Flappy Bird with Kaboom.js

1. Creating a new project in Replit

2. Initializing Kaboom

3. Adding scenes

4. Building the game world

5. Making Flappy fly

6. Adding in the pipes

7. Adding in scoring

8. Collision detection

9. Next steps

10. Code

2. Building Snake with Kaboom.js

1. Overview and Requirements

2. Creating a New Project

3. Getting Started with Kaboom.js

4. Creating the Game Map

5. Adding the Snake

6. Moving the Snake

7. Adding Snake Food

8. Detecting Collisions

9. Improving the Graphics

10. Things to Try

11. Code

3. Building a block-breaking game with Kaboom.js

1. Getting started

2. Loading assets

3. Creating levels

4. Moving the paddle

5. Moving the ball

6. Collisions

7. Winning and losing

8. Powerups

9. Music

10. Where next?

11. Code

4. Build a Space Shooter with Kaboom.js

1. Game Design

2. Creating a New Project on Replit

3. Getting Started with Kaboom.js

4. Setting up Kaboom

5. Adding the main game scene

6. Creating the Game Map

7. Adding the Spaceship

8. Moving the Spaceship

9. Adding Laser Guns

10. Adding Alien Space Bugs

11. Moving the Aliens

12. Shooting the Aliens

13. Exploding the Alien Bugs on Contact

14. Adding Score and Shield UI

15. Allowing the Alien Bugs to Attack

16. Raining Gems

17. Collecting Gems

18. Adding Background Music

19. Playing the Game

20. Credits

21. Things to Try Next

22. Code

5. Building a pseudo-3D game with Kaboom.js

1. Game design

2. Creating a new project

3. Setting up the Kaboom environment

4. Creating the interface layers

5. Creating alien bugs

6. Moving the alien bugs

7. Adding a star field

8. Adding the spaceship cockpit

9. Creating the spaceship’s movement controls

10. Adding weapons

11. Checking for collisions with bullets

12. Checking if alien bugs hit the spaceship

13. Finishing up the game

14. Credits

15. Code

6. Building Asteroids with Kaboom.js

1. Creating a new project and loading assets

2. Setting up Kaboom

3. Setting the scene

4. The player’s ship

5. The asteroids

6. Ending the game

7. Final touches

8. Where to next?

9. Code

7. Building a Mario-like side-scroller with Kaboom.js

1. Designing the game

2. Creating a new project in Replit

3. Setting up Kaboom

4. Creating the level maps

5. Adding a scene

6. Adding the main game scene

7. Making Mario move

8. Adding scrolling

9. Creating a custom component

10. Creating a custom component for the enemies

11. Headbutting surprise boxes

12. Adding special behaviors to Mario

13. Adding more Mario collisions and events

14. Ending when we get to the castle

15. Next steps

16. Credits

17. Code

8. Build a Physics playground with Kaboom.js

1. Getting started

2. Setting the scene

3. Moving the player

4. Pulling objects with the gravity gun

5. Dropping and launching objects with the gravity gun

6. Object collisions

7. Next steps

8. Code

9. Building tic-tac-toe with WebSocket and Kaboom.js

1. How do multiplayer games work?

2. Creating a new project

3. Setting up Socket.IO on the server

4. Tracking the game state

5. Accepting connections

6. Handling new players

7. Handling player actions

8. Handling a rematch request

9. Handling disconnects

10. Checking for the end of the game

11. Setting up Kaboom

12. Setting up Kaboom with Socket.IO

13. Adding the game board

14. Adding player names and game status

15. Connecting to the server

16. Handling updated game state

17. Handling player moves

18. Next Steps

19. Code

Building Flappy Bird with Kaboom.js

Flappy Bird was a smash hit game for mobile phones back in 2013-2014. The inspiration behind the app was the challenge of bouncing a ping pong ball on a paddle for as long as possible without letting it drop to the ground or shoot off into the air. At the peak of its success, the game creator unexpectedly removed it from all app stores, saying that he felt guilty that the game had become addictive for many people. In the wake of the removal, many clones were made to fill the gap left by the original Flappy Bird. After a few months, the original author released new versions of the game.

Let’s take a trip back to 2014 and create our own clone of Flappy Bird using Kaboom! By remaking a game, you can not only learn how to make games, but also extend and change the game in any way you like.

The finished game

Click to open gif

This article is based on this video tutorial, with a few small differences. Mainly, the Flappy assets (graphics and sound) are no longer available by default in the Replit Kaboom asset library, but that’s OK because we’ve included them as a download here, so you can still use them.

Creating a new project in Replit

Head over to Replit and create a new repl. Choose Kaboom as your project type. Give this repl a name, like “Flappy!”.

Creating a new repl

After the repl has booted up, you should see a main.js file under the “Code” section. This is where we’ll start coding. There is already some code in this file, but we’ll replace that.

Download the sprites and asset files we need for the game, and unzip them on your computer. In the Kaboom editor, click the “Files” icon in the sidebar. Now drag and drop all the sprites (image files) into the “sprites” folder, and all the sounds (MP3 files) into the “sounds” folder. Once they have uploaded, you can click on the “Kaboom” icon in the sidebar, and return to the “main” code file.

Upload sprites

Click to open gif

Initializing Kaboom

In the “main” code file, delete all the example code. Now we can add reference to Kaboom, and initialize it:

1 import kaboom from "kaboom";
2
3 kaboom();

Let’s import the game assets (graphics and sound). We can use Kaboom’s loadSprite and loadSound functions:

1 loadSprite("birdy", "sprites/birdy.png");
2 loadSprite("bg", "sprites/bg.png");
3 loadSprite("pipe", "sprites/pipe.png");
4 loadSound("wooosh", "sounds/wooosh.mp3");

The first argument in each load function is the name we want to use to refer to the asset later on in our code. The second parameter is the location of the asset to load.

Adding scenes

Scenes are like different stages in a Kaboom game. There are generally three scenes in games:

- The intro scene, which gives some info and instructions, and waits for the player to press “start”.

- The main game, where we play.

- An endgame, or game over scene, which gives the player their score or overall result, and allows them to start again.

Game scenes

For this tutorial, we’ll omit the intro scene, since we already know what Flappy bird is and how to play it, but you can add your own intro scene later!

Let’s add the code for defining each scene:

1 scene("game", () => {
2
3     // todo.. add scene code here
4 });
5
6
7 scene("gameover", (score) => {
8
9     // todo.. add scene code here
10 });
11
12
13 go("game")

Notice in the gameover scene definition, we add a custom parameter, score. This is so that we can pass the player’s final score to the end game scene to display it.

To start the whole game off, we use the go function, which switches between scenes.

Building the game world

Now that we have the main structure and overhead functions out of the way, let’s start adding in the characters that make up the Flappy world. In Kaboom, characters are anything that makes up the game world, including floor, platforms, etc., and not only the players and bots. They are also known as “game objects”.

We’ll start with the background, using the bg.png image we added earlier. Add this code to the game scene section:

1 add([
2     sprite("bg", {width: width(), height: height()})
3 ]);

Here we use the add function to add a new character to the scene. The add function takes an array of components that we can use to give each game character special properties. In Kaboom, every character is made up of one or more components. There are built-in components for many properties, like sprite, which gives the character an avatar; body, which makes the character respond to gravity; and solid, which makes the character solid, so other characters can’t move through it.

Since the background doesn’t need to do much, just stay in the back and look pretty, we only use the sprite component, which displays an image. The sprite component takes the name of the sprite, which we set when we loaded the sprite earlier, and optionally, the width and height that it should be displayed at on the screen. Since we want the background to cover the whole screen, we need to set the width and height of the sprite to the width and height of the window our game is running in. Kaboom provides the width() and height() functions to get the window dimensions.

If you press the “Run” button at the top of your repl now, you should see the background of the Flappy world come up in the output section of the repl:

Flappy background with buildings, trees and building sky line

Great! Now let’s add in the Flappy Bird. Add this code to the game scene:

1 const player = add([
2     // list of components
3     sprite("birdy"),
4     scale(2),
5     pos(80, 40),
6     area(),
7     body(),
8 ]);

We use the same add function we used for adding the background. This time, we grab a reference, const player, to the returned game object. This is so we can use this reference later when checking for collisions, or flapping up when the player taps the space bar.

You’ll also notice that the character we are adding here has many more components than just the sprite component we used for the background. We already know what the sprite component does, here is what the rest are for:

- The scale component makes the sprite larger on screen by drawing it at 2 times the sprite’s normal image size. This gives a nice pixelated look, while also making it easier to spot the bird.

- The pos component sets the position on the screen that the character should initially be at. It takes X and Y coordinates to specify a position.

- The area component gives the sprite an invisible bounding box around it, which is used when calculating and detecting collisions between characters. We’ll need this so that we can detect if Flappy flies into the pipes.

- The body component makes the character subject to gravity. This means Flappy will fall out of the sky if the player doesn’t do anything.

Press command + s (Mac) or control + s (Windows/Linux) to update the game output window. You should see Flappy added and fall out of the sky very quickly:

Flappy falling out of the sky

Click to open gif

Making Flappy fly

Our next task is to save Flappy from plummeting to their death by giving control to the player to flap Flappy’s wings. We’ll use the spacebar for this. Kaboom has an onKeyPress function, which fires a callback with custom code when the specified key is pressed. Add this code to the game scene to make Flappy fly when the space key is pressed:

1 onKeyPress("space", () => {
2     play("wooosh");
3     player.jump(400);
4 });

In the callback handler, we first play a sound of flapping wings to give the player feedback and add some interest to the game. Then we use the jump method, which is added to our player character through the body component we added earlier. The jump function makes a character accelerate up sharply. We can adjust just how sharp and high the jump should be through the number we pass as an argument to the jump method – the larger the number, the higher the jump. Although Flappy is technically not jumping (you normally need to be on a solid surface to jump), it still has the effect we need.

Update the game output window, and if you press the spacebar now, you’ll be able to keep Flappy in the air! Remember to quickly click in the output window as the game starts, so that it gains focus and can detect player input such as pressing the space key.

Flying-flappy

Click to open gif

Adding in the pipes

Now we can get to the main part of the game – adding in the moving pipes that Flappy needs to fly through.

Here is a diagram of the layout of the pipes in the game.

Pipe gap

We want to move the pipe gap, and therefore the pipes, up and down for each new pipe pair that is created. This is so we don’t have the gap at the center point of the screen constantly – we want it to be slightly different for each pipe pair that comes along. We do want to keep the gap size consistent though.

Let’s start by having the pipe gap in the center of the screen. We’ll give the pipe gap a size PIPE_GAP. Then to place the pipes, the bottom of the upper pipe should be PIPE_GAP/2 pixels above the center point of the window, which is height()/2. Likewise, the top of the lower pipe should be PIPE_GAP/2 pixels below the center point of the window, again which is height()/2.

This way, we place the pipe so that the pipe gap is in the center of the window. Now we want to randomly move this up or down for each new pair of pipes that comes along. One way to do this is to create a random offset, which we can add to the midpoint to effectively move the midpoint of the window up or down. We can use the Kaboom rand function to do this. The rand function has two parameters to specify the range in which the random number should be.

Let’s put that all together. The Y-position of the lower pipe can be calculated as:

height()/2 + offset + PIPE_GAP/2

Remember, the top of the window is y=0, and the bottom is y=height(). In other words, the lower down on the screen a position is, the higher its y coordinate will be.

For the upper pipe, we can calculate the point where the bottom of the pipe should be like this:

height()/2 + offset - PIPE_GAP/2

Kaboom has an origin component that sets the point a character uses as its origin. This is topleft by default, which works well for our lower pipe, as our calculations above are calculating for that point. However, for the upper pipe, our calculations are for the bottom of the pipe. Therefore, we can use the origin component to specify that.

Since we want the pipes to come from the right of the screen toward the left, where Flappy is, we’ll set their X-position to be the width() of the screen.

To identify and query the pipes later, we add the text tag "pipe" to them.

Finally, since we need to create many pipes during the game, let’s wrap all the pipe code in a function that we will be able to call at regular intervals to make the pipes.

Here is the code from all those considerations and calculations. Insert this code to the game scene:

1 const PIPE_GAP = 120;
2
3 function producePipes(){
4     const offset = rand(-50, 50);
5
6     add([
7      sprite("pipe"),
8      pos(width(), height()/2 + offset + PIPE_GAP/2),
9      "pipe",
10      area(),
11     ]);
12
13     add([
14      sprite("pipe", {flipY: true}),
15      pos(width(), height()/2 + offset - PIPE_GAP/2),
16      origin("botleft"),
17      "pipe",
18      area()
19     ]);
20 }

Now we need to do a few more things to make the pipes appear and move.

To move the pipes across the screen, we can use the onUpdate function to update all pipes’ positions with each frame. Note that we only need to adjust the x position of the pipe. Add this code to the game scene part of your code:

1 onUpdate("pipe", (pipe) => {
2     pipe.move(-160, 0);
3 });

Next we’ll generate pipes at a steady rate. We can use the loop function for this. Add the following to the game scene part of the code:

1 loop(1.5, () => {
2     producePipes();
3 });

This calls our producePipes() function every 1.5 seconds. You can adjust this rate, or make it variable to increase the rate as the game progresses.

Update the game output window now and you should see the pipes being generated and moving across the screen. You can also fly Flappy, although crashing into the pipes does nothing for now.

Moving pipes

Click to open gif

Flappy is flapping and the pipes are piling on. The next task is to detect when Flappy flies past a pipe, increasing the player’s score.

Adding in scoring

When Flappy flies past a pipe, the player’s score is incremented. To do this, we’ll need to keep track of which pipes have gone past Flappy. Let’s modify the pipe-generating function producePipes to add a custom property called passed to the pipes. It should look like this now:

1 function producePipes() {
2     const offset = rand(-50, 50);
3
4     add([
5      sprite("pipe"),
6      pos(width(), height() / 2 + offset + PIPE_GAP / 2),
7      "pipe",
8      area(),
9      {passed: false}
10     ]);
11
12     add([
13      sprite("pipe", { flipY: true }),
14      pos(width(), height() / 2 + offset - PIPE_GAP / 2),
15      origin("botleft"),
16      "pipe",
17      area(),
18     ]);
19 }

Next, we’ll add in a variable to track the score, and a text element to display it on screen. Add this code to the game scene:

1 let score = 0;
2 const scoreText = add([
3     text(score, {size: 50})
4 ]);

Now we can modify the onUpdate() event handler we created earlier for moving the pipes. We’ll check if any pipes have moved past Flappy, and update their passed flag, so we don’t count them more than once. We’ll only add the passed flag to one of the pipes, and detect it, so as not to add a point for both the upper and lower pipe. Update the onUpdate handler as follows:

1 onUpdate("pipe", (pipe) => {
2     pipe.move(-160, 0);
3
4     if (pipe.passed === false && pipe.pos.x < player.pos.x) {
5      pipe.passed = true;
6      score += 1;
7      scoreText.text = score;
8     }
9 });

This checks any pipe that we haven’t marked as passed (passed === false) to see if it has passed Flappy (pipe.pos.x < player.pos.x). If the pipe has gone past, we add 1 to the score and update the score text onscreen.

If you update the game output window now, you should see the score increase as you fly past each pipe.

Score increasing

Click to open gif

Collision detection

Now that we have scoring, the last thing to do is collision detection – that is, checking if Flappy has splatted into a pipe. Kaboom has a collides method that is added with the area collider component. We can use that to call a function when the player collides with any character with the "pipe" tag. Add this code to the game scene:

1 player.collides("pipe", () => {
2     go("gameover", score);
3 });

In the collision handler, we use the go function to switch to the gameover scene. We don’t have anything in that scene yet, so let’s update that to show a game over message and the score. We can also keep track of the high score to compare the player’s latest score to. Update the gameover scene as follows:

1 let highScore = 0;
2 scene("gameover", (score) => {
3   if (score > highScore) {
4     highScore = score;
5   }
6
7   add([
8     text(
9       "gameover!\n"
10       + "score: " + score
11       + "\nhigh score: " + highScore,
12       {size: 45}
13     )
14   ]);
15
16   onKeyPress("space", () => {
17     go("game");
18   });
19 });

First, we create a highScore variable where we can track the top score across multiple game plays. Then, in our gameover scene, we check if the latest score passed in is bigger than the highScore we have recorded. If it is, the highScore is updated to the latest score.

To show a “game over” message, and the player’s score along with the high score, we use the add function to add a text component to a new game object or character. We also make the font size large-ish for this message.

Let’s include a quick way for the player to play again and try to beat their score. We use the onKeyPress to listen for the player pressing the space bar. In our key-press handler, we go back to the main game scene, to start the game all over again.

We also need to end the game if Flappy flies too high out of the screen, or plummets down off the screen. We can do this by adding a handler for the player’s onUpdate event, which is called each frame. Here we can check if Flappy’s position is beyond the bounds of the game window. Add this code to the game scene:

1 player.onUpdate(() => {
2     if (player.pos.y > height() + 30 || player.pos.y < -30) {
3      go("gameover", score);
4     }
5 });

This gives a margin of 30 pixels above or below the window, to take account of Flappy’s size. If Flappy is out of these bounds, we go to the gameover scene to end the game.

Update the game output window again and test it out. If you fly into a pipe now, or flap too high, or fall out of the sky, you should be taken to the game over screen:

Game over screen

Click to open gif

Next steps

Here are some ideas you can try to improve your clone of the Flappy Bird game:

- Make the game play faster as the player gets a higher score. You can do this by updating the speed that the pipes move by making the speed parameter passed to the pipe.move method a variable, which increases as the player score increases.

- Add some different types of obstacles, other than the pipes, for Flappy to try to avoid.

- Use the Kaboom sprite editor to create your own graphics for your Flappy world!

- Add in some more sound effects and play some game music using the play function.

Code

You can find the code for this tutorial on Replit

Building Snake with Kaboom.js

Snake was an incredibly popular game, mostly remembered from 1990s era cell phones. At the time, it was often the only game you’d find on a phone. In the most basic form, it’s a super simple game, but still wildly entertaining. It’s also a great game to build when you are learning the basics of game making.

In this tutorial, we’ll implement Snake using Kaboom.js built into Replit

The finished game

Click to open gif

Overview and Requirements

We’ll use the Replit web IDE to create our version of Snake. If you don’t already have a Replit account, create one now.

Let’s think a bit about what we need to do. Snake, at its core, is a series of blocks representing a snake moving around a grid, with the player controlling the direction. It also has simple rules – when the snake touches the sides of the screen, it dies. If the snake crosses itself, it also dies. If the snake eats some food (a different type of block), it grows by 1 block. The food then re-appears at another random place on the screen.

A few components we will need to build are:

- A way to draw the blocks and move them on the screen.

- A way to get steering directions from the player to the snake.

- A way to determine if the snake has gone out of bounds of the screen.

- A way to determine if the snake has crossed over itself (or “bitten itself”, as another analogy).

- A way to randomly place the food on the screen.

- A way to determine if the snake has eaten, or touched, the food.

- A way to grow the snake.

That’s a lot to think about! Let’s get started and create a project in Replit.

Creating a New Project

Log into your Replit account and create a new repl. Choose Kaboom as your project type. Now, give this repl a name, like “snake-kaboom”.

Creating a new repl

After the repl has booted up, you should see a main.js file under the “Scenes” section. This is where we’ll start coding.

Getting Started with Kaboom.js

Kaboom.js is a JavaScript library that contains many useful features to make simple in-browser games. It has functionality to draw shapes and sprites (the images of characters and game elements) to the screen, get user input, play sounds, and more. We’ll explore these features and learn how they work by using some of them in our game.

Kaboom.js also makes good use of JavaScript’s support for callbacks; instead of writing loops to read in keyboard input and check if game objects have collided (bumped into each other), Kaboom.js uses an event model, where it tells us when such an event has occurred. Then we can connect up callback functions that act on these events.

Using Kaboom.js in Replit takes care of all the boilerplate initialisation code, as well as asset loading, so we can concentrate on writing the game logic and making game graphics and sound.

Creating the Game Map

To start, we can get our game board, or map drawn on the screen. This will define the edges of the board so that if the snake crashes into them, we can detect and end the game.

Kaboom.js has built-in support for defining game maps, using a text array and the function addLevel. This takes away a lot of the hassle normally involved in loading and rendering maps.

Replace the example code in main.js file with the following to create the game board:

1 import kaboom from "kaboom";
2
3 kaboom();
4
5 const block_size = 20;
6
7 const map = addLevel([
8     "==============",
9     "=            = ",
10     "=            = ",
11     "=            = ",
12     "=            = ",
13     "=            = ",
14     "=            = ",
15     "=            = ",
16     "=            = ",
17     "=            = ",
18     "=            = ",
19     "=            = ",
20     "=            = ",
21     "==============",
22   ], {
23   width: block_size,
24   height: block_size,
25   pos: vec2(0, 0),
26   "=": () => [
27     rect(block_size, block_size),
28     color(255,0,0),
29     area(),
30     "wall"
31   ]
32 });

On the first line we import the kaboom library, and then initialize the context by calling kaboom(). This will give us a blank canvas with a nice checkerboard pattern. We then create a constant for the size of each block on our grid. This is just so we don’t need to keep typing in the number, and also helps if we want to experiment later with different block sizes etc.

Then we create the game map. The map, or level design, is expressed in an array of strings. Each row in the array represents one row on the screen. So, we can design visually in text what the map should look like. The width and height parameters specify the size of each of the elements in the map. The pos parameter specifies where on the screen the map should be place – we choose (0,0), which is the top left of the screen, as the starting point for the map.

Then Kaboom.js allows us to specify what to draw for each symbol in the text map. We’re only using one symbol here, =, but you can make maps out of many different elements – e.g., a symbol for a wall, a symbol for water, a symbol for a health kit and so on. To tell Kaboom.js what to draw for the symbol, we add the symbol as a key, as in =, and then specify parameters for it. In this code, we draw a red rectangle as each piece of the boundary wall. The area() component generates the collision area which will be useful when we want to check for collision between the snake and wall later on. The string wall assigns a tag to each of the pieces of wall drawn, which will also help us with collision detection later on.

If we run this code, we should see the outline of a red square on the screen, representing the map.

Boundary wall

Adding the Snake

Now that we have a map, let’s add the snake. The snake is made up of a number of blocks moving together. We’ll need to keep track of these so that we can move them together, so an array would be a good data structure to use here.

We also need to start the snake off with a given size, position and direction to move in on the map. It can return to these each time the game ends as well. So we should make a function that we can call whenever a new game starts, or the old one ends, to reset the snake to a default position and size.

We’ll need to add a few variables and constants that our snake drawing function will use. Add these definitions above the const block_size = 20; we added earlier:

1 const directions = {
2   UP: "up",
3   DOWN: "down",
4   LEFT: "left",
5   RIGHT: "right"
6 };
7
8 let current_direction = directions.RIGHT;
9 let run_action = false;
10 let snake_length = 3;
11 let snake_body = [];

First, we define an object with properties for each of the allowable directions the snake can move in. This makes code that checks and changes directions easy to read and change, compared to just using numbers or strings to define the directions. The variable current_direction tracks the direction the snake is moving at any given time. We choose a starting direction, RIGHT, as its default. run_action is a flag variable that we’ll use to flag if we are in the actual game, or setting up, or ending the game. The variable snake_length keeps track of how long the snake tail has become – we start it at a chosen value of 3. Finally, snake_body holds all the screen objects that make up the snake’s body.

Now we can add a function to spawn, and respawn, the snake.

Let’s add this function:

1 function respawn_snake(){
2   destroyAll("snake");
3
4   snake_body = [];
5   snake_length = 3;
6
7   for (let i = 1; i <= snake_length; i++) {
8       let segment = add([
9           rect(block_size ,block_size),
10           pos(block_size ,block_size *i),
11           color(0,0,255),
12           area(),
13           "snake"
14       ]);
15       snake_body.push(segment);
16   };
17   current_direction = directions.RIGHT;
18 }
19
20 function respawn_all(){
21   run_action = false;
22     wait(0.5, function(){
23         respawn_snake();
24         run_action = true;
25     });
26
27 }
28
29 respawn_all();

First, the function gets rid of any existing snake segment objects by using the Kaboom.js destroyAll function. This removes any object with the given tag from the game. Then we reset our segment array to an empty array, and the snake length back to the default.

Then the function sets up a loop to create new snake segments, up to the length we specified. It does this by calling the Kaboom.js add method, which adds a new object to the game. add takes a few parameters, as components of the object to create. We pass in components to specify how to draw the object (using rect), its color, and a tag “snake” to identify the segments when we are checking collisions, and updating/removing segments. We also specify the position for the segment we create. To create the starting snake, we just ensure it is at least one block_size, or block, from the left side, and then add each subsequent segment one more block down per loop. This gives a straight snake pointing down to start. Then we add the new segment to our snake_body array to keep track of it.

Finally, we set a default starting direction for the snake to move in.

You’ll notice that we also add in a function respawn_all, and a call to the function respawn_snake. We’ll use the respawn_all function to call all of our other respawn functions. Currently we have one for the snake, but we’ll also need one for the food when we add it. In the respawn_all function, we also take care to set the run_action flag to false, so that no updates are made while we are setting up or resetting objects. We also wrap the calls in a Kaboom.js wait function, with a small delay of 0.5 seconds. This is because when we detect a “game over” condition, we don’t immediately want to reset the game, as it could be a bit disorienting to a player.

Running the code now, you should see a blue line at the top-left side of the map.

Static snake

Moving the Snake

Now that we’ve got map boundaries, and a snake drawn on the screen, we can work on getting player input and moving the snake around.

Kaboom.js has a function onKeyPress, which can call a supplied function whenever a particular key is pressed. We’ll use that to determine which way the player wants the snake to go. Add this code to get user direction input:

1 onKeyPress("up", () => {
2     if (current_direction != directions.DOWN){
3         current_direction = directions.UP;
4     }
5 });
6
7 onKeyPress("down", () => {
8     if (current_direction != directions.UP){
9         current_direction = directions.DOWN;
10     }
11 });
12
13 onKeyPress("left", () => {
14     if (current_direction != directions.RIGHT){
15         current_direction = directions.LEFT;
16     }
17 });
18
19 onKeyPress("right", () => {
20     if (current_direction != directions.LEFT){
21         current_direction = directions.RIGHT;
22     }
23 });

For each of the named “arrow” keys, we set up a function to call if the key is pressed. In each of these functions, we check to ensure that the new direction input is not the complete opposite direction to which the snake is currently moving. This is because we don’t want to allow the snake to reverse. If the input direction is a legal move, we update the current_direction property to the new direction.

Now we need to think about how to make the snake appear to move on the screen. A way to do this is to check which direction the snake is heading, and add a block in front of the snake in that direction. Then we’ll need to remove a block at the tail-end of the snake. We’ll need to do this a few times in a second so that the snake appears to be moving smoothly. Kaboom.js has a function onUpdate which can be used to update game objects on each frame. Add the following code, which uses the onUpdate function, to move the snake:

1 let move_delay = 0.2;
2 let timer = 0;
3 onUpdate(()=> {
4      if (!run_action) return;
5      timer += dt();
6      if (timer < move_delay) return;
7      timer = 0;
8
9     let move_x = 0;
10     let move_y = 0;
11
12     switch (current_direction) {
13         case directions.DOWN:
14             move_x = 0;
15             move_y = block_size;
16             break;
17         case directions.UP:
18             move_x = 0;
19             move_y = -1*block_size;
20             break;
21         case directions.LEFT:
22             move_x = -1*block_size;
23             move_y = 0;
24             break;
25         case directions.RIGHT:
26             move_x = block_size;
27             move_y = 0;
28             break;
29     }
30
31     // Get the last element (the snake head)
32     let snake_head = snake_body[snake_body.length - 1];
33
34     snake_body.push(add([
35         rect(block_size,block_size),
36         pos(snake_head.pos.x + move_x, snake_head.pos.y + move_y),
37         color(0,0,255),
38         area(),
39         "snake"
40     ]));
41
42     if (snake_body.length > snake_length){
43         let tail = snake_body.shift(); // Remove the last of the tail
44         destroy(tail);
45     }
46
47 });

We set the action to run every 0.2 seconds, or 5 times a second to get smooth movement. Since the action function updates game objects on each frame we use the dt() function to get the time that has elapsed between the previous and current frame, so that we can keep track if 0.2 seconds has elapsed for us to move the snake. If the desired delay has not elapsed we exit early without updating anything otherwise we reset the timer and execute the code to move the snake. You can try experiment with different times to see the effect on the game by adjusting the value of the move_delay variable. We also check the flag variable run_action we defined earlier – if it is false, we exit early without updating anything. Then, the function defines 2 local variables, move_x and move_y, which is used to determine where to place the ‘next’ block relative to the head of the snake.

Then the function switches on the value of the current direction the snake is heading in. For each direction, the move_x and move_y are set to either 0, block_size or -1 * block_size. If the snake is moving left or right, we add or subtract a block from the x dimension accordingly. The same occurs if the snake is moving up or down, but in the y dimension.

After the switch, we get the current snake head by indexing the last element in the snake body array. Now that we have both the current snake head position, and the position amount relative to the snake head to move in, we can create the new snake head by adding a new block game object. This is similar to the code we used in respawn_snake.

Now all that remains is to remove a block at the tail end of the snake, using the built-in array shift function, which removes the first element from an array, and returns that element. Because our ‘oldest’ part of the snake, also known as a tail, is the first element, we call shift on the array, and then the Kaboom.js destroy function to get rid of the segment. We only do this if the current length of the snake body array is greater than our determined snake length. This means if we increase snake_length, the overall length of the snake on the screen will also increase. We can use this when we add food to the game.

Running the project now and clicking into the game screen should allow you to move the snake around. Note that there isn’t collision detection yet, so the snake can go out of bounds without consequence.

Moving the snake

Click to open gif

Adding Snake Food

We have a snake, it moves, and a player can steer it. Let’s add some food that the snake can eat, which will cause it to grow longer. Whenever the snake eats the food, we’ll have to respawn the food again, so let’s write the food creation code in a function as well, like we did for the snake:

1 let food = null;
2
3 function respawn_food(){
4     let new_pos = rand(vec2(1,1), vec2(13,13));
5     new_pos.x = Math.floor(new_pos.x);
6     new_pos.y = Math.floor(new_pos.y);
7     new_pos = new_pos.scale(block_size);
8
9     if (food){
10         destroy(food);
11     }
12     food = add([
13                 rect(block_size ,block_size),
14                 color(0,255,0),
15                 pos(new_pos),
16                 area(),
17                 "food"
18             ]);
19 }

Firstly, we set up a variable food so that we can keep track of food objects we create. You can move this variable up to where we declared other variables, like block_size and snake_body if you want to keep them all neatly in the same place.

Then the function respawn_food does a few things. In the game of snake, once a food block is eaten, another one appears at a random location on the grid. This means we’ll need a random number generator to determine the location to place the food. Kaboom.js has a function called rand which we can use to find a random position on the screen to place the food. We need both random x and y co-ordinates – conveniently, the rand function can accept 2D vectors as the start and end amount for the random range to generate numbers in, and will then return another 2D vector as a result.

Why do we choose a range of 1-13 for the random position of the food? If you look at the map we added earlier, it is 14 spaces across and 14 spaces down. These are the dimensions of our screen in grid blocks. Because we don’t want to draw the food over the boundaries, we use 1-13 to choose blocks within the map. Now, the rand function returns real numbers, with decimals, not integers. This means we need to add the Math.floor call to truncate any decimals off the random numbers, as we don’t want to place the food halfway through a particular grid block. We also need to convert from our grid co-ordinates to regular screen pixels. This is done by multiplying each co-ordinate by the block_size, which specifies the size of each grid block in pixels. We make use of the Kaboom.js scale method on the vec2 class to perform the multiplication.

The next part of the function checks if the food variable already contains an existing food object. If it does, we call destroy to remove that food from the game. Finally, the function creates a new food object by calling the Kaboom.js add function to create a new food object at the random position we made.

To call this new respawn_food function, we need to update our respawn_all function, like this:

1 function respawn_all(){
2   run_action = false;
3     wait(0.5, function(){
4         respawn_snake();
5         respawn_food();
6         run_action = true;
7     });
8 }

Running the game now shows a green food block positioned somewhere randomly on the map:

Adding food

Detecting Collisions

Now that we have all the objects our game needs – a boundary wall, a snake, and food – we can move on to detecting interactions, or collisions, between these objects.

Kaboom.js has a useful function for helping with this: onCollide. The function takes in 2 tags for different game object types, and calls a provided callback function if there is a collision of the objects.

Let’s start with detecting if the snake moves over a food block. Add the code below:

1 onCollide("snake", "food", (s, f) => {
2     snake_length ++;
3     respawn_food();
4 });

We set up the onCollide function with tags for the snake, and the food object. Then, in the callback function, snake_length is increased by 1, and we call respawn_food to replace the eaten food somewhere else on the map.

Running this, and eating the food, you should see the snake grow each time, and the food re-appear on another block:

Eating food

Click to open gif

Now, we can add similar code to detect if the snake has hit the wall:

1 onCollide("snake", "wall", (s, w) => {
2     run_action = false;
3     shake(12);
4     respawn_all();
5 });

In the callback function, we immediately set the run_action flag to false. This is so that the code in the move loop does not run and create the appearance of the snake stuck in the wall. Then the code calls a cool Kaboom.js effect function shake, which “shakes” the screen in a way that makes it feel like the snake has crashed heavily, and communicates quite effectively that the game is over. Finally, we call respawn_all to reset all the game objects.

We can use the same code to detect if the snake has hit itself – we just replace the wall tag with another snake tag:

1 onCollide("snake", "snake", (s, t) => {
2     run_action = false;
3     shake(12);
4     respawn_all();
5 });

Running the game now, and crashing into the wall should look something like this:

Snake prang

Click to open gif

Congratulations! You’ve finished creating Snake in Kaboom.js!

Improving the Graphics

We have a working snake game, but it does look a bit bland. Kaboom.js has good support for sprites, which are small pictures used to represent game objects and characters. Replit also has built-in management and loading of sprites for Kaboom.js to take care of the overhead in using sprites.

Using sprites, let’s give the snake something nicer to eat than a green block. Right click and select “Save image as” on the pizza slice below, and save it to your computer. Then, in Replit, click the upload button next to “Sprites” and upload the pizza to your repl.

Pizza

Adding a pizza sprite

Click to open gif

Now, we can update the respawn_food function to use this sprite, instead of drawing a green block. Remove the lines rect and color, and replace with a call to add the pizza sprite, like this:

1 function respawn_food(){
2     let new_pos = rand(vec2(1,1), vec2(13,13));
3     new_pos.x = Math.floor(new_pos.x);
4     new_pos.y = Math.floor(new_pos.y);
5     new_pos = new_pos.scale(block_size);
6
7     if (food){
8         destroy(food);
9     }
10     food = add([
11                 sprite('pizza'),
12                 pos(new_pos),
13                 area(),
14                 "food"
15             ]);
16 }

We can also update the background to be more interesting. To do this, we can make use of Kaboom.js’ layers concept. This allows us to create different graphic layers, for example one for a static background image, another one for the active game objects over that, and another top layer for stats and scores etc.

We’ll create 2 layers, background and game, to support a background. Download and add the background grass image below to your repl as you did for the pizza slice:

background

Now, we can set up the layers and add the background grass. Add the following code to the top of the main file:

1 layers([
2     "background",
3     "game"
4 ], "game");
5
6 add([
7     sprite("background"),
8     layer("background")
9 ]);

This sets up our 2 layers, and makes the game layer the default layer to draw on. Whenever we call add, we can optionally specify a layer to put the object on – if we don’t specify a layer, it uses whatever we set as default in the call to layers. Then we add our background sprite to the background layer.

Next, we can update the boundaries to look a bit better. Recall that in our map we add with addLevel, each different symbol we use can map to a different game object. Using this, we can create a good looking border fence, with different elements for each side and corners. Download the following 8 sprites as before, and upload them to your repl:

Fence bottom

Fence left

Fence right

Fence top

Post bottom left

Post bottom right

Post top left

Post top right

Now, we can update the level map to use these. Replace the previous addLevel code with the following code:

1 const map = addLevel([
2      "1tttttttttttt2",
3      "l            r ",
4      "l            r ",
5      "l            r ",
6      "l            r ",
7      "l            r ",
8      "l            r ",
9      "l            r ",
10      "l            r ",
11      "l            r ",
12      "l            r ",
13      "l            r ",
14      "l            r ",
15      "3bbbbbbbbbbbb4",
16 ], {
17      width: block_size,
18      height: block_size,
19      pos: vec2(0, 0),
20      "t": ()=> [
21           sprite("fence-top"),
22           area(),
23           "wall"
24      ],
25      "b": ()=> [
26           sprite("fence-bottom"),
27           area(),
28           "wall"
29      ],
30      "l": ()=> [
31           sprite("fence-left"),
32           area(),
33           "wall"
34      ],
35      "r": ()=> [
36           sprite("fence-right"),
37           area(),
38           "wall"
39      ],
40      "1": ()=> [
41           sprite("post-top-left"),
42           area(),
43           "wall"
44      ],
45      "2": ()=> [
46           sprite("post-top-right"),
47           area(),
48           "wall"
49      ],
50      "3": ()=> [
51           sprite("post-bottom-left"),
52           area(),
53           "wall"
54      ],
55      "4": ()=> [
56           sprite("post-bottom-right"),
57           area(),
58           "wall"
59      ],
60 });

The last thing is to upgrade the snake itself. Download the skin below, and upload to the repl as before.

Snake skin

We create snake pieces in 2 places: in the respawn_snake function, and in the draw loop. Update both to use the snake skin sprite instead of a blue block. The respawn_snake function should look like this:

1 function respawn_snake(){
2   snake_body.forEach(segment => {
3       destroy(segment);
4     });
5   snake_body = [];
6   snake_length = 3;
7
8   for (let i = 1; i <= snake_length; i++) {
9       snake_body.push(add([
10           sprite('snake-skin'),
11           pos(block_size  ,block_size * i),
12           area(),
13           "snake"
14       ]));
15   }
16   current_direction = directions.RIGHT;
17 }

In the loop callback, the updated code for adding a new snake segment to the body should now look like this:

1     snake_body.push(add([
2         sprite('snake-skin'),
3         pos(snake_head.pos.x + move_x, snake_head.pos.y + move_y),
4         area(),
5         "snake"
6     ]));

Before we run the game we need to load the sprites that we made reference to in the code snippets above. Add the following code, below the line kaboom(); to load the sprites and make them available in the game:

1 loadSprite("background", "sprites/background.png);
2 loadSprite("fence-top", "sprites/fence-top.png);
3 loadSprite("fence-bottom", "sprites/fence-bottom.png);
4 loadSprite("fence-left", "sprites/fence-left.png);
5 loadSprite("fence-right", "sprites/fence-right.png);
6 loadSprite("post-top-left", "sprites/post-top-left.png);
7 loadSprite("post-top-right", "sprites/post-top-right.png);
8 loadSprite("post-bottom-left", "sprites/post-bottom-left.png);
9 loadSprite("post-bottom-right", "sprites/post-bottom-right.png);
10 loadSprite("snake-skin", "sprites/snake-skin.png);
11 loadSprite("pizza", "sprites/pizza.png);

If you run the game now, you should see it looking much better!

Game graphics

Click to open gif

Things to Try

There is a lot of good functionality in Kaboom.js to try out, and make the game more entertaining. Here are some suggestions:

- Create a 2 player version.

- Add obstacles for the snake.

- Incrementally speed up the game as it goes on, to make it harder. You can do this by adjusting the delay parameter of the loop function as the game progresses.

- Add sound effects and background music.

Code

You can find the code for this tutorial on Replit

Building a block-breaking game with Kaboom.js

In this tutorial, we’ll use the Kaboom framework to develop a simple block-breaking game, similar to classics like Atari’s Breakout and Taito’s Arkanoid.

By the end of this tutorial, you’ll be able to:

- Use the Kaboom framework to develop a polished arcade game with multiple levels.

- Have a basic game to build on by adding your own powerups and block types.

Our finished game will look like this:

The finished game

Click to open gif

We will be using this set of sprites by Michele Bucelli and sound effects from this pack by Jaymin Covy. We’ll also use music by Eric Matyas of Soundimage.org.

We’ve created a single ZIP file with the sprites and sounds you will need for this tutorial, which you can download here.

Getting started

Log into your Replit account and create a new repl. Choose Kaboom as your project type. Give this repl a name, like “blockbreaker”.

Create a new repl

Kaboom repls are quite different from other kinds of repls you may have seen before: instead of dealing directly with files in folders, you’ll be dealing with code, sounds and sprites, the latter of which you can draw directly in Replit’s image editor.

Before we start coding, we need to upload our sprites and sounds. Download this ZIP file and extract it on your computer. Click the “Files” icon on the sidebar and upload everything in the extracted file’s Sounds folder to the “sounds” section of your repl, and everything in the Sprites folder to the “sprites” section of your repl.

Uploading assets

Click to open gif

Once you’ve uploaded the files, you can click on the “Kaboom” icon in the sidebar, and return to the “main” code file.

Loading assets

When you first open your new Kaboom repl, you’ll be greeted by a file containing the sample code below.

1 import kaboom from "kaboom";
2
3 // initialize context
4 kaboom();
5
6 // load assets
7 loadSprite("bean", "sprites/bean.png");
8
9 // add a character to screen
10 add([
11     // list of components
12     sprite("bean"),
13     pos(80, 40),
14     area(),
15 ]);
16
17 // add a kaboom on mouse click
18 onClick(() => {
19     addKaboom(mousePos())
20 })
21
22 // burp on "b"
23 onKeyPress("b", burp)

Before we start developing our game, let’s remove most of this code, leaving only the following lines:

1 import kaboom from "kaboom";
2
3 // initialize context
4 kaboom();

Now we can set the stage for our own game. First, we’ll make the game background black and fix the game’s screen size by altering the Kaboom context initialization. Add the following to the line kaboom();:

1 // initialize context
2 kaboom({
3     width: 768,
4     height: 360,
5     background: [0,0,0]
6 });

Next, we need to import sprites for our game’s objects: the player’s paddle, the ball, and the breakable blocks. As the OpenGameArt sprites we’re using are all in a single image file, we’ll load them using Kaboom’s loadSpriteAtlas() function. This saves us the hassle of splitting each sprite into its own image file. Add the following code to the bottom of your main code file:

1 loadSpriteAtlas("sprites/breakout_pieces.png", {
2     "blocka": {
3         x: 8,
4         y: 8,
5         width: 32,
6         height: 16,
7     },
8     "blockb": {
9         x: 8,
10         y: 28,
11         width: 32,
12         height: 16,
13     },
14     "blockc": {
15         x: 8,
16         y: 48,
17         width: 32,
18         height: 16,
19     },
20     "blockd": {
21         x: 8,
22         y: 68,
23         width: 32,
24         height: 16,
25     },
26     "paddle": {
27         x: 8,
28         y: 152,
29         width: 64,
30         height: 16,
31     },
32     "ball": {
33         x: 48,
34         y: 136,
35         width: 8,
36         height: 8,
37     },
38     "heart": {
39         x: 120,
40         y: 136,
41         width: 8,
42         height: 8,
43     }
44 });

Note that we’ve imported four different block sprites, named block{a-d}. Each sprite is a different color and will be worth a different number of points when broken. We’ve also left most of the sprite sheet’s contents untouched – only a few sprites are needed for basic gameplay.

Next, we need to import a font, which we’ll use to display the player’s score and lives. As Kaboom comes with a number of default fonts we could use, this step is optional, but it will help to give our game a cohesive visual style.

1 loadFont("breakout", "sprites/breakout_font.png", 6, 8,  { chars: "ABCDEFGHIJKLMNOPQ\
2 RSTUVWXYZ  0123456789:!'" });

We’ve used Kaboom’s loadFont() function, specifying the name of the font, the image file to source it from, the width and height of individual characters, and the characters it contains. Take a look at the layout of breakout_font.png to see the format Kaboom expects. Also note that we will not be able to use any characters outside of the ones represented here – this includes lowercase letters.

Breakout font

Lastly, we need to load our sound effects and music. Add the following code at the bottom to do this:

1 // sounds
2 loadSound("blockbreak", "sounds/Explosion5.ogg");
3 loadSound("paddlehit", "sounds/Powerup20.ogg");
4 loadSound("powerup", "sounds/Powerup2.ogg");
5 loadSound("ArcadeOddities", "sounds/Arcade-Oddities.mp3");

Creating levels

We will create two initial levels for our game, using Kaboom’s ASCII art level creation functionality. Add the following level definitions to the bottom of your file:

1 // levels
2 const LEVELS = [
3     [
4         "                        ",
5         "                        ",
6         "dddddddddddddddddddddddd",
7         "cccccccccccccccccccccccc",
8         "bbbbbbbbbbbbbbbbbbbbbbbb",
9         "aaaaaaaaaaaaaaaaaaaaaaaa",
10         "                        ",
11         "                        ",
12         "                        ",
13         "            .           ",
14         "                        ",
15         "                        ",
16         "                        ",
17         "                        ",
18         "                        ",
19         "                        ",
20         "                        ",
21         "                        ",
22         "                        ",
23         "                        ",
24         "                        ",
25         "                        ",
26         "            @           ",
27     ],
28     [
29         " aaaaaaaaaaaaaaaaaaaaaa ",
30         " a                    a ",
31         " a  bbbbbbbbbbbbbbbbb a ",
32         " a  b               b a ",
33         " a  b    ccccccc    b a ",
34         " a  b  ccdddddddcc  b a ",
35         " a  b    ccccccc    b a ",
36         " a  b               b a ",
37         " a  bbbbbbbbbbbbbbbbb a ",
38         " a                    a ",
39         " aaaaaaaaaaaaaaaaaaaaaa ",
40         "                        ",
41         "            .           ",
42         "                        ",
43         "                        ",
44         "                        ",
45         "                        ",
46         "                        ",
47         "                        ",
48         "                        ",
49         "                        ",
50         "                        ",
51         "            @           ",
52     ],
53 ]

In the above levels, a-d are blocks, . is the ball, and @ is the player’s paddle. We will make these definitions with a LevelOpt JSON object, which defines the width and height of individual blocks, and provides definitions for each game object. Add the following code to your file:

1 const LEVELOPT = {
2     width: 32,
3     height: 16,
4     "a": () => [ // block
5         sprite("blocka"),
6         area(),
7         "block",
8         "bouncy",
9         {
10             points: 1
11         }
12     ],
13     "b": () => [ // block
14         sprite("blockb"),
15         area(),
16         "block",
17         "bouncy",
18         {
19             points: 2
20         }
21     ],
22     "c": () => [ // block
23         sprite("blockc"),
24         area(),
25         "block",
26         "bouncy",
27         {
28             points: 4
29         }
30     ],
31     "d": () => [ // block
32         sprite("blockd"),
33         area(),
34         "block",
35         "bouncy",
36         {
37             points: 8
38         }
39     ],
40     "@": () => [ // paddle
41         sprite("paddle"),
42         area(),
43         origin("center"),
44         "paddle",
45         "bouncy",
46         {
47             speed: 400
48         }
49     ],
50     ".": () => [ // ball
51         sprite("ball"),
52         color(WHITE),
53         area(),
54         origin("center"),
55         "ball",
56         {
57             hspeed: 100,
58             vspeed: 50
59         }
60     ]
61 }

Let’s take a closer look at each of these definitions, starting with the first block object.

1     "a": () => [ // block
2         sprite("blocka"),
3         area(),
4         "block",
5         "bouncy",
6         {
7             points: 1
8         }
9     ],

A game object definition in Kaboom is a list of components, tags, and custom attributes.

Components are a core part of Kaboom – they provide different functionality to game objects, from an object’s appearance to functionality such as collision detection. In this case, we have two components: sprite(), which tells the object which sprite to represent itself with and area(), which gives the object the ability to collide with other objects.

While components come with prepackaged behaviors, tags are just labels that we can use to define our own behavior. This object has the tag “block”, which we’ll use to give it block-specific behaviors, such as being destroyed by the ball and giving the player points. It also has the tag “bouncy”, which we’ll use to make the ball bounce off it.

Lastly, our block has a custom points attribute, which will determine how many points it will give the player when it is destroyed. Our four block objects use different sprites and have different point values, but are otherwise identical.

Next, let’s look at the paddle object:

1     "@": () => [ // paddle
2         sprite("paddle"),
3         area(),
4         origin("center"),
5         "paddle",
6         "bouncy",
7         {
8             speed: 400
9         }
10     ],

Like our block objects, the paddle has both sprite() and area() components. We’ve also given it the “bouncy” tag, so that the ball will bounce off it. Using tags like this is a great way to avoid writing the same code multiple times. Unlike our block objects, the paddle has an origin component, set to “center” – this will allow us to move the object from its center rather than its top-left corner.

The paddle object also has a speed attribute, which will determine how fast it moves across the screen.

Our last object is the ball.

1     ".": () => [ // ball
2         sprite("ball"),
3         area(),
4         origin("center"),
5         "ball",
6         {
7             hspeed: 100,
8             vspeed: 50
9         }
10     ]

As a moving object, this is largely similar to the paddle. The main difference is that we give it both horizontal and vertical speed attributes, as it will be moving in all directions, whereas the paddle only moves left and right.

Now that we’ve defined our level layouts and the objects that will populate them, we can create our game scene. In Kaboom, a scene is a unique screen with its own objects and game rules. We can use scenes to separate gameplay from menus and information screens, or even to separate different kinds of minigames in the same project. As scenes can take arguments, we can use a single “game” scene to represent all of our levels. Add the following code below your LEVELOPT definition:

1 scene("game", ({levelIndex, score, lives}) => {
2
3     addLevel(LEVELS[levelIndex], LEVELOPT);
4
5 });

In addition to providing the level number as an argument (levelIndex), we provide both score and lives. This will allow us to preserve both values when the player defeats one level and moves to the next.

Then add this code to the bottom of the main code file to define and call game start function. This function will go() to the first level, setting the player’s score to 0 and their lives to 3. In addition to calling it when the game first loads, we will call this function when we need to restart after a game over.

1 // start game on first level
2 function start() {
3     go("game", {
4         levelIndex: 0,
5         score: 0,
6         lives: 3,
7     });
8 }
9
10 start();

Run your repl now. You should see our first level, with its colorful blocks, paddle and ball, frozen in amber. In the next section, we’ll add some motion.

First level

Moving the paddle

Let’s write some code to control the player’s paddle. First, we need to retrieve a reference to the paddle using get(). We’ll place this code inside the “game” scene, below addLevel:

1     // player's paddle
2     const paddle = get("paddle")[0];

Now we’ll add code to move the paddle left and right. We could do this with the left and right arrow keys, which would give our game a retro feeling, but most modern browser-based block-breaking games have a mouse-controlled paddle. Moreover, as Kaboom automatically translates touch events to mouse events, implementing mouse controls will make our game playable on mobile devices without keyboards. So let’s add some code to have our paddle follow the mouse cursor:

1     // mouse controls
2     onUpdate(() => {
3         if (mousePos().x > 0 && mousePos().x < width() && mousePos().y > 0 && mouseP\
4 os().y < height()) {
5             if (mousePos().x < paddle.worldArea().p1.x) { // left
6                 paddle.move(-paddle.speed, 0);
7             }
8             else if (mousePos().x > paddle.worldArea().p2.x) { // right
9                 paddle.move(paddle.speed, 0);
10             }
11         }
12     });

This code will run every frame. First, it checks whether the mouse cursor is inside the game area. Then it checks whether the cursor is to the paddle’s left or right, and moves the paddle in that direction. The paddle won’t move if it is vertically in line with the cursor.

Note the use of worldArea(). This method, provided by the area() component, returns an object containing two sets of X- and Y-coordinates, p1 and p2. The first set, p1, is the top-left corner of the object’s collision mask, and p2 is its bottom-right corner. By default, an object’s collision mask is a rectangle of the same size as its sprite – Kaboom does not support non-rectangular collision masks.

Rerun your repl now and try out the controls. If you have a touch screen on your device, you can also move the paddle by tapping or dragging your finger.

Moving the paddle

Click to open gif

Moving the ball

Now that we can move the paddle, we need the ball to move too. Add the following code to your file with the “game” scene:

1     // ball movement
2     onUpdate("ball", (ball) => {
3         ball.move(ball.hspeed, ball.vspeed);
4     });

You’ll recall that we set the ball’s hspeed and vspeed in its object definition. Run your repl now, and watch as the ball flies off the screen. Our game won’t last very long if the ball can leave the screen like this, so we need to add some code to have it bounce off the edges. Alter your onUpdate("ball") callback to match the following:

1     onUpdate("ball", (ball) => {
2         // bounce off screen edges
3         if (ball.worldArea().p1.x < 0 || ball.worldArea().p2.x > width()) {
4             ball.hspeed = -ball.hspeed;
5         }
6
7         if (ball.worldArea().p1.y < 0 || ball.worldArea().p2.y > height()) {
8             ball.vspeed = -ball.vspeed;
9         }
10
11         // move
12         ball.move(ball.hspeed, ball.vspeed);
13     });

If the ball goes off the left or right edges of the screen, we reverse its horizontal direction, and if it goes off the top or bottom of the screen, we reverse its vertical direction. Run your repl now to see this effect in action.

Moving the ball

Click to open gif

Collisions

Now that the ball can move and bounce off the screen’s edges, we need it to also bounce off the paddle and the blocks. To achieve this, we’ll write an onCollide() event handler for the tags “ball” and “bouncy”. Add the following code to the “game” scene, below your ball movement code:

1     // collisions
2     onCollide("ball", "bouncy", (ball, bouncy) => {
3         ball.vspeed = -ball.vspeed;
4
5         if (bouncy.is("paddle")) { // play sound
6             play("paddlehit");
7         }
8     });

Note that we’re only changing the vertical direction of the ball, because that’s the important one for our gameplay. While we could implement more complex bounce physics by taking into account which sides the collision occurred on, changing the vertical direction alone gets us the type of ball movement players expect from a block-breaking game.

Now that the player can actually hit the ball with their paddle, we shouldn’t have the ball bounce off the bottom of the screen anymore. Find your onUpdate("ball") callback and remove second condition from the second if statement. Your callback should now look like this:

1     // ball movement
2     onUpdate("ball", (ball) => {
3         // bounce off screen edges
4         if (ball.worldArea().p1.x < 0 || ball.worldArea.p2().x > width()) {
5             ball.hspeed = -ball.hspeed;
6         }
7
8         if (ball.worldArea().p1.y < 0) { // <-- second condition removed
9             ball.vspeed = -ball.vspeed;
10         }
11
12         // move
13         ball.move(ball.hspeed, ball.vspeed);
14     });

The other important collision event that we need to implement is having the ball destroy blocks it hits. Add the following code below the onCollide("ball", "bouncy") callback.

1     onCollide("ball", "block", (ball, block) => {
2         block.destroy();
3         score += block.points;
4         play("blockbreak"); // play sound
5     });

Here we use the destroy() function to remove the block object from play, and then increment our score by the block’s points value.

Now that we’re changing the score variable, it’s important that we display it on the screen, along with lives. Add the following code to the “game” scene, below your collision code:

1     // ui
2     onDraw(() => {
3         drawText({
4             text: `SCORE: ${score}`,
5             size: 16,
6             pos: vec2(8,8),
7             font: "breakout",
8             color: WHITE
9         });
10         drawText({
11             text: `LIVES: ${lives}`,
12             size: 16,
13             pos: vec2(width()*13/16, 8),
14             font: "breakout",
15             color: WHITE
16         });
17     });

We’ve added an onDraw() callback, which will run every frame, after all onUpdate() callbacks have run. The onDraw() callbacks are the only place we can use drawing functions such as drawText(). Also note that we’ve used the font we defined at the start of this tutorial.

Run your repl now, and you should be able to hit the ball with your paddle, destroy blocks, and get points. Our core gameplay is implemented.

Game play

Winning and losing

As it stands, our game is both unforgiving and unrewarding. If you let the ball go off the bottom of the screen, it’s permanently gone, and you have to refresh your browser to try again. If you manage to destroy all the blocks, the game continues without moving to the next level or acknowledging your victory.

Let’s fix these deficiencies now by implementing lives, as well as win and lose conditions. We’ll implement lives in the onUpdate("ball") callback that deals with ball movement. Find this callback and add the following new code just below the second if statement:

1     onUpdate("ball", (ball) => {
2       // bounce off screen edges
3       if (ball.worldArea().p1.x < 0 || ball.worldArea().p2.x > width()) {
4         ball.hspeed = -ball.hspeed;
5       }
6
7       if (ball.worldArea().p1.y < 0) {
8         ball.vspeed = -ball.vspeed;
9       }
10
11       // fall off screen -- NEW CODE BELOW
12       if (ball.pos.y > height()) {
13         lives -= 1;
14         if (lives <= 0) {
15           go("lose", { score: score });
16         }
17         else {
18           ball.pos.x = width()/2;
19           ball.pos.y = height()/2;
20         }
21       }
22       // END OF NEW CODE
23
24       // move
25       ball.move(ball.hspeed, ball.vspeed);
26     });

This code checks whether the ball has fallen of the screen, and if so, decrements lives. If there are lives remaining, it moves the ball back to the middle of the screen. Otherwise, it sends the player to the “lose” scene, which we’ll define soon. But first, we need to provide for the game’s win condition.

We’ll consider a level won once all the blocks have been destroyed. To determine this, we can check whether the number of blocks in the level is 0. We’ll put this check in the ball and block collision callback, after the block is destroyed. Find this code and alter it to resemble the following:

1     onCollide("ball", "block", (ball, block) => {
2         block.destroy();
3         score += block.points;
4         play("blockbreak");
5
6         // level end -- NEW CODE BELOW
7         if (get("block").length === 0) { // next level
8             if (levelIndex < LEVELS.length) {
9                 go("game", {
10                     levelIndex: levelIndex+1,
11                     score: score,
12                     lives: lives
13                     });
14             }
15             else { // win
16                 go("win", { score: score });
17             }
18         }
19     });

Now we need to create our “win” and “lose” scenes. Add the following code for both scenes below the “game” scene and above the start function definition:

1 // gameover screens
2 scene("lose", ({ score }) => {
3
4     add([
5         text(`GAME OVER\n\nYOUR FINAL SCORE WAS ${score}`, {
6             size: 32,
7             width: width(),
8             font: "breakout"
9         }),
10         pos(12),
11     ]);
12
13     add([
14         text(`PRESS ANY KEY TO RESTART`, {
15             size: 16,
16             width: width(),
17             font: "breakout"
18         }),
19         pos(width()/2, height()*(3/4)),
20     ]);
21
22     onKeyPress(start);
23     onMousePress(start);
24 });
25
26 scene("win", ({ score }) => {
27
28     add([
29         text(`CONGRATULATIONS, YOU WIN!\n\nYOUR FINAL SCORE WAS ${score}`, {
30             size: 32,
31             width: width(),
32             font: "breakout"
33         }),
34         pos(width()/2, height()/2),
35     ]);
36
37     add([
38         text(`PRESS ANY KEY TO RESTART`, {
39             size: 16,
40             width: width(),
41             font: "breakout"
42         }),
43         pos(width()/2, height()*(3/4)),
44     ]);
45
46     onKeyPress(start);
47     onMousePress(start);
48 });

These scenes are quite similar to each other: each one displays some text, including the player’s final score, and prompts the player to press any key. Both onKeyPress(start) and onMousePress(start) will call the start function if any keyboard key or mouse button is pressed, or if the screen is tapped on a touch device.

Run your repl now. You should now be able to play through both levels of our block-breaking game (or lose and reach the game over screen).

Game over screen

Powerups

There’s one more sprite we loaded at the start of the tutorial that we haven’t used yet – the heart. This will be a powerup. We’ll have it randomly appear in place of destroyed blocks and start falling. If the player catches it with their paddle, they will gain an additional life.

Find your onCollide("ball", "block") code and add the new code specified below:

1     // collisions
2     onCollide("ball", "block", (ball, block) => {
3         block.destroy();
4         score += block.points;
5         play("blockbreak");
6
7         // level end
8         if (get("block").length === 0) { // next level
9             if (levelIndex < LEVELS.length) {
10                 go("game", {
11                     levelIndex: levelIndex+1,
12                     score: score,
13                     lives: lives
14                     });
15             }
16             else { // win
17                 go("win", { score: score });
18             }
19         }
20
21         // powerups -- NEW CODE BELOW
22         if (chance(0.05)) { // extra life
23             add([
24                 sprite("heart"),
25                 pos(block.pos),
26                 area(),
27                 origin("center"),
28                 cleanup(),
29                 "powerup",
30                 {
31                     speed: 80,
32                     effect() { lives++; }
33                 }
34             ]);
35         }
36     });

This code uses Kaboom’s chance() function to set our powerup to spawn after a block is destroyed 5% of the time. The powerup is a game object with similar components to other moving objects we’ve defined. The cleanup() component will ensure it’s automatically destroyed when it leaves the screen. Additionally, we give it a movement speed and an effect() function, which we’ll call when it hits the paddle.

Just below the onCollide("ball", "block") callback, add the following two callbacks to define our powerup’s movement and collision behavior:

1     // powerups
2     onUpdate("powerup", (powerup) => {
3         powerup.move(0, powerup.speed);
4     });
5
6     paddle.onCollide("powerup", (powerup) => {
7         powerup.effect();
8         powerup.destroy();
9         play("powerup");
10     });

Run your repl now and you should see the occasional extra life powerup as you play.

Music

As a final touch, we’ll add some music to our game. Near the bottom of your file, just above the invocation of start(), add the following code:

1 // play music
2 const music = play("ArcadeOddities");
3 music.loop();

loop() will ensure that the music plays continuously.

Where next?

We’ve built a simple but polished block-breaking game. From here, you might want to make the following additions:

- Extra levels.

- New powerups, such as a longer paddle, multiple balls, or even a paddle-mounted laser gun.

- Bigger and smaller blocks, blocks that can take multiple hits, or moving blocks.

Code

You can find the code for this tutorial on Replit

Build a Space Shooter with Kaboom.js

In this tutorial, we’ll build a space shooter game with a platformer feel. We’ll use Kaboom.js for the game engine, and we’ll code it using Replit online IDE (Integrated Development Environment).

Here’s how the game will look when we’re done:

The finished game

Click to open gif

You can download this zip file with all the sprites and sounds you’ll need for this tutorial.

Game Design

Here’s what we’re aiming for in this game:

- Fast action: the player moves around a lot.

- Good sound effects: to immerse the player in the game and contribute to the overall game vibe.

- Lots of shooting opportunities.

- Increasing challenge: the game gets harder and faster as the player gets better.

In our game, a player flies a spaceship around a faraway planet, collecting gems and dodging or shooting alien bugs that explode on contact. The spaceship will lose shield strength each time an alien bug hits it. With every 1000 points the player earns, the game gets faster and more bugs appear.

Creating a New Project on Replit

Let’s head over to Replit and create a new repl. Choose Kaboom as your project type. Give this repl a name, like “Space Shooter”.

Creating an Repl

After the repl has booted up, you should see a main.js file under the “Code” section. This is where we’ll start coding.

Getting Started with Kaboom.js

Kaboom.js is a JavaScript library that contains a lot of useful features for making simple browser games. It has functionality to draw shapes and sprites (the images of characters and game elements) to the screen, get user input, play sounds, and more. We’ll use some of these features in our game to explore how Kaboom works.

The Replit Kaboom interface is specialised for game-making. Besides the Space Invader icon, you’ll notice a few special folders in the file tray, like “Code”, “Sprites”, and “Sounds”. These special folders take care of loading up assets, and all the necessary code to start scenes and direct the game. You can read up more about this interface here.

If you haven’t already, download this zip file which contains all the sprites and sounds for the game. Extract the file on your computer, then add the sprites to the “Sprites” section in the Replit editor, and the sounds to the “Sounds” section.

Uploading sprites

Click to open gif

Kaboom makes good use of JavaScript’s support for callbacks: instead of writing loops to read keyboard input and check if game objects have collided, Kaboom uses an event model that tells us when these events have happened. We can then write callback functions to act on these events.

A Kaboom game is made up of “scenes”, which are like levels, or different parts and stages of a game. Scenes have multiple “layers”, allowing us to have game backgrounds, main game objects (like the player, bullets, enemies, etc), and UI elements (like the current score, health, etc).

Setting up Kaboom

To start, we need to set up Kaboom with the screen size and colors we want for the game window. Replace the code in main.js with the code below:

1 import kaboom from "kaboom";
2
3 kaboom({
4   background: [0, 0, 0],
5   width: 440,
6   height: 275,
7   scale: 1.5
8 });

Here we import the kaboom library, and then initialize the context by calling kaboom({ ... }). We also set the size of the view to 440x275 pixels and scale the background by a factor of 1.5 on screen.
Now, let’s load up the sprites and sounds we will need in this game. This code loads each of the graphic elements we’ll use, and gives them a name, so we can refer to them when we build the game characters:

1 loadRoot("sprites/");
2 loadSprite("stars", "stars.png");
3 loadSprite("gem", "gem.png");
4 loadSprite("spaceship", "spaceship.png");
5 loadSprite("alien", "alien.png");
6
7 loadRoot("sounds/");
8 loadSound("shoot","shoot.wav");
9 loadSound("score","score.wav");
10 loadSound("music","music.mp3");
11 loadSound("explosion","explosion.wav");

The first line, loadRoot, specifies which folder to load all the sprites and game elements from, so we don’t have to keep typing it in for each sprite. Then each line loads a game sprite and gives it a name so that we can refer to it in code later. We use similar code to load the sounds we will need in this game, but instead of loadSprite we use loadSound to load sounds.

Adding the main game scene

Kaboom “scenes” allow us to group logic and levels together. In this game we’ll have 2 scenes:

- A “main” scene, which will contain the game levels and all the logic to move the spaceship.

- An “endGame” scene which will display when the game is over.

1 scene("main", () => {
2
3   layers([
4     "bg",
5     "obj",
6     "ui",
7   ], "obj");
8
9
10   add([
11     sprite("stars"),
12     layer("bg")
13   ]);
14
15   // todo.. add main scene code here
16
17 });
18
19 go("main");

We define the scene using the scene function. This function takes a string as the scene name – we’re calling the scene “main”.
Then we create 3 layers: “background” (bg), “object” (obj) and “user interface” (ui). The obj layer is set as the default layer. We then add the stars sprite to the background layer.

Finally, we use the go function to go to the main scene when the game starts up.

Note The code snippets in the sections that follow have to be added within the body of the main scene unless specified otherwise.

Creating the Game Map

Let’s get a scene layout, or map, drawn on the screen. This will define the ground and platforms in the game.

Kaboom has built-in support for defining game maps using text and the function addLevel. This takes away a lot of the hassle normally involved in loading and rendering maps.

The code below creates the game map. Add it to the main.js file, within the main scene (below the code to add the stars sprite to the background layer).

1 // Game Parameters
2 const MAP_WIDTH = 440;
3 const MAP_HEIGHT = 275;
4 const BLOCK_SIZE = 11;
5
6 const map = addLevel([
7   "--------------------------------------------",
8   "-                                          -",
9   "-                                          -",
10   "-                                          -",
11   "-                                          -",
12   "-                                          -",
13   "-                                          -",
14   "-                                          -",
15   "-                                          -",
16   "-                                          -",
17   "-                                pppppp    -",
18   "-                                          -",
19   "-                                          -",
20   "-                                          -",
21   "-                                          -",
22   "-                                          -",
23   "-   pppppp                                 -",
24   "-                                          -",
25   "-                                          -",
26   "-                 pppppp                   -",
27   "-                                          -",
28   "-                                          -",
29   "-                                          -",
30   "-                                          -",
31   "============================================",
32   "                                            "
33 ], {
34   width: BLOCK_SIZE,
35   height: BLOCK_SIZE,
36   pos: vec2(0, 0),
37   "=": ()=> [
38     rect(BLOCK_SIZE, BLOCK_SIZE),
39     color(150,75,0),
40     "ground",
41     area(),
42     solid()
43   ],
44   "p": ()=> [
45     rect(BLOCK_SIZE, BLOCK_SIZE),
46     color(0,0,255),
47     "platform",
48     area(),
49     solid()
50   ],
51   "-": ()=> [
52     rect(BLOCK_SIZE/10, BLOCK_SIZE),
53     color(0,0,0),
54     "boundary",
55     area(),
56     solid()
57   ],
58 });

First, we add some game parameters, which we’ll use when we define the size of the map, and the default block size for map elements.

Next, we create the game map. The map, or level design, is expressed in an array of strings. Each row in the array represents one row on the screen, so we can design visually in text what the map should look like. The width and height parameters specify the size of each of the elements in the map. The pos parameter specifies where on the screen the map should be placed – we chose 0,0, which is the top left of the screen, as the starting point for the map.

Kaboom allows us to specify what to draw for each symbol in the text map. You can make maps out of different elements, e.g. a symbol for a wall, a symbol for ground, a symbol for a hump, and so on. To tell Kaboom what to draw for the symbol, we add the symbol as a key, for example =, and then specify parameters for it.

In this code, we have 3 different type of fixed map elements: = representing the ground, p representing platforms, and - representing the invisible boundaries of the screen. Each of the map elements has a tag (ground, platform, boundary) which is the string name grouping the individual pieces together. This allows us to refer to them collectively later.

If we run the code, we should see the game map, like this:

Game map

Adding the Spaceship

Let’s add the spaceship using the add function:

1 const player = add([
2   sprite("spaceship"),
3   pos(100, 200),
4   body(),
5   area(),
6   scale(1),
7   rotate(0),
8   origin("center"),
9   "player",
10   {
11     score : 0,
12     shield : 100
13   }
14 ]);

The add function constructs a game object using different components, e.g. pos, body, scale, etc. Each of these components gives the object different features.

Notably, the body component makes the object react to gravity: the spaceship falls if it’s not on the ground or a platform. The rotate component allows us to tilt the spaceship in the direction the player wants to go, providing good visual feedback. By default, all operations are calculated around the top left corner of game objects. To make the tilt work correctly, we add the origin component and set it to center, so that the tilt adjusts the angle from the center of the object.

Kaboom also allows us to attach custom data to a game object. We’ve added score to hold the player’s latest score, and shield to hold the percentage of the ship’s protection shield still available. We can adjust these as the player picks up items or crashes into aliens.

When we created the map earlier, we added the solid component to map objects. This component marks objects as solid, meaning other objects can’t move past them.

Moving the Spaceship

We’ll allow a few different moves for the spaceship: change direction left or right and fly up. We also need to keep track of which way the spaceship is facing, so that we’ll know which side to shoot lasers from later.

To handle the changing and tracking of direction, add the following code:

1 const directions = {
2   LEFT: "left",
3   RIGHT: "right"
4 }
5
6 let current_direction = directions.RIGHT;
7
8 onKeyDown("left", () => {
9     player.flipX(-1);
10     player.angle = -11;
11     current_direction = directions.LEFT;
12     player.move(-100,0);
13 });
14
15 onKeyDown("right", () => {
16     player.flipX(1);
17     player.angle = 11;
18     current_direction = directions.RIGHT;
19     player.move(100,0);
20 });
21
22
23 onKeyRelease("left", ()=>{
24     player.angle = 0;
25 });
26
27 onKeyRelease("right", ()=>{
28     player.angle = 0;
29 });

First, we create a constant object defining the directions our game allows. Then we create a variable to track the current_direction the spaceship is facing.

Then we add the key-handling code. The key names left and right refer to the left and right arrow keys on the keyboard. Kaboom provides the onKeyDown event, which lets us know if a certain key is being pressed. We create onKeyDown event handlers for each of the arrow keys. As long as the given key is held down, onKeyDown calls the event handler repeatedly.

The code inside each onKeyDown event does the following:

- The flipX function mirrors the player’s spaceship image so that it looks different depending on the direction it is facing. We use -1 to flip it to appear facing the left, 1 the right.

- The function player.angle slightly tilts the spaceship while the key is being held down. This is so the spaceship looks like it is about to move in the given direction.

- The current_direction tracking variable is updated. We’ll use this variable when we add shooting.

- The move function moves the spaceship in the given direction.

We also have onKeyRelease event handlers for the left and right keys. These reset the spaceship’s tilt angle to 0 (i.e. straight up) when the ship is no longer moving in that direction.

Now we want to have the spaceship fly up when we press the up arrow key. To do this, we’ll take advantage of Kaboom’s jump attribute (which is part of the body component) and repurpose it for flying up. Add the following code to the main scene:

1 onKeyDown("up", () => {
2       player.jump(100);
3 });

Adding Laser Guns

Because the game takes place in outer space, the weapon of choice is a laser gun. We’ll need to add functions to create the bullet when the player fires, and to control the direction of the bullets. We’ll also need to add another key handler to check when the player presses a key to “fire”, which is the space key in this game.

1 const BULLET_SPEED = 400;
2
3 onKeyPress("space", () => {
4     spawnBullet(player.pos);
5 });
6
7 function spawnBullet(bulletpos) {
8     if (current_direction == directions.LEFT){
9         bulletpos = bulletpos.sub(10,0);
10     } else if (current_direction == directions.RIGHT){
11         bulletpos = bulletpos.add(10,0);
12     }
13     add([
14         rect(6, 2),
15         pos(bulletpos),
16         origin("center"),
17         color(255, 255, 255),
18         area(),
19         "bullet",
20         {
21             bulletSpeed : current_direction == directions.LEFT?-1*BULLET_SPEED: BULL\
22 ET_SPEED
23         }
24     ]);
25
26     play("shoot", {
27         volume: 0.2,
28         detune: rand(-1200, 1200),
29     });
30 };

First, we add a constant BULLET_SPEED to define the speed at which the laser “bullets” fly across the screen. Then we use the onKeyPress event to trigger the shooting. Notice onKeyPress only calls the event handler once as the key is pressed, unlike the onKeyDown event we used for moving. This is because it’s more fun if the player needs to bash the “fire” button as fast as possible to take down an enemy, rather than just having automatic weapons.

The onKeyPress handler calls the spawnBullet function with the player’s current position. This function handles creating a new laser shot in the correct direction. The first few lines of the method adjust the bullet’s starting position a little to the left or right of the spaceship’s position. This is because the position of the spaceship that gets passed to the function is the center of the spaceship (remember the origin component we added to it earlier). We adjust it a little so that the bullet looks like it is coming from the edge of the spaceship.

Then we add a new bullet object to the game using the add function. We don’t use a sprite for the bullet, but draw a rect, or rectangle, with our given color. We tag it bullet so we can refer to it later when detecting if it hit something. We also give it a custom property, bulletSpeed, which is the distance and direction we want the bullet to move on each frame.

Finally, we add sound effects when the player shoots. The play function plays our “shoot.wav” file. We adjust the volume down a bit, so it fits in better with the overall sound mix. We use the detune parameter along with a random number generator, rand, to change the pitch of the sound each time it’s played. This is so the sound doesn’t become too repetitive and also because it sounds weird and “spacey”.

Now that we’ve set up the bullet, we need to make it move on each frame. To do this we can use the onUpdate event, using the bullet tag to identify the objects we want to update:

1 onUpdate("bullet", (b) => {
2     b.move(b.bulletSpeed,0);
3     if ((b.pos.x < 0) ||(b.pos.x > MAP_WIDTH)) {
4         destroy(b);
5     }
6 });

With each frame, the action event updates the objects with the matching tag, in this case bullet. We call move on the bullet, using the custom value for bulletSpeed that we assigned to it on creation. We also check to see if the bullet has gone off the screen, and if it has, we destroy it.

We also need to destroy the bullet if it hits a platform. We can do this using the Kaboom onCollide event. Add the following code:

1 onCollide("bullet","platform", (bullet, platform) =>{
2     destroy(bullet);
3 });

Run the code now, and you should be able to shoot.

Laser firing

Click to open gif

Adding Alien Space Bugs

Now that we have a spacecraft, and it can shoot, we need something to shoot at. Let’s add some hostile exploding alien space bugs. We’ll want to have them coming in a relatively constant stream to keep the game challenging. We also want them coming in from different sides and angles to keep the player on their toes. We’ll add a new function to control the creation of alien space bugs:

1 const ALIEN__BASE_SPEED = 100;
2 const ALIEN_SPEED_INC = 20;
3
4 function spawnAlien() {
5     let alienDirection = choose([directions.LEFT, directions.RIGHT]);
6     let xpos = (alienDirection == directions.LEFT ? 0:MAP_WIDTH);
7
8     const points_speed_up = Math.floor(player.score / 1000);
9     const alien_speed = ALIEN__BASE_SPEED + (points_speed_up * ALIEN_SPEED_INC);
10     const new_alien_interval = 0.8 - (points_speed_up/20);
11
12     add([
13         sprite("alien"),
14         pos(xpos, rand(0, MAP_HEIGHT-20)),
15         area(),
16         "alien",
17         {
18             speedX: rand(alien_speed * 0.5, alien_speed * 1.5) * (alienDirection == directio\
19 ns.LEFT ? 1: -1),
20             speedY: rand(alien_speed * 0.1, alien_speed * 0.5) * choose([-1,1])
21         },
22     ]);
23
24     wait(new_alien_interval, spawnAlien);
25 }
26
27 spawnAlien();

We create 2 parameters for the alien’s speed: a base rate and an incremental rate. Each time the player gains another 1000 points, we’ll add to the incremental rate.

Tip: You can put these parameters and all the others we have defined at the top of the file, so that they are easy to find and adjust if you want to tweak the game parameters later.

Then we define the spawnAlien function. To randomly choose the side of the screen the alien will fly in from, we use the Kaboom choose function, which picks an element at random from an array. From the chosen direction, we can determine the alien’s starting position on the x axis (horizontal plane).

Then we go into the calculation to figure out the speed that the alien should move at. First, we check if we need to increase the alien’s speed based on the player’s score. We divide the player’s score by 1000 (since the aliens’ speed increases with every 1000 points the player earns). We get rid of decimals by using the Math.floor function, which is built into JavaScript. The result is our points_speed_up value.

Next we take the ALIEN_BASE_SPEED and add the incremental rate multiplied by our points_speed_up value.

We also calculate a new rate at which aliens are spawned, making the aliens not only faster at moving, but also faster at respawning.

Now that we’ve calculated our basic parameters, we create a new alien using the add function again:

- sprite('alien') creates the alien with the image alien.

- pos(xpos, rand(0, MAP_HEIGHT-20)) sets the starting position of the alien. We calculated the x pos from the randomly chosen direction. We also add a random y (vertical) position for the alien, between the top (position 0) of the map, and the bottom (MAP_HEIGHT) of the map (screen co-ordinates start from the top left of the screen). We remove 20 pixels from the bottom bounds, to account for the ground.

- We add the "alien" tag to the object, so we can identify and call it in other parts of the code.

- We also add a custom object with the speed of this particular alien, broken into it’s speed along the x and y axis. For the speed along the x-axis speedX, we add a random component so that not all aliens move at exactly the same speed. Then we multiply the speed by -1 or 1 depending on whether the alien is meant to be moving left or right across the screen.

Finally, we use Kaboom’s wait function to wait a short amount of time before calling spawnAlien again to create a new alien. We also have a call to spawnAlien to get it started when the game starts.

Moving the Aliens

To move the aliens, we’ll create a handler to attach to the onUpdate event, which fires for each alien object on every frame, like we did for the bullets:

1 onUpdate("alien", (alien) => {
2     alien.move(alien.speedX, alien.speedY);
3
4     if ((alien.pos.y - alien.height > MAP_HEIGHT) || (alien.pos.y < 0)) {
5         destroy(alien);
6     }
7     if ((alien.pos.x < -1 * alien.width) ||(alien.pos.x > MAP_WIDTH)) {
8         destroy(alien);
9     }
10 });

First, the function moves the alien by the amount we calculated earlier and saved to the alien’s custom data.

Then the function checks to see if the alien has moved out of bounds of the map area. If it has, we destroy it, as it is no longer visible. Having too many active objects can decrease performance, so this step is important.

Run the code now, you should see moving aliens.

Aliens

Click to open gif

Shooting the Aliens

Now that we have moving aliens, a moving spaceship, and laser bullets, let’s add the code to deal with a laser bullet hitting an alien. Of course, we want this to have a cool explosion and sound effect to give good feedback to the player.

1 onCollide("alien","bullet", (alien, bullet) =>{
2     makeExplosion(alien.pos, 5, 5, 5);
3     destroy(alien);
4     destroy(bullet);
5     play("explosion", {
6         volume: 0.2,
7         detune: rand(0, 1200),
8     });
9 });

This is similar to the code used before to check if a bullet has hit a platform. We destroy both the bullet and alien to remove them from the scene. Then we use the play function to play the explosion sound effect. We set the volume so it fits in the mix, and we also put a random detune (pitch adjust) on the sound, to vary it and make it more interesting when a lot of aliens are being shot at.

We also call out to a function to create an explosion around the area where the alien bug used to be. This code is from the “shooter” example on the Kaboom examples page (which is a great game). It makes a series of bright white flashes around the explosion site, giving a cool cartoon or comic-book-like feel to the explosions. Add this code:

1 function makeExplosion(p, n, rad, size) {
2         for (let i = 0; i < n; i++) {
3             wait(rand(n * 0.1), () => {
4                 for (let i = 0; i < 2; i++) {
5                     add([
6                         pos(p.add(rand(vec2(-rad), vec2(rad)))),
7                         rect(1, 1),
8                         color(255,255,255),
9                         origin("center"),
10                         scale(1 * size, 1 * size),
11                         grow(rand(48, 72) * size),
12                         lifespan(0.1),
13                     ]);
14                 }
15             });
16         }
17 }
18
19 function lifespan(time) {
20         let timer = 0;
21         return {
22             update() {
23                 timer += dt();
24                 if (timer >= time) {
25                     destroy(this);
26                 }
27             },
28         }
29 }
30
31 function grow(rate) {
32     return {
33         update() {
34             const n = rate * dt();
35             this.scale.x += n;
36             this.scale.y += n;
37         },
38     };
39 }

The makeExplosion function has four arguments (inputs to the function). These are:

- p, the center position to base the explosions around

- n, the number of main flashes to make

- rad, the radius or distance from p to make the flashes in

- size, the size of each of the flashes

The function creates a for loop to loop for n times (the number of main flashes we want to make). It uses the Kaboom wait function to leave a little bit of time (0.1) seconds between each main flash.

Another for loop loops twice to create 2 sub flashes, using the Kaboom add function to add a rectangle shape for each flash, and setting the color to bright white (color components in Kaboom go from 0-1). This rectangle starts out at 1 pixel in each dimension. Then the scale component is added to increase the size of the flash to the size passed in to the function - we’ll use this later when we “grow” the explosion. The origin component is used to set the origin of the rectangle to it’s center - this will be used when we “grow” the flash to give the impression that it is starting from a small point and exploding. We set the origin as the center so that scale is calculated from this position, giving it a more natural feel.

To make the flashes appear around the position p that we specified, the pos component is adjusted by a random amount, ranging from -rad to rad, the radius we specified (in other words, the blast area).

Then there are references to two custom components - lifespan and grow. Kaboom allows us to define our own components to give objects any behaviour or attributes we want. All we need to do is create a function that returns an object with a method called update, which is then called for each frame of the object the component is added to.

Let’s first look at the custom component grow. This is used to create the effect of the flash expanding outwards, like a firework explosion starting at a small point and getting larger until it disappears. In grow’s update function, the object is scaled up (available because we used the scale component on the object) on each frame. This is calculated from the rate passed in - which is the size the object should grow per second, multiplied by the time difference from the last frame, using the Kaboom dt function, which provides that time difference in seconds for us. The explosion flash will keep on growing in each frame, so we need a way to end the explosion before it covers the entire screen.

This brings us to the lifespan component. This is implemented to automatically destroy the object after a short time, to solve the ever-growing explosion problem. It works by having a timer variable, which is updated each frame with the difference in time from the last frame, using the Kaboom dt function again. When the timer count is more than the time parameter passed into the component, the object is automatically destroyed. This creates the impression of a quick explosion blast.

Shooting Aliens

Click to open gif

Exploding the Alien Bugs on Contact

When the alien bugs hit something solid, they should explode. To do this, we’ll add the following code:

1 onCollide("alien","platform", (alien, platform) =>{
2     makeExplosion(alien.pos, 5, 3, 3);
3     destroy(alien);
4     play("explosion", {
5         volume: 0.1,
6         detune: rand(-1200, 1200),
7     });
8 });
9
10 onCollide("alien","ground", (alien, ground) =>{
11     makeExplosion(alien.pos, 5, 3, 3);
12     destroy(alien);
13     play("explosion", {
14         volume: 0.1,
15         detune: rand(-1200, 1200),
16     });
17 });

Here we have 2 collision handlers: one for aliens hitting a platform, and one for aliens hitting the ground. They both do the same thing. First, since we have a great explosion creating function, we use it gratuitously. Then we destroy the alien object to remove it from the scene. Finally, we play an explosion sound effect at a lower volume, as this explosion is not caused by the player and doesn’t directly affect them. We also add the usual random detune function to modify the sound each time and keep it interesting.

Adding Score and Shield UI

Let’s add the UI to show the ship’s shield health and the player’s overall score.

First, add text for the player’s score:

1 add([
2     text("SCORE: ", { size: 8, font: "sink"}),
3     pos(100, 10),
4     origin("center"),
5     layer("ui"),
6 ]);
7
8 const scoreText = add ([
9     text("000000", { size: 8, font: "sink"}),
10     pos(150, 10),
11     origin("center"),
12     layer("ui"),
13 ]);

Here we add two new objects, rendered with the text component. The first is just the static label for the score. The second is the text placeholder for the actual score. Note that the layer component is used in both cases to place the text on the UI layer we created at the start of the tutorial. We haven’t had to specify the layer for all our other game objects, because we set the obj layer as the default to use when we defined the layers.

Now that we have the UI components for showing the score, we need a function to update the score when it changes, and reflect it on the UI.

1 function updateScore(points){
2     player.score += points;
3     scoreText.text = player.score.toString().padStart(6,0);
4     play("score", {
5         volume: 0.5,
6         detune: rand(-1200, 1200),
7     });
8 }

This updateScore function takes as its argument the number of points to add to the score and adds them to the player’s current score - remember we added score as a custom property when we created the player (spaceship) object.

Next we update the scoreText UI element we created previously. The player’s score is converted to a string using JavaScript’s toString method, which is part of every object in JavaScript. It is also modified with padStart, which makes sure the resulting score string is exactly 6 digits long, using 0s to put in front of the string (start) if the number is smaller than 6 digits long. This makes nice placeholders for the score, and gives a cue to the users as to the maximum score they could reach. Finally, we play a little sound to indicate that points have been earned. As before, we vary the pitch each time using detune to keep the sound fresh.

To increment the score when the alien bugs get hit by a bullet, update the onCollide event we added earlier for a bullet and alien bug as follows:

1 onCollide("alien","bullet", (alien, bullet) =>{
2     makeExplosion(alien.pos, 5, 5, 5);
3     destroy(alien);
4     destroy(bullet);
5     play("explosion", {
6         volume: 0.2,
7         detune: rand(0, 1200),
8     });
9     updateScore(10);  // new line
10 });

The next UI element to add is the ship’s shield health. This would be great as a kind of health-bar-style display, that starts out green and turns red when the shield is low. The game should end when the shield is fully depleted, as the spaceship is then totally destroyed.

1 add([
2     text("SHIELD: ", { size: 8, font: "sink"}),
3     pos(300, 10),
4     origin("center"),
5     layer("ui"),
6 ]);
7
8 const shieldHolder = add ([
9     rect(52,12),
10     pos(350, 10),
11     color(100,100,100),
12     origin("center"),
13     layer("ui"),
14 ]);
15
16 const shieldHolderInside = add ([
17     rect(50,10),
18     pos(350, 10),
19     color(0,0,0),
20     origin("center"),
21     layer("ui"),
22 ]);
23
24 const shieldBar = add ([
25     rect(50,10),
26     pos(325, 5),
27     color(0,255,0),
28     layer("ui"),
29 ]);

First, we add a text label so that players know what the bar represents. To create the shield bar UI, we use 3 elements :

- A border, or shieldHolder, to outline the bar.

- A black inner block to make the holder look like a thin line, shieldHolderInside.

- The shieldBar itself, which will get shorter as the shield is damaged.

Now we need a function to call when we want to update the shield’s health:

1 function updatePlayerShield(shieldPoints){
2     player.shield += shieldPoints;
3     player.shield = Math.max(player.shield, 0);
4     player.shield = Math.min(player.shield, 100);
5
6     shieldBar.width = 50 * (player.shield / 100);
7
8     if (player.shield < 20) shieldBar.color = rgb(255,0,0);
9     else if (player.shield < 50) shieldBar.color = rgb(255,127,0);
10     else shieldBar.color = rgb(0,255,0);
11
12     if (player.shield <=0){
13         destroy(player);
14         for (let i = 0; i < 500; i++) {
15             wait(0.01 *i, ()=>{
16                 makeExplosion(vec2(rand(0,MAP_WIDTH,), rand(0, MAP_HEIGHT)), 5, 10, \
17 10);
18                 play("explosion", {
19                     detune: rand(-1200, 1200)
20                 });
21             });
22         }
23         wait(2, ()=>{
24             go("endGame");
25         });
26     }
27 }

This function has an argument for the number of shieldPoints to update the shield by and adjusts the custom shield property on the UI layer. It also clamps the minimum and maximum amount the shield can be to between 0 and 100.

The function sets the width of the shieldBar (its dimension along the x axis) to the percentage of the shield available (player.shield / 100), multiplied by the full width of the bar, 50.

Then the function updates the color of the bar depending on the health of the shield:

- Less than 20% health, shield bar is red;

- Less than 50% but more than 20% health, shield bar is orange;

- The shield bar is set to green for all other health values, in other words, when health is over 50%.

The final step in the shield health function is to check if the shield health is depleted, and end the game if it is.

When the game ends, we destroy the spaceship to remove it from the scene. Now we have another opportunity to create some more explosions using the makeExplosion function we added earlier. This time we can go really big! To make a big impact, we create a for loop to set off 500 explosions all over the screen for seriously dramatic effect. We use the Kaboom wait function to have a small delay between each explosion so that they don’t all go off at once. Then we make each explosion happen at random positions on the map, passing in other parameters to the makeExplosion function to set the blast radius, number of sub-explosions and general size. We also play the explosion sound effect using Kaboom’s play function. This time we don’t adjust the volume down, as we want the sound to be as dramatic as possible. We detune it randomly again to create a true cacophony and sense of mayhem.

After setting off all those sound effects and visual fireworks, we wait for 2 seconds for everything to settle down, and then use the Kaboom function go to switch to a new scene, endGame, and wait for the player to play again. Add the code below to create the endGame scene, at the bottom of main.js below the line go("main"):

1 scene("endGame", ()=> {
2   const MAP_WIDTH = 440;
3   const MAP_HEIGHT = 275;
4
5   add([
6     text("GAME OVER ", {size: 40, font: "sink"}),
7     pos(MAP_WIDTH / 2, MAP_HEIGHT / 3),
8     origin("center"),
9     layer("ui"),
10   ]);
11
12
13   onKeyRelease("enter", ()=>{
14     go("main");
15   });
16 });

This scene adds a large “GAME OVER” text over the screen until the player presses and releases the enter key. Then the onKeyRelease event returns the player to the main scene, and uses go to switch scenes and restart the game. Because this is a new scene, in a new scope, we need to add the MAP_WIDTH and MAP_HEIGHT constants again.

Allowing the Alien Bugs to Attack

Now that we have mechanisms for updating points and shield health, we can add the code dealing with alien bugs hitting the spaceship to the main scene:

1 const ALIEN_SHIELD_DAMAGE = -15;
2
3 onCollide("alien", "player", (alien, player) =>{
4     shake(20);
5     makeExplosion(alien.pos, 8, 8, 8);
6     destroy(alien);
7     play("explosion",
8     {
9       detune: -1200,
10       volume : 0.5
11     });
12     updatePlayerShield(ALIEN_SHIELD_DAMAGE);
13 });

This is a big event - it’s the way the ship shield gets damaged and it can be fatal - so we want to add a bit more dramatic effect. Kaboom can create a cool screen-shaking effect, as if the player has been hit, which we can invoke by calling shake with a number representing how dramatic the shake should be. Then we add some visual effect with the makeExplosion function. We also destroy the alien and play the explosion effect again, this time a bit louder as the alien exploding has directly affected the player. We also detune the effect to the lowest pitch we can, to make it “feel” more direct, particularly if the player has a sub-woofer.

Then we call the updatePlayerShield function we defined previously, with a constant that defines by how much a shield is damaged per hit. You can move the constant to the top of the main scene file to keep it neat if you want.

Raining Gems

It’s time to add the element that gives the game its purpose: gems the player can collect to earn points. Add this function to the main scene to create a gem:

1 function spawnGem(){
2     let xpos = rand(BLOCK_SIZE, MAP_WIDTH - BLOCK_SIZE);
3     add([
4         sprite("gem"),
5         pos(xpos, BLOCK_SIZE),
6         area(),
7         body(),
8         "gem"
9     ]);
10 }
11
12 onUpdate("gem", (gem)=>{
13     if (gem.pos.y > MAP_HEIGHT) {
14         destroy(gem);
15         spawnGem();
16     }
17
18 });
19
20 spawnGem();

On this weird planet in outer space, the gems rain from the sky, which is the top of the map for our purposes. We calculate a random position, xpos, along the x axis for the gem to appear on by calling the Kaboom rand function. We don’t want the gems to fall right at the edge of the screen, as they will be cut off and the spaceship won’t be able to get to them because of the boundary elements we added all around the screen. So we limit the random xpos to one BLOCK_SIZE from each edge.

Now we add the gem sprite to the scene. The pos component is set to the xpos we calculated, with the y component set to one BLOCK_SIZE from the top of the screen. This is to avoid the gem getting stuck on our upper boundary. We also give the gem the body component, which makes it subject to Kaboom gravity so that it falls down towards the ground. We give it the label gem so that we can refer to it later.

Then we add the onUpdate event handler for the gem - we need to do this for all objects with a body component so that interactions with solid objects are taken care of. Sometimes, if the frame rate gets too low (if there’s a lot of action, or the computer’s slow), some body and solid interaction maybe missed, and the object falls through the solid. This could cause gems to fall through the ground, out of reach of the player’s spaceship. To account for this possibility, we check if the gem’s y position is beyond the bounds of the map, and destroy it and spawn a new gem if it is.

Finally, we call spawnGem() to start the gem raining process.

Collecting Gems

Now that gems are raining down, we can add a handler to pick up when the player’s spaceship moves over a gem. This is how the spaceship “collects” gems, and will earn the player points. Add the following onCollide event handler:

1 const POINTS_PER_GEM = 100;
2
3 player.onCollide("gem", (gem) => {
4   destroy(gem);
5   updateScore(POINTS_PER_GEM);
6   wait(1, spawnGem);
7 });

This fires whenever the spaceship and a gem collides. We destroy the gem to remove it from the scene, and call the updateScore function we added earlier to update the player’s points by the amount declared in the POINTS_PER_GEM constant. Then we wait one second before another gem is spawned for the player to collect.

Run the code now and start collecting gems.

Collecting gems

Click to open gif

Adding Background Music

Having sound effects is cool, but games generally need a soundtrack to tie all the sounds together. Kaboom allows us to play a sound file on loop as constant background music. Add this code to the bottom of main.js file to play the track:

1 const music = play("music");
2 music.loop();

The music is a track called “Battle of Pogs” by “Komiku” from “Free music archive”, a good resource for music that you can legally use in your games.

Playing the Game

Congratulations, you’ve finished making this Kaboom game! Try running and playing the game to see what score you can get. You can also experiment with adjusting the parameters to see how they change the gameplay.

Credits

The game art and sounds used in this tutorial are from the following sources:

- Music : https://freemusicarchive.org/music/Komiku/Captain_Glouglous_Incredible_Week_Soundtrack/pog

- Laser : https://freesound.org/people/sunnyflower/sounds/361471/

- Explosion: https://freesound.org/people/tommccann/sounds/235968/

- Point Beep : https://freesound.org/people/LittleRobotSoundFactory/sounds/270303/

- Gem: https://opengameart.org/content/planetcute-gem-bluepng

- Space Background: https://opengameart.org/content/space-background-8

- Alien Bug: https://opengameart.org/content/8-bit-alien-assets

The spaceship was made by Ritza.

Thank you to all the creators for putting their assets up with a Creative Commons license and allowing us to use them.

Things to Try Next

Here are a few things you can try to add to the game and polish it up:

- Self healing on the shield. Perhaps add back 1 or 2 shield points every 10 seconds, so that players can go further if they dodge the aliens.

- A better ending screen, with the player’s score.

- An intro scene, explaining the game and the controls.

- Different types of alien bugs. Perhaps a large “boss” bug that can also shoot back.

Code

You can find the code for this tutorial on Replit

Building a pseudo-3D game with Kaboom.js

Three-dimensional games became popular in the late 80’s and early 90’s with games like the early Flight Simulator and Wolfenstein 3D. But these early games were really 2.5D, or pseudo-3D: the action takes place in 2 dimensions, and the world only appears to be 3D.

Kaboom.js is a 2D game engine, but we can use some of those early game designers’ techniques to create a pseudo-3D game. This game is roughly based on our 2D space shooter game tutorial, but we’ll use a view from the cockpit of the spaceship instead of the side-scrolling view.

The finished game

Click to open gif

You can download this zip file with all the sprites and sounds you’ll need for this tutorial.

Game design

Here’s what we want from this game:

- A sense of depth to give the illusion of 3D

- The feeling of freedom of movement throughout space

We’ll make use of Kaboom’s scale component to achieve the sense of depth, representing our sprites as smaller if they are meant to be further away, and larger when they are closer. We’ll create a feeling of moving through space using an algorithm to generate a star field, like the early Windows screensavers.

Creating a new project

Head over to Replit and create a new repl. Choose Kaboom as your project type. Give this repl a name, like “3D Space Shooter”.

Creating a new repl

After the repl has booted up, you should see a main.js file under the “Code” section. This is where we’ll start coding.

Setting up the Kaboom environment

The Kaboom interface on Replit is specialised for game-making. Besides the Space Invader icon, you’ll notice a few special folders in the file try, like “Code”, ‘“Sprites”, and “Sounds”. These special folders take care of loading up assets, and all the necessary code to start scenes and direct the game. You can read up more about the Kaboom interface here.

If you haven’t already, download this zip file containing all the sprites and sounds for the game. Extract the file on your computer, then add the sprites to the “Sprites” folder, and the sounds to the “Sounds” folder.

Uploading sprites

Click to open gif

To set up the game play environment, we need to set up Kaboom with the screen size and colors we want for the game window. Replace the code in main.js with the code below:

1 import kaboom from "kaboom";
2
3 kaboom({
4   background: [0, 0, 0],
5   width: 320,
6   height: 200,
7   scale: 2
8 });

Here we import the kaboom library, and then initialize the context by calling kaboom({ ... }). We set the size of the view to 320x200 pixels and scale to make the background twice the size on screen.
Now, let’s load up the sprites and sounds we will need in this game. The code below loads each of the graphic elements we’ll use, and gives them a name, so we can refer to them when we build the game characters:

1 loadRoot("sprites/")
2 loadSprite("cockpit", "cockpit.png")
3 loadSprite("alien", "alien.png")
4
5 loadRoot("sounds/")
6 loadSound("shoot","shoot.wav")
7 loadSound("explosion","explosion.wav")

The first line, loadRoot, specifies which folder to load all the sprites and game elements from, so we don’t have to keep typing it in for each sprite. Then each line loads a game sprite and gives it a name so that we can refer to it in code later. We use similar code to load the sounds we will need in this game, but instead of loadSprite we use loadSound to load sounds.

Creating the interface layers

Kaboom games are made up of “Scenes”, which are like levels, or different parts and stages of a game. The IDE has a default “main” scene already, which we can use for our main game code. Each scene has multiple “Layers”, allowing us to have backgrounds that don’t affect the game, main game objects (like the player, bullets, enemies, and so on), and UI elements (like the current score and health).

Add the following code to the main.js file to create the 3 layers “Background (bg)”, “Object (obj)”, “User Interface (ui)”:

1 layers([
2     "bg",
3     "obj",
4     "ui",
5 ], "obj");

The obj layer is set as the default layer and that’s where the game action will take place. We’ll use the bg layer to draw the star field, as we don’t interact with the objects on that layer. Then we’ll use the ui layer to draw fixed foreground objects, like the cockpit of the spaceship the player is travelling in.

Creating alien bugs

As in the 2D version of this game, the point of our game is to avoid and shoot down exploding alien bugs. This time, instead of the bugs coming from the left and right of the screen, we’ll make it appear as though they are coming toward the player from “inside” the screen.

To create this effect, we’ll start by making the alien bugs small and spread out over the screen, and have them get bigger and loom toward the center of the screen as they get closer.

We need a 3D coordinate system to work out how our elements should move. We’ll create a system like the one in the image below, with 0 for all three dimension axes in the center. This is how we’ll track the movements of the aliens in code. When we draw them to the screen, we’ll convert these coordinates into the 2D screen coordinate system.

3D co-ordinate system

Let’s add the following code to the main scene file to achieve this:

1 const SCREEN_WIDTH = 320;
2 const SCREEN_HEIGHT = 200;
3 const ALIEN_SPEED = 200;
4
5
6 let aliens = [];
7
8 function spawnAlien(){
9     const x = rand(0 , SCREEN_WIDTH);
10     const y = rand(0 , SCREEN_HEIGHT);
11
12     var newAlien = add([
13         sprite("alien"),
14         pos(x,y),
15         scale(0.2),
16         area(),
17         rotate(0),
18         {
19             xpos: rand(-1*SCREEN_WIDTH/2, SCREEN_WIDTH/2),
20             ypos: rand(-1*SCREEN_HEIGHT/2, SCREEN_HEIGHT/2),
21             zpos: 1000,
22             speed: ALIEN_SPEED + rand(-0.5* ALIEN_SPEED, 0.5*ALIEN_SPEED)
23         },
24         "alien"
25     ]);
26
27     aliens.push(newAlien);
28 }
29
30 loop(0.8, spawnAlien);

First, we define some general constants for the size of the screen and the speed at which aliens will move. This way, we don’t have to keep remembering and typing numbers, and it’s easier to change these aspects later if we need to. We also create an array to hold each alien object we create so that we can keep track of all of them. This will be especially important when we add movement to the aliens.

The function spawnAlien creates a new alien at a random location on the screen. The first lines calculate a random x and y position to place the alien on the screen initially. This isn’t logically needed, as we’ll calculate the alien’s actual position later from our 3D coordinate system and calculate the projected screen position on each frame. But we need to pass a position pos component to the add method when we create a new object, so any random position will do.

There are two more components we include when constructing the alien object:

- scale, allowing us to adjust the size of the alien over time as if it’s getting closer, and

- rotate, allowing us to rotate the aliens so we can simulate ‘rolling’ when changing the spaceship’s direction.

We also add the coordinates of the alien’s position in the 3D system to the alien object as custom properties. We start with a fixed zpos, or position on the Z axis, far from the screen.

Then we set the alien’s speed, varied by a random amount of up to half the base speed faster or slower so that there’s some variety in the way aliens approach the ship. We’ll use these custom values when we calculate the alien’s position on each frame.

Finally, we add the new alien to the aliens array we created earlier to keep track of it.

Outside the function, we make use of the Kaboom loop functionality to call the spawnAlien function to create new aliens at regular intervals.

Moving the alien bugs

Now we need to have the aliens we’ve generated move with each frame. Here’s the code:

1     onUpdate("alien", (alien)=>{
2         alien.zpos -= alien.speed * dt();
3
4         alien.scale = 2 - (alien.zpos * 0.002);
5
6         const centerX = SCREEN_WIDTH * 0.5;
7         const centerY = SCREEN_HEIGHT * 0.25;
8
9         alien.pos.x  = centerX + alien.xpos * (alien.zpos * 0.001);
10         alien.pos.y = centerY + alien.ypos * (alien.zpos * 0.001);
11
12         if (alien.zpos <= 1 ){
13             destroyAlien(alien);
14         }
15     });
16
17     function destroyAlien(alien){
18         aliens = aliens.filter(a => a != alien);
19         destroy(alien);
20     }

First we add a new event handler onto the onUpdate event, and filter for any objects tagged alien. The onUpdate event handler is fired for each frame. In this event handler function, we adjust the zpos of the alien to make it ‘move’ a little closer to the screen. We use the dt() function to get the time from the last frame, together with the speed per second we assigned to the alien when we constructed it, to calculate the alien’s new zpos in our 3D coordinate system. We then translate that value to screen coordinates, and mimic the z-axis position by adjusting the size, or scale, of the alien sprite.

Remember that screen coordinates start with (0,0) in the top left corner of the screen, and our 3D coordinate system starts with (0,0,0) in the ‘center’ of the system. To translate between the 2 systems, we need to find the center of the screen so that we can center the 3D system over it. We do this by by halving the screen WIDTH and HEIGHT by 2. The screen is the red rectangle in the image below, showing how the 3D system will be centered on it.

Overlay 3d system over 2d system

Now we can add the alien’s x and y positions in 3D coordinate space relative to the center point of the screen. We bias the center point “up” a bit, as this will seem to be the center of the spaceship’s view when we add the cockpit later. We also modify each of these x and y positions by a factor relating to the alien’s z position: As the alien approaches, its zpos value decreases, and our factor uses this value to draw the alien nearer to the center of the screen. This enhances the depth illusion and makes it feel to the player that the aliens are coming at them.

Finally, we see if the alien is very close by seeing if the zpos < 1. If it is, we destroy the alien to remove it from the scene, as it’s either gone past our spaceship or crashed into it. We create a small helper function, destroyAlien, to manage this, so that we also remove the alien from the tracking array.

If you run the code now, you should see the aliens start to move toward you.

Aliens coming at you

Click to open gif

Adding a star field

Now that we have the aliens moving and coming at us, let’s add another element to give a further sense of depth and show that we are in outer space: the star field generator. We can implement the star field in a similar way as we did for the aliens’ movement. One difference will be that we will use color, or more specifically intensity, instead of scaling to proxy for the z-axis. Another difference is that we’ll have the stars spread away from the center rather than towards it, as if the ship is going past them. This also makes it seem like we’re travelling at warp speed, which is cool.

1 const STAR_COUNT = 1000;
2 const STAR_SPEED = 5;
3 var stars = [];
4
5 function spawnStars(){
6     for (let i = 0; i < STAR_COUNT; i++) {
7         const newStar = {
8           xpos: rand(-0.5*SCREEN_WIDTH, 0.5*SCREEN_WIDTH),
9           ypos: rand(-0.5*SCREEN_HEIGHT, 0.5*SCREEN_HEIGHT),
10           zpos: rand(1000)
11         };
12         stars.push(newStar);
13     }
14 }
15
16 spawnStars();
17
18 onUpdate(()=>{
19   const centerX = SCREEN_WIDTH * 0.5;
20   const centerY = SCREEN_HEIGHT * 0.5;
21
22   stars.forEach((star)=>{
23     star.zpos -= STAR_SPEED;
24     if (star.zpos <=1)
25     {
26       star.zpos = 1000;
27     }
28     const x = centerX + star.xpos / (star.zpos * 0.001);
29     const y = centerY + star.ypos / (star.zpos * 0.001);
30
31     if (x>= 0 && x<= SCREEN_WIDTH && y>=0 && y<= SCREEN_HEIGHT) {
32       const scaled_z = star.zpos * 0.0005;
33       const intensity = (1 - scaled_z) * 255;
34
35       drawRect({
36         width: 1,
37         height: 1,
38         pos: vec2(x,y),
39         color: rgb(intensity, intensity, intensity)
40       });
41     }
42   })
43 });

While this is very similar to the code we added above for the alien bugs, you’ll notice the spawnStars function has a few differences to the spawnAlien function, such as:

- We create all the stars at once. This is because we need a significant star field to start with, not just a few stars every second.

- We don’t create a Kaboom object for each star. This is because we don’t need the collision handling and other overhead that comes with a Kaboom object, especially since we are generating a lot of stars (const STAR_COUNT = 1000; ). Instead, we store the stars’ info in custom object literals, and add each of these to the stars array.

- We set the initial z-pos of the stars to a random value from 0 to 1000, using the Kaboom rand function. We do this because we create all the stars at once, so we seed the stars at random positions on the z-axis to give the feeling of depth to the star field. If the stars were all initialised to the same z-pos, they would move in unison, and it would look like a mass of pixels were coming at us - a bit weird!

Now take a look at the onUpdate event handler for our stars. It differs from the event handler for our alien bugs in a few ways:

- We don’t use an object filter to look for the stars, as we didn’t create them as Kaboom objects. Instead, we just cycle through each star in the stars array.

- Instead of destroying the star and removing it from the array when it reaches the ‘front’ of the screen, we recycle it by resetting its z-pos back to 1000.

- We also check if the star is out of the screen view. If it is, we don’t draw it, to save a bit of overhead.

- Instead of using the z-pos to calculate a value by which to scale the star, we use it to calculate the star’s intensity, or brightness. Kaboom uses color values in the range 0-255. So we first scale the z-pos down to below 1. Then we subtract it from 1 to create an inverse relationship between z-pos and intensity. We then multiply the intensity value by 255 to scale it to a value that is within the range 0-255. In other words, stars further away from us have higher zpos values, giving us lower color intensity. This makes stars far away glow dimly, while those closer to our view look brighter.

- Finally, we use the Kaboom’s drawRect method to directly draw the star to the screen. As there is no pixel level drawing function in Kaboom, we create a rectangle of size 1 to draw just one pixel.

Adding the spaceship cockpit

Now that we have a star field to fly through, let’s add the player’s spaceship. Our game uses the spaceship pilot’s point of view. Add the following code to add a view from the spaceship cockpit.

1     const cockpit = add([
2         sprite("cockpit"),
3         layer("ui"),
4         rotate(0),
5         pos(SCREEN_WIDTH/2, SCREEN_HEIGHT/2 ),
6         origin("center"),
7         scale(0.275)
8     ]);

This adds the cockpit sprite (image) to the ui layer. We also add the rotate component to it, so that we can add some rotation effects when the spaceship is flying. We use the origin component to center the image in the middle of the screen, which also provides the axis to rotate the sprite around when banking (turning) the spaceship. Then we use a scaling factor to scale the image down to the size of the screen. We scale the image as it’s much larger (1334×834) than the size of the game screen (320x200). We could resize the image in an image editing programme, but we would lose some detail and sharpness. Note that the factor of the scale means that the image is still a little larger than the screen size. This gives us a bit of overlap available for when we rotate the image when banking the spaceship.

Run the game now and you should see the view from inside the spaceship.

Spaceship view

Click top open gif

Creating the spaceship’s movement controls

Our basic game world is up and running, now let’s add some controls so we can move around in it. We’ll allow a few different moves for the spaceship: bank left or right, and fly up or down.

Consider for a moment how the spaceship moves through the game world. We can’t move the cockpit left or right, up or down - it would just disappear off the screen. One way of simulating movement from the point of view of the cockpit is to keep it stationary and move all the other game elements.

To achieve this, let’s add some helper functions to move the game objects. Here’s the code:

1 function shiftAliens(x, y){
2     aliens.forEach((alien) =>{
3         alien.xpos += x / (alien.zpos*0.01);
4         alien.ypos += y / (alien.zpos*0.01);
5     });
6 }
7
8 function shiftStars(x, y){
9     stars.forEach(star =>{
10         star.xpos += x *0.01;
11         star.ypos += y *0.01;
12     });
13 }

These 2 functions take x and y values for the amount we want to “move” by, and uses these to move the aliens and the stars. In each case, we loop through the arrays holding the alien or star game objects. We make some adjustments to the values supplied to the functions to account for the perception that, when we move, objects further away appear to move “less” than objects close to us. In the case of the stars, we assume they are all in the far distance, so we scale down the amounts to move by a constant factor. In the case of the aliens, some are far away, while others are right up against the spaceship. To account for this variance, we adjust the amount to move an alien by a factor related to its distance from us, or zpos. Aliens close by will appear to move more than those far away.

Now we can add some event handlers for keyboard input:

1     const MOVE_DELTA = 2000;
2
3     onKeyDown("left", () => {
4         const delta =  MOVE_DELTA * dt();
5         shiftAliens(delta, 0);
6         shiftStars(delta*0.01, 0);
7         camRot(5.7);
8     });
9
10     onKeyDown("right", () => {
11         const delta =  -1 * MOVE_DELTA * dt();
12         shiftAliens(delta, 0);
13         shiftStars(delta*0.01, 0);
14         camRot(-5.7);
15     });
16
17
18     onKeyDown("up", () => {
19         const delta = MOVE_DELTA * dt();
20         shiftAliens(0, delta);
21         shiftStars(0,delta*0.01);
22     });
23
24     onKeyDown("down", () => {
25         const delta = -1 * MOVE_DELTA * dt();
26         shiftAliens(0, delta);
27         shiftStars(0, delta*0.01);
28     });
29
30     onKeyRelease("left", ()=>{
31         camRot(0);
32     });
33
34     onKeyRelease("right", ()=>{
35         camRot(0);
36     });

Because we’re moving the aliens and stars to make it appear as if the spaceship is moving, these elements must move in the opposite direction to that of the arrow key being used. When we use the left key to move the spaceship, our scene moves to the right. We use the Kaboom events onKeyDown and onKeyRelease to attach event handlers for direction controls to the arrow keys on the keyboard. In each of the onKeyDown event handlers, we get the time elapsed from the last frame by calling the dt() function, and multiply it by a constant MOVE_DELTA, representing the amount to move by each second. For the right and up keys, our elements move left and down respectively, so we make the amount to move negative - recall that we are moving the objects in our 3D coordinate system. Then we call the 2 helper functions we defined above with the amount to move the objects in the x and y dimensions.

In the event handlers for left and right keys, we also make use of the Kaboom camRot effect. This effect rotates all objects by the amount we specify, giving the perception of the spaceship banking hard while turning. We add in two additional event handlers on onKeyRelease for the left and right keys to reset the rotation when the player stops turning.

Give the game a run, and you should be able to control the spaceship.

Flying controls

Clcik to open gif

Adding weapons

Now we’re flying through the alien bug field, but if an alien makes contact with our spaceship, it will explode and damage us. We need some weapons to shoot the aliens with and protect ourselves. For this, we need to implement some lasers. First, let’s add cross hairs to aim with:

1 const vertical_crosshair = add([
2     rect(1, 10),
3     origin('center'),
4     pos(SCREEN_WIDTH*0.5, SCREEN_HEIGHT*0.33),
5     color(0, 255, 255),
6     layer("ui")
7 ]);
8
9 const horizontal_crosshair = add([
10     rect(10, 1),
11     origin('center'),
12     pos(SCREEN_WIDTH*0.5, SCREEN_HEIGHT*0.33),
13     color(0, 255, 255),
14     layer("ui")
15 ]);

This adds 2 lines at a point halfway across the screen, and about 1/3 down the screen, which is roughly the center of the view out of the spaceship window. Since Kaboom doesn’t have a line component, we use rect to draw rectangles with a width of 1 pixel, effectively a line. We add the cross hairs to the UI layer, so they are always on top of the aliens and stars.

Now we have a point to aim at, let’s add the lasers. Our player will shoot using the spacebar, and we want a classic laser effect: 2 lasers, one shooting from each side of the ship towards the same point to give the effect of shooting into the distance, towards a vanishing point.

1 const BULLET_SPEED = 10;
2 function spawnBullet() {
3
4     const BULLET_ORIGIN_LEFT = vec2(SCREEN_WIDTH *0.25, (SCREEN_HEIGHT - SCREEN_HEIG\
5 HT*0.33));
6     const BULLET_ORIGIN_RIGHT = vec2(SCREEN_WIDTH - (SCREEN_WIDTH*0.25), (SCREEN_HEI\
7 GHT - SCREEN_HEIGHT*0.33));
8
9     const BULLET_VANISHING_POINT = vec2(SCREEN_WIDTH * 0.5, SCREEN_HEIGHT *0.33);
10
11     add([
12         rect(1, 1),
13         pos(BULLET_ORIGIN_LEFT),
14         area(),
15         color(255, 0, 0),
16         "bullet",
17         {
18             bulletSpeed:  BULLET_SPEED ,
19             targetPos: BULLET_VANISHING_POINT
20         }
21     ]);
22
23     add([
24         rect(1, 1),
25         pos(BULLET_ORIGIN_RIGHT),
26         color(255, 0, 0),
27         "bullet",
28         {
29             bulletSpeed:  -1*BULLET_SPEED,
30             targetPos: BULLET_VANISHING_POINT
31         }
32     ]);
33
34     play("shoot", {
35         volume: 0.2,
36         detune: rand(-1200, 1200),
37     });
38 }

When the player fires, we call the spawnBullet function to create a new set of laser bullets. First, we calculate the position the bullets will be coming from. To make it seem as though they’re coming from under the spaceship either side, we calculate our bullets’ starting positions a quarter way from each side of the screen and about a third of the way from the bottom using the multipliers 0.25 and 0.33 respectively.

Then we calculate where we want the bullets to end up. This is the same position as the cross hairs.

Using these values, we create 2 bullet objects - simple 1 pixel objects with the tag bullet and color set to red (255,0,0) so they look menacing. We also add custom properties to the object: A speed for the bullet to move at, and the vanishing point where its course ends.

As a final detail, we set our “shoot” sound to play as each bullet is created, adjusting the volume and applying a randomly generated detune value so that the pitch of the sound is slightly different each time it’s played.

We’ve got our bullets, their sounds and their trajectories, so let’s make them go from their origin point to the vanishing point at the cross hairs by moving them with each frame:

1 onUpdate("bullet", (b) => {
2
3     const m = (b.pos.y - b.targetPos.y) / (b.pos.x - b.targetPos.x);
4     const c = b.targetPos.y - m*(b.targetPos.x);
5
6     let newX = b.pos.x + b.bulletSpeed;
7     let newY = m * newX + c;
8     b.pos.x = newX
9     b.pos.y = newY;
10     // Remove the bullet once it has hit the vanishing point y line
11     if ((b.pos.y < SCREEN_HEIGHT*0.33)) {
12         destroy(b);
13     }
14 });
15
16 onKeyDown("space", () => {
17     spawnBullet();
18 });

Here we use the onUpdate event handler, filtered to bullet objects.

To calculate the bullet’s next position on its trajectory for each frame, we need to find the values for the slope (m) and y-intercept (in this case, c) of the straight line between the bullet’s current position and its end position. Our first 2 lines of the function express those variable parameters as formulas. Let’s take a moment to see how we came to those formulas.

We began with the equation for a straight line:

1 y = m*x + c

Since we have the x and y coordinates for the start and end of the trajectory, we can use them to find the values of the unknowns m and c by solving simultaneous equations:

1 y_start = m*x_start + c         (1)
2 y_target = m*x_target + c       (2)
3
4 re-arranging (2):
5 c = y_target - m*x_target
6
7 Substitute (2) into (1) for c:
8 y_start = m*x_start + (y_target - m*x_target)
9 y_start - y_target  = m*x_start - m*x_target
10                     = m*(x_start - x_target)
11 so m = (y_start - y_target) / (x_start - x_target)
12
13 Now we can solve for c:
14 c = y_target - m*x_target

Now that we can express m and c as formulas, we use them in our code to calculate the parameters.

Our code goes on to advance the bullet’s current x position by the bullet speed amount, and we can figure out the corresponding new y position using the m and c values calculated above with our new x position. We then update the bullet’s new position (pos) with these new values.

We want the bullets to disappear once they hit the target at the vanishing point, so we go on to check if the bullet has crossed the horizontal cross hairs. If it has, we remove the bullet from the scene using the destroy function.

Finally, we have an event handler for the space key, which calls the spawnBullet function whenever it is pressed.

Try this out now, and you should be able to shoot some laser bullets into space.

Shooting

Click to open gif

Checking for collisions with bullets

Now that we can shoot bullets, we need to check if they hit an alien bug. If they did, we explode the alien.

1 const BULLET_SLACK = 10;
2 onCollide("alien","bullet", (alien, bullet) =>{
3     if (bullet.pos.y > SCREEN_HEIGHT*0.33 + BULLET_SLACK) return;
4     makeExplosion(bullet.pos, 5, 5, 5);
5     destroy(alien);
6     destroy(bullet);
7 });

We make use of the Kaboom event onCollide which is fired when 2 game objects are overlapping or touching each other. We pass in the tags for the aliens and bullets, so we know when they collide.

We want to limit bullet hits to only be around the target area, so that the 3D perspective is kept. But because they could collide at any point along the path the bullet takes, we check if the collision has taken place at around the cross hairs area. Then, if is in the target zone, we remove both the bullet and the alien from the scene, and call a function to create an explosion effect. This is the same code used in the 2D Space Shooter tutorial.

1 function makeExplosion(p, n, rad, size) {
2         for (let i = 0; i < n; i++) {
3             wait(rand(n * 0.1), () => {
4                 for (let i = 0; i < 2; i++) {
5                     add([
6                         pos(p.add(rand(vec2(-rad), vec2(rad)))),
7                         rect(1, 1),
8                         scale(1 * size, 1 * size),
9                         lifespan(0.1),
10                         grow(rand(48, 72) * size),
11                         origin("center"),
12                     ]);
13                 }
14             });
15         }
16 }
17
18 function lifespan(time) {
19         let timer = 0;
20         return {
21             update() {
22                 timer += dt();
23                 if (timer >= time) {
24                     destroy(this);
25                 }
26             },
27         }
28 }
29
30 function grow(rate) {
31     return {
32         update() {
33             const n = rate * dt();
34             this.scale.x += n;
35             this.scale.y += n;
36         },
37     };
38 }

We won’t explain this code here, but if you’d like to know how it works, visit the 2D Space Shooter tutorial to learn more.

Run this now, and you should be able to shoot the alien bugs down.

Shooting explosions

Click to open gif

Checking if alien bugs hit the spaceship

Now we can add functionality to check if an alien bug makes it past our laser and explodes into the spaceship. Since the cockpit covers the entire screen, we can’t make use of the onCollide function to check if an alien has hit the cockpit, as it would always be colliding. Instead, we can check the z value of the alien, plus if it is within an area of the spacecraft that would cause damage. We’ll use a “strike zone” in the center of the cockpit view as the area that aliens can do damage to the craft. Outside that area, we’ll assume that the aliens go around or up and over the spacecraft.

To implement this scheme, add a definition for the strike zone:

1 const STRIKE_ZONE = {x1:80, x2:240, y1:20, y2:100};

Then we can modify the onUpdate("alien",....) event handler that we added earlier in “Moving the Alien Bugs” section. In the part of the function where we check if the alien is close to us (if (alien.zpos <= 1 )), update the code as follows:

1    if (alien.zpos < 1 ){
2         //check if the alien has hit the craft
3         if (alien.pos.x >= STRIKE_ZONE.x1 &&
4             alien.pos.x <= STRIKE_ZONE.x2 &&
5             alien.pos.y >= STRIKE_ZONE.y1 &&
6             alien.pos.y <= STRIKE_ZONE.y2){
7                 shake(20);
8                 makeExplosion(alien.pos, 10, 10, 10);
9         }
10         destroyAlien(alien);
11     }

We’ve modified the code to check if the alien is really close to us (alien.zpos < 1 ), and if it is, we check if it is within the bounds of the STRIKE_ZONE area. The strike zone is a rectangle - you could implement more complex shapes if you wanted to be more accurate about where the alien can hit. However, a rectangle approximation is OK for this game.

If the alien is close enough, and within our strike zone, we use the shake effect to make it “feel” like we’ve been hit. Then we create an explosion at the point of impact for some visual effects.

Colliding

Click to open gif

Finishing up the game

Congratulations, we’ve got all the main elements of flying and shooting and damage in the game. The next thing to do would be to add a scoring system, and a way to reduce the spaceship’s health or shield when it gets hit. You can look at the tutorial for the 2D version of this game, and copy the scoring and health code from there into this game. You can also copy the code for background music and more sound effects.

Happy coding and have fun!

Credits

The game art and sounds used in this tutorial are from the following sources:

Laser : https://freesound.org/people/sunnyflower/sounds/361471/

Explosion: https://freesound.org/people/tommccann/sounds/235968/

Alien Bug: https://opengameart.org/content/8-bit-alien-assets

The spaceship cockpit was made by Ritza.

Thank you to all the creators for putting their assets up with a Creative Commons license and allowing us to use them.

Code

You can find the code for this tutorial on Replit

Building Asteroids with Kaboom.js

Following our previous tutorial on building Snake, in this tutorial we’ll implement Asteroids, one of the first major arcade game hits, developed by Atari in 1979. We’ll use Replit’s built-in game development platform, Kaboom.js, and cover the following topics:

- Getting set up in Kaboom.js.

- Calculating movement angles for our spaceship and bullet mechanics.

- Random generation of asteroid placement.

- Defining the effects of object collisions.

- Developing a polished game with animation and sound effects.

Our finished game will look like this:

The finished game

Click to open gif

We will use these Asteroids sprites and this space background from OpenGameArt.org, and the following sounds from FreeSound.org:

- Laser

- Explosion

- Rocket thrust

We will also be using music by Eric Matyas of Soundimage.org.

We’ve created a single ZIP file with the sprites and sounds you will need for this tutorial, which you can download here.

Creating a new project and loading assets

Log into your Replit account and create a new repl. Choose Kaboom as your project type. Give this repl a name, like “asteroids”.

Creating an REPL

Kaboom repls are quite different from other kinds of repls you may have seen before: instead of dealing directly with files in folders, you’ll be dealing with scenes, sounds and sprites.

Before we start coding, we need to upload our sprites and sounds. Download this ZIP file and extract it on your computer. Click the “Files” icon on the sidebar then, upload everything in Sounds folder to the “sounds” section of your repl, and everything in the Sprites folder to the “sprites” section of your repl.

Uploading sprites

Click to open gif

Once you’ve uploaded the files, you can click on the “Kaboom” icon in the sidebar, and return to the “main” code file.

Setting up Kaboom

To start, we need to initialise and set up Kaboom with the scale we want for the game window. Replace the code in main.js with the code below:

1 import kaboom from "kaboom";
2
3 kaboom({
4   scale: 1.5
5 });

This will give us a blank canvas with a nice checkerboard pattern.

Now, let’s load up the sprites and sound files to make them available in the game. This code loads each of the graphic elements we’ll use, and gives them a name so we can refer to them when we build the game objects:

1 loadRoot("sprites/");
2 loadSprite("space", "space.jpg");
3 loadSprite("rocket1", "rocket1.png");
4 loadSprite("rocket2", "rocket2.png");
5 loadSprite("rocket3", "rocket3.png");
6 loadSprite("rocket4", "rocket4.png");
7 loadSprite("ship", "ship.png");
8 loadSprite("bullet", "bullet.png");
9 loadSprite("asteroid", "asteroid.png");
10 loadSprite("asteroid_small1", "asteroid_small1.png");
11 loadSprite("asteroid_small2", "asteroid_small2.png");
12 loadSprite("asteroid_small3", "asteroid_small3.png");
13 loadSprite("asteroid_small4", "asteroid_small4.png");
14
15 loadRoot("sounds/");
16 loadSound("rocket_thrust", "rocket_thrust.wav");
17 loadSound("laser", "laser.wav");
18 loadSound("explosion", "explosion.mp3");
19 loadSound("Steamtech-Mayhem_Looping","Steamtech-Mayhem_Looping.mp3");

The first line, loadRoot, specifies which folder to load all the sprites and game elements from, so we don’t have to keep typing it in for each sprite. Then each line loads a game sprite and gives it a name so that we can refer to it in code later. We also use similar code to load sound elements for our game using the loadSound function.

Setting the scene

A Kaboom.js game is made up of scenes, which you can think of as different screens, levels or stages. You can use scenes for game levels, menus, cut-scenes and any other screens your game might contain. In this tutorial, we’ll just use one scene, which will contain the entire game logic.

Scenes are further divided into layers, which are populated by game objects (also called sprites). The layer an object is on will determine when it gets drawn and which other objects it can collide with. In this game, we’ll use three layers: the background layer (bg), the object layer (obj), and UI layer (ui). To initialize these layers, add the code below to the bottom of the main.js file:

1 scene("main", ()=> {
2   layers([
3     "bg",
4     "obj",
5     "ui",
6   ], "obj");
7
8   // add more scene code here
9 });
10
11 go("main");

On the first line we define the main scene, and then we declare the game layers.
These layers will be drawn in the order declared. The majority of gameplay will happen in the obj layer, so we’ve set that as the default layer. Any objects we create will be placed in this layer, unless we specify a different layer when we create the object.

Note: The code snippets in the sections that follow should be added within the body of the main scene unless specified otherwise.

The bg layer will be drawn first, beneath everything else, and we’ll use that to specify a background image for our game. Do that now by adding the following code to within the body of the main scene.

1 // Background
2 add([
3     sprite("space"),
4     layer("bg")
5 ]);

Here we’re adding a very simple game object: the space sprite we uploaded earlier on the background layer. Later game objects, such as the player’s ship and the asteroids, will be more complicated.

The final layer, ui, is where we will display information such as the player’s remaining lives and total score. Let’s draw the score now. First, we have to declare a global variable named score, with the following line:

1 let score = 0;

Now we’ll create an empty object on the UI layer, as follows:

1 // UI
2 ui = add([
3     layer("ui")
4 ]);

Although Kaboom allows us to create objects that display text, this text is set once at object creation and has to be updated manually, which doesn’t really make sense for a real-time UI. Instead of doing that, we’ll use our ui object’s draw event callback to draw text containing the current score. Add the following code:

1 ui.on("draw", () => {
2   drawText({
3     text: "Score: " + score,
4     size: 14,
5     font: "sink",
6     pos: vec2(8, 24)
7   })
8 });

Callbacks are a key concept in JavaScript and Kaboom makes heavy use of them. Some callbacks, such as the one above, are called on every frame of the game (around 60 times a second). Others are called when specific events happen, such as user keypresses. Because this callback will be invoked so often, our score will always be up to date.

Run your repl now and marvel at the vast emptiness of space.

Empty with score

The player’s ship

Now let’s populate that empty space. Enter the following code below the UI drawing code to create the player:

1 // The ship
2 const player = add([
3     sprite("ship"),
4     pos(160, 120),
5     rotate(0),
6     origin("center"),
7     solid(),
8     area(),
9     "player",
10     "mobile",
11     "wraps",
12     {
13         turn_speed: 4.58,
14         speed: 0,
15         max_thrust: 48,
16         acceleration: 2,
17         deceleration: 4,
18         lives: 3,
19         can_shoot: true,
20         laser_cooldown: 0.5,
21         invulnerable: false,
22         invulnerablity_time: 3,
23         animation_frame: 0,
24         thrusting: false
25     }
26 ]);

Here we’re creating a game object with a number of components, each of which give our object some data or behavior. These are:

- The sprite component, which draws the ship sprite.

- The pos (position) component, which places the player near the center of the screen in the Replit browser.

- The rotate component, which will allow the player to turn the ship with the left and right arrow keys.

- The origin component, which sets the sprite’s origin to “center”, so that when we rotate the ship, it will rotate around the middle of its sprite rather than the default top-left corner.

- The area component, which generates the collision area for the ship to detect collisions.

Following that initial configuration, we’re giving the player object three tags: player, mobile and wraps. Kaboom uses a tagging system to apply behavior to objects – you can think of this as similar to multiple inheritance. By tagging objects with shared behavior, we can save ourselves from duplicating code.

Finally, we assign a number of custom properties to our player object. We’ll use these properties to handle a variety of gameplay functions, such as moving and shooting.

If you run your repl now, you should see the ship sprite in the middle of a blank screen. In the next section, we’ll implement movement controls.

Ship in space

Movement controls

In this game, our player will turn the nose of the spaceship with the left and right arrow keys, and thrust forward and backward with the up and down arrow keys. We’ll handle movement in two phases: user input, and actually moving the ship. First, let’s allow the player to turn their ship to the left and right. Add the following code:

1 // Movement keys
2 onKeyDown("left", () => {
3     player.angle -= player.turn_speed;
4 });
5 onKeyDown("right", () => {
6     player.angle += player.turn_speed;
7 });

Run your repl now, and you should be able to turn the ship to the left and right by pressing the respective arrow keys. If you think it turns too fast or too slow, change the value of turn_speed in the player creation code.

Now let’s implement the up and down keys, so the player can move around the scene. Enter the following code beneath the player turning code:

1 onKeyDown("up", () => {
2     player.speed = Math.min(player.speed+player.acceleration, player.max_thrust);
3     play("rocket_thrust", {
4         volume: 0.1,
5         speed: 2.0,
6     });
7 });

Rather than having the spaceship go from zero to max speed immediately, we want to simulate a gradual acceleration to our max speed. To achieve this, we use Math.min() to set the player’s speed to the minimum value between current speed plus acceleration and its maximum speed. This will make the ship gradually increase in speed until it reaches max_thrust. Play around with the values of acceleration and max_thrust in the player creation code and see what feels right to you.

Additionally, we set our rocket thrust sound to play while accelerating. Kaboom allows us to manipulate sounds in a few different ways, such as changing their speed and volume.

We’ll handle deceleration by doing the opposite to acceleration, using Math.max() to choose the maximum between the player’s speed minus their deceleration, and their maximum speed in reverse (i.e. negative). Add the following code below the acceleration controls:

1 onKeyDown("down", () => {
2     player.speed = Math.max(player.speed-player.deceleration, -player.max_thrust);
3     play("rocket_thrust", {
4         volume: 0.2,
5         speed: 2.0,
6     });
7 });

If you run your repl now and try to accelerate or decelerate, the sound will play, but the ship will go nowhere. This is because we’re manipulating player.speed, which is a custom property that Kaboom hasn’t attached any special behavior to (unlike player.angle). Let’s add some movement parameters now.

Movement

Movement in Kaboom and most other 2D game development systems is handled using X and Y coordinates on a plane. To move an object left, you subtract from its X coordinate, and to move it right, you add to its X coordinate. To move an object up, you subtract from its Y coordinate, and to move it down, you add to its Y coordinate. Therefore, basic four-directional movement in games like Snake or Pacman is quite straightforward. The directional movement we need for Asteroids (commonly called tank controls) is more complex, requiring some calculations.

At a high level, we want to move a given distance (player.speed) in a given direction (player.angle). As a first step, let’s create an onUpdate callback for our mobile tag. This code, like our UI drawing code above, will be run by every object with the “mobile” tag on every frame of the game, so we can use it for more than just the player object. Add the following code at the bottom of your main scene:

1 // Movement
2 onUpdate("mobile", (e) => {
3   e.move(pointAt(e.speed, e.angle));
4 });

First, we move our object, using the function pointAt(), which takes a speed and an angle and returns the corresponding X and Y co-ordinates as a vec2 object, Kaboom’s 2D vector type. This data type is provided by Kaboom specifically for working with X and Y coordinates, and comes with a number of useful functions, such as addition and subtraction.

Now we need to create the pointAt() function. Before we jump into the code, let’s think about the problem. Our movement can be drawn as an angled line on a plane, and its X and Y coordinates as horizontal and vertical lines connected to it, giving us a right-angled triangle.

Triangle

We know the values of the triangle’s angles: one is 90 degrees, and the other two are equal to player.angle. The length of the triangle’s hypotenuse (the red line in the diagram above) is player.speed: this is the distance our ship will be traveling.

We now need to calculate the lengths of the other two sides to get the X and Y values for our movement vector. We can do this using the trigonometric sine and cosine functions, like so:

1 sine(angle) = y / distance
2 y = distance * sin(angle)
3
4 cosine(angle) = x / distance
5 x = distance * cos(angle)

Remember, in Kaboom and most other 2D game development platforms, the Y axis is inverted, so we have to make it negative. Add the pointAt() function below, at the top of the main scene code.

1 function pointAt(distance, angle) {
2   let radians = -1*deg2rad(angle);
3   return vec2(distance * Math.cos(radians), -distance * Math.sin(radians));
4 }

Run your repl now and take the ship for a spin. You should be able to thrust forward and backward, moving according to where the ship’s nose is pointing.

There’s one little problem though: thrust too long, and you’ll fly off the screen. In any other game, we might solve that by enclosing the play area with walls, but that doesn’t seem quite right in the infinite expanse of space. The original Asteroids solved this by having the player and other key objects wrap around the screen, i.e. appear at the bottom after going over the top, or at the left edge after going past the right edge. Let’s implement that now, using the “wraps” tag we assigned to our player object when we created it. Add the following code to the bottom of your main scene:

1 // Wrap around the screen
2 onUpdate("wraps", (e) => {
3     if (e.pos.x > width()) {
4         e.pos.x = 0;
5     }
6     if (e.pos.x < 0) {
7         e.pos.x = width();
8     }
9     if (e.pos.y > height()) {
10         e.pos.y = 0;
11     }
12     if (e.pos.y < 0) {
13         e.pos.y = height();
14     }
15 });

This is a fairly straightforward piece of code that checks whether an object’s position is outside of the room and, if so, places it on the other side. The width() and height() functions are Kaboom built-ins that return the size of the game canvas. Run your repl now and try it out.

Rocket animation

Our ship can move now, but it would be nice to see its rockets firing when the player presses the up arrow key, as well as hear them. Reactive animations like these make games feel more responsive and look more dynamic.

Kaboom has methods for handling animations when a game object uses an animated sprite (i.e. one with multiple images), but this isn’t quite what we want here. Rather than animating the player’s ship, we need to animate the rocket thrust that appears behind it when a thrusting key is pressed, so we’ll need to handle all the animation code ourselves. Luckily, Kaboom makes this fairly simple.

First, let’s create an array with our four rocket sprites. Add the following code at the bottom of your main scene:

1 // Animate rocket
2 const thrust_animation = ["rocket1", "rocket2", "rocket3", "rocket4"];

Then we need some way to indicate when to start and stop the animation. We can use Kaboom’s onKeyPress and onKeyRelease events for this, as well as two of the properties we defined for our player (thrusting and animation_frame). Add the following code:

1 // rocket animation helpers
2 onKeyPress("up", () => {
3     player.thrusting = true;
4     player.animation_frame = 0;
5 });
6 onKeyRelease("up", () => {
7     player.thrusting = false;
8 });

Now let’s draw the animation. We’ll use a draw event callback, which lets us make the player draw other things in addition to its own sprite. Add the following code:

1 // draw current rocket animation frame
2 onDraw("player", (p) => {
3   if (player.thrusting) {
4     // draw current frame
5     drawSprite( {
6       sprite: thrust_animation[p.animation_frame],
7       pos: p.pos.add(pointAt(-p.height/2 , p.angle)),
8       origin: "center",
9       angle: p.angle
10     });
11   }
12 });

Here we’re using our pointAt function again, but this time we’re looking for the rocket end of the ship, rather than its nose. We use our thrust_animation array in conjunction with the player’s animation_frame value to figure out which rocket image to draw.

To actually make the rocket animate (i.e. cycle through animation frames), we’ll use Kaboom’s onUpdate function, and create a callback that changes the animation frame every 0.1 seconds. Add the following code:

1 let move_delay = 0.1;
2 let timer = 0;
3 // loop rocket animation
4 onUpdate(() => {
5   timer += dt();
6   if (timer < move_delay) return;
7   timer = 0;
8
9   if (player.thrusting) {
10     player.animation_frame++;
11     if (player.animation_frame >= thrust_animation.length) { // wrap to start
12       player.animation_frame = 0;
13     }
14   }
15 });

That’s it! Run your repl and move your ship around.

Ship thrusting

Shooting

To make our ship fire laser bullets, we need to create bullet objects just in front of the ship’s nose, and have them travel in the same direction the ship is pointing. Here we can reuse our new pointAt() function. Add the following code beneath the existing movement control code:

1 // Shooting
2 onKeyDown("space", () => {
3     add([
4         sprite("bullet"),
5         pos(player.pos.add(pointAt(player.width/2, player.angle))),
6         rotate(player.angle),
7         origin("center"),
8         area(),
9         "bullet",
10         "mobile",
11         "destructs",
12         {
13             speed: 100
14         }
15     ]);
16     play("laser");
17 });

Here we’re creating a bullet object at the tip of the ship’s nose. We calculate the position of the tip by running our pointAt() function with a distance of half the ship sprite’s width, and the sprite’s angle. We use half the sprite’s width because the ship sprite’s origin is at its center. Additionally, we rotate the bullet according to the ship’s angle (again using center as the origin) and assign a number of tags to it. Note that because we’re tagging it with “mobile” and giving it both a rotate component and a speed property, it will use the same movement code we wrote for our player object.

Try the game out now, and hold down the spacebar to shoot. Do you notice anything about your ship’s firing rate?

Too many bullets

At the moment, a bullet object will be created in every frame while space is down. That’s a lot of bullets, and might make the game too easy, as well as slowing it to a crawl on less capable devices. We need to add a cooldown period, and we can do so by altering our shooting code to look like this:

1 // Shooting
2 onKeyDown("space", () => {
3     if (player.can_shoot) { // new if statement
4         add([
5             sprite("bullet"),
6             pos(player.pos.add(pointAt(player.width/2, player.angle))),
7             rotate(player.angle),
8             origin("center"),
9             area(),
10             "bullet",
11             "mobile",
12             "destructs",
13             {
14                 speed: 100
15             }
16         ]);
17         play("laser");
18         player.can_shoot = false; //
19         wait(player.laser_cooldown, () => {
20             player.can_shoot = true;
21         });
22     }
23 });

Here, we use two of the properties we defined in the player object above, can_shoot and laser_cooldown, to implement a cooldown mechanism. We will only create a bullet if can_shoot is true, and we set it to false immediately after each shot. Then we use Kaboom’s wait timer to set it to true after laser_cooldown number of seconds. Because this timer is an asynchronous callback, the rest of the game can continue while the laser cooldown period passes.

Run your repl and test whether the ship’s laser fires at the expected intervals.

Laser rate

The asteroids

Now that we’ve added shooting, we need to add something to shoot at. It’s time to create the asteroids this game gets its name from.

Creation

Add the following code at the bottom of the main scene:

1 // Asteroids
2 const NUM_ASTERIODS = 5;
3
4 for (let i = 0; i < NUM_ASTERIODS; i++) {
5     var spawnPoint = asteroidSpawnPoint();
6     var a = add([
7         sprite("asteroid"),
8         pos(spawnPoint),
9         rotate(rand(1,90)),
10         origin("center"),
11         area(),
12         solid(),
13         "asteroid",
14         "mobile",
15         "wraps",
16         {
17             speed: rand(5, 10),
18             initializing: true
19         }
20     ]);
21
22     a.pushOutAll();
23
24 }

Here we’re creating a constant number of asteroids, and assigning them a random position, direction of movement and speed. The asteroid creation code is largely similar to our player creation code, but with fewer custom properties. One key difference is the presence of the solid component, which marks the asteroid as a solid object that other objects shouldn’t be able to pass through.

The one custom property that’s unique to asteroids is initializing. Because we’re spawning each asteroid in a random position, there’s a chance we might spawn one on top of another, or on top of the player.

One approach to avoiding this might be to ensure we don’t spawn any two asteroids at the same coordinates, but we might still end up spawning them close enough to overlap with each other. We would then need to take into account the size of asteroids and prevent asteroids from spawning at any of those coordinates, and our code would quickly become complicated.

Instead of doing that, we can leverage Kaboom’s collision detection to achieve the same effect. Right after we create the asteroid, we can check if it’s overlapping with another “mobile”-tagged object (i.e. another asteroid, or the player’s ship), and if so, we randomise its position again. We can use a while loop to repeat this action until our asteroid lands up in a free space. Add the following code inside the asteroid creation for-loop, below the add function:

1     while (a.isColliding("mobile")) {
2         spawnPoint = asteroidSpawnPoint();
3         a.pos = spawnPoint;
4         a.pushOutAll();
5     }
6
7     a.initializing = false;

We want the asteroid to be in an “initializing” state while we’re finding its starting position. When we implement its actions later on, we’ll check the value of its initializing property to prevent it from harming the player or affecting other asteroids while it’s still spawning.

Before we move on, let’s implement the asteroidSpawnPoint() function. Add the following code near the top of the main scene, just beneath the pointAt() function:

1 function asteroidSpawnPoint() {
2      // spawn randomly at the edge of the scene
3      return choose([rand(vec2(0), vec2(width(), 0)),
4              rand(vec2(0), vec2(0, height())),
5              rand(vec2(0, height()), vec2(width(), height())),
6              rand(vec2(width(), 0), vec2(width(), height()))]);
7 }

This function uses Kaboom’s choose() and rand() functions to choose a random location on the edge of the scene to spawn an asteroid.

Collisions

If you’ve seen any movies set in outer space, you’ll know that the main thing asteroids do is crash into spaceships and each other, even though the real-life asteroid belt in our solar system is not nearly dense enough for asteroid collisions to be a frequent occurrence.

There are three types of collisions we need to account for:

- Player and asteroid, which damages the player, causing them to lose a life.

- Bullet and asteroid, which destroys the asteroid.

- Asteroid and asteroid, which causes both asteroids to bounce off each other.

Let’s go through the code for each of these, adding it to our main scene just below the code we wrote to make objects wrap around the screen. First, player and asteroid:

1 // Collisions
2 onCollide("player", "asteroid", (p, a) => {
3     if (!a.initializing) {
4         p.lives--;
5     }
6 });

This code reduces the player’s lives by one as long as the asteroid is not initializing.

Next, add the code for collisions between a bullet and an asteroid:

1 onCollide("bullet", "asteroid", (b, a) => {
2     if (!a.initializing) {
3         destroy(b);
4         destroy(a);
5         play("explosion");
6         score++;
7     }
8 });

This very simple code destroys both the bullet and the asteroid, plays an explosion sound, and increments the game score.

Finally, add the following to handle asteroid collisions.

1 onCollide("asteroid", "asteroid", (a1, a2) => {
2     if (!(a1.initializing || a2.initializing)) {
3         a1.speed = -a1.speed;
4         a2.speed = -a2.speed;
5     }
6 });

This code makes the asteroids appear to bounce off each other by reversing their movement direction.

Run the game now and see what happens when you ram your ship into some asteroids!

Asteroids

Ending the game

The player’s ship can now lose lives by crashing into asteroids, but this doesn’t mean much at the moment, as we haven’t added any code to end the game if the player runs out of lives, or even to display the lives remaining. Let’s do that now. First, let’s change the code in the player–asteroid collision to trigger a custom “damage” event instead of just subtracting a life.

1 // Collisions
2 onCollide("player", "asteroid", (p, a) => {
3     if (!a.initializing) {
4         p.trigger("damage"); // previously lives--
5     }
6 });

Now we can add the code to handle this event just below the collision code:

1 // Take damage
2 player.on("damage", () => {
3     player.lives--;
4
5     // destroy ship if lives finished
6     if (player.lives <= 0) {
7         destroy(player);
8     }
9 });

When objects are destroyed in Kaboom, the “destroy” event is triggered. We’ll use this event to show a game over screen with the player’s score by adding the following code:

1 // End game on player destruction
2 player.on("destroy", () => {
3     add([
4         text(`GAME OVER\n\nScore: ${score}\n\n[R]estart?`, { size: 20 }),
5         pos(width()/2, height()/2),
6         layer("ui")
7     ]);
8 });

We need to give the player a way to restart the game and try again. Add the following code just below the previous block:

1 // Restart game
2 onKeyPress("r", () => {
3     go("main");
4 });

The go function is a Kaboom built-in for moving between scenes. As this game only has one scene, we can use it to reset the scene and thus the game to its initial state.

Lastly, we need to add the player’s lives to the game UI, so they know how much more damage they can afford to take. Find the ui object code near the top of the main scene and alter it to resemble the below:

1 ui.on("draw", () => {
2   drawText({
3     text: "Score: " + score,
4     size: 14,
5     font: "sink",
6     pos: vec2(8, 24)
7   })
8
9   // lives (new code below)
10   drawText({
11     text: "Lives: ",
12     size: 12,
13     font: "sink",
14     pos: vec2(8),
15   });
16   for (let x = 64; x < 64 + (16 * player.lives); x += 16) {
17     drawSprite({
18       sprite: "ship",
19       pos: vec2(x, 12),
20       angle: -90,
21       origin: "center",
22       scale: 0.5
23     });
24   }
25 });

This code draws a number of scaled down player spaceships equal to the number of remaining lives.

Lives

Final touches

Our game is fully playable now, but it’s still missing some niceties, and one core gameplay feature that you should be able to identify if you’ve played Asteroids before. In this final section, we’ll add the following:

- Some background music.

- Smaller, faster asteroids that our large asteroids break into when destroyed.

- Temporary invulnerability for the player for a few seconds after they take damage.

Background music

Add the following code somewhere in your main scene.

1 // Background music
2 const music = play("Steamtech-Mayhem_Looping");
3 music.loop();

The first line plays the piece Steamtech Mayhem from Soundimage.org and the second line will ensure that it repeats as long as the game is running.

Smaller asteroids

To create smaller asteroids when a large asteroid is destroyed, we’ll use a destroy event callback, which will run every time an asteroid is destroyed. Add the following code to the bottom of your main scene:

1 // Asteroid destruction
2 on("destroy", "asteroid", (a) => {
3     if (!a.is("small")) {
4         // create four smaller asteroids at each corner of the large one
5         positions = [a.pos.add(vec2(a.width/4, -a.height/4)),
6                     a.pos.add(vec2(-a.width/4, -a.height/4)),
7                     a.pos.add(vec2(-a.width/4, a.height/4)),
8                     a.pos.add(vec2(a.width/4, a.height/4))];
9
10         // small asteroids move out from the center of the explosion
11         rotations = [16,34,65,87];
12
13         for (let i = 0; i < positions.length; i++) {
14             var s = add([
15                 sprite(`asteroid_small${i+1}`),
16                 pos(positions[i]),
17                 rotate(rotations[i]),
18                 origin("center"),
19                 area(),
20                 solid(),
21                 "asteroid",
22                 "small",
23                 "mobile",
24                 "wraps",
25                 {
26                     speed: rand(15, 25),
27                     initializing: false
28                 }
29             ]);
30
31             s.pushOutAll();
32         }
33     }
34 });

Our small asteroids are mostly similar to our large ones. Differences include the addition of the small tag, the less random approach to initial placement, the higher speed, and the selection of one of four different possible small asteroid sprites.

To make our game true to the original Asteroids, we should give the player more points for destroying these fast, small asteroids. Find and modify the bullet–asteroid collision code as below:

1 onCollide("bullet", "asteroid", (b, a) => {
2     if (!a.initializing) {
3         destroy(b);
4         destroy(a);
5         play("explosion");
6         score = a.is("small") ? score + 2 : score++; // 2 points for small, 1 for big
7     }
8 });

Small asteroids

Temporary invulnerability

A nice touch to make our game a little more forgiving is temporary invulnerability for the player after they lose a life. We can add this by finding and altering the player’s damage event callback as follows:

1 // Take damage
2 player.on("damage", () => {
3     if (!player.invulnerable) { // new if statement
4         player.lives--;
5     }
6
7     // destroy ship if lives finished
8     if (player.lives <= 0) {
9         destroy(player);
10     }
11     else // new code
12     {
13         // Temporary invulnerability
14         player.invulnerable = true;
15
16         wait(player.invulnerablity_time, () => {
17             player.invulnerable = false;
18             player.hidden = false;
19         });
20     }
21 });

Here we’re making the player invulnerable and then using a wait callback to make them vulnerable again after a given number of seconds, similar to what we did for the laser timeout. We’re also making sure the player is visible by setting player.hidden to false, because the way we’re going to indicate the player’s invulnerability is by having their ship flash rapidly. Find and update the onUpdate event callback we added earlier for rocket thrust animation :

1 onUpdate(() => {
2   timer += dt();
3   if (timer < move_delay) return;
4   timer = 0;
5
6   if (player.thrusting) {
7     player.animation_frame++;
8     if (player.animation_frame >= thrust_animation.length) { // wrap to start
9       player.animation_frame = 0;
10     }
11   }
12
13   // new if statement
14   if (player.invulnerable) {
15     player.hidden = !player.hidden;
16   }
17 });

Where to next?

We’ve covered a lot of ground in this tutorial and touched on a lot of Kaboom’s features. From here, you can start making your own games with Kaboom, or if you want to extend this one, here are some ideas:

- Power-ups, such as extra lives and time-limited weapon upgrades.

- Enemy spaceships that fire at the player.

- A third, even smaller size of asteroid.

- More animations, for explosions and laser firing.

Code

You can find the code for this tutorial on Replit

Building a Mario-like side-scroller with Kaboom.js

The Mario series is one of the most known and loved game series of all time. The first Mario game was released by Nintendo in the mid-80s, and people haven’t stopped playing Mario since.

Tons of games still use the basic side-scroller formula of Mario, so it’s a good game to build to learn the basics of game making. We’ll build it in the new Kaboom game engine. Kaboom has many useful functions for building platform games, and we’ll try to go through as many as we can in this tutorial.

The finished game

Click to open gif

Designing the game

We’d like to make a game that has the Mario essence. This means we need a few things:

- The ability to jump and bump into reward boxes.

- A big and small character type.

- The ability to attack enemies by jumping on them.

- The classic Mario scrolling and camera motion.

For the graphics, we will use a tile set from this creator.

Creating a new project in Replit

Head over to Replit and create a new repl. Choose Kaboom as your project type. Give this repl a name, like “Mario”.

New Mario repl

After the repl has booted up, you should see a main.js file under the “Code” section. This is where we’ll start coding. It already has some code in it, but we’ll replace that.

Download this archive of sprite and asset files that we’ll need for the game, and unzip them on your computer. In the Kaboom editor, click the “Files” icon in the sidebar. Now drag and drop all the sprite and asset files into the “sprites” folder. Once they have uploaded, you can click on the “Kaboom” icon in the sidebar, and return to the “main” code file.

Uploading sprites

Click to open gif

Setting up Kaboom

To start, we need to set up Kaboom with the screen size and colors we want for the game window. Replace the code in main.js with the code below:

1   import kaboom from "kaboom";
2
3   kaboom({
4     background: [134, 135, 247],
5     width: 320,
6     height: 240,
7     scale: 2,
8   });

This creates a new Kaboom canvas with a nice Mario sky-blue background. We also set the size of the view to 320x240 pixels, which is a very low resolution for a modern game, but the right kind of pixelation for a Mario-type remake. We use scale to make the background twice the size on screen - you can increase this value if you have a monitor with very high resolution. Click the “Run” button, and you should see a lovely blue sky in the output window.

Blue sky

Now, let’s load up some of the sprites so we can add them to the blue sky scene. This code loads each of the graphic elements we’ll use, and gives them a name so we can refer to them when we build the game characters:

1 loadRoot("sprites/");
2 loadAseprite("mario", "Mario.png", "Mario.json");
3 loadAseprite("enemies", "enemies.png", "enemies.json");
4 loadSprite("ground", "ground.png");
5 loadSprite("questionBox", "questionBox.png");
6 loadSprite("emptyBox", "emptyBox.png");
7 loadSprite("brick", "brick.png");
8 loadSprite("coin", "coin.png");
9 loadSprite("bigMushy", "bigMushy.png");
10 loadSprite("pipeTop", "pipeTop.png");
11 loadSprite("pipeBottom", "pipeBottom.png");
12 loadSprite("shrubbery", "shrubbery.png");
13 loadSprite("hill", "hill.png");
14 loadSprite("cloud", "cloud.png");
15 loadSprite("castle", "castle.png");

The first line, loadRoot, specifies which folder to load all the sprites and game elements from, so we don’t have to keep typing it in for each sprite. Then each line loads a game sprite and gives it a name so that we can refer to it in code later.

Notice that the mario and enemies sprites are loaded with the function loadAseprite, and have an extra parameter specifying a .json file. This extra file is in a file format made by Aseprite, which is a pixel art and animation app. If you open the Mario.png file, you’ll see that it has many different images of Mario in different positions, which are frames of Mario animations. The .json file from Aseprite contains all the information needed to animate Mario in our game. Kaboom knows how to interpret this file, and we can pick which animation we want to run at any time by choosing one from the frameTags list in the .json file and using the .play() method on a sprite. We can also choose a particular frame to show at any time, using the sprite’s .frame property, and specifying the frame number to use, starting from 0.

Mario Aseprite file

Creating the level maps

Let’s add 2 levels to start. You can create and add as many levels as you want - that’s one of the great benefits of writing your own game!

Kaboom has a really cool way of defining levels. It allows us to draw a layout of the level using only text. Each letter or symbol in this text map can be mapped to a character in the Kaboom game. In Kaboom, characters are anything that makes up the game world, including floor, platforms, and so on, and not only the players and bots.

Add the following to define the levels:

1 const LEVELS = [
2   [
3     "                                                                               \
4                  ",
5     "                                                                               \
6                  ",
7     "                                                                               \
8                  ",
9     "                                                                               \
10                  ",
11     "                                                                               \
12                  ",
13     "                                                                               \
14                  ",
15     "                                                                               \
16                  ",
17     "      -?-b-                                                                    \
18                  ",
19     "                                                    ?        ?                 \
20                  ",
21     "                                                                               \
22                  ",
23     "                                      _                 ?                      \
24                  ",
25     "                                 _    |                                        \
26                  ",
27     "                           _     |    |                _                       \
28                  ",
29     "       E                   |     |    |   E   E        |                       \
30      H           ",
31     "================     ==========================================================\
32 =================",
33     "================     ==========================================================\
34 =================",
35   ],
36   [
37     "                                                                               \
38               ",
39     "                                                                               \
40               ",
41     "                                                                               \
42               ",
43     "                                       ?                                       \
44               ",
45     "                                                                               \
46               ",
47     "                                   -?-                                         \
48               ",
49     "                                                                               \
50               ",
51     "      -?-b-                  -?-                                               \
52               ",
53     "                                                                               \
54               ",
55     "                                                                               \
56               ",
57     "                                                                               \
58               ",
59     "                                                                               \
60               ",
61     "       _                                            _                          \
62               ",
63     "       |                                            |          E    E          \
64   H           ",
65     "================     ==========================================================\
66 ==============",
67     "================     ==========================================================\
68 ==============",
69   ]
70 ];

Now we can map each symbol and letter in the levels to a character definition:

1 const levelConf = {
2   // grid size
3   width: 16,
4   height: 16,
5   pos: vec2(0, 0),
6   // define each object as a list of components
7   "=": () => [
8     sprite("ground"),
9     area(),
10     solid(),
11     origin("bot"),
12     "ground"
13   ],
14   "-": () => [
15     sprite("brick"),
16     area(),
17     solid(),
18     origin("bot"),
19     "brick"
20   ],
21   "H": () => [
22     sprite("castle"),
23     area({ width: 1, height: 240 }),
24     origin("bot"),
25     "castle"
26   ],
27   "?": () => [
28     sprite("questionBox"),
29     area(),
30     solid(),
31     origin("bot"),
32     'questionBox',
33     'coinBox'
34   ],
35   "b": () => [
36     sprite("questionBox"),
37     area(),
38     solid(),
39     origin("bot"),
40     'questionBox',
41     'mushyBox'
42   ],
43   "!": () => [
44     sprite("emptyBox"),
45     area(),
46     solid(),
47    // bump(),
48     origin("bot"),
49     'emptyBox'
50   ],
51   "c": () => [
52     sprite("coin"),
53     area(),
54     solid(),
55     //bump(64, 8),
56     cleanup(),
57     lifespan(0.4, { fade: 0.01 }),
58     origin("bot"),
59     "coin"
60   ],
61   "M": () => [
62     sprite("bigMushy"),
63     area(),
64     solid(),
65     //patrol(10000),
66     body(),
67     cleanup(),
68     origin("bot"),
69     "bigMushy"
70   ],
71   "|": () => [
72     sprite("pipeBottom"),
73     area(),
74     solid(),
75     origin("bot"),
76     "pipe"
77   ],
78   "_": () => [
79     sprite("pipeTop"),
80     area(),
81     solid(),
82     origin("bot"),
83     "pipe"
84   ],
85   "E": () => [
86     sprite("enemies", { anim: 'Walking' }),
87     area({ width: 16, height: 16 }),
88     solid(),
89     body(),
90     //patrol(50),
91     //enemy(),
92     origin("bot"),
93     "badGuy"
94   ],
95   "p": () => [
96     sprite("mario", { frame: 0 }),
97     area({ width: 16, height: 16 }),
98     body(),
99     //mario(),
100     //bump(150, 20, false),
101     origin("bot"),
102     "player"
103   ]
104 };

That looks like a lot, but it’s really one pattern repeated for each element. Let’s take it apart.

The first 3 lines of the config set the default width and height of each element in the level maps. We use pos to specify where to position the whole map in the Kaboom canvas. This is normally going to be (0,0), i.e. at the top left of the screen.

Next we have definitions for each of the symbols we used in the map. Each definition is a function that returns an array of components. In Kaboom, every character is made up of 1 or more components. Components give special properties to each character. There are built-in components for many properties, like sprite to give the character an avatar, body, to make the character respond to gravity, solid to make the character solid so other characters can’t move through it, and many others.

Kaboom also allows you to write your own custom components to create any property or behavior you like for a character. The components patrol, mario, enemy, and bump are all custom here. You’ll notice that those custom components are commented out (//), as we’ll need to create the implementations for them before we can use them. We’ll do that later in this tutorial.

Adding a scene

Kaboom “scenes” allow us to group logic and levels together. In this game we’ll have 2 scenes:

- A “start” or intro scene, which waits for a player to press a button to start the game. We’ll also return to this scene if the player dies, so they can start again.

- A main “game” scene, which will contain the game levels and all the logic to move Mario, and the logic for the enemies and rewards, etc.

We can use the go function to switch between scenes.

Let’s add the “start” scene, and make the game go to that scene by default:

1 scene("start", () => {
2
3   add([
4     text("Press enter to start", { size: 24 }),
5     pos(vec2(160, 120)),
6     origin("center"),
7     color(255, 255, 255),
8   ]);
9
10   onKeyRelease("enter", () => {
11     go("game");
12   })
13 });
14
15 go("start");

We define the scene using the scene function. This function takes a string as the scene name – we’re calling the scene “start”. We’re using an inline function here, using arrow function notation. You could also use the function keyword, if you’d like to specify a function in that way.

We also add some instruction text in the scene function, using the text component and setting the text’s content and size. The pos component sets the position of the text on the screen, and the origin component specifies that the center of the text should be used to position it. Finally, we set the color of the text to white using the color component, which takes RGB (red, green, blue) values from 0-255.

We also have a call to the function onKeyRelease, which listens for the enter key being pressed. If the enter key is pressed, we go to the main game scene (which we’ll add shortly!).

Finally, we use the go function to go to the start scene when the game starts up.

After copying the code into your repl, press Command + S (Mac) or Control + S (Windows/Linux) to update the output window. You should see something like this:

Start scene

Note that if you push enter now to start the game, you’ll get an error message. Don’t worry, we’ll sort that out soon.

Adding the main game scene

Now let’s get the game scene up and running. Add the code below:

1 scene("game", (levelNumber = 0) => {
2
3   layers([
4     "bg",
5     "game",
6     "ui",
7   ], "game");
8
9
10   const level = addLevel(LEVELS[levelNumber], levelConf);
11
12   add([
13     sprite("cloud"),
14     pos(20, 50),
15     layer("bg")
16   ]);
17
18   add([
19     sprite("hill"),
20     pos(32, 208),
21     layer("bg"),
22     origin("bot")
23   ])
24
25   add([
26     sprite("shrubbery"),
27     pos(200, 208),
28     layer("bg"),
29     origin("bot")
30   ])
31
32   add([
33     text("Level " + (levelNumber + 1), { size: 24 }),
34     pos(vec2(160, 120)),
35     color(255, 255, 255),
36     origin("center"),
37     layer('ui'),
38     lifespan(1, { fade: 0.5 })
39   ]);
40
41   const player = level.spawn("p", 1, 10)
42
43 });

Press Command + S or Control + S now, and push enter at the start screen prompt. You should see our replica of Mario World instead of the error message.

You should also see the enemy character wobble its feet, like it’s trying to walk. This is because when we added the enemy definition E in the level config, we specified in the sprite component that it must use the Walking animation, which is defined in the enemies.json file. Kaboom starts the character using that animation.

Static Mario world

Click to open gif

Ok, back to looking at the code we added and what it does. First, we define a new scene like we did for the start scene. This time, we specify a parameter levelNumber that can be passed to the scene. We give this parameter a default value of 0. This will be the first level in our LEVELS array - remember, arrays start at index 0, so 0 is level 1. This parameter will let us call the same scene again when we get to the end of the level, but with 1 as the parameter so that we can play the next level. You can specify any parameters you like or need when creating a scene, and you can pass values from one scene to another. This is very useful, for example if you want to pass the player score to an end game scene, or pass in player options from the start scene.

Next, we define some layers. Layers allow us to have backgrounds that don’t affect the game - we call that layer bg. We’ll place all the main game objects (like our hero, his enemies, and any other objects he interacts with) on the game layer, and all the UI elements (like current score, health, etc.) on the ui level. We make game the default layer, so if we don’t specify a layer component on a game object, it will be drawn on the game layer.

In the next line of code, we initialize and create the level by calling the addLevel function. Here we pass in the level from the LEVELS array, using the index from the levelNumber parameter we added to the scene. We also pass in the configuration for all the symbols in the level map that we assigned to the levelConf variable. At this point, the map and all the characters in it are drawn to the screen. Note that, because the map is much wider than the size we set in the settings for the Kaboom window, we only see part of the map. This is great, because it will allow us to show more of the map as Mario starts walking.

Then we add a few elements to the background layer - clouds, hills and shrubberies. Note the use of the layer component on these elements. We do this so that they don’t interact with our game objects - they just add some visual interest. You can add as many as you like - the original Mario has them in a repeating pattern across the whole level.

We also add some temporary text to the ui layer to let the player know which level they are on. Notice that we use the lifespan component here to automatically fade out and remove the info text after 1 second.

Finally, we add Mario to the game! We could have added him by placing his symbol, p, on our level map definition. However, by adding him manually to the scene using level.spawn(), we can get a reference to him. This will be useful later when we are dealing with collisions and other interactions. We also set the position we want the character to initially be placed at.

Making Mario move

The scene is all set up, so let’s add in some interaction. The player will use the arrow keys to move Mario left and right, and the space bar to make him jump. We’ll use the onKeyDown function for moving left and right, as we want Mario to keep moving as long as the player holds down either key. Then we can use the onKeyPress function to make Mario jump. The player will need to push the space key each time they want Mario to jump - it’s always fun to smash buttons! Add the following code at the bottom of the game scene:

1     const SPEED = 120;
2
3     onKeyDown("right", () => {
4       player.flipX(false);
5       player.move(SPEED, 0);
6     });
7
8     onKeyDown("left", () => {
9       player.flipX(true);
10       if (toScreen(player.pos).x > 20) {
11         player.move(-SPEED, 0);
12       }
13     });
14
15     onKeyPress("space", () => {
16       if (player.grounded()) {
17         player.jump();
18       }
19     });

Take a look at theonKeyDown handlers "right" and "left". We use the flipX method that is added to the character through the sprite component. If this is true, it draws the sprite as a mirror image. This flip will make Mario face in the correct direction. We call the move method, which is added by the pos component. Our move method takes in the number of pixels to move per second, which we set in the SPEED constant. You might want to move this constant definition nearer to the top of the file, so it’s easier to find if you want to tweak it later.

In the "left" handler, there is also another check. In Mario, you can’t walk back to previous parts of a level once it’s gone off screen. We can simulate this by checking if Mario is near the left edge of the screen. We get Mario’s current position by calling the pos method which is added by the pos component. However, this position will be relative to the whole level, and not just the onscreen view. To help us figure out if Mario is near the edge of the screen, and not just at the beginning of the level, we can use the toScreen function, which converts “game world” or level co-ordinates to actual screen co-ordinates.

When a player releases the space key, we want to make Mario jump. To do this, we can call the jump method, which is added to the character through the body component. However, jump will make the character shoot up, even if it is already in the air. To prevent this double jumping, we first check if the player is standing on some solid object. The body component also adds the grounded() function, which returns true if the player is indeed standing on a solid object.

Press Command + S or Control + S to update the output, and test it out. Mario should move around, but it doesn’t look very natural and Mario-like - yet! Another thing you’ll notice is that the screen does not scroll when Mario walks to the right, so we can’t get to the rest of the level. Let’s fix that first.

Mario moving

Click to open gif

Adding scrolling

Kaboom has a number of functions to control the “camera” of the scene. The camera represents the field of view that the player can see. At the moment, the camera only shows the first part of the level. By using the camPos function, we can move the camera to show more of the level as Mario walks across the scene.

Let’s add this code to the game scene:

1 player.onUpdate(() => {
2   // center camera to player
3   var currCam = camPos();
4   if (currCam.x < player.pos.x) {
5     camPos(player.pos.x, currCam.y);
6   }
7 });

Here we add a handler to the onUpdate event for the player. This is called for each frame that is rendered. In this handler, we get the camera’s current position by calling the camPos function without any arguments. Then we can check if Mario is further to the right of the scene than the camera is. If he is, then we set the camera’s X position to that of Mario, so essentially the camera is following Mario. We only do this if he is further to the right of the camera, and not for positions further to the left. This is because we don’t want the player to be able to go back on a level.

Update the output again and test it out. As you move Mario past the center of the screen, the camera should start following him, giving the sense of scrolling.

Scrolling

Click to open gif

Creating a custom component

To add more abilities to the characters and features to the game, we’ll use components that we create ourselves and can add to our games objects. To define a new component in Kaboom, we make a function that returns an object with a few required fields and methods. Here’s a template to illustrate code for a custom component (not part of the game code):

1 function customComponent(args) {
2   return {
3     id: "name",
4     require: ["component1", "component2",],
5     add() {
6     },
7     update() {
8     },
9   };
10 }

In the object we return, Kaboom requires an id, which is a unique name for the component. Kaboom also needs a require property, which is a list of other components this component needs in order to work. When a component is first initialized on a game object, Kaboom calls the add() method so we have the opportunity to run any setup code we need. The method update() is called on every game frame, so we can make animation and collision updates there.

One behavior we need is for the enemy characters to walk up and down, instead of just standing in one place. Let’s make a custom component we can add to our enemy characters so that they automatically move back and forth, or patrol their part of the level. Add the code below to the bottom of main.js:

1 function patrol(distance = 100, speed = 50, dir = 1) {
2   return {
3     id: "patrol",
4     require: ["pos", "area",],
5     startingPos: vec2(0, 0),
6     add() {
7       this.startingPos = this.pos;
8       this.on("collide", (obj, side) => {
9         if (side === "left" || side === "right") {
10           dir = -dir;
11         }
12       });
13     },
14     update() {
15       if (Math.abs(this.pos.x - this.startingPos.x) >= distance) {
16         dir = -dir;
17       }
18       this.move(speed * dir, 0);
19     },
20   };
21 }

We’ve called this custom component “patrol”, as it will make a character move some distance, at a set speed, and then turn around and walk in the opposite direction, dir. The character will also turn and move in the opposite direction if it collides with another game object. Because we use the move method (which is part of the pos component), and the collide event handler (which is part of the area component), we add the pos and area components to the require list.

When the component is first initialized, we want to record its starting position. This is so that we know how far the character is from where it started off, and therefore we’ll know when we must turn it to move in the opposite direction. We do this by making use of the add() method. As we mentioned above, Kaboom will call this method on our component when the character is added to the scene. We read the position of the character at that time by calling this.pos: this refers to our character (as our component is made part of the character, this is reference to the combination of all the components making up the character). We can save this initial position to a property of the component object, in our case one called startingPos. We then attach a collide handler, so we know if the character bumps into anything, so we can turn it and move it in the opposite direction again.

The collide handler has 2 arguments passed to it: the obj that our character collided with, and the side of character that was hit. We only want to flip the direction our character moves in if it was hit from the sides left and right. To change the direction, we flip the sign of our dir variable, which we’ll use in the update() method.

The update() method is called for each frame. In it, we first check if the character is further than the specified maximum distance from its starting position. If it is, we switch the sign of the dir variable to make the character move in the opposite direction. Note that we we use the Math.abs function, which returns the absolute value of a number. The absolute value of a number is always positive, and this allows us to compare it to our distance variable, which is also a positive value. Then we call the move method (provided by the pos component) and use the speed variable (passed in when the component was created) along with the dir variable to specify how fast and in which direction to move the character for this frame.

Now that we’ve created this component, let’s use it on a character. Uncomment the lines //patrol wherever you see it in the levelConf setup we created. Update the output and test it out. You should see the enemy character walk back and forth.

Patrol component

Click to open gif

You’ll notice we also make use of the patrol component on the bigMushy character, which we’ll use to make Mario grow from small Mario to big Mario. We’ll get to that in a bit.

Creating a custom component for the enemies

Now that the enemies are moving around, we can give them some more behaviors and properties. One of the most important things to do is to squash the enemies if Mario jumps on them. If you take a look at the enemies.png sprite file, you’ll see that the 3rd frame (index 2) is an image of the enemy, but squashed.

Enemies-index

We can swap out the animation that is played when the enemy is patrolling for this frame. Kaboom has a built in lifespan component that also has a fade out function. This component allows us to slowly fade out the squashed enemy from the scene, and then automatically remove it entirely once the specified lifetime is reached. Kaboom also allows us to dynamically add and remove components from characters using the use and unuse methods. These methods are not yet documented, but you can find them (and more tricks!) by looking at the Kaboom source code. Let’s use this knowledge to build a custom component to handle the enemy getting squashed and fading out of the scene:

1 function enemy() {
2   return {
3     id: "enemy",
4     require: ["pos", "area", "sprite", "patrol"],
5     isAlive: true,
6     update() {
7
8     },
9     squash() {
10       this.isAlive = false;
11       this.unuse("patrol");
12       this.stop();
13       this.frame = 2;
14       this.area.width = 16;
15       this.area.height = 8;
16       this.use(lifespan(0.5, { fade: 0.1 }));
17     }
18   }
19 }

We define the custom component as we did before. Because we need to stop the enemy from patrolling, we require the patrol custom component. We also require the sprite component so we can stop the animation and set the squashed frame to display. The area component is necessary, as the squashed enemy frame is half the height of the regular enemy frames (8 pixels vs 16 pixels). We’re going to need to adjust the height of the area so that the collision zone the enemy occupies once squashed is correct.

As a side note, pressing F1 in the game turns on Kaboom debugging, which will draw the area box around each game element, so you can easily see when characters collide. It also shows other handy info, like the frame rate and character properties.

Debug mode

Back to our code. We execute our enemy squash in the squash method. We have a flag called isAlive, which we’ll use to determine if the enemy is able to hurt Mario. This is usually true, but set to false once the enemy is squashed and harmless. We also unuse the patrol component so that the enemy stops walking back and forth. Then we call stop, which is a method added by the sprite component. Calling stop stops playing the current animation. Then we set the frame of the sprite to use to 2, which is the squashed enemy frame, and update the area width and height to be the same size as the frame. Finally, we call use to add the lifespan component so that the character is removed from the scene after 0.5 seconds, and fades out for 0.1 seconds.

Now let’s add this custom component to the enemy. First, uncomment the //enemy(), line in the levelConf setup we created earlier. Now create a collision handler in the game scene between Mario and the enemy, so we know when it gets squashed:

1 let canSquash = false;
2
3 player.onCollide("badGuy", (baddy) => {
4   if (baddy.isAlive == false) return;
5   if (canSquash) {
6     // Mario has jumped on the bad guy:
7     baddy.squash();
8   } else {
9     // Mario has been hurt. Add logic here later...
10   }
11 });

In this onCollide handler, we check if the player, Mario, collides with a badGuy - which is the tag we gave to the enemies in the levelConf setup above. Then we attach our handler, which takes the baddy character Mario collided with. We first check if the baddy is still alive - if not we leave early, as there is no real interaction between Mario and a dead enemy. Then, we check the canSquash variable - if it is set to true, that means Mario has jumped onto the enemy. In this case, we call the squash method, which we created in the custom component for the enemy. This will execute all the logic we added there, and “kill” the enemy. We leave a bit of room in the handler to come back and add logic if Mario collides with the enemy without jumping on it - we’ll add in that logic later.

Modify the onKeyPress handler for the space key as follows:

1   onKeyPress("space", () => {
2     if (player.grounded()) {
3       player.jump();
4       canSquash = true;
5     }
6   });

Here, we set the canSquash variable to true to allow the player to squash the enemy if the player has jumped over it upon collision.

Add the following code to the player.onUpdate handler:

1 if (player.grounded()) {
2   canSquash = false;
3 }

This code will reset the canSquash variable so that the player will not squash the enemy if it hasn’t jumped over it in the collision handler we added earlier.

Update the output and test our game out. If you jump on an enemy, it should be squashed and then disappear after half a second.

Squash enemy

Click to open gif

Headbutting surprise boxes

Another key Mario action is headbutting the surprise boxes (the ones with “?” on them). In Mario World, this could release a coin, a super mushroom (one that makes Mario grow bigger), etc. When Mario headbutts these boxes, the box is ‘bumped’ and moves up and down quickly, while releasing its surprise. Once the box is empty, the “?” is removed from it. Let’s create the logic to control these boxes. As above, we’ll make more use of custom components.

We’ll take care of the boxes for coins and for the grow-bigger mushrooms. If you take a look in the levelConf setup we added in the beginning, you’ll see entries for coin and mushroom “question boxes”. The only real difference between the two is the final tag, which marks which surprise the box should release. We also have definitions for an empty box (!), the coin (c), and the mushroom (M).

Let’s create a component that makes the box jump up and fall back down when it is headbutted. We can also re-use it on the coin to make it flip as it’s bumped out of the box. We’ll call this new component bump:

1   function bump(offset = 8, speed = 2, stopAtOrigin = true) {
2     return {
3       id: "bump",
4       require: ["pos"],
5       bumpOffset: offset,
6       speed: speed,
7       bumped: false,
8       origPos: 0,
9       direction: -1,
10       update() {
11         if (this.bumped) {
12           this.pos.y = this.pos.y + this.direction * this.speed;
13           if (this.pos.y < this.origPos - this.bumpOffset) {
14             this.direction = 1;
15           }
16           if (stopAtOrigin && this.pos.y >= this.origPos) {
17             this.bumped = false;
18             this.pos.y = this.origPos;
19             this.direction = -1;
20           }
21         }
22       },
23       bump() {
24         this.bumped = true;
25         this.origPos = this.pos.y;
26       }
27     };
28   }

This looks a bit more complicated than our other custom components, but that’s only because it has code for the object moving in both directions. First off, we have a few parameters when creating this component:

- offset is how far up we want the object to be bumped before settling down again.

- speed is how fast we want it to move when bumped.

- stopAtOrigin specifies whether we want the object to return to its original position after being bumped, or just keep falling down - if this parameter is false, then bumping the object will make it look like it got dislodged and it will fall down.

The object this component is added to must also have the pos component. We’ll use that to move the object when it is bumped.

We add a method, bump, which we can call from a collision handler or elsewhere. This sets the property bumped to true. This is a flag in the update method that will trigger the bump behavior. In the bump method, we also record the y position of the object in a property origPos so that we can stop the object at its original position if the stopAtOrigin flag has been set.

In the update method, which is run on each frame, we first check if the bumped flag has been set. If it has, we update the y position of the object using the speed we set, and in the current direction. On a screen, the top of the screen is where y = 0, and the bottom is the max height of the screen. Therefore to start, direction is set to -1 to move the object up. Then we have a check to see if the new position of the object is higher than the offset parameter distance from the object’s original position. If it is, we reverse the direction, so the object now starts moving back down.

Next, we have a check to see if the flag stopAtOrigin is set. If the object has fallen down to its original position (or further), we set the bumped flag back to false and update the object’s position exactly back to its original position. We also set the direction flag back to -1, so the object is back in a state that it can be bumped again.

Now that this bump component exists, you can uncomment the //bump(), lines in the levelConf setup we created earlier.

To trigger the bump and add the code to make the surprise come out, we’ll need to add a collision handler. Believe it or not, Kaboom has a special case collision event called headbutt (which is not documented, but you can also find it in the source code) just for this type of thing!

1 player.on("headbutt", (obj) => {
2   if (obj.is("questionBox")) {
3     if (obj.is("coinBox")) {
4       let coin = level.spawn("c", obj.gridPos.sub(0, 1));
5       coin.bump();
6     } else
7     if (obj.is("mushyBox")) {
8       level.spawn("M", obj.gridPos.sub(0, 1));
9     }
10     var pos = obj.gridPos;
11     destroy(obj);
12     var box = level.spawn("!", pos);
13     box.bump();
14   }
15 });

In the handler for headbutt, we are passed the object, obj, that the player headbutted. We check to see if it is indeed one of our questionBox objects. If it is, we check if it is a coinBox (a coin must pop out) or a mushyBox (a grow-bigger mushroom should pop out).

If it is a coinBox, we spawn a new coin 1 block above the coin box, using the configuration c we setup for a coin in the levelConf in the beginning. Then we call bump on the coin to invoke our custom component’s method to make it appear to flip up out of the box.

If it is a mushyBox, we do the same, except we don’t bump the mushroom. The mushroom has our custom patrol component added to it (check in the levelConf for M), so it will start moving immediately. We set the patrol distance very large on the mushroom so it won’t automatically turn around, it will just keep going until if falls of the screen.

Then, to replace the questionBox with an empty box, we first record its position, then destroy it, and spawn a new empty box (! in the levelConf) to take its place. Finally, we bump this new box to give it the motion we want.

Cool, time to update the output and test this out. When you jump up using the space key and headbutt the question boxes now, they should move and have things pop out!

Question-boxes

Click to open gif

Adding special behaviors to Mario

Now we’ve got the basics of all the other game elements down, it’s time to create a custom component for Mario himself. This component will need to do quite a bit, as Mario is the main character. Here are the things it will need to handle:

- Make Mario get bigger or smaller.

- Run the “running” animation when Mario is running, and change to a standing or jumping frame in other cases.

- “Freeze” Mario when he gets to the castle or has died, so the player can no longer move him.

- Handle Mario dying, with a classic spring up and out of the scene.

Ok, here it is, our Mario custom component:

1 function mario() {
2     return {
3       id: "mario",
4       require: ["body", "area", "sprite", "bump"],
5       smallAnimation: "Running",
6       bigAnimation: "RunningBig",
7       smallStopFrame: 0,
8       bigStopFrame: 8,
9       smallJumpFrame: 5,
10       bigJumpFrame: 13,
11       isBig: false,
12       isFrozen: false,
13       isAlive: true,
14       update() {
15         if (this.isFrozen) {
16           this.standing();
17           return;
18         }
19
20         if (!this.grounded()) {
21           this.jumping();
22         }
23         else {
24           if (keyIsDown("left") || keyIsDown("right")) {
25             this.running();
26           } else {
27             this.standing();
28           }
29         }
30       },
31       bigger() {
32         this.isBig = true;
33         this.area.width = 24;
34         this.area.height = 32;
35       },
36       smaller() {
37         this.isBig = false;
38         this.area.width = 16;
39         this.area.height = 16;
40       },
41       standing() {
42         this.stop();
43         this.frame = this.isBig ? this.bigStopFrame : this.smallStopFrame;
44       },
45       jumping() {
46         this.stop();
47         this.frame = this.isBig ? this.bigJumpFrame : this.smallJumpFrame;
48       },
49       running() {
50         const animation = this.isBig ? this.bigAnimation : this.smallAnimation;
51         if (this.curAnim() !== animation) {
52           this.play(animation);
53         }
54       },
55       freeze() {
56         this.isFrozen = true;
57       },
58       die() {
59         this.unuse("body");
60         this.bump();
61         this.isAlive = false;
62         this.freeze();
63         this.use(lifespan(1, { fade: 1 }));
64       }
65     }
66   }

Firstly, we require the character to have a few other components: body, so we can determine if Mario is jumping or on the ground; area, so we can change the collision box area of Mario as he grows or shrinks; sprite, so we can start and stop animations and set static frames; and our custom bump component, so we can throw Mario off the screen if he dies.

If we take a peek in the Mario.json file along with the Mario.png sprite file, we’ll see that there are some animations defined in the frameTags section that we can use. The Running animation contains all the frames to make Mario appear to be running when he is in small size. Similarly, RunningBig has all the frames for when Mario is running while he is in big size. We can also see that a good frame for small Mario standing still, or stopped, is the first frame, or frame 0. A good frame to use for big Mario standing still or stopped is frame 8. Good frames for Mario jumping when he is small and big are 5 and 13 respectively. So we don’t have to keep remembering all these magic strings and numbers, we set them as properties of the Mario component.

If we measure the size of the big Mario images, we’ll see that the tightest crop we can get on them is about 24x32 pixels. For small Mario, the size is 16x16 pixels. We’ll use this knowledge to set the correct Mario animation and area collision boxes when changing between animations and sizes.

In the mario component, we define a number of custom methods. Let’s go through them.

- The bigger and smaller methods provide a way to change the size of Mario. We set a flag isBig that we check in the other methods to choose appropriate animations and frames. We also set the collision area size appropriate for the size of Mario.

- The standing and jumping methods are called from our main update method, which is called with each frame. In these 2 methods, we stop any animation that is currently running using the stop method provided by the sprite component. Then, depending on the size of Mario, determined by the isBig flag, we set the appropriate static frame to make Mario look like he is standing still or jumping.

- In the running method, we find the correct running animation depending on whether Mario is big or small. Then we check if that animation is the same animation that Mario is currently using, by calling the curAnim method provided by the sprite component. If they are not the same, we update the current animation by calling play to start the new animation. We first check the current animation because, if we set the animation regardless of what is currently playing, we’d keep resetting the current animation to the beginning with each frame and make it appear as a static frame.

- The freeze method sets a flag isFrozen, which is used in the update method to determine whether Mario can move.

- When Mario is killed, we can call the die method. This first removes the body component on Mario so that he is no longer subject to gravity or collisions, because these are things that ghosts are not worried about. Then we call the bump method that is added by our custom bump component. This shoots Mario up into the air, and back down again. We also set the isAlive flag to false, to signal to any collision handlers that Mario is dead before they try kill him again, or give him a 1-up mushroom or coin. We freeze() Mario so that he reverts to a standing pose and keyboard input doesn’t affect him, and finally, we use the lifespan component to fade Mario out and remove him from the scene.

We also have the required update method, which Kaboom calls every frame. In this method, we check if Mario is frozen, in which case we call our standing method to update Mario’s pose. Then we check if Mario is not on the ground, in other words he is in the air, using the grounded() method provided by the body component. This method returns true if he is on a solid object, or false otherwise. If this comes back false, we call the jumping method to stop any animations and set the static jumping frame.

If Mario is not jumping, i.e. he is grounded, then we check if the user currently has the left or right key down. If so, this means Mario is running(). The final condition is if Mario is on the ground but is not moving, then he must be standing().

Nice! Uncomment the mario component line in the levelConf to activate this new component on Mario. Update the output and test the animation changes out. Instead of Mario in a single pose, you should see an animation as he runs, changing to a static frame as he stands still or jumps.

Now we can hook up collision handlers to check if Mario has eaten the mushroom to grow larger, or if he gets injured or killed by an enemy or falling off the screen.

Adding more Mario collisions and events

First, let’s add a collision handler between the mushroom and Mario. Then we can call our bigger method from our custom mario component to grow him.

1 player.onCollide("bigMushy", (mushy) => {
2   destroy(mushy);
3   player.bigger();
4 });

In this handler, we remove the mushroom from the scene, and then make Mario bigger().

Let’s add some more code to the handler we created earlier for Mario colliding with an enemy. There, we only handled the case of Mario jumping on the enemy. We still need to account for Mario being injured or killed by the enemy. Update the badGuy collision handler like this:

1 player.onCollide("badGuy", (baddy) => {
2   if (baddy.isAlive == false) return;
3   if (player.isAlive == false) return;
4   if (canSquash) {
5     // Mario has jumped on the bad guy:
6     baddy.squash();
7   } else {
8     // Mario has been hurt
9     if (player.isBig) {
10       player.smaller();
11     } else {
12       // Mario is dead :(
13       killed();
14     }
15   }
16 });

We add in a condition at the top of the handler to exit early if Mario is not alive.

In the else condition, if Mario did not squash the bad guy, we check if we are dealing with big Mario. If so, Mario World rules are that he doesn’t die, he just gets smaller(). However, if Mario is already small, he unfortunately is now dead. We call out to another function killed to handle Mario’s death scene. Let’s add that function:

1 function killed() {
2   // Mario is dead :(
3   if (player.isAlive == false) return; // Don't run it if mario is already dead
4   player.die();
5   add([
6     text("Game Over :(", { size: 24 }),
7     pos(toWorld(vec2(160, 120))),
8     color(255, 255, 255),
9     origin("center"),
10     layer('ui'),
11   ]);
12   wait(2, () => {
13     go("start");
14   })
15 }

This function does a few things. First, we run the code in the die method from the custom mario component. Then we add some text to notify the player that the game is over. We wait for 2 seconds as we pay Mario our final respects, and then we go back to the start scene to play again.

Another way Mario can die in Mario World is if he falls off the platform into the void. We can check for this by modifying the player.onUpdate handler we added earlier for moving the camera:

1 player.onUpdate(() => {
2   // center camera to player
3   var currCam = camPos();
4   if (currCam.x < player.pos.x) {
5     camPos(player.pos.x, currCam.y);
6   }
7
8   if (player.isAlive && player.grounded()) {
9     canSquash = false;
10   }
11
12   // Check if Mario has fallen off the screen
13   if (player.pos.y > height() - 16){
14     killed();
15   }
16
17 });

Here, we check if Mario’s y co-ordinate is greater than the height of the Kaboom window, less the size of one platform block, which is 16 pixels. If this is the case, it means Mario has fallen off the top row of the platform, and therefore has been killed().

We also need to update our left and right key handler events to check if Mario isFrozen. In this case the handlers should just return early without moving Mario:

1     onKeyDown("right", () => {
2       if (player.isFrozen) return;
3       player.flipX(false);
4       player.move(SPEED, 0);
5     });
6
7     onKeyDown("left", () => {
8       if (player.isFrozen) return;
9       player.flipX(true);
10       if (toScreen(player.pos).x > 20) {
11         player.move(-SPEED, 0);
12       }
13     });

Then we also modify the space key handler to only jump if Mario is still alive:

1 onKeyPress("space", () => {
2     if (player.isAlive && player.grounded()) {
3       player.jump();
4       canSquash = true;
5     }
6 });

Time to update the output and test all these changes out! First thing to test is if Mario grows bigger by eating the mushroom. Second thing to check is if Mario then gets smaller again by colliding with an enemy. Also check if Mario is killed when colliding with an enemy when he is small, or when falling off the platform.

Mario bigger and killed

Click to open gif

Ending when we get to the castle

The final thing to do for this tutorial is to handle the case when Mario reaches the castle. At this point, when Mario is at the door, we want him to freeze, a congratulations message to appear to the player, and then move on to the next level.

We can use a regular collision handler to check if Mario is at the castle. Notice in the setup for the castle in the levelConf we added earlier, we set the collision area of the castle to a very narrow, completely vertical line of width 1 pixel and height 240, which is the screen height:

1   "H": () => [
2     sprite("castle"),
3     area({ width: 1, height: 240 }),
4     origin("bot"),
5     "castle"
6   ],

This is so that the collision between Mario and the castle is only registered when Mario gets to the center of the castle, where the door is. We can visualize this by pressing F1 in the game to enable the debugger and look at the area box at the castle:

Castle area

The reason we make the area box the height of the screen is make sure the player can’t accidentally jump over the ending point and fall off the end of the level.

Now let’s add our collision handler for the castle to the game scene:

1 player.onCollide("castle", (castle, side) => {
2   player.freeze();
3   add([
4     text("Well Done!", { size: 24 }),
5     pos(toWorld(vec2(160, 120))),
6     color(255, 255, 255),
7     origin("center"),
8     layer('ui'),
9   ]);
10   wait(1, () => {
11     let nextLevel = levelNumber + 1;
12
13     if (nextLevel >= LEVELS.length) {
14       go("start");
15     } else {
16       go("game", nextLevel);
17     }
18   })
19 });

First, we freeze Mario so the player can’t control him anymore. Then we add our “Well Done!” text message to the center of the screen. We wait a second before incrementing our level number and going to the next level, or going back to the start of the game if we have completed all levels.

Next steps

There are few things left to do to complete the game:

- Add in some scoring. You can check out a previous Kaboom tutorial, like Space Shooter, to see how scoring works.

- Add in sounds and music. If you get your own copy of the Mario soundtrack and effects, you can use the play sound function in Kaboom to get those classic tunes blasting as you play.

- Add in some more levels. This is the really fun part, where you get to create Mario levels you wish existed.

- You can also add in some more of the Mario World game characters.

Credits

https://twitter.com/Arrow_N_TheKnee for the Mario Visual Assets

Code

You can find the code for this tutorial on Replit

Build a Physics playground with Kaboom.js

In this tutorial, we will be building a simple physics playground with Kaboom.js. This will be a 2D side-view platformer in which the player can use a gravity gun to pick up and shoot objects in the world, similar to games like Half-Life 2 and Rochard.

The finished game

Click to open gif

By the end of this tutorial, you will:

- Be familiar with advanced usage of the Kaboom JavaScript game development framework.

- Be able to build a simple 2D physics engine.

- Have the basis for a physics-based platformer, which you can extend into a full game.

Getting started

Log into your Replit account and create a new repl. Choose Kaboom as your project type. Give this repl a name, like “gravity”.

Creating a new Repl

Kaboom repls are quite different from other kinds of repls you may have seen before: instead of dealing directly with files in folders, you’ll be dealing with code, sounds and sprites, the latter of which you can draw directly in Replit’s image editor.

Setting the scene

When you first open your new Kaboom repl, you’ll be greeted by a file containing the sample code below.

1 import kaboom from "kaboom";
2
3 // initialize context
4 kaboom();
5
6 // load assets
7 loadSprite("bean", "sprites/bean.png");
8
9 // add a character to screen
10 add([
11     // list of components
12     sprite("bean"),
13     pos(80, 40),
14     area(),
15 ]);

This code loads a sprite for Kaboom’s mascot Bean, and places Bean near the top of the screen. Before we start coding, we’ll remove the code for adding Bean, leaving the following lines:

1 import kaboom from "kaboom";
2
3 // initialize context
4 kaboom();

Our edited code initialises Kaboom and gives us a blank canvas to work with. We’ll start by defining a level containing walls, movable crates, and the player object. Add the following code beneath kaboom();:

1 // level
2 addLevel([
3     "========================",
4     "=                      =",
5     "=                      =",
6     "=                      =",
7     "=                      =",
8     "=            @         =",
9     "=     ##               =",
10     "=     ==========       =",
11     "=                      =",
12     "=                      =",
13     "=                      =",
14     "=                      =",
15     "=                      =",
16     "=                      =",
17     "=                      =",
18     "=                      =",
19     "========================"
20 ], {
21     width: 32,
22     height: 32,
23     "=": () => [ // wall
24         rect(32, 32),
25         color(CYAN),
26         area(),
27         solid(),
28         "wall"
29     ],
30     "#": () => [ // crate
31         rect(32, 32),
32         color(RED),
33         z(1),
34         outline(1),
35         origin("center"),
36         area(),
37         body(),
38         "movable"
39     ],
40     "@": () => [ // player
41         circle(32),
42         color(GREEN),
43         z(2),
44         outline(2),
45         area({ width: 64, height: 64}),
46         body(),
47         origin("center"),
48         {
49             speed: 120,
50             jumpspeed: 1000
51         },
52         "player"
53     ],
54 });

This code uses Kaboom’s addLevel() function to visually construct a level. This function takes two arguments: an ASCII art representation of the level, and a JSON object defining the width and height of individual blocks and providing definitions for each of the objects used. Let’s take a closer look at each of these definitions, starting with the wall object.

1     "=": () => [ // wall
2         rect(32, 32),
3         color(CYAN),
4         area(),
5         solid(),
6         "wall"
7     ],

A game object definition in Kaboom is a list of components and tags, and optionally custom attributes and functions. Components are a core part of Kaboom – they provide different functionality to game objects, from an object’s appearance to functionality such as collision detection.

This wall object has four components:

- rect(), which draws a rectangle to represent the object.

- color(), which gives the rectangle a color.

- area(), which provides collision detection for the object.

- solid(), which will prevent other objects from moving past it.

We’ve given the object the tag “wall”. Objects can have multiple tags, which can be used to define custom behavior, such as collision detection between objects with particular tags.

Next, let’s look at the crate definition:

1     "#": () => [ // crate
2         rect(32, 32),
3         color(RED),
4         z(1),
5         outline(1),
6         origin("center"),
7         area(),
8         body(),
9         "movable"
10     ],

Compared to our wall, we’ve given our crate the following additional components:

- z(), which defines the crate’s z-order, ensuring it will be drawn on top of walls.

- outline(), which will draw a single-pixel outline around the object.

- origin(), which sets the sprite’s origin to “center”, so that we can move it around from its center, rather than the default top-left corner.

- body(), which makes our crate subject to gravity.

We’ve given it the tag “movable”, a generic tag we can use for all objects that can be manipulated by the player’s gravity gun.

Finally, let’s look at the player object:

1     "@": () => [ // player
2         circle(32),
3         color(GREEN),
4         z(2),
5         outline(2),
6         area({ width: 64, height: 64}),
7         body(),
8         origin("center"),
9         {
10             speed: 120,
11             jumpspeed: 1000
12         },
13         "player"
14     ],

Note the following:

- As we’re using the circle component to draw the player, we must specify a width and height for the area() component. All collision areas in Kaboom.js are rectangular (as of v2000.1.6).

- We’ve added speed and jumpspeed custom variables to the player object, which we’ll use to control its movement speed and jump height.

Run your repl now, and you’ll see your level, with player, crates and walls. As we’ve placed the player in mid-air, you should see them fall to the platform below, confirming the presence of gravity.

Level

Moving the player

Let’s write some code to control the player. First, we need to retrieve a reference to the player using get():

1 // player
2 player = get("player")[0];

Now we’ll add code to move the player left and right. As we’ll use the mouse to control the player’s gravity gun, it makes ergonomic sense to control the player with the WASD keys rather than the arrow keys. Add the following code:

1 onKeyDown("a", () => {
2     player.move(-player.speed, 0)
3 });
4 onKeyDown("d", () => {
5     player.move(player.speed, 0)
6 });

Here we detect the onKeyDown event for the A key to move the player to the left and D key to move the player to the right. The move() function automatically checks for collisions with solid objects, so the player will only move if there is space to do so.

1 onKeyDown("w", () => {
2     if (player.isGrounded()) {
3         player.jump(player.jumpspeed)
4     }
5 });

The isGrounded() and jump() functions are provided by the body() component, making basic platformer movement simple to implement in Kaboom.

Rerun your repl (or refresh your repl’s webview) now, and you should be able to move left and right with A and D, and to jump with W. If the player jumps off the platform, they will fall out of view. We can fix this by having the game camera follow the player. Add the following code below your keyboard-handling code:

1 // camera follow player
2 player.onUpdate(() => {
3     camPos(player.pos);
4 });

This code will run every frame and keep the camera focused on the player as they move around. Refresh your in-repl browser and try it out.

Pulling objects with the gravity gun

Now that we’ve got our generic platformer functionality implemented, it’s time to add the gravity gun. The player will aim their gravity gun with the mouse. It will have a range, which we’ll show as a line. Movable objects that fall into that range will have a thicker outline drawn around them. When the player holds down the right mouse button, objects in range will travel towards them, until they reach the edge of a secondary, “holding” range. Objects in the holding range will move with the player until the left mouse button is clicked, at which point they will be launched into the air. Alternatively, the player can click the right mouse button to drop the held object.

In the code above, we’ve implemented horizontal movement (walking) and vertical movement (jumping). Implementing our gravity gun will require us to implement movement at arbitrary angles. To this end, we will need to write a couple of helper functions. Go to the top of your file, and add the following code beneath the kaboom(); line:

1 // helper functions
2 function angleBetween(p1, p2) {
3     return -Math.atan2(p2.y - p1.y, p2.x - p1.x);
4 }
5
6 function pointAt(distance, angle) {
7     return vec2(distance*Math.cos(angle),
8                 -distance*Math.sin(angle));
9 }

The first function, angleBetween(), uses JavaScript’s Math.atan2() to determine the angle two points make with the horizontal. The second function, pointAt(), uses sines and cosines to determine the point at a certain distance and angle.

Next, we’ll add two custom components:

- gravitygun(), which will define attributes and functions for our player’s gravity gun.

- physics(), which will define attributes and functions for the movable objects the gravity gun will manipulate.

Add the following code for the gravitygun() component beneath your helper function definitions:

1 // custom components
2 function gravitygun(range, hold, firepower) {
3     return {
4         id: "gravitygun",
5         require: ["pos"],
6     }
7 }

Components are written as functions that return objects of a specific format. Each component should have an id, and a list of components it depends on attached to the require attribute. Beyond that, components can have arbitrary attributes, which will be assigned to their parent object, and special functions, which will be integrated into the game’s event loop.

The only dependency for our component is pos(), as it will not work on an object that doesn’t have a position in the world. Function arguments will allow us to customize this component, in the form of specifying the gravity gun’s range, hold distance, and firepower.

Let’s add some custom attributes to our component, to help us aim and fire the gravity gun. Expand your component code to match the following:

1 function gravitygun(range, hold, firepower) {
2     return {
3         id: "gravitygun",
4         require: ["pos"],
5         firepower: firepower, // new code from this line
6         range: range,
7         hold: hold,
8         aimAngle: angleBetween(this.pos, mouseWorldPos()),
9         rangeLine: {
10             p1: this.pos,
11             p2: this.pos,
12             color: BLACK
13         },
14         holdLine: {
15             p1: this.pos,
16             p2: this.pos,
17             color: BLUE
18         },
19     }
20 }

In the above code:

- firepower is the amount of force to launch objects with.

- range is the length of our gravity gun’s pulling range.

- hold is the distance from the player’s center at which objects will be held once pulled in.

- aimAngle is the angle of our gravity gun’s aim, which will be the angle created with the horizontal.

- rangeLine and holdLine are lines we will use to represent the gravity gun’s range and hold.

Now we need to add a draw() function to draw both lines, and an update() function to recalculate the gravity gun’s aim angle and the position of both lines as the mouse cursor moves around. Alter your component code to include these functions at the bottom:

1 function gravitygun(range, hold, firepower) {
2     return {
3         id: "gravitygun",
4         require: ["pos"],
5         firepower: firepower,
6         range: range,
7         hold: hold,
8         aimAngle: angleBetween(this.pos, mouseWorldPos()),
9         rangeLine: {
10             p1: this.pos,
11             p2: this.pos,
12             color: BLACK
13         },
14         holdLine: {
15             p1: this.pos,
16             p2: this.pos,
17             color: BLUE
18         },
19         draw() { // new code from this line
20             drawLine(this.rangeLine);
21             drawLine(this.holdLine);
22         },
23         update() {
24             this.aimAngle = angleBetween(this.pos, mouseWorldPos());
25             this.rangeLine.p1 = this.pos;
26             this.rangeLine.p2 = this.pos.add(pointAt(this.range, this.aimAngle));
27             this.holdLine.p1 = this.pos;
28             this.holdLine.p2 = this.pos.add(pointAt(this.hold, this.aimAngle));
29         }
30     }
31 }

Our gravity gun component is complete. Now we need to add the physics() component, for objects affected by the gravity gun. Add the following code just below the last code you added:

1 function physics(mass) {
2     return {
3         id: "physics",
4         require: ["area", "body"],
5         mass: mass,
6         inRange: false,
7         inHold: false,
8         held: false,
9         dropping: false,
10         direction: 0,
11         speed: 0,
12         draw() {
13             if (this.inRange) {
14                 drawRect({
15                     width: this.width,
16                     height: this.height,
17                     pos: vec2(this.pos.x - this.width/2, this.pos.y - this.height/2),
18                     opacity: 0,
19                     outline: { color: BLACK, width: 4}
20                 });
21             }
22         },
23     }
24 }

This component requires both area and body – to be affected by the gravity gun, objects must have a collision area and be subject to regular gravity. We also give our physics objects a mass, direction and speed, and several variables to aid the gravity gun. Finally, we define a draw() function, which will draw a thick rectangle around the object when it is in gravity gun range.

Now we need to add the gravitygun() component to the player object and the physics() component to the crate object. Find your level creation code and add the new component lines to the objects as below:

1     "#": () => [ // crate
2         rect(32, 32),
3         color(RED),
4         z(1),
5         outline(1),
6         origin("center"),
7         area(),
8         body(),
9         physics(100), // <-- NEW LINE
10         "movable"
11     ],
12     "@": () => [
13         circle(32),
14         color(GREEN),
15         z(2),
16         outline(2),
17         area({ width: 64, height: 64}),
18         body(),
19         origin("center"),
20         gravitygun(150, 55, 40), // <-- NEW LINE
21         {
22             speed: 120,
23             jumpspeed: 1000
24         },
25         "player"
26     ],

We now have everything in place to write the code that will allow us to pull objects with the gravity gun. We’ll do this in an onUpdate() event callback for objects with the “movable” tag. Add the following code to the bottom of the main.js file:

1 // gravity gun pull and hold
2 onUpdate("movable", (movable) => {
3     // test collisions
4     myRect = movable.worldArea()
5     movable.inRange = testRectLine(myRect, player.rangeLine);
6     movable.inHold = testRectLine(myRect, player.holdLine);
7 });

First, we get the coordinates for the movable object’s collision rectangle, using worldArea(), a function provided by the area() component. We then use the testRectLine() function to determine whether our player’s range line or hold line intersects with this collision rectangle.

Now let’s have the gravity gun pull objects in range when the player holds down the right mouse button. Add the following code below the line where you assigned movable.inHold in the body of the onUpdate event callback:

1     // gravity gun pull
2     if (isMouseDown("right")) {
3         if (movable.inHold) {
4             movable.held = true;
5         }
6         else if (movable.inRange) {
7             movable.moveTo(player.holdLine.p2);
8         }
9     }

If the object is in holding range, we set held to true. If the object is in pulling range, we move it into holding range. We use the moveTo() function, provided by the pos() component, without a speed argument. This means the object will teleport to the specified position.

Next, we’ll handle holding objects once they’ve been pulled in. Add the following code beneath the code you added above:

1     // gravity gun hold
2     if (movable.held) {
3         movable.moveTo(player.holdLine.p2)
4     }

This code will ensure that objects stay in the holding position when held, so the player will be able to move them around in an arc.

Finally, we’ll need to move our object after it’s been launched. Add the following code below the lines you added above:

1     // gravity gun launch
2     movable.moveBy(dir(movable.direction).scale(movable.speed));
3     movable.speed = Math.max(0, movable.speed-1); //friction

When we launch an object, we’ll give it a direction and a non-zero speed. We use moveBy(), provided by the pos() component, to move the object unless there are other solid objects in the way. To give the object a more natural movement arc, we will simulate friction by decreasing its speed every frame until it reaches zero.

Run your repl now and try out the gravity gun. You should be able to pull crates into the holding position, and then move them around your head.

Gravity gun pull

Fixing the wall-clipping bugs

If you play around with the game, you should notice a couple of wall clipping bugs. When holding an object, you can force it to teleport below the platform you’re standing on by aiming them. Similarly, objects will teleport through platforms if you pull them from the other side.

Wall clip

In both cases, this is because Kaboom’s moveTo() function does not take solid objects into account, so we’ll have to do that ourselves.

We’ll fix the first bug by adding a new, collision-aware movement function to our physics() component. Find the component definition and append the function to the object it returns, just below the draw() function definition:

1         moveToNoCollide(dest) {
2             const diff = dest.sub(this.pos);
3             return this.moveBy(diff.unit().scale(diff.len()));
4         }

This function calculates the movement vector needed to move from the current position to the destination, and then passes this vector to the collision-aware function moveBy(). Our moveBy() function will return a Collision object if it detects a collision while moving. We’ll return this and use it to cancel the hold if certain conditions are met.

Find the if (movable.held) block in your onUpdate("movable") event callback and alter it to match the code below:

1     // gravity gun hold
2     if (movable.held) {
3         col = movable.moveToNoCollide(player.holdLine.p2)
4         if (col != null && col.target.solid) {
5             if (col.target.is("player")) { // disregard player collisions
6                 movable.moveTo(player.holdLine.p2);
7             }
8             else movable.held = false;
9         }
10     }

Here we’ve replaced moveTo() with moveToNoCollide(), and we’re doing some checks on the collision object returned. If the collision is with the player, we ignore it and move the object back to the hold position. Otherwise, we cancel the hold.

Refresh your repl browser now and try to clip a held crate through the floor. You should be unsuccessful.

To fix the second clipping bug, we’re going to need to detect solid objects between the movable object being pulled and the player’s hold position. We’ll write a new helper function to do this. Add the following code near the top of your file, just under the definition of pointAt():

1 function checkCollisionLine(line, tag) {
2     collision = get(tag).some((object) => {
3         return testRectLine(object.worldArea(), line);
4     });
5
6     return collision;
7 }

The checkCollisionLine() function tests whether an object with a given tag intersects with a line. We do this by getting all objects with the tag and testing each one until we find a collision. The some() method will stop executing after a single true is returned, so this isn’t quite as inefficient as it could be, but it’s still probably not the optimal way to do this. Nonetheless, it should work fine while our game is relatively small.

To use this function, find the if (isMouseDown("right")) in your onUpdate("movable") event callback and make the modifications shown:

1     // gravity gun pull
2     if (isMouseDown("right")) {
3         colLine = { // new object definition
4             p1: movable.pos,
5             p2: player.holdLine.p2
6         }
7         if (movable.inHold) {
8             movable.held = true;
9         }
10         // expanded expression below
11         else if (movable.inRange && !checkCollisionLine(colLine, "wall")) {
12             movable.moveTo(player.holdLine.p2);
13         }
14     }

To prevent the player from pulling objects through walls, we check that the line between the object in range and the player’s hold position is free of walls before moving it. Rerun your repl now, and you should be unable to clip objects through walls.

Dropping and launching objects with the gravity gun

Now that we have a stable pulling and holding implementation, we need to be able to drop and launch objects. Add the following code at the bottom of your file:

1 // gravity gun drop
2 onMousePress("right", () => {
3     holdList = get("movable").filter((element) => {
4         return element.held;
5     });
6     if (holdList.length) {
7         selected = holdList[0];
8         selected.held = false;
9     }
10 });

When the right mouse button is clicked, we retrieve all the “movable”-tagged objects and filter out the ones that aren’t currently held. Only one object can be held at a time, so we select that object and release it.

If you restart your repl now, you may have trouble getting dropping to work. Because we use the right mouse button both for pulling and dropping, you need to be quite precise to avoid immediately grabbing objects after dropping them.

Let’s implement a dropping timeout for dropped objects to make this more user-friendly. Add the following lines below selected.held = false:

1         selected.dropping = true;
2         wait(0.2, () => {
3             selected.dropping = false;
4         });

This code uses a wait() callback, which will execute after the given number of seconds.

We defined the dropping attribute as part of our physics() component, so now all we need to do is make our pull respect the timeout. Find the if (isMouseDown("right")) line in your onUpdate("movable") event callback and alter it to resemble the following:

1     // gravity gun pull
2     if (isMouseDown("right") && !movable.dropping) {

Now you should be able to drop objects more easily.

Finally, let’s add some code to launch objects when the left mouse button is clicked. Enter the following code at the bottom of your file.

1 // gravity gun launch
2 onMousePress("left", () => {
3     rangeList = get("movable").filter((element) => {
4         return element.held;
5     });
6     if (rangeList.length) {
7         selected = rangeList[0]; // 1st for now
8         selected.held = false;
9         selected.direction = rad2deg(-player.aimAngle);
10         selected.speed = player.firepower;
11     }
12 });

This code is largely similar to the dropping code, but instead of specifying a drop timeout, we give the previously held object a direction and a speed. The direction will be the player’s aimAngle, which we need to convert to degrees for Kaboom. The speed will be the firepower value we defined in our gravitygun() module.

Restart your repl and play around with picking up, dropping and launching crates. Some odd behavior you may notice is that objects launched straight will tend to slide along the ceiling for a while as they bleed off their speed. We can prevent this by adding the following code to kill a movable object’s speed on collision with a wall:

1 onCollide("movable", "wall", (a, b) => {
2     a.speed = 0;
3 });

Object collisions

Our physics playground wouldn’t be complete without collisions between objects, so let’s add some simple code to make objects push each other. Add the following collision handler to the bottom of your file:

1 // physics object collisions
2 onCollide("movable", "movable", (a, b) => {
3     if (a.speed > b.speed) b.direction = a.direction;
4     else if (a.speed <= b.speed) a.direction = b.direction;
5     finalSpeed = (a.speed) + (b.speed) / (a.mass + b.mass);
6     a.speed = finalSpeed;
7     b.speed = finalSpeed;
8 });

This code will assign the direction of the fastest object to both objects, and determine the speed of both from an altered form of a simple one-dimensional collision formula. Play around with the values and calculations and see what sort of behavior you prefer. Remember, it’s more important for a game to be fun than for it to be realistic.

Next steps

We’ve built a rudimentary physics-based platform engine. From here, there’s a lot you can do to expand this into a full game. Consider the following ideas:

- Add enemies to throw crates at.

- Add more movable objects of different sizes and masses.

- Add puzzles that can be solved by arranging crates in specific patterns, and launching them from specific angles.

- Improve the physics simulation with 2D collisions, rotating objects, and objects with different behaviors, such as bouncing and shattering.

Code

You can find the code for this tutorial on Replit

Building tic-tac-toe with WebSocket and Kaboom.js

Tic-tac-toe, or noughts and crosses, or Xs and Os, is a simple classic game for 2 players. It’s usually played with paper and pen, but it also makes a good first game to write for networked multiplayer.

In this tutorial, we’ll create a 2-player online tic-tac-toe game using a Node.js server. Socket.IO will enable realtime gameplay across the internet. We’ll use Kaboom.js to create the game interface.

The finished game

Click to open gif

How do multiplayer games work?

Multiplayer games have an architecture that typically looks something like this:

Game server architecture

Players (clients) connect to a game server over the internet. The game runs on the game server, where all the game rules, scores and other data are processed. The players’ computers render the graphics for the game, and send player commands (from the keyboard, mouse, gamepad, or other input device) back to the game server. The game server checks if these commands are valid, and then updates the game state. The game state is a representation of all the variables, players, data and information about the game. This game state is then transmitted back to all the players and the graphics are updated.

A lot of communication needs to happen between a player’s computer and the game server in online multiplayer games. This generally requires a 2-way, or bidirectional, link so that the game server can send data and notify players of updates to the game state. This link should ideally be quick too, so a more permanent connection is better.

With the HTTP protocol that websites usually use, a browser opens a connection to a server, then makes a request to the server, and the server sends back data, and closes the connection. There is no way for the server to initiate sending data to the browser. HTTP is also heavy on overhead, since it opens and closes a connection each time data is requested and sent.

WebSocket is an advanced internet protocol that allows us to create a 2-way, persistent connection between a browser and a server. We’ll use the Socket.IO package to help us manage WebSocket connections in this project.

Creating a new project

For this project, we’ll need to create 2 repls - 1 using Node.js for the game server, and 1 using Kaboom for the players. Head over to Replit and create a two new repls:

- To create the server project, choose “Node.js” as your project type. Give this repl a name, like “tic-tac-toe-server”.

New Server repl

- To create the player project, choose “Kaboom” as your project type. Give this repl a name, like “tic-tac-toe”.

New Player repl

We’ll code in the server repl to start, and then switch between repls as we build the game.

Setting up Socket.IO on the server

Add the following code to the file called index.js in the server project to import Socket.IO:

1 const http = require('http');
2 const sockets = require('socket.io')
3
4 const server = http.createServer();
5 const io = sockets(server,  {
6   cors: {
7     origin: "https://tic-tac-toe.<YOUR-USER-NAME>.repl.co",
8     methods: ["GET", "POST"]
9   }
10 });
11
12 server.listen(3000, function() {
13   console.log('listening on 3000');
14 });

In the first 2 lines, we import the built-in node http package and the socket.io package. The http package enables us to run a simple HTTP server. The socket.io package extends that server to add WebSocket functionality.

To create the HTTP server, we use the http.createServer(); method. Then we set up Socket.IO by creating a new io object. We pass in the HTTP server object along with some configuration for CORS. CORS stands for “Cross Origin Resource Sharing”, and it’s a system that tells the server which other sites are allowed to connect to it and access it. We set origin to allow our player repl to connect. Replace the origin value with the URL of the player project repl you set up earlier.

In the last 2 lines, we start the server up by calling its listen method, along with a port to listen on. We use 3000, as this is a standard for Node.js. If it starts successfully, we write a message to the console to let us know.

We’ll add the rest of the server code above the server.listen line, as we only want to start the server up after all the other code is ready.

Tracking the game state

Now that we have a server, lets think a bit about how we will represent, or model, the game. Our tic-tac-toe game will have a few different properties to track:

- The status of the game: What is currently happening? Are we waiting for players to join, are the players playing, or is the game over?

- The current positions on the tic-tac-toe board. Is there a player in a grid block, or is it empty?

- All the players. What are their names, and which symbol are they using, X or O?

- The current player. Whose turn is it to go?

- If the game ends in a win, who won it?

For the status of the game, we’ll add an enumeration of the possible states we can expect. This makes it easier to track and use them as we go through the different phases of the game.

1 const Statuses = {
2   WAITING: 'waiting',
3   PLAYING: 'playing',
4   DRAW: 'draw',
5   WIN: 'win'
6 }

- WAITING: We are waiting for all the players to join the game.

- PLAYING: The players can make moves on the board.

- DRAW: The game has ended in a draw.

- WIN: A player has won the game.

Now let’s add a game state object to track everything:

1 let gameState = {
2   board: new Array(9).fill(null),
3   currentPlayer: null,
4   players : [],
5   result : {
6     status : Statuses.WAITING
7   }
8 }

First, we have a representation of the tic-tac-toe board as an array with 9 elements. This is how the array elements are mapped to the board:

Tic-Tac-Toe board mapped to array indices

Each number in the blocks represents the index at which the board position is represented in the array. Initially, we fill all the elements of the array with null to indicate that the block is open. When players make a move to occupy an open space, we’ll add a reference to the player instead. That way we can keep track of which blocks are empty, and which are occupied by which player.

Next, we have currentPlayer, which we will alternately set to each player when it’s their turn to move.

Then there is an array called players, which will hold references to both of the players in the game. This will allow us to show the names of the players on screen, as well as generally keep track of the players.

The result field is updated after every move. This field will contain the status of the game (as we defined above). As it’s represented as an object, it will also be able to hold extra fields. We’ll use that functionality to add a reference to the winner of the game, if the game ends in a win.

Accepting connections

When a player connects via WebSocket, Sockets.IO will fire a connection event. We can listen for this event and handle tracking the connection, as well as creating listeners for other custom events. There are a few custom events we can define here, that our players will emit:

- addPlayer: We’ll use this event for a player to request joining the game.

- action: This is used when a player wants to make a move.

- rematch: Used when a game is over, but the players want to play again.

We can also listen for the built-in disconnect event, which will alert us if a player leaves the game (for example, by closing the browser window or if their internet connection is lost).

Let’s add the code that will hook up our listeners to the events:

1 io.on('connection', function (connection) {
2   connection.on('addPlayer', addPlayer(connection.id));
3   connection.on('action', action(connection.id));
4   connection.on('rematch', rematch(connection.id));
5   connection.on('disconnect', disconnect(connection.id));
6 });

Next we’ll implement each of these listener functions, starting with addPlayer.

Side Note: Normally in examples for custom listeners, you’ll see the handler code added immediately with an anonymous function, like this:

1 io.on('connection', function (connection) {
2   connection.on('addPlayer', (data)=> {
3       // some code here
4   });
5
6    connection.on('action', (data)=> {
7       // some code here
8   });
9
10   // etc ...
11 });

This is convenient, especially when there are a couple of handlers, each with only a small amount of code. It’s also handy because in each of the handler functions, you still have access to the connection object, which is not passed on each event. However, it can get a little messy and unwieldy if there are many event handlers, with more complex logic in each.

We’re doing it differently so that we can separate the handlers into functions elsewhere in the code base. We do have one problem to solve though: if they are separate functions, how will they access the connection parameter in such a way that we can tell which player sent the command? With the concept of closures, which are well-supported in Javascript, we can make functions that return another function. In this way, we can pass in the connection.id parameter to the first wrapping function, and it can return another function that takes the data arguments from the Socket.IO event caller. Because the second function is within the closure of the first, it will have access to the connection.id parameter. The pattern looks like this:

1 io.on('connection', function (connection) {
2   connection.on('addPlayer', addPlayer(connection.id));
3   connection.on('action', action(connection.id)) ;
4   // etc ...
5 });
6
7
8 function addPlayer(socketId){
9     return (data)=>{
10         // code here
11     }
12 }
13
14 function action(socketId){
15     return (data)=>{
16         // code here
17     }
18 }

Handling new players

Add the following function to handle adding players:

1 function addPlayer(socketId){
2   return (data)=>{
3     const numberOfPlayers = gameState.players.length;
4     if (numberOfPlayers >= 2){
5       return;
6     }
7
8     let nextSymbol = 'X';
9     if (numberOfPlayers === 1){
10       if (gameState.players[0].symbol === 'X'){
11         nextSymbol = 'O';
12       }
13     }
14
15     const newPlayer = {
16       playerName: data.playerName,
17       id: socketId,
18       symbol: nextSymbol
19     };
20
21     gameState.players.push(newPlayer);
22     if (gameState.players.length === 2){
23       gameState.result.status = Statuses.PLAYING;
24       gameState.currentPlayer = newPlayer;
25     }
26     io.emit('gameState', gameState);
27
28   }
29 }

This function does quite a bit. Let’s go through the main features.

- First it checks to see how many players are already in the game. If there are already 2 players, it returns early without changing anything. If this check passes, it goes on to add a new player. Note that even when there is no space in the game for a new player, we don’t disconnect the player - they still get updates and can watch the match.

- Next, the function figures out which symbol, X or O, the new player should be. It will assign X to the first player. If there is already a player, and the existing player’s symbol is X, then it will assign O to the new player. Note that there is a possible case where there is only one player, and their symbol is O. This would occur if there are 2 players, and the player with the X symbol disconnects from the game, leaving only the player with the O symbol. This is why we always check what symbol the existing player in the game has.

- Then the function constructs a new player object with some identifying information, including the name that the player sends through, the socketId they connected on, and their symbol. When a new player requests to join, we expect them to send an object with a field playerName to tell us their handle.

- Now we add the new player to the player array in our gameState object, so that they are part of the game.

- We go on to check if we have 2 players, and start playing if we do. We begin by updating the status of the game to PLAYING, and set the currentPlayer, i.e. the player who is first to go, as the latest player to have joined.

- Finally, we use the Socket.IO emit function to send the updated gameState to all connections. This will allow them to update the players’ displays.

Handling player actions

The next handler takes care of the moves players make. We expect that the incoming data from the player will have a property called gridIndex to indicate which block on the board the player wants to mark. This should be a number that maps to the numbers for each block in the board, as in the picture earlier on.

1 function action(socketId){
2   return (data)=> {
3     if (gameState.result.status === Statuses.PLAYING && gameState.currentPlayer.id =\
4 == socketId){
5       const player = gameState.players.find(p => p.id === socketId);
6       if (gameState.board[data.gridIndex] == null){
7         gameState.board[data.gridIndex] = player;
8         gameState.currentPlayer = gameState.players.find(p => p !== player);
9         checkForEndOfGame();
10       }
11     }
12     io.emit('gameState', gameState);
13   }
14 }

In this function, we check a couple of things first:

- The game status must be PLAYING - players can’t make moves if the game is in any other state.

- The player attempting to make the move must be the currentPlayer, i.e. the player whose turn it is to go.

If these conditions are met, we find the player in the gameState.players array using the built-in find method on arrays, by looking for the player by their socketId.

Now we can check if the board position (gridIndex) requested by the player is available. We check that the value for that position in the gameState.board array is null, and if it is, we assign the player to it.

The player has made a successful move, so we give the other player a turn. We switch the gameState.currentPlayer to the other player by using the array find method again, to get the player who does not match the current player.

We also need to check if the move the player made changed the status of the game. Did that move make them win the game, or is it a draw, or is the game still in play? We call out to a function checkForEndOfGame to check for this. We’ll implement this function a little later, after we’re done with all the handlers.

Finally, we send out the latest gameState to all the players (and spectators) to update the game UI.

Handling a rematch request

Let’s make it possible for a player to challenge their opponent to a rematch when the game has ended:

1 function rematch(socketId){
2   return (data) => {
3     if (gameState.players.findIndex(p=> p.id === socketId) < 0) return; // Don't let\
4  spectators rematch
5     if (gameState.result.status === Statuses.WIN || gameState.result.status === Stat\
6 uses.DRAW){
7       resetGame();
8       io.emit('gameState', gameState);
9     }
10   }
11 }

This function first checks if the connection sending the rematch request is actually one of the players, and not just a spectator. If we can’t find a match for a player, we return immediately, making no changes.

Then we check if the game is in one of the final states, either WIN or DRAW. If it is, we call out to a function resetGame to set up the game again. Finally, we send out the latest gameState to all the players.

Let’s implement the resetGame function:

1 function resetGame(){
2   gameState.board = new Array(9).fill(null);
3
4   if (gameState.players.length === 2){
5     gameState.result.status = Statuses.PLAYING;
6     const randPlayer = Math.floor(Math.random() * gameState.players.length);
7     gameState.currentPlayer = gameState.players[randPlayer];
8   } else {
9     gameState.result.status = Statuses.WAITING;
10     gameState.currentPlayer = null;
11   }
12 }

Let’s take a look at what we’re doing here:

- First, our function creates a new array for the gameState board. This effectively clears the board, setting all the positions back to null, or empty.

- Then it checks that there are still 2 players connected. If there are, it sets the game status back to PLAYING and chooses at random which player’s turn it is to go. We choose the first player randomly so that there isn’t one player getting an advantage by going first every time.

If there is only one player remaining, we set the game status to WAITING instead, and listen for any new players who want to join. We also set the currentPlayer to null, as we will choose which player should go once the new player has joined.

Handling disconnects

The last handler we need to implement is if a connection to a player is lost. This could be because the player has exited the game (by closing the browser tab), or has other internet issues.

1 function disconnect(socketId){
2   return (reason) => {
3     gameState.players = gameState.players.filter(p => p.id != socketId);
4     if (gameState.players !== 2){
5       resetGame();
6       io.emit('gameState', gameState);
7     }
8   }
9 }

This function uses the built-in array filter function to remove the player that disconnected from the server. Since it’s possible that the disconnect event isn’t from a player but from a spectator, we check the number of players left after filtering the disconnecting socket from the player list. If there aren’t 2 players remaining after filtering, we reset the game and send out the updated game state.

Checking for the end of the game

Now we can get back to implementing the checkForEndOfGame() function we referenced in the action handler.

We’re only interested in detecting 2 cases: A win or a draw.

There are just 8 patterns that determine if a player has won at tic-tac-toe. Let’s map them to our board with its indexed blocks:

All possible win lines

We can encode each of these winning patterns into an array of 3 numbers each. Then we can add each of those patterns to a larger array, like this:

1 const winPatterns = [
2   [0, 1, 2],
3   [3, 4, 5],
4   [6, 7, 8],
5   [0, 3, 6],
6   [1, 4, 7],
7   [2, 5, 8],
8   [0, 4, 8],
9   [2, 4, 6]
10 ]

Now that we have each winning pattern in an array, we can loop through each of them to see if there is a player that has positions that match any of the patterns.

Since the players are also in an array in gameState.players, we can loop through that array, and check each player against the winning pattern array. If a player matches any of these patterns, we can change the game status to WIN and set that player as the winner in the results.

Here is the code to do that:

1 function checkForEndOfGame(){
2
3   // Check for a win
4   gameState.players.forEach(player => {
5     winPatterns.forEach(seq => {
6       if (gameState.board[seq[0]] == player
7           && gameState.board[seq[1]] == player
8           && gameState.board[seq[2]] == player){
9             gameState.result.status = Statuses.WIN;
10             gameState.result.winner = player;
11          }
12     });
13   });
14
15   // Check for a draw
16   if (gameState.result.status != Statuses.WIN){
17     const emptyBlock = gameState.board.indexOf(null);
18     if (emptyBlock == -1){
19       gameState.result.status = Statuses.DRAW;
20     }
21   }
22 }

We also check for a draw in this function. A draw is defined as when all the blocks are occupied (no more moves can be made), but no player has matched one of the win patterns. To check if there are no more empty blocks, we use the array method indexOf to find any null values in gameState.board array. Remember that null means an empty block here. The indexOf method will return -1 if it can’t find any null values. In that case, we set the game status to DRAW, ending the game.

Now we have all the functionality we need on the server, let’s move on to building the Kaboom website the players will use to play the game.

Setting up Kaboom

To start, we need to set up Kaboom with the screen size and colors we want for the game window. Replace the code in main.js with the code below:

1   import kaboom from "kaboom";
2
3   kaboom({
4     background: [0, 0, 0],
5     width: 1000,
6     height: 600
7   });

This creates a new Kaboom canvas with a black background.

Setting up Kaboom with Socket.IO

Now we can add a reference to Socket.IO. Normally, in a plain HTML project, we could add a <script> tag and reference the Socket.IO client script, hosted automatically on our game server. However, here we will add the script programmatically. We can do this by accessing the document object available in every browser, and insert a new element with our script. Add the following code to the main.js file below the code to initialise Kaboom.

1 let script = document.createElement("script");
2 script.src = 'https://tic-tac-toe-server.<YOUR_USER_NAME>.repl.co' + '/socket.io/soc\
3 ket.io.js'
4 document.head.appendChild(script);

Replace the <YOUR_USER_NAME> part of the URL with your Replit username. This code inserts the new <script> tag into the <head> section of the underlying HTML page that Kaboom runs in.

Let’s move on to creating the relevant scenes for our game. Kaboom “scenes” allow us to group logic and levels together. In this game we’ll have 2 scenes:

- A “startGame” scene that will prompt for the player’s name.

- A “main” scene, which will contain all the logic to play the tic-tac-toe game.

Let’s move on to the code to prompt the player to enter their name.

1 scene("startGame", () => {
2   const SCREEN_WIDTH = 1000;
3   const SCREEN_HEIGHT = 600;
4
5   add([
6     text("What's your name? ", { size: 32, font: "sinko" }),
7     pos(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 3),
8     origin("center")
9   ]);
10
11   const nameField = add([
12     text("", { size: 32, font: "sinko" }),
13     pos(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2),
14     origin("center")
15   ]);
16
17   charInput((ch) => {
18     nameField.text += ch;
19   });
20
21   keyRelease("enter", () => {
22     go("main", { playerName: nameField.text });
23   })
24
25 });
26
27 go("startGame");

To keep the calculations for the UI layout simpler, we’ll use a fixed size for the screen. That’s where the 2 constants for the screen width and height come in.

We use the Kaboom add function to display the prompt “What’s your name?” on the screen, using the text component. We choose a position halfway across the screen, SCREEN_WIDTH / 2, and about a third of the way down the screen, SCREEN_HEIGHT / 3. We add the origin component, set to center, to indicate that the positions we set must be in the center of the text field.

Then we add another object with an empty "" text component. This will display the characters the player types in. We position it exactly halfway down and across the screen. We also hold a reference to the object in the constant nameField.

To get the user’s keyboard input, we use the Kaboom function charInput. This function calls an event handler each time a key on the keyboard is pressed. We take that character and append it to the text in the nameField object. Now, when a player presses a key to enter their name, it will show up on the screen.

Finally, we use the Kaboom function keyRelease to listen for when the player pushes the enter key. We’ll take that as meaning they have finished entering their name and want to start the game. In the handler, we use the Kaboom go function to redirect to the main scene of the game.

Adding the game board

Now we can add the UI elements for the game itself. Create the “main” scene in your Kaboom repl by adding the following code to draw the tic-tac-toe board:

1  scene("main", ({ playerName }) => {
2
3   // Board
4   add([
5     rect(1, 400),
6     pos(233, 100),
7   ]);
8
9   add([
10     rect(1, 400),
11     pos(366, 100),
12   ]);
13
14   add([
15     rect(400, 1),
16     pos(100, 233),
17   ]);
18
19   add([
20     rect(400, 1),
21     pos(100, 366),
22   ]);
23
24 });

This adds 4 rectangles with a width of 1 pixel and length of 400 pixels to the screen - each rectangle is more like a line. This is how we draw the lines that create the classic tic-tac-toe board shape. The first 2 rectangles are the vertical lines, and the second 2 are the horizontal lines. We place the board closer to the left side of the screen, instead of the center, to save space for game information to be displayed on the right hand side of the screen.

If you run the game, and enter your name, you should see the board layout like this:

Board layout

Now we need to add a way to draw the X and O symbols in each block. To do this, we’ll add objects with text components in each block of the board. First, we’ll make an array containing the location and size of each block. Add the following code snippets within the “main” scene we created above:

1 const boardSquares = [
2   {index: 0, x: 100, y: 100, width:133, height: 133 },
3   {index: 1, x: 233, y: 100, width:133, height: 133 },
4   {index: 2, x: 366, y: 100, width:133, height: 133 },
5   {index: 3, x: 100, y: 233, width:133, height: 133 },
6   {index: 4, x: 233, y: 233, width:133, height: 133 },
7   {index: 5, x: 366, y: 233, width:133, height: 133 },
8   {index: 6, x: 100, y: 366, width:133, height: 133 },
9   {index: 7, x: 233, y: 366, width:133, height: 133 },
10   {index: 8, x: 366, y: 366, width:133, height: 133 }
11 ];

We can run through this array and create a text object that we can write to when we want to update the symbols on the board. Let’s create a function to do that.

1 function createTextBoxesForGrid(){
2   boardSquares.forEach((square)=>{
3     let x = square.x + square.width*0.5;
4     let y = square.y + square.height*0.5;
5     square.textBox = add([
6       text('', 40),
7       pos(x, y),
8       origin('center')
9     ]);
10   })
11 }
12
13 createTextBoxesForGrid();

This function uses the array forEach method to loop through each “square” definition in the boardSquares array. We then find the center x and y of the square, and add a new text object to the screen, and also add it to the square definition on the field textBox so we can access it later to update it. We use the origin component to ensure the text is centered in the square.

Finally, we call the function to create the text boxes.

Adding player names and game status

Now let’s add some areas for the player’s names and for the current status of the game (whose turn it is to play, if someone has won, or if it’s a draw).

1 // Players and game status elements
2 const playerOneLabel = add([
3   text('', { size: 20, font: "sinko" }),
4   pos(600, 100),
5 ]);
6
7 const playerTwoLabel = add([
8   text('', { size: 20, font: "sinko" }),
9   pos(600, 150),
10 ]);
11
12 const statusLabel = add([
13   text('', { size: 20, font: "sinko" }),
14   pos(600, 200),
15   color(0, 255, 0)
16 ])

Here we add 3 objects with text components. The first 2 are placeholders for the player names and symbols. The third one is for the game status. They are positioned to the right of the screen, and contain empty text to start. We’ll change the contents as we receive new game states from the server. The last object has a color component to set the color of the text to green. This is to make the status message stand out from the rest of the text.

Connecting to the server

To connect to the game server, we need to initialize the Socket.IO library we dynamically added earlier. We need to provide the URL to the server repl, so copy that from the output window of the server repl:

Copying server url

Now add this code along with the server URL to the “main” scene in the player repl:

1 var socket = io('https://tic-tac-toe-server.<YOUR_USER_NAME>.repl.co');
2
3 socket.on('connect', function(){
4   socket.emit("addPlayer", {
5     playerName: playerName
6   });
7 });

In the first line, we initialize the Socket.IO client library to connect to the server. Then we add a listener to the connect event. This lets us know when we have established a connection to the server.

If we have a connection, we then emit an event to the server, with our custom event type addPlayer. We also add in the player name, which we passed to this scene from the startGame scene. Emitting the addPlayer event to the server will cause the addPlayer event handler to fire on the server side, adding the player to the game, and emitting back the game state.

Handling updated game state

Remember that our server emits a gameState event whenever something changes in the game. We’ll listen for that event, and update all the UI elements in an event handler.

First, we need to add the definitions of each status as we have done on the server side, so that we can easily reference them in the code:

1 const Statuses = {
2     WAITING: 'waiting',
3     PLAYING: 'playing',
4     DRAW: 'draw',
5     WIN: 'win'
6 }

Now we can add a listener and event handler:

1 socket.on('gameState', function(state){
2   for (let index = 0; index < state.board.length; index++) {
3     const player = state.board[index];
4     if (player != null){
5       boardSquares[index].textBox.text = player.symbol;
6     } else
7     {
8       boardSquares[index].textBox.text = '';
9     }
10   }
11
12   statusLabel.text = '';
13   switch (state.result.status) {
14     case Statuses.WAITING:
15       statusLabel.text = 'Waiting for players....';
16       break;
17     case Statuses.PLAYING:
18       statusLabel.text = state.currentPlayer.playerName + ' to play';
19     break;
20     case Statuses.DRAW:
21       statusLabel.text = 'Draw! \nPress R for rematch';
22     break;
23     case Statuses.WIN:
24       statusLabel.text = state.result.winner.playerName + ' Wins! \nPress R for rema\
25 tch';
26     break;
27     default:
28       break;
29   }
30
31   playerOneLabel.text = '';
32   playerTwoLabel.text = '';
33   if (state.players.length > 0){
34     playerOneLabel.text = state.players[0].symbol + ': ' + state.players[0].playerNa\
35 me;
36   }
37
38   if (state.players.length > 1){
39     playerTwoLabel.text = state.players[1].symbol + ': ' + state.players[1].playerNa\
40 me;
41   }
42
43 });

This function looks quite long, but it’s mainly just updating the text boxes we added.

First, we loop through the board positions array that is passed from the server on the state payload, to check each block for a player positioned on it. If there is a player on a block, we write that player’s symbol to the corresponding text box, found in the boardSquares array we created above. If there is no player in the block, i.e it’s a null value, we write an empty string to the text block.

Then we update the statusLabel to show what is currently happening in the game. We use a switch statement to create logic for each of the possibilities. We write a different message to the statusLabel text box depending on the status, drawing from data in the gameState object.

Next we update the player name text boxes. First we reset them, in case one of the players has dropped out. Then we update the text boxes with the players’ symbols and names. Note that we first check if there are the corresponding players in the array.

Now that we’re done with updating from the game state, let’s try running the game again. Open the game window in a new tab so that requests to the repl server don’t get blocked by the browser due to the CORS header ‘Access-Control-Allow-Origin’ not matching in the embedded window.

Open in new tab

Make sure the server is also running, and enter your name. You should see something like this:

Waiting for another player

You can connect to your game in another browser tab, and enter another name. Then you should see both names come up, and the status message change to allow a player to make a move. Of course, we haven’t yet implemented the code to enable making a move from the UI, so let’s do that now.

Handling player moves

We want a player to be able to click on a block to place their move. Kaboom has a function onMouseRelease that we can use to handle mouse click events. All we need then is the position the mouse cursor is at, and we can map that to one of the board positions using our boardSquares array to do the lookup. We’ll use the Kaboom function mousePos to get the coordinates of the mouse:

1 onMouseRelease(() => {
2   const mpos = mousePos();
3   // find the square we clicked on
4   for (let index = 0; index < boardSquares.length; index++) {
5     const square = boardSquares[index];
6     if (mpos.x > square.x
7         && mpos.x < square.x + square.width
8         && mpos.y > square.y
9         && mpos.y < square.y + square.height){
10           socket.emit("action", {
11             gridIndex: square.index
12           });
13           break;
14         }
15   }
16 });

If we find a ‘hit’ on one of the board squares, we emit our action event. We pass the index of the square that was clicked on as the payload data. The server listens for this event, and runs the logic we added for the action event on the server side. If the action changes the game state, the server will send back the new game state, and the UI elements update.

The only other input we need to implement is to check if the player wants a rematch. To do that, we’ll assign the r key as the rematch command. We can use the Kaboom function charInput to listen for key press events. We’ll check if the key is r, or R, then emit the rematch event. We don’t have any data to pass with that, so we’ll just pass null.

1 charInput((ch) => {
2   if (ch === 'r' || ch === 'R'){
3     socket.emit("rematch", null)
4   }
5 });

Now you can run the game (and the server), and open the game in another tab, and you should be able to play tic-tac-toe against yourself! Send a link to the game to a friend, and see if they can join and play against you.

Playing tic tac toe

Next Steps

Now that you know the basics of creating a multiplayer online game, try your hand at making some different games, like checkers or chess or go.

Code

You can find the code for this tutorial on Replit