Building a Phaser 3 Game with ECS and React

Phaser 3 + ECS inside a React app

by on 6 minute read


I recently decided to play around with Phaser 3 again with the help of the good people here at Ourcade!

One thing I tasked myself with was to rebuild the Beginning ECS in Phaser 3 tutorial but have it work with ReactJS!

Looking to learn how to use an Entity Component System(ECS) in your Phaser 3 games?

Check out this Ourcade video that uses bitECS–the same library currently being used in the development of Phaser 4.

So before you continue with my tutorial, ensure you understand how the base project was built with Phaser 3, Parcel, and BitECS. I urge you to go check it out if you haven't already.

The aim of this article is to show how you can use the tank game with ReactJS. The details regarding Entity Component System in Phaser has been omitted since it is covered in Beginning ECS in Phaser 3.

Getting Started

Ensure you have node and npm installed on your system. Then navigate to your project directory.

1
2
3
4
5
// To create a typescript react app scaffold run the following Command
npx create-react-app <project-name> --template typescript

// Install Libraries to be used in creating phaser game within react
npm install --save phaser bitecs regenerator-runtime

Make sure your folder structure is similar to the screenshot below.

No need for any setup in package.json as create-react-app does that for you.

Project Structure

Setup your Phaser Configuration File

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// src/PhaserGame.ts file

import Phaser from 'phaser'

import { Bootstrap, Game } from './scenes'

const config: Phaser.Types.Core.GameConfig = {
	type: Phaser.AUTO,
	parent: 'phaser-container',
	backgroundColor: '#282c34',
	scale: {
		mode: Phaser.Scale.ScaleModes.RESIZE,
		width: window.innerWidth,
		height: window.innerHeight,
	},
	physics: {
		default: 'arcade',
		arcade: {
			gravity: { y: 0 },
			debug: true,
		},
	},
	scene: [Bootstrap, Game],
}
// eslint-disable-next-line import/no-anonymous-default-export
export default new Phaser.Game(config)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// src/index.tsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'

import App from './App'

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(<App />)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/* src/index.css */

body {
	margin: 0;
	font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
		'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
		'Helvetica Neue', sans-serif;
	-webkit-font-smoothing: antialiased;
	-moz-osx-font-smoothing: grayscale;
}

Import your Phaser Configuration in the App.tsx entry file and ensure the div id is the same as the parent field in your Phaser Game Config. This allows Phaser to create a canvas element under your desired div tag.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// src/App.tsx

import './App.css'
import './PhaserGame'

function App() {
	return <div id="phaser-container" className="App"></div>
}

export default App
1
2
3
4
5
/* src/App.css */

.App {
	text-align: center;
}

Now that this has been done you don’t have to worry about anything other than creating your Game scenes, components, and systems.

Setup your scenes in this manner:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// scenes/Bootstrap.ts

import Phaser from 'phaser'

export class Bootstrap extends Phaser.Scene {
	constructor() {
		super('bootstrap')
	}

	init() {}

	preload() {
		// This Loads my assets created with texture packer
		this.load.multiatlas('tankers', 'assets/tanker-game.json', 'assets')
	}

	create() {
		this.createNewGame()
	}

	update() {}

	private createNewGame() {
		// this launches the game scene
		this.scene.launch('game')
	}
}

💡 Personal Tip

I always like to create an entry point for my folder where I export all the files in that folder like the example below 👇

1
2
3
// src/scenes/index.ts

export * from './Bootstrap'

This enables me to import Scenes in any other file in the following format:

1
import { Bootstrap } from './scenes'

You may notice that I don’t have to add ./scenes/Bootstrap because it has been specified in ./scenes/index.ts. It acts as the entry point for all files in the scenes folder.

Now THE GAME Scene

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
import Phaser from 'phaser'
import { createWorld, addEntity, addComponent, System, IWorld } from 'bitecs'
import {
	Position,
	Rotation,
	Velocity,
	Sprite,
	Player,
	CPU,
	Input,
	ArcadeSprite,
	ArcadeSpriteStatic,
} from '../components'
import {
	createSpriteSystem,
	createMovementSystem,
	createPlayerSystem,
	createCPUSystem,
	createArcadeSpriteSystem,
	createArcadeSpriteStaticSystem,
} from '../systems'
enum Textures {
	TankBlue = 0,
	TankRed = 1,
	TankGreen = 2,
	TankSand = 3,
	TankDark = 4,
	TreeBrownLarge = 5,
	TreeBrownSmall = 6,
	TreeGreenLarge = 7,
	TreeGreenSmall = 8,
}
const TextureKeys = [
	'tank_blue.png',
	'tank_red.png',
	'tank_green.png',
	'tank_sand.png',
	'tank_dark.png',
	'treeBrown_large.png',
	'treeBrown_small.png',
	'treeGreen_large.png',
	'treeGreen_small.png',
]

export class Game extends Phaser.Scene {
	private world?: IWorld
	private spriteSystem?: System
	private spriteStaticSystem?: System
	private movementSystem?: System
	private playerSystem?: System
	private cpuSystem?: System
	private cursors!: Phaser.Types.Input.Keyboard.CursorKeys

	constructor() {
		super('game')
	}

	init() {
		this.cursors = this.input.keyboard.createCursorKeys()
	}

	create() {
		const { width, height } = this.scale

		this.world = createWorld()

		const tank = addEntity(this.world)

		addComponent(this.world, Position, tank)
		Position.x[tank] = 200
		Position.y[tank] = 200

		addComponent(this.world, Rotation, tank)
		addComponent(this.world, Velocity, tank)
		addComponent(this.world, Input, tank)

		addComponent(this.world, ArcadeSprite, tank)
		ArcadeSprite.texture[tank] = Textures.TankBlue

		addComponent(this.world, Player, tank)

		// Create Large Tree
		const largeTree = addEntity(this.world)
		addComponent(this.world, Position, largeTree)
		addComponent(this.world, ArcadeSpriteStatic, largeTree)

		Position.x[largeTree] = 400
		Position.y[largeTree] = 400

		ArcadeSpriteStatic.texture[largeTree] = Textures.TreeGreenLarge

		// Create Small Tree
		const smallTree = addEntity(this.world)
		addComponent(this.world, Position, smallTree)
		addComponent(this.world, ArcadeSpriteStatic, smallTree)

		Position.x[smallTree] = 300
		Position.y[smallTree] = 200

		ArcadeSpriteStatic.texture[smallTree] = Textures.TreeBrownSmall

		// Create random CPU Tanks
		for (let i = 0; i < 5; i++) {
			const cpuTank = addEntity(this.world)

			addComponent(this.world, Position, cpuTank)
			Position.x[cpuTank] = Phaser.Math.Between(width * 0.25, width * 0.75)
			Position.y[cpuTank] = Phaser.Math.Between(height * 0.25, height * 0.75)

			addComponent(this.world, Rotation, cpuTank)
			Rotation.angle[cpuTank] = 0

			addComponent(this.world, Velocity, cpuTank)
			Velocity.x[cpuTank] = 0
			Velocity.y[cpuTank] = 0

			addComponent(this.world, ArcadeSprite, cpuTank)
			ArcadeSprite.texture[cpuTank] = Phaser.Math.Between(1, 4)

			addComponent(this.world, CPU, cpuTank)
			CPU.timeBetweenActions[cpuTank] = Phaser.Math.Between(100, 500)

			addComponent(this.world, Input, cpuTank)
		}

		const spriteGroup = this.physics.add.group()
		const spriteStaticGroup = this.physics.add.staticGroup()
		this.physics.add.collider(spriteGroup, spriteStaticGroup)
		this.physics.add.collider(spriteGroup, spriteGroup)

		this.spriteSystem = createArcadeSpriteSystem(spriteGroup, TextureKeys)
		this.spriteStaticSystem = createArcadeSpriteStaticSystem(
			spriteStaticGroup,
			TextureKeys
		)
		this.movementSystem = createMovementSystem()
		this.playerSystem = createPlayerSystem(this.cursors)
		this.cpuSystem = createCPUSystem(this)
	}

	update() {
		if (!this.world) return

		this.playerSystem?.(this.world)
		this.cpuSystem?.(this.world)
		this.movementSystem?.(this.world)
		this.spriteSystem?.(this.world)
		this.spriteStaticSystem?.(this.world)
	}
}

If you found this helpful please let me know what you think in the comments. You are welcome to suggest changes.

Thank you for reading this article! You can also find the source code on Github.

You can follow me on all my social handles @officialyenum and please subscribe to me on Medium here. 👏 It would would mean a lot!

Learn to make an Infinite Runner in Phaser 3 with TypeScript!

Drop your email into the box below to get this free 90+ page book and join our newsletter.

Learn more about the book here.

Phaser 3 ecs react reactjs

Want tips and techniques more suited for you?


You may also like...


Video Guides


Beginner Guides


Articles Recommended For You

Command Pattern to Undo Player Actions

by on

Are you looking for a clean and reusable way to implement undo for player actions? Perhaps you are making a turn-based …

15 minute read

Advanced Logging with the Strategy Pattern

by on

Have you ever tried debugging a problem with your game that only seems to happen in production? The Developer Tools …

7 minute read

State Pattern for Character Movement in Phaser 3

by on

Writing clean and well-organized code is something all game developers aspire to. We want code to be reusable and easy …

7 minute read

Memory Match in Modern Javascript with Phaser 3 - Part 6

by on

If you've got the basics of Phaser 3 in modern JavaScript down then it might be time to try making something a bit more …

8 minute read

Didn't find what you were looking for?


comments powered by Disqus