Build a Platformer Game with Phaser.js (Step by Step)
Build a 2D platformer game with Phaser.js featuring player movement with WASD/arrow keys, gravity and jumping, collision detection, platforms, collectible coins, an enemy AI, scoring, multiple levels, and a game-over/restart cycle.
What You’ll Build
You’ll build a complete browser-based platformer game where the player runs and jumps across platforms, collects coins, avoids enemies, and progresses through levels. The game runs entirely in the browser with Phaser 3’s physics engine handling gravity, collisions, and sprite rendering. This same game framework powers the mini-game in Doda Browser’s new tab page.
Why Build a Platformer Game?
Game development teaches you concepts that apply everywhere: the game loop (update/render cycle), physics simulation (velocity, acceleration, collision), state machines (idle, running, jumping, dead), and sprite management. Phaser.js makes 2D game development accessible with a well-documented API and built-in Arcade physics.
Prerequisites
- Basic JavaScript and HTML knowledge
- Phaser.js basics or general game dev interest
- A code editor and modern browser
- Node.js for the dev server
Step 1: Project Setup
mkdir platformer-game
cd platformer-game
npm init -y
npm install phaser<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Platformer Game</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #111; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
</style>
</head>
<body>
<script src="node_modules/phaser/dist/phaser.min.js"></script>
<script src="game.js"></script>
</body>
</html>Step 2: Main Game Configuration
// game.js
const config = {
type: Phaser.AUTO,
width: 800,
height: 600,
physics: {
default: "arcade",
arcade: {
gravity: { y: 800 },
debug: false,
},
},
scene: [MenuScene, GameScene, GameOverScene],
backgroundColor: "#87CEEB",
};
const game = new Phaser.Game(config);Step 3: Menu Scene
class MenuScene extends Phaser.Scene {
constructor() {
super({ key: "MenuScene" });
}
create() {
this.cameras.main.setBackgroundColor("#1a1a2e");
this.add.text(400, 200, "PLATFORMER", {
fontSize: "64px",
fill: "#e94560",
fontFamily: "monospace",
fontStyle: "bold",
}).setOrigin(0.5);
this.add.text(400, 280, "Collect coins. Avoid enemies.", {
fontSize: "18px",
fill: "#eee",
fontFamily: "monospace",
}).setOrigin(0.5);
const startBtn = this.add.text(400, 380, "[ START GAME ]", {
fontSize: "28px",
fill: "#0f3460",
fontFamily: "monospace",
backgroundColor: "#e94560",
padding: { x: 20, y: 10 },
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
startBtn.on("pointerover", () => startBtn.setScale(1.1));
startBtn.on("pointerout", () => startBtn.setScale(1));
startBtn.on("pointerdown", () => this.scene.start("GameScene", { score: 0, level: 1 }));
this.add.text(400, 500, "WASD / Arrows to move | Space to jump", {
fontSize: "14px",
fill: "#888",
fontFamily: "monospace",
}).setOrigin(0.5);
}
}Step 4: Main Game Scene
class GameScene extends Phaser.Scene {
constructor() {
super({ key: "GameScene" });
}
init(data) {
this.score = data.score || 0;
this.level = data.level || 1;
this.coinsCollected = 0;
this.coinsTotal = 0;
this.isGameOver = false;
}
create() {
// Background
this.cameras.main.setBackgroundColor("#87CEEB");
// Platforms group
this.platforms = this.physics.add.staticGroup();
// Ground
this.platforms.create(400, 590, "ground").setScale(2, 1).refreshBody();
this.platforms.create(200, 460, "ground");
this.platforms.create(600, 380, "ground");
this.platforms.create(400, 280, "ground");
this.platforms.create(100, 200, "ground");
this.platforms.create(700, 200, "ground");
// Player
this.player = this.physics.add.sprite(100, 500, "player");
this.player.setBounce(0.1);
this.player.setCollideWorldBounds(true);
this.player.body.setGravityY(200);
// Coins
this.coins = this.physics.add.group();
const coinPositions = [
[200, 420], [400, 340], [600, 340],
[300, 240], [500, 240], [100, 160],
[700, 160], [400, 120],
];
this.coinsTotal = coinPositions.length;
coinPositions.forEach(([x, y]) => {
const coin = this.coins.create(x, y, "coin");
coin.setBounceY(Phaser.Math.FloatBetween(0.2, 0.4));
});
// Enemies
this.enemies = this.physics.add.group();
this.spawnEnemy(500, 350); // Patrolling enemy
this.spawnEnemy(300, 520);
// Colliders
this.physics.add.collider(this.player, this.platforms);
this.physics.add.collider(this.coins, this.platforms);
this.physics.add.collider(this.enemies, this.platforms);
this.physics.add.overlap(this.player, this.coins, this.collectCoin, null, this);
this.physics.add.overlap(this.player, this.enemies, this.hitEnemy, null, this);
// Controls
this.cursors = this.input.keyboard.createCursorKeys();
this.wasd = {
up: this.input.keyboard.addKey("W"),
down: this.input.keyboard.addKey("S"),
left: this.input.keyboard.addKey("A"),
right: this.input.keyboard.addKey("D"),
};
this.spaceBar = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
// HUD
this.scoreText = this.add.text(16, 16, "", {
fontSize: "20px",
fill: "#fff",
fontFamily: "monospace",
stroke: "#000",
strokeThickness: 3,
});
this.levelText = this.add.text(16, 42, "", {
fontSize: "16px",
fill: "#fff",
fontFamily: "monospace",
stroke: "#000",
strokeThickness: 3,
});
this.updateHUD();
}
update() {
if (this.isGameOver) return;
// Player movement
const speed = 200;
const onGround = this.player.body.touching.down || this.player.body.blocked.down;
if (this.cursors.left.isDown || this.wasd.left.isDown) {
this.player.setVelocityX(-speed);
this.player.setFlipX(true);
} else if (this.cursors.right.isDown || this.wasd.right.isDown) {
this.player.setVelocityX(speed);
this.player.setFlipX(false);
} else {
this.player.setVelocityX(0);
}
if ((this.cursors.up.isDown || this.wasd.up.isDown || this.spaceBar.isDown) && onGround) {
this.player.setVelocityY(-450);
}
// Enemy patrol behavior
this.enemies.children.iterate((enemy) => {
if (enemy && enemy.active) {
enemy.setVelocityX(enemy.patrolSpeed);
if (enemy.x >= enemy.patrolEnd) {
enemy.patrolSpeed = Math.abs(enemy.patrolSpeed) * -1;
enemy.setFlipX(true);
} else if (enemy.x <= enemy.patrolStart) {
enemy.patrolSpeed = Math.abs(enemy.patrolSpeed);
enemy.setFlipX(false);
}
}
});
// Check if fallen off world
if (this.player.y > 620) {
this.gameOver();
}
}
spawnEnemy(x, y) {
const enemy = this.enemies.create(x, y, "enemy");
enemy.patrolStart = x - 100;
enemy.patrolEnd = x + 100;
enemy.patrolSpeed = 80;
enemy.setBounce(0);
enemy.setCollideWorldBounds(true);
enemy.setVelocityX(enemy.patrolSpeed);
}
collectCoin(player, coin) {
coin.disableBody(true, true);
this.score += 100;
this.coinsCollected++;
this.updateHUD();
// Check level complete
if (this.coinsCollected >= this.coinsTotal) {
this.nextLevel();
}
}
hitEnemy(player, enemy) {
// Check if player is falling (stomping enemy)
if (player.body.velocity.y > 0) {
enemy.disableBody(true, true);
player.setVelocityY(-350);
this.score += 200;
this.updateHUD();
} else {
this.gameOver();
}
}
nextLevel() {
this.level++;
this.scene.restart({ score: this.score, level: this.level });
}
gameOver() {
this.isGameOver = true;
this.player.setTint(0xff0000);
this.physics.pause();
this.time.delayedCall(1000, () => {
this.scene.start("GameOverScene", { score: this.score, level: this.level });
});
}
updateHUD() {
this.scoreText.setText(`Score: ${this.score}`);
this.levelText.setText(`Level: ${this.level} | Coins: ${this.coinsCollected}/${this.coinsTotal}`);
}
}Step 5: Game Over Scene
class GameOverScene extends Phaser.Scene {
constructor() {
super({ key: "GameOverScene" });
}
init(data) {
this.finalScore = data.score || 0;
this.finalLevel = data.level || 1;
}
create() {
this.cameras.main.setBackgroundColor("#1a1a2e");
this.add.text(400, 180, "GAME OVER", {
fontSize: "56px",
fill: "#e94560",
fontFamily: "monospace",
fontStyle: "bold",
}).setOrigin(0.5);
this.add.text(400, 260, `Score: ${this.finalScore}`, {
fontSize: "28px",
fill: "#fff",
fontFamily: "monospace",
}).setOrigin(0.5);
this.add.text(400, 300, `Reached Level: ${this.finalLevel}`, {
fontSize: "18px",
fill: "#ccc",
fontFamily: "monospace",
}).setOrigin(0.5);
const restartBtn = this.add.text(400, 400, "[ PLAY AGAIN ]", {
fontSize: "28px",
fill: "#0f3460",
fontFamily: "monospace",
backgroundColor: "#e94560",
padding: { x: 20, y: 10 },
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
restartBtn.on("pointerover", () => restartBtn.setScale(1.1));
restartBtn.on("pointerout", () => restartBtn.setScale(1));
restartBtn.on("pointerdown", () => this.scene.start("GameScene", { score: 0, level: 1 }));
const menuBtn = this.add.text(400, 470, "[ MAIN MENU ]", {
fontSize: "22px",
fill: "#fff",
fontFamily: "monospace",
padding: { x: 16, y: 8 },
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
menuBtn.on("pointerover", () => menuBtn.setScale(1.1));
menuBtn.on("pointerout", () => menuBtn.setScale(1));
menuBtn.on("pointerdown", () => this.scene.start("MenuScene"));
}
}Step 6: Asset Generation (Procedural)
Since we’re not using image files, we’ll generate sprites programmatically:
// assets.js — Load before game.js
// Generate textures at runtime
function generateTextures(scene) {
// Player texture (32x48)
const playerGfx = scene.make.graphics({ add: false });
playerGfx.fillStyle(0x3498db);
playerGfx.fillRect(0, 0, 32, 48);
playerGfx.fillStyle(0xf1c40f);
playerGfx.fillCircle(16, 12, 8); // Head
playerGfx.fillStyle(0x2980b9);
playerGfx.fillRect(6, 24, 20, 24); // Body
playerGfx.generateTexture("player", 32, 48);
playerGfx.destroy();
// Enemy texture (32x32)
const enemyGfx = scene.make.graphics({ add: false });
enemyGfx.fillStyle(0xe74c3c);
enemyGfx.fillRect(0, 0, 32, 32);
enemyGfx.fillStyle(0xc0392b);
enemyGfx.fillCircle(16, 16, 14);
enemyGfx.fillStyle(0x000);
enemyGfx.fillCircle(10, 12, 3);
enemyGfx.fillCircle(22, 12, 3);
enemyGfx.generateTexture("enemy", 32, 32);
enemyGfx.destroy();
// Coin texture (16x16)
const coinGfx = scene.make.graphics({ add: false });
coinGfx.fillStyle(0xf1c40f);
coinGfx.fillCircle(8, 8, 7);
coinGfx.fillStyle(0xf39c12);
coinGfx.fillCircle(8, 8, 5);
coinGfx.generateTexture("coin", 16, 16);
coinGfx.destroy();
// Ground texture (64x32)
const groundGfx = scene.make.graphics({ add: false });
groundGfx.fillStyle(0x27ae60);
groundGfx.fillRect(0, 0, 64, 32);
groundGfx.fillStyle(0x2ecc71);
groundGfx.fillRect(0, 0, 64, 8);
groundGfx.lineStyle(1, 0x229954);
for (let i = 0; i < 64; i += 16) {
groundGfx.lineBetween(i, 8, i + 8, 32);
}
groundGfx.generateTexture("ground", 64, 32);
groundGfx.destroy();
}
// Override create to generate textures first
const originalCreate = GameScene.prototype.create;
GameScene.prototype.create = function() {
generateTextures(this);
originalCreate.call(this);
};Step 7: Run
npx serve .Open http://localhost:3000. The game menu appears. Click START GAME to begin.
Expected gameplay:
- Player character renders on platforms with blue color
- WASD/Arrow keys move left/right, Space/Up jumps
- Yellow coins positioned on platforms
- Red enemies patrol back and forth
- Collecting a coin: coin disappears, score +100
- Touching enemy from side: game over
- Landing on enemy from above: enemy destroyed, score +200
- Collect all coins: next level with reset coins
- Falling off screen: game over
Architecture
flowchart LR
A[Player Input] --> B[Keyboard Manager]
B --> C[Player Movement]
C --> D[Arcade Physics Engine]
D --> E[Collision Detection]
E --> F{Overlap?}
F -->|Player + Coin| G[Collect Coin]
F -->|Player + Enemy Top| H[Stomp Enemy]
F -->|Player + Enemy Side| I[Game Over]
E --> J[Platform Collision]
D --> K[Gravity + Velocity]
G --> L[Update Score]
H --> L
L --> M[Check Win Condition]
M -->|All coins| N[Next Level]
M -->|Not all| O[Continue Play]
I --> P[Game Over Scene]
N --> Q[Restart Scene]
Common Errors
1. Player falls through platforms
The staticGroup platforms must have refreshBody() called after setting scale/position. Without it, the physics body doesn’t match the visual size. Our code calls setScale(2, 1).refreshBody() for the wide ground platform.
2. “Cannot read property ‘setVelocityX’ of undefined”
This happens if player or enemy is referenced before create() finishes. Our spawnEnemy is called inside create() after the group is initialized. If you add enemies in update(), check they exist with if (enemy && enemy.active).
3. Coins/Enemies don’t collide with platforms
Static groups can collide with dynamic groups but not with each other. We add colliders for player + platforms, coins + platforms, and enemies + platforms separately. If you add a new game object type, remember to add its collider too.
4. Double jump (player can jump in mid-air)
The jump is gated by onGround check: player.body.touching.down || player.body.blocked.down. If this condition is false, the player can’t jump. If the player can still double-jump, the collision shape might be slightly above the platform. Add player.body.setSize(28, 46) to shrink the hitbox slightly.
Practice Questions
1. Why use physics.add.staticGroup() for platforms instead of a dynamic group?
Static groups don’t have velocity or gravity — they stay in place. This is perfect for platforms because they shouldn’t move or fall. Static bodies also skip collision response calculations, improving performance compared to dynamic bodies that calculate bounce, friction, and velocity changes.
2. How does the enemy patrol logic work?
Each enemy stores patrolStart, patrolEnd, and patrolSpeed. In update(), the enemy moves horizontally at patrolSpeed. When it reaches either boundary, patrolSpeed reverses direction. This creates a back-and-forth patrolling pattern.
3. Why check player.body.velocity.y > 0 in hitEnemy?
If the player is falling (positive Y velocity) and touches an enemy from above, that’s a stomp — the enemy dies and the player bounces up. If the player is rising or stationary, it’s a side/bottom collision — the player dies. This differentiates between “player stomps enemy” and “enemy hits player.”
4. Challenge: Add double-jump power-up
Create a power-up item (different color). When collected, set this.canDoubleJump = true. Track jump state: jumpsUsed = 0. Reset on ground contact. Allow a second jump only if this.canDoubleJump && this.jumpsUsed < 2.
5. Challenge: Add parallax scrolling background
Create three background layers (sky, mountains, trees) that scroll at different speeds relative to the camera. Use this.cameras.main.setScroll() or Phaser tileSprite layers. Each layer moves at 0.1x, 0.3x, 0.6x of the player’s velocity for depth effect.
FAQ
Next Steps
- Explore more game development concepts
- Learn Phaser.js animations and particle effects
- Add WebSocket multiplayer with a game server
- Build the Chat App project for real-time communication patterns
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro