Case study: Treasure Hunter

I’ve told you that you now have all the skills you need to start making games. What? You don’t believe me? Let me prove it to you! Let’s take a close at how to make a simple object collection and enemy avoidance game called Treasure Hunter. (You’ll find it in the examples folder.)

Treasure Hunter

Treasure Hunter is a good example of one of the simplest complete games you can make using the tools you’ve learnt so far. Use the keyboard arrow keys to help the explorer find the treasure and carry it to the exit. Six blob monsters move up and down between the dungeon walls, and if they hit the explorer he becomes semi-transparent and the health meter at the top right corner shrinks. If all the health is used up, “You Lost!” is displayed on the stage; if the explorer reaches the exit with the treasure, “You Won!” is displayed. Although it’s a basic prototype, Treasure Hunter contains most of the elements you’ll find in much bigger games: texture atlas graphics, interactivity, collision, and multiple game scenes. Let’s go on a tour of how the game was put together so that you can use it as a starting point for one of your own games.

The code structure

Open the treasureHunter.html file and you’ll see that all the game code is in one big file. Here’s a birds-eye view of how all the code is organized.

  1. //Setup Pixi and load the texture atlas files - call the `setup`
  2. //function when they've loaded
  3. function setup() {
  4. //Initialize the game sprites, set the game `state` to `play`
  5. //and start the 'gameLoop'
  6. }
  7. function gameLoop(delta) {
  8. //Runs the current game `state` in a loop and renders the sprites
  9. }
  10. function play(delta) {
  11. //All the game logic goes here
  12. }
  13. function end() {
  14. //All the code that should run at the end of the game
  15. }
  16. //The game's helper functions:
  17. //`keyboard`, `hitTestRectangle`, `contain` and `randomInt`

Use this as your world map to the game as we look at how each section works.

Initialize the game in the setup function

As soon as the texture atlas images have loaded, the setup function runs. It only runs once, and lets you perform one-time setup tasks for your game. It’s a great place to create and initialize objects, sprites, game scenes, populate data arrays or parse loaded JSON game data.

Here’s an abridged view of the setup function in Treasure Hunter, and the tasks that it performs.

  1. function setup() {
  2. //Create the `gameScene` group
  3. //Create the `door` sprite
  4. //Create the `player` sprite
  5. //Create the `treasure` sprite
  6. //Make the enemies
  7. //Create the health bar
  8. //Add some text for the game over message
  9. //Create a `gameOverScene` group
  10. //Assign the player's keyboard controllers
  11. //set the game state to `play`
  12. state = play;
  13. //Start the game loop
  14. app.ticker.add(delta => gameLoop(delta));
  15. }

The last two lines of code, state = play; and gameLoop() are perhaps the most important. Adding the gameLoop to Pixi’s ticker switches on the game’s engine, and causes the play function to be called in a continuous loop. But before we look at how that works, let’s see what the specific code inside the setup function does.

Creating the game scenes

The setup function creates two Container groups called gameScene and gameOverScene. Each of these are added to the stage.

  1. gameScene = new Container();
  2. app.stage.addChild(gameScene);
  3. gameOverScene = new Container();
  4. app.stage.addChild(gameOverScene);

All of the sprites that are part of the main game are added to the gameScene group. The game over text that should be displayed at the end of the game is added to the gameOverScene group.

Displaying text

Although it’s created in the setup function, the gameOverScene shouldn’t be visible when the game first starts, so its visible property is initialized to false.

  1. gameOverScene.visible = false;

You’ll see ahead that, when the game ends, the gameOverScene‘s visible property will be set to true to display the text that appears at the end of the game.

Making the dungeon, door, explorer and treasure

