Phaser. Unit 10. Halloween merge game

Introduction

In this unit we will develop a game to merge objects inspired by the Suika Game, also known as the Watermelon Game. Although our game will be quite similar, it will feature a Halloween theme, from this specific Suika Clone version:

Halloween

CSS code (styles.css)

We are going to insert some CSS code to remove any margin and padding, as well as the scrollbars, to ensure that the maximum window size is used when the game starts. We will also display an image as the background for the game and we will define some border-radius to smooth the corners of the headstone:

body {
  height: 100vh;
  margin: 0;
  overflow: hidden;
  background-image: url(assets/background.jpg);
  background-size: cover;
}

canvas {
  border-radius: 20px;
}

HTML code (index.html)

The HTML source code inside the “index.html” file must be structured to link all necessary resources and scripts required to run the game:

  • preload.js will be responsible for loading all assets (images, sounds, etc.) before the game starts.
  • create.js will set up initial game objects and game states.
  • update.js will handle the logic that updates the state of the game when something happens (dropper movement, collisions, game over, etc).
  • config.js will contain configuration settings for the game, such as screen dimensions, physics settings, and global variables.

This HTML setup ensures that when the page is loaded in a browser, all necessary components are initialized in the correct order, starting with the loading of the Phaser framework, followed by specific game scripts that define and control the gameplay. This structure provides a clean and organized way to manage different aspects of game development:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Suika Clone</title>
  <link rel="stylesheet" href="style.css">
</head>

<body>
  <div id="gameContainer"></div>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>
  <script src="preload.js"></script>
  <script src="create.js"></script>
  <script src="update.js"></script>
  <script src="config.js"></script>
</body>

</html>

Constants and Phaser configuration (config.js)

Inside the “config.js” file we will first define a list of game objects, each with a name and a specific radius. These objects include various Halloween-themed items like candies, spiders, and pumpkins. They will be used as the objects to be merged in the game, each one bigger than the previous one.

In the second part of the file, a new Phaser game instance is configured with specific settings such as canvas size, scaling to fit the container while maintaining aspect ratio, and center alignment. The game is set to be transparent and uses the Matter physics engine to manage physical interactions. Essential game functions like preload for asset loading and create for setting up initial game states are also defined:

const objects = [
  { name: "candy", radius: 30 },
  { name: "spider", radius: 35 },
  { name: "eye", radius: 40 },
  { name: "candle", radius: 50 },
  { name: "apple", radius: 65 },
  { name: "hat", radius: 70 },
  { name: "skull", radius: 80 },
  { name: "pumpkin", radius: 90 },
  { name: "cat", radius: 100 },
  { name: "ghost", radius: 110 },
  { name: "cauldron", radius: 120 },
];

new Phaser.Game({
  width: 600,
  height: 1000,
  parent: "gameContainer",
  scale: {
    mode: Phaser.Scale.ScaleModes.FIT,
    autoCenter: Phaser.Scale.Center.CENTER_BOTH
  },
  transparent: true,
  physics: {
    default: "matter",
    matter: {
      debug: false,
    },
  },
  scene: {
    preload: preload,
    create: create
  },
});

Images, music and sounds (assets folder)

We will need some images and sounds. You may use any assets you like, but we are providing the original ones so that you can easily develop the same game. Some examples are shown below, and also a zip file containing all the assets can be downloaded here.

Background and headstone images

Background
Headstone

Score numbers

Objects

Candy
Spider
Eye
Candle
Apple
Hat
Skull
Pumpkin
Cat
Ghost
Cauldron

Background music

Sounds

Match sound (when two objects are merged)
Game over sound (when the stack of objects reaches the ceiling)

Loading the assets (preload.js)

The provided preload function is part of a Phaser game’s lifecycle and is crucial for setting up the initial state of the game as well as loading all necessary assets before the gameplay can begin. This function ensures that all graphical and audio components required for the game are loaded into memory and are accessible during the game to avoid delays and enhance the gaming experience.

In this function, the game starts by initializing essential game state variables such as the score, which is set to zero, and a boolean flag gameOver, set to false, indicating the game is currently active. These are fundamental for tracking gameplay progress and status.

