HTML5 game Spears N’ Coins with Phaser 4.1 and TypeScript: a tiny endless runner like Dashy Panda

Talking about Spears N' Coins game, Game development, HTML5, Javascript, Phaser and TypeScript.

Collect stuff and avoid spikes. It’s Dashy Panda‘s gameplay, and I just built a HTML5 game with the same mechanics called Spears N’ Coins.

Starting from the official Create Phaser Game app (read my tutorial here), I built a complete game in about 440 lines featuring:

  • Customizable options
  • Endless scrolling
  • Parallax scrolling
  • Pixel Art
  • Timelines
  • Animations
  • Easy and forgiving collision detection
  • Game states
  • Procedural generation
  • Countdown
  • Custom events
  • Persistent best score

Practically all the basics of game development, in just a few hundred lines of code.

Here is the result:

Click or tap to run, release to stop. Avoid spikes – you can go over or under them – and collect coins to increase the score and add extra time.

I am going to share the source code, but a series of tutorials will follow.

Starting with what was created by the Create Phaser Game App, I first deleted all the files that weren’t strictly necessary for the game itself, then simplified package.json, keeping only the basic options:

JavaScript
{
  "name": "spears-n-coins",
  "private": true,
  "scripts": {
    "dev": "vite --config vite/config.dev.mjs",
    "build": "vite build --config vite/config.prod.mjs"
  },
  "dependencies": {
    "phaser": "^4.1.0"
  },
  "devDependencies": {
    "terser": "^5.39.0",
    "typescript": "~5.7.2",
    "vite": "^6.3.1"
  }
}

Then I customized src/main.ts to change the resolution and add options to better handle the pixel art:

TypeScript
import { Game as MainGame } from './scenes/Game';
import { AUTO, Game, Scale, Types } from 'phaser';

const config: Types.Core.GameConfig = {
    type: AUTO,
    width: 320,
    height: 180,
    antialias: false,
    pixelArt: true,
    parent: 'game-container',
    backgroundColor: '#028af8',
    scale: {
        mode: Scale.FIT,
        autoCenter: Scale.CENTER_BOTH,
    },
    scene: [
        MainGame
    ]
};

const StartGame = (parent: string) => {
    return new Game({ ...config, parent });
}

export default StartGame;

Finally, src/game/scenes/Game.ts contains the game itself:

TypeScript
import * as Phaser from 'phaser';

const GAME_CONFIG = {
    gameTime: 30,
    heroSpeed: 50,
    heroMaxX: 64,
    spears: {
        distance: { min: 30, max: 60 },
        speed: { min: 200, max: 400 },
        pause: { min: 300, max: 600 },
        height: { min: 20, max: 50 }
    },
    bushes: {
        distance: { min: 60, max: 120 }    
    },
    trees: {
        distance: { min: 40, max: 90 }  
    },
    coins: {
        distance: { min: 30, max: 50 },
        score: 1,
        timeBonus: 1
    },
    localStorageName: 'spearsncoins'
}

enum GameState {
    Title = 'title',
    Standing = 'standing',
    Running = 'running',
    GameOver = 'gameover',
}

type BestScore = {
    score: number;
};

function randomBetween(range: { min: number; max: number }): number {
    return Phaser.Math.Between(range.min, range.max);
}

export class Game extends Phaser.Scene {
    private hero: Phaser.GameObjects.Sprite;
    private terrain: Phaser.GameObjects.TileSprite;
    private bushes: Phaser.GameObjects.Sprite[];
    private trees: Phaser.GameObjects.Sprite[];
    private coins: Coin[];
    private spears: Spear[];   
    private gameState: GameState;
    private score: number;  
    private bestScore: number;
    private titleLayer: Phaser.GameObjects.Layer;
    private scoreText: Phaser.GameObjects.BitmapText;
    private bestScoreText: Phaser.GameObjects.BitmapText;
    private timeText: Phaser.GameObjects.BitmapText;
    private timeLeft: number;
    private countdownTimer: Phaser.Time.TimerEvent;

    constructor() {
        super('Game');
    }