The player, exit door, treasure chest and the dungeon background image are all sprites made from texture atlas frames. Very importantly, they’re all added as children of the gameScene.

  1. //Create an alias for the texture atlas frame ids
  2. id = resources["images/treasureHunter.json"].textures;
  3. //Dungeon
  4. dungeon = new Sprite(id["dungeon.png"]);
  5. gameScene.addChild(dungeon);
  6. //Door
  7. door = new Sprite(id["door.png"]);
  8. door.position.set(32, 0);
  9. gameScene.addChild(door);
  10. //Explorer
  11. explorer = new Sprite(id["explorer.png"]);
  12. explorer.x = 68;
  13. explorer.y = gameScene.height / 2 - explorer.height / 2;
  14. explorer.vx = 0;
  15. explorer.vy = 0;
  16. gameScene.addChild(explorer);
  17. //Treasure
  18. treasure = new Sprite(id["treasure.png"]);
  19. treasure.x = gameScene.width - treasure.width - 48;
  20. treasure.y = gameScene.height / 2 - treasure.height / 2;
  21. gameScene.addChild(treasure);

Keeping them together in the gameScene group will make it easy for us to hide the gameScene and display the gameOverScene when the game is finished.

Making the blob monsters

The six blob monsters are created in a loop. Each blob is given a random initial position and velocity. The vertical velocity is alternately multiplied by 1 or -1 for each blob, and that’s what causes each blob to move in the opposite direction to the one next to it. Each blob monster that’s created is pushed into an array called blobs.

  1. let numberOfBlobs = 6,
  2. spacing = 48,
  3. xOffset = 150,
  4. speed = 2,
  5. direction = 1;
  6. //An array to store all the blob monsters
  7. blobs = [];
  8. //Make as many blobs as there are `numberOfBlobs`
  9. for (let i = 0; i < numberOfBlobs; i++) {
  10. //Make a blob
  11. let blob = new Sprite(id["blob.png"]);
  12. //Space each blob horizontally according to the `spacing` value.
  13. //`xOffset` determines the point from the left of the screen
  14. //at which the first blob should be added
  15. let x = spacing * i + xOffset;
  16. //Give the blob a random `y` position
  17. let y = randomInt(0, stage.height - blob.height);
  18. //Set the blob's position
  19. blob.x = x;
  20. blob.y = y;
  21. //Set the blob's vertical velocity. `direction` will be either `1` or
  22. //`-1`. `1` means the enemy will move down and `-1` means the blob will
  23. //move up. Multiplying `direction` by `speed` determines the blob's
  24. //vertical direction
  25. blob.vy = speed * direction;
  26. //Reverse the direction for the next blob
  27. direction *= -1;
  28. //Push the blob into the `blobs` array
  29. blobs.push(blob);
  30. //Add the blob to the `gameScene`
  31. gameScene.addChild(blob);
  32. }

Making the health bar

When you play Treasure Hunter you’ll notice that when the explorer touches one of the enemies, the width of the health bar at the top right corner of the screen decreases. How was this health bar made? It’s just two overlapping rectangles at exactly the same position: a black rectangle behind, and a red rectangle in front. They’re grouped together into a single healthBar group. The healthBar is then added to the gameScene and positioned on the stage.

  1. //Create the health bar
  2. healthBar = new PIXI.Container();
  3. healthBar.position.set(stage.width - 170, 4)
  4. gameScene.addChild(healthBar);
  5. //Create the black background rectangle
  6. let innerBar = new PIXI.Graphics();
  7. innerBar.beginFill(0x000000);
  8. innerBar.drawRect(0, 0, 128, 8);
  9. innerBar.endFill();
  10. healthBar.addChild(innerBar);
  11. //Create the front red rectangle
  12. let outerBar = new PIXI.Graphics();
  13. outerBar.beginFill(0xFF3300);
  14. outerBar.drawRect(0, 0, 128, 8);
  15. outerBar.endFill();
  16. healthBar.addChild(outerBar);
  17. healthBar.outer = outerBar;

You can see that a property called outer has been added to the healthBar. It just references the outerBar (the red rectangle) so that it will be convenient to access later.

  1. healthBar.outer = outerBar;

You don’t have to do this; but, hey why not! It means that if you want to control the width of the red outerBar, you can write some smooth code that looks like this:

  1. healthBar.outer.width = 30;