The function then proceeds to load various types of assets:

  1. Images: It loads specific images like the “headstone” and “newgame” graphics from predefined paths within the assets directory. Additionally, it loads a series of images representing numbers (0-9), used for displaying the score on the game interface. Each object defined in the objects array ensures that all visual elements related to gameplay objects are available.
  2. Audio: Three types of audio files are loaded: background music, a sound effect for matching items (used during successful gameplay interactions), and a game-over sound (used to signify the end of the game). This enhances the feedback for the player, contributing to the overall immersive experience of the game.

Overall, the preload function is essential for managing the game’s resources, loading everything needed. This setup allows the game to run more efficiently and provides a seamless player experience by having all necessary assets ready at game start:

function preload() {
  this.score = 0;
  this.gameOver = false;

  this.load.image("headstone", "assets/headstone.png");
  this.load.image("newgame", "assets/new-game.png");

  for (let i = 0; i <= 9; i++) {
    this.load.image(`${i}`, `assets/numbers/${i}.png`);
  }

  for (const object of objects) {
    this.load.image(`${object.name}`, `assets/objects/${object.name}.png`);
  }

  this.load.audio("background-music", "assets/background-music.mp3");
  this.load.audio("match-sound", "assets/match-sound.mp3");
  this.load.audio("game-over-sound", "assets/game-over-sound.mp3");
}

Setting up the headstone with the score, the dropper, and particles and light effects (create.js)

The code inside “create.js” will focus on initializing the game components and configuring interactions in a structured manner within the create function. This function serves as the central setup hub where multiple functions are called to configure specific aspects of the game environment and mechanics.

The game begins with setting up audio elements to enrich the gameplay experience with background music and sound effects for specific actions like matches or game over events. Visual aspects are addressed next, including dynamic lighting effects that follow the cursor, creating an engaging and responsive atmosphere. Additionally, particle systems are configured to visually represent interactions such as collisions, adding a layer of polish to the game dynamics.

Game objects and physical boundaries are established through functions like setupHeadstone, which also configures the visual representation and interaction boundaries within the gameplay area. A crucial gameplay mechanic is introduced with setupCeiling and setupDropper, where objects interact within set limits, and a dropper mechanism is used for object placement, enhancing the interactive component of the game.

Other functionalities include a restart button, allowing players to easily reset the game, thereby improving user experience and engagement. The setupEvents function ties all interactive elements together, enabling the game to respond to user inputs and collisions, ensuring a seamless and dynamic gameplay experience. Overall, this code snippet is fundamental in laying down the mechanics and aesthetic elements of the game, making it ready for engaging and responsive play:

function create() {
  setupAudio.call(this);
  setupLight.call(this);
  setupParticles.call(this);
  setupHeadstone.call(this);
  drawScore.call(this);
  setupCeiling.call(this);
  setupDropper.call(this);
  setupRestartButton.call(this);
  setupEvents.call(this);
}

function setupAudio() {
  this.backgroundMusic = this.sound.add('background-music', { volume: 0.75 }).play({ loop: -1 });
  this.matchSound = this.sound.add('match-sound', { volume: 1.00 });
  this.gameOverSound = this.sound.add('game-over-sound', { volume: 1.00 });
}

// Setup the light at the cursor
function setupLight() {
  this.light = this.lights
    .addLight(this.input.activePointer.x, this.input.activePointer.y, 1000, 0x99ffff, 0.75)
    .setScrollFactor(0);
  this.lights.enable().setAmbientColor(0xdddddd);
}

// Define the particles to be created when a pair of objects collide
function setupParticles() {
  this.particles = this.add.particles(0, 0, objects[0].name, {
    lifespan: 1000,
    speed: { min: 200, max: 350 },
    scale: { start: 0.1, end: 0 },
    rotate: { start: 0, end: 360 },
    alpha: { start: 1, end: 0 },
    gravityY: 200,
    emitting: false
  });
}

