JavaScript Development Space

Building A Memory Game with PhaserJS and ReactJS

Add to your RSS feedSeptember, 27th 202417 min read
Building A Memory Game with PhaserJS and ReactJS

Today, we’ll create a simple game using PhaserJS and ReactJS. Despite the simplicity of the game, we will cover all the key aspects of game development with PhaserJS. We’ll explore concepts like scenes, prefabs, how to integrate PhaserJS with ReactJS, how to add third-party libraries and components, and how to use tweens for animations.

What is the PhaserJS ?

PhaserJS is a fast, open-source HTML5 game framework used for building 2D games for desktop and mobile platforms. It provides a robust set of tools for game development, including physics engines, sprite handling, animations, input handling, and more. Developers can create games using JavaScript or TypeScript, with support for WebGL and Canvas rendering.

What are we going to build?

We’ll create a simple game where 10 cards are displayed, and the goal is to open them one by one within 30 seconds. You’ll be able to adjust the time limit and the number of cards as needed.

Memory game PhaserJS

1. Setup the Game

We will use the official PhaserJS starter template integrated with React. This will save us time by providing pre-built EventEmitter and Scenes, allowing us to focus on the core game logic.

Clone the template

git clone https://github.com/phaserjs/template-react-ts phaser-memory-game

We've copied the template, now let's install all the necessary dependencies.

cd phaser-memory-game && npm install

Once that’s done, let’s go through the template's structure before we clean it up.

Project structure

Let's quickly go over the purpose of the folders. We won't be reviewing the configuration files, as it's assumed you're already familiar with React and Vite.

Project structure

  • public: Contains assets and CSS.
  • game: The main folder for the game.
  • App.tsx: Connects PhaserJS with React.
  • vite: Vite configuration folder.

Inside the game folder:

  • scenes: Holds game scene files.
  • EventBus: Manages events between Phaser and React.
  • PhaserGame.tsx: React component that includes the game.
  • main.ts: The main configuration for PhaserJS.

Clean the template files

Remove everything from App.tsx except the following code:

tsx
1 import { useRef } from 'react';
2 import { IRefPhaserGame, PhaserGame } from './game/PhaserGame';
3
4 function App() {
5 // References to the PhaserGame component (game and scene are exposed)
6 const phaserRef = useRef<IRefPhaserGame | null>(null);
7
8 return (
9 <div id='app'>
10 <PhaserGame ref={phaserRef} />
11 </div>
12 );
13 }
14
15 export default App;

Here, we pass a ref to our game component, and that’s all.

Now, remove everything from PhaserGame.tsx except this code:

tsx
1 import { forwardRef, useLayoutEffect, useRef } from 'react';
2 import StartGame from './main';
3
4 export interface IRefPhaserGame {
5 game: Phaser.Game | null;
6 scene: Phaser.Scene | null;
7 }
8
9 export const PhaserGame = forwardRef<IRefPhaserGame>(function PhaserGame(
10 { currentActiveScene },
11 ref,
12 ) {
13 const game = useRef<Phaser.Game | null>(null!);
14
15 useLayoutEffect(() => {
16 if (game.current === null) {
17 game.current = StartGame('game-container');
18
19 if (typeof ref === 'function') {
20 ref({ game: game.current, scene: null });
21 } else if (ref) {
22 ref.current = { game: game.current, scene: null };
23 }
24 }
25
26 return () => {
27 if (game.current) {
28 game.current.destroy(true);
29 if (game.current !== null) {
30 game.current = null;
31 }
32 }
33 };
34 }, [ref]);
35
36 return <div id='game-container'></div>;
37 });

Next, delete the files: EventBus.ts, scenes/MainMenu.ts and scenes/GameOver.ts, as they are not needed for our project.

Modify game/main.ts file

ts
1 import { AUTO, Game } from 'phaser';
2 import { Boot } from './scenes/Boot';
3 import { Game as MainGame } from './scenes/Game';
4 import { Preloader } from './scenes/Preloader';
5
6 // Find out more information about the Game Config at:
7 // https://newdocs.phaser.io/docs/3.70.0/Phaser.Types.Core.GameConfig
8 const config: Phaser.Types.Core.GameConfig = {
9 type: AUTO,
10 width: 1280,
11 height: 720,
12 parent: 'game-container',
13 backgroundColor: '#ffffff',
14 scale: {
15 width: 1280,
16 height: 720,
17 },
18 physics: {
19 default: 'arcade',
20 arcade: {
21 debug: false,
22 },
23 },
24 render: {
25 antialiasGL: false,
26 pixelArt: true,
27 },
28 canvasStyle: `display: block; width: 100%; height: 100%;`,
29 autoFocus: true,
30 audio: {
31 disableWebAudio: false,
32 },
33 scene: [Boot, Preloader, MainGame],
34 };
35
36 const StartGame = (parent: string) => {
37 const game = new Game({ ...config, parent });
38
39 return game;
40 };
41
42 export default StartGame;