That’s pretty neat and readable, so we’ll keep it!

Making the message text

When the game is finished, some text displays “You won!” or “You lost!”, depending on the outcome of the game. This is made using a text sprite and adding it to the gameOverScene. Because the gameOverScene‘s visible property is set to false when the game starts, you can’t see this text. Here’s the code from the setup function that creates the message text and adds it to the gameOverScene.

  1. let style = new TextStyle({
  2. fontFamily: "Futura",
  3. fontSize: 64,
  4. fill: "white"
  5. });
  6. message = new Text("The End!", style);
  7. message.x = 120;
  8. message.y = app.stage.height / 2 - 32;
  9. gameOverScene.addChild(message);

Playing the game

All the game logic and the code that makes the sprites move happens inside the play function, which runs in a continuous loop. Here’s an overview of what the play function does

  1. function play(delta) {
  2. //Move the explorer and contain it inside the dungeon
  3. //Move the blob monsters
  4. //Check for a collision between the blobs and the explorer
  5. //Check for a collision between the explorer and the treasure
  6. //Check for a collision between the treasure and the door
  7. //Decide whether the game has been won or lost
  8. //Change the game `state` to `end` when the game is finished
  9. }

Let’s find out how all these features work.

Moving the explorer

The explorer is controlled using the keyboard, and the code that does that is very similar to the keyboard control code you learnt earlier. The keyboard objects modify the explorer’s velocity, and that velocity is added to the explorer’s position inside the play function.

  1. explorer.x += explorer.vx;
  2. explorer.y += explorer.vy;

Containing movement

But what’s new is that the explorer’s movement is contained inside the walls of the dungeon. The green outline shows the limits of the explorer’s movement.

Displaying text

That’s done with the help of a custom function called contain.

  1. contain(explorer, {x: 28, y: 10, width: 488, height: 480});

contain takes two arguments. The first is the sprite you want to keep contained. The second is any object with x, y, width and height properties that define a rectangular area. In this example, the containing object defines an area that’s just slightly offset from, and smaller than, the stage. It matches the dimensions of the dungeon walls.

Here’s the contain function that does all this work. The function checks to see if the sprite has crossed the boundaries of the containing object. If it has, the code moves the sprite back into that boundary. The contain function also returns a collision variable with the value “top”, “right”, “bottom” or “left”, depending on which side of the boundary the sprite hit. (collision will be undefined if the sprite didn’t hit any of the boundaries.)

  1. function contain(sprite, container) {
  2. let collision = undefined;
  3. //Left
  4. if (sprite.x < container.x) {
  5. sprite.x = container.x;
  6. collision = "left";
  7. }
  8. //Top
  9. if (sprite.y < container.y) {
  10. sprite.y = container.y;
  11. collision = "top";
  12. }
  13. //Right
  14. if (sprite.x + sprite.width > container.width) {
  15. sprite.x = container.width - sprite.width;
  16. collision = "right";
  17. }
  18. //Bottom
  19. if (sprite.y + sprite.height > container.height) {
  20. sprite.y = container.height - sprite.height;
  21. collision = "bottom";
  22. }
  23. //Return the `collision` value
  24. return collision;
  25. }

You’ll see how the collision return value will be used in the code ahead to make the blob monsters bounce back and forth between the top and bottom dungeon walls.

Moving the monsters

The play function also moves the blob monsters, keeps them contained inside the dungeon walls, and checks each one for a collision with the player. If a blob bumps into the dungeon’s top or bottom walls, its direction is reversed. All this is done with the help of a forEach loop which iterates through each of blob sprites in the blobs array on every frame.

  1. blobs.forEach(function(blob) {
  2. //Move the blob
  3. blob.y += blob.vy;
  4. //Check the blob's screen boundaries
  5. let blobHitsWall = contain(blob, {x: 28, y: 10, width: 488, height: 480});
  6. //If the blob hits the top or bottom of the stage, reverse
  7. //its direction
  8. if (blobHitsWall === "top" || blobHitsWall === "bottom") {
  9. blob.vy *= -1;
  10. }
  11. //Test for a collision. If any of the enemies are touching
  12. //the explorer, set `explorerHit` to `true`
  13. if(hitTestRectangle(explorer, blob)) {
  14. explorerHit = true;
  15. }
  16. });