    preload(): void {
        this.load.setPath('assets');
        this.load.spritesheet('hero', 'sprites/hero.png', {
            frameWidth: 16,
            frameHeight: 24
        });
        this.load.spritesheet('coin', 'sprites/coin.png', {
            frameWidth: 8,
            frameHeight: 8
        });
        this.load.spritesheet('pickup', 'sprites/coin_pickup.png', {
            frameWidth: 8,
            frameHeight: 16
        });
        this.load.image('terrain', 'sprites/terrain.png');
        this.load.image('spear', 'sprites/spear.png');
        this.load.image('bush', 'sprites/bush.png');
        this.load.image('tree', 'sprites/tree.png');
        this.load.image('sky', 'sprites/sky.png');
        this.load.bitmapFont('font', 'fonts/font.png', 'fonts/font.fnt');
        this.load.audio('clock', 'sounds/clock.mp3');
        this.load.audio('coin', 'sounds/coin.mp3');
        this.load.audio('lose', 'sounds/lose.mp3');
    }

    create(): void {

        const rawData: string | null = localStorage.getItem(GAME_CONFIG.localStorageName);
        const savedBestScore: BestScore | null = rawData !== null ? JSON.parse(rawData) : null;
        this.bestScore = savedBestScore !== null ? savedBestScore.score : 0;
     
        this.anims.create({
            key: 'idle',
            frames: this.anims.generateFrameNumbers('hero', { frames: [ 4, 5, 6, 7 ] }),
            frameRate: 8,
            repeat: -1
        });

        this.anims.create({
            key: 'run',
            frames: this.anims.generateFrameNumbers('hero', { frames: [ 8, 9, 10, 11 ] }),
            frameRate: 8,
            repeat: -1
        });

        this.anims.create({
            key: 'coin',
            frames: this.anims.generateFrameNumbers('coin', { frames: [ 0, 1, 2, 3, 4, 5 ] }),
            frameRate: 8,
            repeat: -1
        });

        this.anims.create({
            key: 'pickup',
            frames: this.anims.generateFrameNumbers('pickup', { frames: [ 0, 1, 2, 3, 4, 5 ] }),
            frameRate: 8
        });

        this.add.tileSprite(0, 0, this.scale.width, this.scale.height, 'sky').setOrigin(0);
        
        this.terrain = this.add.tileSprite(0, this.scale.height - 48, this.scale.width, 48, 'terrain');
        this.terrain.setOrigin(0);
        this.terrain.setDepth(1);

        this.trees = [];
        const maxTrees: number = Math.ceil(this.scale.width / GAME_CONFIG.trees.distance.min);
        for (let i: number = 0; i < maxTrees; i++) {
            const tree: Phaser.GameObjects.Sprite = this.add.sprite(0, this.terrain.y, 'tree');
            tree.setOrigin(0, 1);
            this.trees.push(tree);
        }

        this.bushes = [];
        const maxBushes: number = Math.ceil(this.scale.width / GAME_CONFIG.bushes.distance.min);
        for (let i: number = 0; i < maxBushes; i++) {
            const bush: Phaser.GameObjects.Sprite = this.add.sprite(0, this.terrain.y, 'bush');
            bush.setOrigin(0, 1);
            this.bushes.push(bush);
        }

        this.spears = [];
        const maxSpears: number = Math.ceil(this.scale.width / GAME_CONFIG.spears.distance.min);
        for (let i: number = 0; i < maxSpears; i++) {
            this.spears.push(new Spear(this, 0, this.terrain.y + 10, 'spear'));
        }

        this.coins = [];
        const maxCoins: number = Math.ceil(this.scale.width / GAME_CONFIG.coins.distance.min);
        for (let i: number = 0; i < maxCoins; i++) {
            const coin: Coin = new Coin(this, 0, this.terrain.y - 16, 'coin');
            coin.on('animationcomplete-pickup', () => {
                const rightMostCoin: number = Math.max(...this.coins.map(c => c.x));
                coin.reset(rightMostCoin);
            });
            this.coins.push(coin);     
        }

        this.hero = this.add.sprite(10, this.terrain.y, 'hero');
        this.hero.setOrigin(0, 1);
       
        this.bestScoreText = this.add.bitmapText(this.scale.width - 1, 2, 'font', 'Best: ' + this.bestScore.toString() , 8)
        this.bestScoreText.setOrigin(1, 0);

        this.scoreText = this.add.bitmapText(2, 2, 'font', '0', 8)
        this.scoreText.setOrigin(0, 0);

        this.timeText = this.add.bitmapText(this.scale.width / 2, 50, 'font', GAME_CONFIG.gameTime.toString(), 16);
        this.timeText.setOrigin(0.5, 0);

        this.titleLayer = this.add.layer();
       
        const titleText = this.add.bitmapText(this.scale.width / 2, 30, 'font', 'SPEARS N\' COINS', 16);
        titleText.setOrigin(0.5, 0);
        this.titleLayer.add(titleText);

        const infoText = this.add.bitmapText(this.scale.width / 2, 60, 'font', '-= TAP TO RUN =-', 8);
        infoText.setOrigin(0.5, 0);
        this.titleLayer.add(infoText);

        this.time.addEvent({
            delay: 500,
            repeat: -1,
            callback: () => {
                infoText.visible = !infoText.visible;
            }
        }); 

        this.showSplash();

        this.input.on('pointerdown', () => this.handleRunning(true));
        this.input.on('pointerup', () => this.handleRunning(false));
        this.input.on('pointerupoutside', () => this.handleRunning(false));
        this.input.on('gameout', () => this.handleRunning(false)); 

        this.events.on('cointaken', (coin: Coin) => {
            coin.take();
            this.sound.play('coin');
            this.score += GAME_CONFIG.coins.score;
            if (this.score > this.bestScore) {
                this.bestScore = this.score;
                this.bestScoreText.setText('BEST: ' + this.bestScore.toString());
                localStorage.setItem(GAME_CONFIG.localStorageName, JSON.stringify({ score: this.bestScore }));
            }
            this.timeLeft += GAME_CONFIG.coins.timeBonus;
            this.scoreText.setText(this.score.toString());
            this.timeText.setText(this.timeLeft.toString());
        });
    }

