Skip to content
Build a Platformer Game with Phaser.js (Step by Step)

Build a Platformer Game with Phaser.js (Step by Step)

DodaTech Updated Jun 20, 2026 9 min read

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

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

How do I add my own character sprites?
Replace the procedural texture generation with image files. Load them in preload(): this.load.image("player", "assets/player.png"). Use sprite sheets for animation: this.load.spritesheet("player", "assets/player-sheet.png", { frameWidth: 32, frameHeight: 48 }). Create animations with this.anims.create().
How do I add sound effects?
Phaser supports Web Audio. Load audio files in preload(): this.load.audio("coin", "assets/coin.wav"). Play on events: this.sound.play("coin"). For background music, use this.sound.add("bgm", { loop: true }) and call .play().
How do I save high scores?
Use localStorage in the browser: localStorage.setItem("highscore", score). Read it with localStorage.getItem("highscore"). In the GameOver scene, compare the current score with the saved high score and display a “New High Score!” message if beaten.

Next Steps

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro