| name | phaser-patterns |
| description | Phaser 3 TypeScript coding patterns: scene lifecycle (create/init/update/shutdown), graphics API, interactive objects, tweens, keyboard input, real-time game loop, scene transitions, and anti-patterns. Load when implementing any Phaser scene, game object, or renderer. |
Always extend Phaser.Scene
export class MyScene extends Phaser.Scene {
constructor() {
super({ key: 'MySceneKey' });
}
}
Scene lifecycle order
constructor → init(data) → preload() → create() → update()
init() — receive data from previous scene, set defaults
create() — build all objects, store references, register input
update() — runs every frame, keep it lean (no heavy queries)
Interactive objects
const btn = this.add.text(x, y, 'Click me', { fontFamily: 'monospace', fontSize: '18px', color: '#ffffff' })
.setOrigin(0.5)
.setInteractive({ useHandCursor: true });
btn.on('pointerover', () => btn.setColor('#00d4aa'));
btn.on('pointerout', () => btn.setColor('#ffffff'));
btn.on('pointerdown', () => { });
const zone = this.add.zone(x, y, width, height).setInteractive({ useHandCursor: true });
btn.disableInteractive();
Grid / board input — always use a world-space Zone
When implementing a grid or board (tile map, tic-tac-toe, etc.), never put
setInteractive() on a Container that is itself a child of another Container.
Phaser 3.60+ does not propagate the parent Container's world transform to the
child's hit area, so clicks either miss or land on the wrong cell.
Instead, place a single Zone at the board's exact world coordinates directly
in the Scene (not inside any Container). Calculate the cell from
pointer.worldX / pointer.worldY:
const BOARD_X = 190, BOARD_Y = 90;
const BOARD_W = 420, BOARD_H = 420;
const CELL_W = 140, CELL_H = 140;
const boardZone = this.add
.zone(BOARD_X + BOARD_W / 2, BOARD_Y + BOARD_H / 2, BOARD_W, BOARD_H)
.setInteractive({ useHandCursor: true });
boardZone.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
const lx = pointer.worldX - BOARD_X;
const ly = pointer.worldY - BOARD_Y;
if (lx < 0 || lx >= BOARD_W || ly < 0 || ly >= BOARD_H) return;
const col = Math.floor(lx / CELL_W);
const row = Math.floor(ly / CELL_H);
const index = row * 3 + col;
handleCellClick(index);
});
boardZone.disableInteractive();
boardZone.setInteractive({ useHandCursor: true });
Cell / tile Container objects stay pure rendering — no setInteractive,
no pointerdown listeners, no setSize for hit detection.
Drawing shapes
const gfx = this.add.graphics();
gfx.fillStyle(0xff6b35, 1);
gfx.fillRect(x, y, width, height);
gfx.fillCircle(x, y, radius);
gfx.fillRoundedRect(x, y, w, h, 8);
gfx.lineStyle(2, 0x00d4aa, 1);
gfx.strokeRect(x, y, width, height);
gfx.clear();
Score and dynamic text
private scoreText!: Phaser.GameObjects.Text;
create(): void {
this.scoreText = this.add.text(16, 16, 'Score: 0', {
fontFamily: 'monospace',
fontSize: '20px',
color: '#ffffff',
}).setDepth(10);
}
updateScore(value: number): void {
this.scoreText.setText(`Score: ${value}`);
}
Depth ordering
gfx.setDepth(0);
sprite.setDepth(1);
uiText.setDepth(10);
Tweens
this.tweens.add({
targets: gameObject,
alpha: { from: 0, to: 1 },
scaleX: 1.05,
duration: 200,
ease: 'Back.easeOut',
yoyo: false,
repeat: 0,
});
this.tweens.chain({
targets: obj,
tweens: [
{ alpha: 0, duration: 150 },
{ alpha: 1, duration: 150 },
],
});
Scene transitions with fade
this.cameras.main.fadeOut(300, 8, 8, 19);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('TargetScene', { key: 'value' });
});
this.cameras.main.fadeIn(300, 8, 8, 19);
Passing data between scenes
this.scene.start('GameScene', { difficulty: Difficulty.Hard } satisfies SceneData);
init(data: SceneData): void {
this.difficulty = data?.difficulty ?? Difficulty.Easy;
}
Real-time game loop
private tickEvent!: Phaser.Time.TimerEvent;
create(): void {
this.tickEvent = this.time.addEvent({
delay: 150,
callback: this.tick,
callbackScope: this,
loop: true,
});
}
private tick(): void {
const state = this.logic.tick();
this.renderer.draw(this.logic.snake, this.logic.food);
if (state === GameState.GameOver) {
this.tickEvent.remove();
this.endGame();
}
}
Keyboard input
this.input.keyboard?.on('keydown', (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowUp': this.handleUp(); break;
case 'ArrowDown': this.handleDown(); break;
case 'ArrowLeft': this.handleLeft(); break;
case 'ArrowRight': this.handleRight(); break;
}
});
Scene shutdown — always clean up
shutdown(): void {
this.tickEvent?.remove();
this.input.keyboard?.removeAllListeners();
this.tweens.killAll();
}
Audio — always wrap in try/catch
try {
this.sound.play('sfx-click');
} catch {
}
Cache references in create(), never re-query in update()
update(): void {
this.children.getByName('scoreText').setText(`${score}`);
}
private scoreText!: Phaser.GameObjects.Text;
create(): void { this.scoreText = this.add.text(...); }
update(): void { this.scoreText.setText(`${score}`); }
Common anti-patterns
class GameScene extends Phaser.Scene {
private board: CellValue[][] = [];
}
class GameScene extends Phaser.Scene {
private logic = new GameLogic();
}
this.sound.play('sfx');
try { this.sound.play('sfx'); } catch { }
const data = init_data as any;
init(data: SceneData): void {
this.difficulty = data?.difficulty ?? Difficulty.Easy;
}
class Cell extends Phaser.GameObjects.Container {
constructor(scene, x, y) {
super(scene, x, y);
this.setInteractive(new Phaser.Geom.Rectangle(-70, -70, 140, 140),
Phaser.Geom.Rectangle.Contains);
}
}