    private showSplash(): void {
        this.hero.setX(10);
        this.hero.play('idle');    
        this.gameState = GameState.Title;
        this.score = 0;
        this.scoreText.setText('0');
        this.titleLayer.setVisible(true);
        this.timeText.setVisible(false);
        this.placeStuff();
    }

    private placeStuff(): void {
        let posX: number = GAME_CONFIG.heroMaxX;
        this.spears.forEach((spear: Spear) => {
            posX += randomBetween(GAME_CONFIG.spears.distance);
            spear.setX(posX);
            if (spear.timeline) {
                spear.timeline.destroy();
            }
            spear.startAnimation();
        })
        posX = 0;
        this.bushes.forEach((bush: Phaser.GameObjects.Sprite) => {
            posX += randomBetween(GAME_CONFIG.bushes.distance);
            bush.setX(posX);
        })
        posX = 0;
        this.trees.forEach((tree: Phaser.GameObjects.Sprite) => {
            posX += randomBetween(GAME_CONFIG.trees.distance);
            tree.setX(posX);
        })
        posX = this.scale.width / 2;
        this.coins.forEach((coin: Coin) => {
            posX += randomBetween(GAME_CONFIG.coins.distance);
            coin.setX(posX);
            coin.play('coin');
        })
    }

    private handleRunning(run: boolean): void {
        if (!run && this.gameState === GameState.Title || this.gameState === GameState.GameOver) {
            return;
        }
        if (this.gameState === GameState.Title && run) {
            this.startGame();
        }
        this.gameState = run ? GameState.Running : GameState.Standing;
        this.hero.play(run ? 'run' : 'idle');
        
    }

    private startGame(): void {
        this.titleLayer.setVisible(false);
        this.gameState = GameState.Running;
        this.timeLeft = GAME_CONFIG.gameTime;
        this.timeText.setVisible(true);
        this.timeText.setText(this.timeLeft.toString())
        this.countdownTimer = this.time.addEvent({
            delay: 1000,
            repeat: -1,
            callback: () => {
                this.timeLeft --;
                this.timeText.setText(this.timeLeft.toString());
                if (this.timeLeft <= 5 && this.timeLeft > 0) {
                    this.sound.play('clock');
                }
                if (this.timeLeft <= 0) {
                    this.gameOver();
                }
            },
        });  
    }

    private gameOver(): void {
        if (this.gameState === GameState.GameOver) {
            return;
        }
        this.sound.play('lose');
        this.gameState = GameState.GameOver;  
        this.hero.anims.stop();
        this.countdownTimer.remove();
        const blink: Phaser.Time.TimerEvent = this.time.addEvent({
            delay: 100,
            repeat: 25,
            callback: () => {
                this.hero.visible = !this.hero.visible;
                if (blink.getRepeatCount() === 0) {
                    this.showSplash();
                }
            }
        })
    }