function setupHeadstone() {
  // Draw the image of the headstone
  this.add
    .nineslice(0, 0, "headstone")
    .setOrigin(0)
    .setDisplaySize(+this.game.config.width, +this.game.config.height)
    .setPipeline("Light2D")
    .setDepth(-2);

  // Define a box where the score will be drawn at the top of the headstone
  this.scoreBox = this.add.renderTexture(
    +this.game.config.width / 2, 150,
    +this.game.config.width, 100
  ).setScale(0.8);

  // Create a grup of objects to put all the falling objects
  this.objects = this.add.group();

  // Define the limits of the headstone where the objects will be located
  this.matter.world.setBounds(
    65, 0,
    +this.game.config.width - 130, +this.game.config.height - 1
  );
}

// Draw a line to check when the objects reach the top of the headstone
function setupCeiling() {
  this.ceiling = this.matter.add.rectangle(
    +this.game.config.width / 2, 100,
    +this.game.config.width, 200
  );
  this.ceiling.isStatic = true;

  const line = this.add
    .rectangle(160, 200, +this.game.config.width - 320, 2, 0xccccff)
    .setOrigin(0)
    .setAlpha(0.1)
    .setDepth(-2);
  line.postFX.addShine();
  line.postFX.addGlow();
}

// Configure the dropper with the first object and add a glow effect
function setupDropper() {
  this.dropper = this.add.image(this.input.activePointer.x, 0, objects[0].name);
  const glow = this.dropper.postFX.addGlow(0x99ddff);
  this.tweens.addCounter({
    yoyo: true, repeat: -1, from: 1, to: 3, duration: 1000,
    onUpdate: tween => glow.outerStrength = tween.getValue()
  });
  updateDropper.call(this, objects[0]);
}

// When the game is over a button is shown to restart the game
function setupRestartButton() {
  const centerX = this.game.config.width / 2;
  const centerY = this.game.config.height / 2;
  const button = this.add.image(centerX, centerY, "newgame")
    .setScale(0.4)
    .setInteractive({ useHandCursor: true })
    .setVisible(false);

  button.postFX.addGlow(0x000000, 0.75);

  const tweenOptions = scale => ({ targets: button, scale, ease: "Linear", duration: 100 });

  button.on("pointerover", () => this.tweens.add(tweenOptions(0.5)));
  button.on("pointerout", () => this.tweens.add(tweenOptions(0.4)));
  button.on("pointerup", () => restart.call(this));
  this.restartButton = button;
}

// Configure all the events that will be used in the game (dropper and objects collisions)
function setupEvents() {
  this.input.on("pointermove", pointer => moveDropper.call(this, pointer));
  this.input.on("pointerdown", pointer => moveDropper.call(this, pointer));
  this.input.on("pointerup", () => nextObject.call(this));
  this.matter.world.on('collisionstart', event => handleCollisions.call(this, event));
}

Updating the dropper and controlling the objects collisions (update.js)

The “update.js” file will include all the functions to define the dropper mechanism and to implement controls for managing object collisions in the game.

The functions setDropperX and moveDropper handle the positioning of a draggable game object, referred to as the “dropper,” which players can move horizontally. The position is carefully constrained within predefined game boundaries to ensure it does not move outside the playable area. The updateDropper function updates the visual representation of the dropper based on game events, such as selecting different objects to be dropped next, and also includes visual effects like shining to highlight active elements.

Gameplay interaction is further expanded through the addObject function, which introduces new game objects into the scene at specified positions, simulating physical properties like friction and bounce. The nextObject function controls the sequence of objects being introduced, ensuring that gameplay progresses logically and consistently. The scoring system is integrated into various game actions, with the drawScore function rendering the current score visually in a dedicated area, providing immediate feedback on player performance.

Collision handling is crucial in this game setup, as seen in the handleCollisions function. This function manages the effects when two objects of the same type collide, including updating the score, playing sound effects, and possibly escalating to a higher level of complexity by introducing new, more challenging objects. The game’s flow is controlled by the gameOver function, which triggers when certain game-ending conditions are met, handling tasks such as displaying restart options and stopping all ongoing game sounds.

Finally, the restart function allows players to reset the game to its initial state, providing a fresh start and ensuring that the game can be played repeatedly without needing to reload the entire game.

Overall, these functions collectively manage the core mechanics of the game, including object manipulation, scoring, event handling, and game state transitions, creating a dynamic and interactive gaming experience:

