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:
{
"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:
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:
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.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.