You can see in this code above how the return value of the contain function is used to make the blobs bounce off the walls. A variable called blobHitsWall is used to capture the return value:

  1. let blobHitsWall = contain(blob, {x: 28, y: 10, width: 488, height: 480});

blobHitsWall will usually be undefined. But if the blob hits the top wall, blobHitsWall will have the value “top”. If the blob hits the bottom wall, blobHitsWall will have the value “bottom”. If either of these cases are true, you can reverse the blob’s direction by reversing its velocity. Here’s the code that does this:

  1. if (blobHitsWall === "top" || blobHitsWall === "bottom") {
  2. blob.vy *= -1;
  3. }

Multiplying the blob’s vy (vertical velocity) value by -1 will flip the direction of its movement.

Checking for collisions

The code in the loop above uses hitTestRectangle to figure out if any of the enemies have touched the explorer.

  1. if(hitTestRectangle(explorer, blob)) {
  2. explorerHit = true;
  3. }

If hitTestRectangle returns true, it means there’s been a collision and a variable called explorerHit is set to true. If explorerHit is true, the play function makes the explorer semi-transparent and reduces the width of the health bar by 1 pixel.

  1. if(explorerHit) {
  2. //Make the explorer semi-transparent
  3. explorer.alpha = 0.5;
  4. //Reduce the width of the health bar's inner rectangle by 1 pixel
  5. healthBar.outer.width -= 1;
  6. } else {
  7. //Make the explorer fully opaque (non-transparent) if it hasn't been hit
  8. explorer.alpha = 1;
  9. }

If explorerHit is false, the explorer’s alpha property is maintained at 1, which makes it fully opaque.

The play function also checks for a collision between the treasure chest and the explorer. If there’s a hit, the treasure is set to the explorer’s position, with a slight offset. This makes it look like the explorer is carrying the treasure.

Displaying text

Here’s the code that does this:

  1. if (hitTestRectangle(explorer, treasure)) {
  2. treasure.x = explorer.x + 8;
  3. treasure.y = explorer.y + 8;
  4. }

Reaching the exit door and ending the game

There are two ways the game can end: You can win if you carry the treasure to the exit, or you can lose if you run out of health.

To win the game, the treasure chest just needs to touch the exit door. If that happens, the game state is set to end, and the message text displays “You won”.

  1. if (hitTestRectangle(treasure, door)) {
  2. state = end;
  3. message.text = "You won!";
  4. }

If you run out of health, you lose the game. The game state is also set to end and the message text displays “You Lost!”

  1. if (healthBar.outer.width < 0) {
  2. state = end;
  3. message.text = "You lost!";
  4. }

But what does this mean?

  1. state = end;

You’ll remember from earlier examples that the gameLoop is constantly updating a function called state at 60 times per second. Here’s the gameLoopthat does this:

  1. function gameLoop(delta){
  2. //Update the current game state:
  3. state(delta);
  4. }

You’ll also remember that we initially set the value of state to play, which is why the play function runs in a loop. By setting state to end we’re telling the code that we want another function, called end to run in a loop. In a bigger game you could have a tileScene state, and states for each game level, like leveOne, levelTwo and levelThree.

So what is that end function? Here it is!

  1. function end() {
  2. gameScene.visible = false;
  3. gameOverScene.visible = true;
  4. }

It just flips the visibility of the game scenes. This is what hides the gameScene and displays the gameOverScene when the game ends.

This is a really simple example of how to switch a game’s state, but you can have as many game states as you like in your games, and fill them with as much code as you need. Just change the value of state to whatever function you want to run in a loop.

And that’s really all there is to Treasure Hunter! With a little more work you could turn this simple prototype into a full game – try it!