This is the standard configuration where we set the game’s width and height, canvas styles, and more. While the final configuration file is quite important, everything here is fairly intuitive.

Our template still contains unnecessary assets and code, but we will clean those up as we progress through the development.

2. Starting Game Development

Let’s begin developing our game.

Display the background.

Background of the game

Save this image as assets/bg.png.

Add the Background in Boot.ts

The boot scene is specifically designed to load assets that will be used in the game. This way, when we access to the next scene, our background will already be loaded.

ts
1 // Boot.ts
2
3 import { Scene } from 'phaser';
4
5 export class Boot extends Scene {
6 constructor() {
7 super('Boot');
8 }
9
10 preload() {
11 // The Boot Scene is typically used to load in any assets you require for your Preloader, such as a game logo or background.
12 // The smaller the file size of the assets, the better, as the Boot Scene itself has no preloader.
13
14 this.load.image('background', 'assets/bg.png');
15 }
16
17 create() {
18 this.scene.start('Preloader');
19 }
20 }

We have loaded the image, so now we can display it in any of our scenes using the identifier background.

Now, let's show our background in the other scenes.

Preload.ts

ts
1 import { Scene } from 'phaser';
2
3 export class Preloader extends Scene {
4 constructor() {
5 super('Preloader');
6 }
7
8 init() {
9 // We loaded this image in our Boot Scene, so we can display it here
10 this.add.image(window.innerWidth / 2, window.innerHeight / 2, 'background');
11 }
12
13 create() {
14 this.scene.start('MainMenu');
15 }
16 }

In the preloader, we display our background at the center of the screen by calculating the center using window DOM.

Game.ts

ts
1 import { Scene } from 'phaser';
2
3 export class Game extends Scene {
4 background: Phaser.GameObjects.Image;
5
6 constructor() {
7 super('Game');
8 }
9
10 create() {
11 this.background = this.add.image(0, 0, 'background').setOrigin(0, 0);
12 }
13 }

Here's a breakdown:

this.background adds a background image at coordinates (0, 0) and sets its origin to the top-left corner (setOrigin(0, 0)).

Run

npm run dev
Bg Added

Everything is set; we've displayed our background. Now, let's move on and add the cards.

3. Adding the cards

Upload the card assets to the public/assets folder.

Card Card1 Card2 Card3 Card4 Card5

P.S. This is demo graphics created with AI. The quality is not great, but it will suffice for showcasing the capabilities of PhaserJS.

Next, we need to load these assets into the game using our Preloader scene.

ts
1 preload() {
2 // Load the assets for the game - Replace with your own assets
3 this.load.setPath("assets");
4
5 this.load.image("card", "card.png");
6 this.load.image("card1", "card1.png");
7 this.load.image("card2", "card2.png");
8 this.load.image("card3", "card3.png");
9 this.load.image("card4", "card4.png");
10 this.load.image("card5", "card5.png");
11 }

We've set up the assets folder and loaded the card assets.

Now, let's test it... we'll try displaying a card in the top-left corner. To do this, add the following code to the Game scene.

Background with Card

We can see the card in the top-left corner. Now, we need to distribute 10 such cards. To achieve this, we'll create the getCardsPositions function.

But first, we need CONSTANTS. Let's create a utils folder inside the src directory and add a constants.ts file.

ts
1 export const ROWS = 2;
2 export const COLS = 5;
3 export const CARDS = [1, 2, 3, 4, 5];
4 export const TIMEOUT = 30;

These are all the constants we need for the game. You'll understand the purpose of each one a bit later. Now, let's return to our getCardsPositions function.

ts
1 getCardsPosition(): { x: number; y: number }[] {
2 const cardWidth = 196 + 5;
3 const cardHeight = 306 + 5;
4 const positions = [];
5 const offsetX =
6 (+this.sys.game.config.width - cardWidth * COLS) / 2 +
7 cardWidth / 2;
8 const offsetY =
9 (+this.sys.game.config.height - cardHeight * ROWS) / 2 +
10 cardHeight / 2;
11
12 let id = 0;
13 for (let r = 0; r < ROWS; r++) {
14 for (let c = 0; c < COLS; c++) {
15 positions.push({
16 x: offsetX + c * cardWidth,
17 y: offsetY + r * cardHeight,
18 delay: ++id * 100,
19 });
20 }
21 }
22 Phaser.Utils.Array.Shuffle(positions);
23 return positions;
24 }

