phaser-pixi-2d-games
Use when building a 2D web game with Phaser (3.x/4.x) or PixiJS 8 in TypeScript - framework vs renderer, Phaser scenes/game objects/arcade physics/groups as pools/scale manager/loader, scene shutdown
SKILL.mdcatalog/agents/game-developer/skills/phaser-pixi-2d-games/SKILL.md
GitHubPhaser and PixiJS 2D Games
Overview
Phaser is a game framework: scenes, game objects, input, physics, audio, tweens, a loader and a scale manager. PixiJS is a fast 2D renderer: display objects, textures and a ticker — scenes, physics, input mapping and audio are yours to add. Pick by what the repository already uses; never put both in one game.
Core principle: The engine draws and dispatches; rules live in plain TypeScript modules with no engine import, so Vitest can run them (web-game-performance-and-testing).
Phaser: structure
// main.ts
new Phaser.Game({
type: Phaser.AUTO,
parent: "game",
backgroundColor: "#101018",
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, width: 1280, height: 720 },
physics: { default: "arcade", arcade: { gravity: { x: 0, y: 900 }, debug: false } },
scene: [BootScene, PreloadScene, GameScene, HudScene],
});
// GameScene.ts — the scene wires engine objects to plain rules
export class GameScene extends Phaser.Scene {
private player!: Player;
private bullets!: Phaser.Physics.Arcade.Group;
private rules!: CombatRules; // plain TS, no Phaser import
constructor() { super("Game"); }
create(data: { seed: number }): void {
this.rules = new CombatRules(definitions.combat, mulberry32(data.seed ?? Date.now()));
this.player = new Player(this, 200, 500);
this.bullets = this.physics.add.group({ classType: Bullet, maxSize: 64, runChildUpdate: true });
this.physics.add.overlap(this.bullets, this.enemies, (b, e) => this.onHit(b as Bullet, e as Enemy));
this.scene.launch("Hud"); // HUD as a parallel scene listening to events
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.cleanup());
}
update(_time: number, deltaMs: number): void {
this.rules.tick(deltaMs / 1000); // Phaser's delta is milliseconds
this.player.applyIntent(this.input.keyboard!, deltaMs / 1000);
}
private fire(x: number, y: number): void {
const b = this.bullets.get(x, y) as Bullet | null; // reuse from the pool; null when maxSize reached
b?.launch(600);
}
private cleanup(): void {
this.game.events.off("settings-changed", this.applySettings, this);
}
}
- Scenes: Boot (config) → Preload (loader with a progress bar) → gameplay scenes; HUD/UI in a parallel scene (
scene.launch) talking through events, not object references. - Game objects as classes extending
Phaser.GameObjects.Sprite/Phaser.Physics.Arcade.Sprite, added withscene.add.existing(this)andscene.physics.add.existing(this). - Groups are pools:
maxSize,get(), and on releasesetActive(false).setVisible(false)plus disabling the body (group.killAndHide(obj),body.enable = false). Nevernew+destroyper shot. - Arcade physics steps on its own fixed rate (
arcade.fps, default 60); move bodies with velocity/acceleration, not by settingx/yeach frame. Matter physics for real shapes and constraints, at higher cost. - Listeners registered on
this.game.events, the registry, or DOM/window must be removed onSHUTDOWN; listeners on the scene's own emitter die with it. Restarting a scene that leaked listeners doubles every handler. - Loader: texture atlases (
this.load.atlas(key, png, json)), audio with format alternatives (this.load.audio("hit", ["hit.ogg", "hit.m4a"])), a pack file for large asset lists. - Input: map keys to actions once (
this.input.keyboard!.addKeys({ jump: "SPACE", left: "A", right: "D" })); pointer and touch through the same action layer; gamepads withinput: { gamepad: true }in the config. - Phaser 4 keeps the v3 scene and game-object model with a new renderer; follow the repository's major version and its migration notes rather than mixing APIs.
PixiJS 8: the renderer
const app = new Application();
await app.init({ resizeTo: window, background: "#101018", antialias: false }); // async in v8
document.getElementById("game")!.appendChild(app.canvas); // `canvas`, not `view`
await Assets.init({ manifest: "assets/manifest.json" });
const level = await Assets.loadBundle("level-1");
const world = new Container();
app.stage.addChild(world);
const hero = new Sprite(level.hero);
world.addChild(hero);
app.ticker.add((ticker) => {
const dt = ticker.deltaMS / 1000; // seconds; ticker.deltaTime is a frame-scaled factor (~1 at 60 FPS)
sim.step(dt);
hero.position.set(sim.hero.x, sim.hero.y);
});
- Version 8 changes that break v7 code: async
app.init,app.canvas, the ticker callback receiving theTicker, the chainableGraphicsAPI (new Graphics().rect(0, 0, 10, 10).fill(0xff0000)),ParticleContainerholding lightweightParticles. - Load through
Assetswith bundles per level; unload a level's bundle when leaving it (Assets.unloadBundle). destroy({ children: true })removes a subtree; passtexture: trueonly for textures nothing else shares.- Many identical sprites: one atlas texture so the batcher draws them together;
ParticleContainerfor thousands of particles;cullable = trueon off-screen-heavy scenes. - Pixi has no physics or input mapping: the simulation (accumulator loop, game-loop-and-time) and the action layer are your modules, and the ticker only renders their state.
Shared rules
- TypeScript
strict; noany, noascasts to silence the compiler — type game object factories and event payloads. - One source of randomness, seeded (
mulberry32(seed)), passed into rules; the seed comes from the URL in debug builds so tests and screenshots are reproducible. - Text: bitmap fonts for frequently changing numbers (score, timers);
Textobjects regenerate a texture on every change. - Pixel art:
pixelArt: true(Phaser) orantialias: falseplus nearest scaling (Pixi), integer zoom, and positions rounded at render time. - Scale and letterbox to a fixed logical resolution; never size the game from
window.innerWidthinside rules.
Common Mistakes
- Treating Phaser's or Pixi's
deltaas seconds. - Creating and destroying bullets and particles every frame.
- Scene restarts that leak listeners on
game.events. - Rules written inside
Scene.updatethat Vitest cannot import without a canvas. - Pixi v7 code (
app.view, synchronousnew Application({...})) pasted into a v8 project.
Red Flags
import Phaserorimport { Sprite } from "pixi.js"in a file under the rules/logic folder.this.add.textupdated every frame for a score counter.- A scene with
events.onon a global emitter and no matchingoff.