function setDropperX(x) {
  const padding = 65;
  const radius = this.dropper.displayWidth / 2;
  const maxWidth = this.game.config.width - radius - padding;
  x = Math.max(radius + padding, Math.min(x, maxWidth));
  this.dropper.setX(x);
}

// Moves the dropper when the event "pointermove" happens
function moveDropper(pointer) {
  setDropperX.call(this, pointer.x);
  this.light.setPosition(pointer.x, pointer.y);
}

// Updates the image of the dropper when the event "pointerup" happens
function updateDropper(object) {
  this.dropper
    .setTexture(object.name)
    .setName(object.name)
    .setDisplaySize(object.radius * 2, object.radius * 2)
    .setY(object.radius + 205);
  setDropperX.call(this, this.input.activePointer.x);

  // Check which object is the same as the one in the dropper and make it shine
  this.objects.getChildren().forEach((gameObject) => {
    if (gameObject instanceof Phaser.GameObjects.Image) {
      gameObject.postFX.clear();

      if (gameObject.name === object.name) gameObject.postFX.addShine();
    }
  });
}

// Renders the score inside the box defined previously
function drawScore() {
  this.scoreBox.clear();
  const chars = this.score.toString().split("");

  const textWidth = chars.reduce((acc, c) => acc + this.textures.get(c).get().width, 0);

  let x = (this.scoreBox.width - textWidth) / 2;

  chars.forEach(char => {
    this.scoreBox.drawFrame(char, undefined, x, 0);
    x += this.textures.get(char).get().width;
  });
}

function addObject(x, y, object) {
  this.objects.add(this.matter.add
    .image(x, y, object.name)
    .setName(object.name)
    .setDisplaySize(object.radius * 2, object.radius * 2)
    .setCircle(object.radius)
    .setFriction(0.005)
    .setBounce(0.2)
    .setDepth(-1)
    .setOnCollideWith(this.ceiling, () => gameOver.call(this)));
}

// Creates and puts a new object on the headstone
function nextObject() {
  if (!this.dropper.visible || this.gameOver) return;

  this.dropper.setVisible(false);
  this.time.delayedCall(500, () => this.dropper.setVisible(!this.gameOver));

  addObject.call(this,
    this.dropper.x, this.dropper.y,
    objects.find(object => object.name === this.dropper.name)
  );

  updateDropper.call(this, objects[Math.floor(Math.random() * 5)]);
}

// Function executed when two objects of the same type collide
function handleCollisions(event) {
  for (const { bodyA, bodyB } of event.pairs) {
    if (bodyA.gameObject?.name === bodyB.gameObject?.name) {
      if (navigator.vibrate) navigator.vibrate(50);
      
      const objectIndex = objects.findIndex(object => object.name === bodyA.gameObject.name);
      if (objectIndex === -1) return;

      this.score += (objectIndex + 1) * 2;
      drawScore.call(this);

      bodyA.gameObject.destroy();
      bodyB.gameObject.destroy();

      this.particles
        .setTexture(objects[objectIndex].name)
        .emitParticleAt(bodyB.position.x, bodyB.position.y, 10);

      this.matchSound.play();
      
      const newObject = objects[objectIndex + 1];
      if (!newObject) return;

      addObject.call(this, bodyB.position.x, bodyB.position.y, newObject);
      return;
    }
  }
}

function gameOver() {
  this.gameOver = true;
  this.restartButton.setVisible(true);
  this.dropper.setVisible(false);
  this.game.sound.stopAll();
  this.gameOverSound.play({ loop: -1 });
}

function restart() {
  this.score = 0;
  this.gameOver = false;
  this.game.sound.stopAll();
  this.scene.restart();
}

Proposed exercise: Using your own assets

Create a new version of the game using your own assets (images, and sounds), and even including more objects. You can also change any other things you like.

Do not forget to create all the files required to run the code in this unit:

  • styles.css
  • index.html
  • preload.js
  • create.js
  • update.js
  • config.js
  • assets folder

You may find many free assets in OpenGameArt.

Enjoy the game!

You can enjoy playing this wonderful game online here.