This function calculates the positions of cards on the game board and returns an array of objects, each containing the x and y coordinates of a card.

Card Dimensions:

cardWidth: The width of each card is set to 196 plus 5 pixels of padding (total: 201). cardHeight: The height of each card is set to 306 plus 5 pixels of padding (total: 311).

Offset Calculation:

offsetX: The horizontal offset centers the cards on the screen. It subtracts the total width of the card grid from the total game width, divides it by 2 to center it, and adds half a card width to ensure proper positioning. offsetY: Similarly, the vertical offset centers the cards on the screen using the total height of the card grid and game height.

Loop through Rows and Columns:

  • The function loops through ROWS and COLS (presumably constants representing the number of rows and columns on the grid).
  • For each row (r) and column (c), it calculates the position of a card and pushes it into the positions array.
  • The x position is calculated using the offset plus the column index multiplied by the card width.
  • The y position is calculated similarly using the row index multiplied by the card height.
  • delay is an optional property that adds a unique delay to each card's animation, incrementing by 100ms with each card.

Shuffle:

The Phaser.Utils.Array.Shuffle method randomly shuffles the positions array to ensure the cards are distributed randomly.

Purpose:

This function sets up the grid of cards in random positions, ensuring they are evenly distributed and properly centered on the game board.

Now we'll get the positions in the create method and generate the cards in a loop.

ts
1 create() {
2 this.background = this.add.image(0, 0, "background").setOrigin(0, 0);
3
4 const positions = this.getCardsPosition();
5
6 for (const pos of positions) {
7 this.add.sprite(pos.x, pos.y, "card").setOrigin(0.5, 0.5);
8 }
9 }

We have displayed the cards on the screen. The next step is to create a separate prefab, which will help us move all the necessary code into the Card class. This way, we can organize our code better and clean up the Game scene.

Create prefab card

In Phaser, prefabs (classes) are reusable game objects that encapsulate both functionality and appearance, allowing developers to create instances of objects with predefined properties and behaviors.