    private scrollBy(deltaX: number): void {
        this.hero.x = GAME_CONFIG.heroMaxX;
        this.terrain.tilePositionX = (this.terrain.tilePositionX + deltaX) % 16;                
        this.spears.forEach((spear: Spear) => {
            spear.x -= deltaX;
            if (spear.x < -spear.displayWidth) {
                const rightmostSpear: number = Math.max(...this.spears.map(s => s.x));
                spear.x = rightmostSpear + randomBetween(GAME_CONFIG.spears.distance);
                spear.timeline.destroy();
                spear.startAnimation();
            }
        });
        this.bushes.forEach((bush: Phaser.GameObjects.Sprite) => {
            bush.x -= deltaX;
            if (bush.x < -bush.displayWidth) {
                const rightmostBush: number = Math.max(...this.bushes.map(b => b.x));
                bush.x = rightmostBush + randomBetween(GAME_CONFIG.bushes.distance);
            }
        });
        this.trees.forEach((tree: Phaser.GameObjects.Sprite) => {
            tree.x -= deltaX * 0.5;
            if (tree.x < -tree.displayWidth) {
                const rightmostTree: number = Math.max(...this.trees.map(t => t.x));
                tree.x = rightmostTree + randomBetween(GAME_CONFIG.trees.distance);
            }
        });
        const heroBounds: Phaser.Geom.Rectangle = this.hero.getBounds();
        this.coins.forEach((coin: Coin) => {
            coin.x -= deltaX;
            if (!coin.taken && coin.getBounds().left < heroBounds.right - 5) {
                this.events.emit('cointaken', coin);
            }
        })
    }

    update(time: number, deltaTime: number): void {
        if (this.gameState === GameState.GameOver) {
            return;
        }
        if (this.gameState === GameState.Running) {
            const heroDestination = this.hero.x + GAME_CONFIG.heroSpeed / 1000 * deltaTime; 
            if (heroDestination <= GAME_CONFIG.heroMaxX) {
                this.hero.x = heroDestination;
            }
            else {
                this.scrollBy(heroDestination -  GAME_CONFIG.heroMaxX)
            }
        }
        const heroBounds: Phaser.Geom.Rectangle = this.hero.getBounds();
        this.spears.forEach((spear: Spear) => {
            const spearBounds: Phaser.Geom.Rectangle = spear.getBounds();
            if (spearBounds.left < heroBounds.right - 6 && spearBounds.right > heroBounds.left + 6 && spearBounds.top < heroBounds.bottom - 4 && spearBounds.top > heroBounds.top) {
                spear.timeline.pause();
                this.gameOver();  
            }
        });
    }
}

class Spear extends Phaser.GameObjects.Sprite {

    private start: number;
    timeline: Phaser.Time.Timeline;
    
    constructor(scene: Phaser.Scene, x: number, y: number, texture: string) {
        super(scene, x, y, texture);
        this.start = y;
        this.setOrigin(0, 0);
        scene.add.existing(this);
    }

    startAnimation(): void {

        this.y = this.start;
        const destination: number = this.start - randomBetween(GAME_CONFIG.spears.height);
        const durationGoingUp: number = randomBetween(GAME_CONFIG.spears.speed);
        const durationGoingDown: number = randomBetween(GAME_CONFIG.spears.speed);
        const pauseOnTop: number = randomBetween(GAME_CONFIG.spears.pause);
        const pauseOnBottom: number = randomBetween(GAME_CONFIG.spears.pause);
        this.timeline = this.scene.add.timeline([
            {
                at: 0,
                tween: {
                    targets: this,
                    y: destination,
                    duration: durationGoingUp,
                    ease: 'Sine.easeInOut'
                }
            },
            {
                at: durationGoingUp + pauseOnTop,
                tween: {
                    targets: this,
                    y: this.start,
                    duration: durationGoingDown,
                    ease: 'Sine.easeInOut'
                }
            },
            {
                at: durationGoingUp + pauseOnTop + durationGoingDown + pauseOnBottom,
                stop: true
            }
        ]);

        this.timeline.repeat().play();
  
    }
}

class Coin extends Phaser.GameObjects.Sprite {

    taken: boolean;
    private originalY: number;

    constructor(scene: Phaser.Scene, x: number, y: number, texture: string) {
        super(scene, x, y, texture);
        scene.add.existing(this);
        this.taken = false;
        this.originalY = y;
        this.play('coin');
        this.setOrigin(0, 0);
    }

    take(): void {
        this.taken = true;
        this.y = this.originalY - 8;
        this.play('pickup');
    }

    reset(x: number): void {
        this.x = x + randomBetween(GAME_CONFIG.coins.distance);
        this.y = this.originalY;
        this.taken = false;
        this.play('coin');
    }
}

The code is still uncommented but quite easy to understand, I hope. Anyway, more tutorials about the making of this game will follow.

Here you can download the full Phaser project, powered by Vite. Don’t know what I am talking about? There’s a free minibook to get you started, and a guide to Create Phaser Game app.