Key Features of Prefabs in Phaser:

  • Encapsulation: A prefab can include properties (like position, size, or appearance) and methods (like actions or behaviors) that define how an object should behave in the game.

  • Reusability: Once a prefab is defined, you can create multiple instances of it throughout your game without duplicating code. This promotes DRY (Don't Repeat Yourself) principles and simplifies maintenance.

  • Composition: Prefabs can be composed of other prefabs or game objects. This allows developers to build complex objects using simpler, reusable components.

  • Ease of Modification: Changes made to the prefab will automatically be reflected in all instances, making it easy to update behavior or appearance across the entire game.

  • Scene Management: Prefabs help organize game scenes by grouping related functionality and assets into cohesive units, making the codebase cleaner and easier to navigate.

Create file prefabs/Card.ts

ts
1 class Card extends Phaser.GameObjects.Sprite {
2 isOpened: boolean = false;
3 positionX = 0;
4 positionY = 0;
5 delay = 0;
6
7 constructor(scene: Phaser.Scene, value: number) {
8 super(scene, 0, 0, 'card');
9 this.scene = scene;
10 this.value = value;
11 this.setOrigin(0.5, 0.5);
12 this.scene.add.existing(this);
13 this.setInteractive();
14 }
15
16 init(x: number, y: number, delay: number) {
17 this.positionX = x;
18 this.positionY = y;
19 this.delay = delay;
20 this.setPosition(-this.width, -this.height);
21 }
22 }
23
24 export default Card;

The code here may seem a bit odd, but we actually need it for future animations. You'll understand why we defined the variables positionX and this.positionY a bit later.

This code defines a Card class that extends Phaser.GameObjects.Sprite. The class includes properties to track the card's state and methods for initialization and interaction.

The init function is called automatically by Phaser, so we don't need to call it ourselves anywhere.

We just need to connect everything in the Game.ts file. We'll do this in a separate method called createCards and call it from the create function.

1 // Game.ts
2
3 cards: Card[] = [];
4
5 create() {
6 this.background = this.add.image(0, 0, "background").setOrigin(0, 0);
7
8 this.createCards();
9 }
10
11 createCards() {
12 for (const card of CARDS) {
13 for (let i = 0; i < ROWS; i++) {
14 this.cards.push(new Card(this, card));
15 }
16 }
17 }

Now create method init to initialize the cards

ts
1 initCards() {
2 const positions = this.getCardsPosition();
3
4 this.cards.forEach((card) => {
5 const position = positions.pop();
6 card.init(position?.x, position?.y, position?.delay);
7 card.setPosition(card.positionX, card.positionY);
8 });
9 }

Don't forget to call it from the create method.

And voilà, we see the cards again!

4. Displaying the Cards on the Screen

So far, we have only displayed one card, which is the backside of all the cards. Now, we need to show all the cards. We have five cards: card1, card2, ..., card5, and we already have a constant named CARDS, which is an array containing the numbers from 1 to 5. We just need to put it all together.

For now, we'll simply sort the array of cards and display their numbers in the prefab; we have already prepared everything for this.

In the initCards method, add the following code:

js
1 Phaser.Utils.Array.Shuffle(positions);

Phaser provides utility classes, including one for working with arrays, which we will use for "sorting".

Then, let's return to the Card class and add the value to our sprite.

js
1 super(scene, 0, 0, 'card' + value);

Now we can see all the cards opened.

Cards open

5. Handling Input Events

We need the cards to flip open on click, rather than being open all the time.

Remove the recent changes in Card.ts, where we added the value just for testing. The cards will only be flipped open when clicked.

js
1 // Card.ts
2
3 super(scene, 0, 0, 'card');

Creating the First Animation

We need to implement four simple methods for complete control of the card: openCard, closeCard, flipCard, and showCard. Let's get started!

openCard:

js
1 openCard() {
2 this.isOpened = true;
3 this.flipCard();
4 }

**closeCard: **

js
1 closeCard() {
2 if (this.isOpened) {
3 this.isOpened = false;
4 this.flipCard();
5 }
6 }

The property isOpened is a boolean that indicates whether the card is open or not. Finally, the method calls flipCard() to trigger the animation or logic responsible for visually flipping the card back to its closed state.

flipCard:

js
1 flipCard() {
2 this.scene.tweens.add({
3 targets: this,
4 scaleX: 0,
5 ease: "Linear",
6 duration: 150,
7 onComplete: () => {
8 this.showCard();
9 },
10 });
11 }

This method adds a tween animation to the scene. A tween is used to change a property of an object over time in a smooth, animated way. This reduces the horizontal scale of the card to 0, making it appear as if the card is flipping horizontally and disappearing from view. Once the card finishes the flip (scaleX reaches 0), the onComplete function is triggered. It calls the showCard method.

showCard:

js
1 showCard() {
2 // This line determines which texture (image) to show on the card.
3 const texture = this.isOpened ? `card${this.value}` : "card";
4 this.setTexture(texture);
5 // This adds another tween animation, just like in the flipCard method, to animate the card's horizontal scaling.
6 this.scene.tweens.add({
7 targets: this,
8 scaleX: 1,
9 ease: "Linear",
10 duration: 150,
11 });
12 }

After flipping (where scaleX was set to 0), this sets the horizontal scale back to 1, making the card appear at its full width again.

We just need to introduce the openedCard field in the Game class. This field will either be null or of type Card. When one of the cards is opened, we will store it in this variable, and we will reset it to null when the card is closed.

js
1 openedCard: null | Card = null;

Now we can create a handler function

Let's call it onCardClicked

js
1 onCardClicked(pointer: { x: number; y: number }, card: Card) {
2 // The first condition checks if the clicked card (card) is already open (card.isOpened). If so, the function returns false to prevent any further actions.
3
4 if (card.isOpened) {
5 return false;
6 }
7 if (this.openedCard) {
8 if (this.openedCard.value === card.value) {
9 this.openedCard = null;
10 this.openCardCount++;
11 } else {
12 // If the cards don’t match, the previous card (this.openedCard) is closed by calling this.openedCard.closeCard(), and openedCard is updated to reference the newly clicked card.
13 this.openedCard.closeCard();
14 this.openedCard = card;
15 }
16 } else {
17 // If no card is currently open (this.openedCard is null), the clicked card is set as openedCard.
18 this.openedCard = card;
19 }
20 card.openCard();
21
22 if (this.openCardCount === this.cards.length / 2) {
23 this.start();
24 }
25 }

This function controls the card-flipping logic. It handles card clicks, checks for matches, tracks the opened card, and manages the game's progress.

Now we just need to connect this function in the createCards method.

js
1 this.input.on('gameobjectdown', this.onCardClicked, this);

The last argument(this) is the context of the card.

Our game is almost ready; we've implemented the core game mechanics. The cards flip with an animation along the X-axis, and matching cards are remembered in sequence. The next step is to create the animation where the cards fly into position.

6. Card Flying Animation

We need to position the cards above the screen so that they appear to fly in from outside. We'll place all the cards in the top left corner, outside the screen boundaries, and then move them one by one. This is where the delay value we calculated earlier comes into play.

Add move function to the Card prefab:

js
1 move() {
2 this.scene.tweens.add({
3 targets: this,
4 x: this.positionX,
5 y: this.positionY,
6 ease: "Linear",
7 delay: this.delay,
8 duration: 250,
9 onComplete: () => {
10 this.showCard();
11 },
12 });
13 }

The move() method animates the card to its designated position using the PhaserJS tween system.

After the animation is complete, it calls the showCard() method to reveal the card by flipping it or showing its texture.

Let's create two new functions in the Game class: showCards() to move the cards positioned off-screen, and start() to begin the game with the card movement animation.

  • showCards() will loop through all the cards and call their move method to animate them onto the screen.
  • start() will serve as the trigger to initiate this animation at the beginning of the game.

showCards:

js
1 showCards() {
2 this.cards.forEach((card) => {
3 card.move();
4 });
5 }

start:

js
1 start() {
2 this.openCardCount = 0;
3 this.timeout = TIMEOUT;
4 this.initCards();
5 this.showCards();
6 this.cards.forEach((card) => {
7 card.closeCard();
8 });
9 }

Don’t forget to import TIMEOUT constant from the utils/constants.ts.

Call it from the create method:

js
1 this.start();

Remove the setPosition call from the initCards method

js
1 initCards() {
2 const positions = this.getCardsPosition();
3 Phaser.Utils.Array.Shuffle(positions);
4
5 this.cards.forEach((card) => {
6 const position = positions.pop();
7 card.init(position?.x, position?.y, position?.delay);
8 // card.setPosition(card.positionX, card.positionY);
9 });
10 }

Run the test

npm run dev
Card Flying Animation

We’ve completed the Card prefab. Here’s the full code for the class:

ts
1 class Card extends Phaser.GameObjects.Sprite {
2 isOpened: boolean = false;
3 positionX = 0;
4 positionY = 0;
5 delay = 0;
6
7 constructor(scene: Phaser.Scene, value: number) {
8 super(scene, 0, 0, 'card');
9 this.scene = scene;
10 this.value = value;
11 this.setOrigin(0.5, 0.5);
12 this.scene.add.existing(this);
13 this.setInteractive();
14 }
15
16 init(x: number, y: number, delay: number) {
17 this.positionX = x;
18 this.positionY = y;
19 this.delay = delay;
20 this.setPosition(-this.width, -this.height);
21 }
22
23 move() {
24 this.scene.tweens.add({
25 targets: this,
26 x: this.positionX,
27 y: this.positionY,
28 ease: 'Linear',
29 delay: this.delay,
30 duration: 250,
31 onComplete: () => {
32 this.showCard();
33 },
34 });
35 }
36
37 openCard() {
38 this.isOpened = true;
39 this.flipCard();
40 }
41
42 closeCard() {
43 if (this.isOpened) {
44 this.isOpened = false;
45 this.flipCard();
46 }
47 }
48
49 flipCard() {
50 this.scene.tweens.add({
51 targets: this,
52 scaleX: 0,
53 ease: 'Linear',
54 duration: 150,
55 onComplete: () => {
56 this.showCard();
57 },
58 });
59 }
60
61 showCard() {
62 const texture = this.isOpened ? `card${this.value}` : 'card';
63 this.setTexture(texture);
64 this.scene.tweens.add({
65 targets: this,
66 scaleX: 1,
67 ease: 'Linear',
68 duration: 150,
69 });
70 }
71 }
72
73 export default Card;

Conclusion

We’ve successfully built a game using PhaserJS, integrating core mechanics such as flipping cards, animating movements, and handling user input. This project showcases how PhaserJS can be a powerful framework for creating dynamic and interactive games with rich visual experiences. From loading assets to handling game logic, PhaserJS provides an intuitive API that simplifies game development while offering robust features for animations and user interactions.

Through this game, we've learned how to:

  • Organize game logic into scenes and prefabs.
  • Animate objects with tweens for smooth transitions.
  • Manage game assets and ensure efficient loading through preloader scenes.
  • Implement custom events and interactive elements like card flipping.
  • Integrate PhaserJS into a broader development environment, like React, to create responsive, cross-functional applications.

This foundation sets the stage for building more complex games, adding multiplayer functionality, or incorporating additional physics and AI components.

Related Posts:

JavaScript Development Space

© 2024 JavaScript Development Blog. All rights reserved.