GoodLookRetail BlogEmber
Engineering

Testing PixiJS Games with Playwright

How to reach inside a canvas-rendered game, access PixiJS components programmatically, and run real automation tests — end to end.

A
Ankit Kothari
27 June 2026 · 13 min read
18 Views0 Comments13 Min Read
Testing PixiJS Games with Playwright

Canvas games are invisible to standard test tools. This article shows you exactly how to bridge that gap using a custom GameBridge, `page.evaluate()`, and labelled PixiJS sprites — with a complete slot game test suite you can drop in today.

---

## 01 — Why Canvas Testing Is Hard

Standard web automation tools — Selenium, Playwright, Cypress — work by querying the DOM. They find a button with `page.click('button#spin')` and fire a click on that element. This works perfectly for React apps, dashboards, and admin panels.

PixiJS games render everything onto a single `<canvas>` element. From the DOM's perspective there is exactly **one element** — the canvas. There are no buttons, no sprites, no UI labels in the DOM. The entire visual tree lives inside PixiJS's scene graph, completely invisible to standard selectors.

| What Playwright Can See | What Playwright Cannot See |
|---|---|
| `<canvas id="pixi-app">` | Sprite named "spinButton" |
| Canvas bounding box | Text node "Balance: $1,000" |
| Mouse coordinates on canvas | Reel containers reel_0 … reel_4 |
| Keyboard events | Win banner visibility |
| Page load / network events | PixiJS animation state |

> ⚠️ **The core challenge:** Playwright lives in Node.js. Your PixiJS game runs in the browser. They can't share objects directly. The bridge between them is `page.evaluate()` — it runs arbitrary JavaScript inside the browser and returns serialisable results back to Node.

---

## 02 — The Architecture: PixiJS–Playwright Bridge

The strategy has two sides. The **game side** exposes a controlled API on `window`. The **test side** calls that API via `page.evaluate()`. This keeps tests clean and decoupled from game internals.

```
┌─────────────────────────┐ ┌─────────────────────────┐
│ Node.js / Playwright │ │ Browser / PixiJS Game │
│ │ │ │
│ test('spin', async()=> │ ──────► │ window.__GAME_BRIDGE__ │
│ page.evaluate(() => │ JSON │ = new GameBridge(app) │
│ window.__GAME_ │ ◄────── │ │
│ BRIDGE__.getState()│ │ window.__PIXI_APP__ │
│ ) │ │ = pixiApp │
│ ) │ │ │
└─────────────────────────┘ └─────────────────────────┘
Node.js page.evaluate() Browser
```

> 💡 **Key rule:** `page.evaluate()` can only return JSON-serialisable values. You cannot return PixiJS objects — they contain circular references and WebGL textures. Always extract plain data (`{ x, y, visible, text }`) before returning.

---

## 03 — Game Side: Exposing the Stage

The first step happens in your game code. Attach the PixiJS application and a testing API to `window` — but only in non-production so this never ships to real players.

### Install dependencies

```bash
npm install pixi.js@8
npm install -D playwright @playwright/test typescript @types/node
npx playwright install chromium
```

### Bootstrap with Test Bridge

```typescript
// src/main.ts

import { Application } from 'pixi.js';
import { GameBridge } from './testing/GameBridge';

declare global {
interface Window {
__PIXI_APP__: Application;
__GAME_BRIDGE__: GameBridge;
}
}

async function bootstrap() {
const app = new Application();

await app.init({
width: 1280,
height: 720,
backgroundColor: 0x1a1a2e,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
});

document.body.appendChild(app.canvas);

const gameScene = await loadGameScene(app);
app.stage.addChild(gameScene);

// Only expose in non-production builds
if (import.meta.env.MODE !== 'production') {
window.__PIXI_APP__ = app;
window.__GAME_BRIDGE__ = new GameBridge(app);
console.log('[TEST] Game bridge attached to window.__GAME_BRIDGE__');
}
}

bootstrap();
```

### The GameBridge Class

```typescript
// src/testing/GameBridge.ts

import { Application, Container, Text } from 'pixi.js';

export interface SpriteInfo {
label: string;
x: number;
y: number;
width: number;
height: number;
visible: boolean;
alpha: number;
children: number;
}

export class GameBridge {
constructor(private app: Application) {}

/** Walk the PixiJS scene graph and find a child by its label */
findByLabel(label: string, root?: Container): Container | null {
const node = root ?? this.app.stage;

if ((node as any).label === label) return node;

for (const child of node.children) {
const found = this.findByLabel(label, child as Container);
if (found) return found;
}
return null;
}

/** Find all children whose label starts with a prefix */
findAllByPrefix(prefix: string, root?: Container): Container[] {
const results: Container[] = [];
const walk = (node: Container) => {
if ((node as any).label?.startsWith(prefix)) results.push(node);
node.children.forEach(c => walk(c as Container));
};
walk(root ?? this.app.stage);
return results;
}

/** Return serialisable info — safe to pass across page.evaluate() */
getSpriteInfo(label: string): SpriteInfo | null {
const node = this.findByLabel(label);
if (!node) return null;

return {
label: (node as any).label,
x: node.x,
y: node.y,
width: node.width,
height: node.height,
visible: node.visible,
alpha: node.alpha,
children: node.children.length,
};
}

/** Fire a PixiJS pointer event on a named component */
triggerEvent(label: string, event: string): boolean {
const node = this.findByLabel(label);
if (!node) return false;
(node as any).emit(event);
return true;
}

/** Read the text value of a PixiJS Text node */
getTextContent(label: string): string | null {
const node = this.findByLabel(label) as Text | null;
return node?.text ?? null;
}

/** Get current game state — implement this in your game */
getGameState(): string {
return (window as any).__GAME_STATE__ ?? 'idle';
}

/** Get current player balance */
getBalance(): number {
return (window as any).__GAME_STATE_BALANCE__ ?? 0;
}

/** Force a win result for deterministic testing */
forceWin(opts: { multiplier: number }): void {
(window as any).__FORCE_WIN__ = opts;
}

/** Dump the full scene graph as text (for debugging failed tests) */
dumpSceneGraph(root?: Container, depth = 0): string {
const node = root ?? this.app.stage;
const label = (node as any).label ?? node.constructor.name;
const pad = ' '.repeat(depth);
let out = `${pad}└ ${label} (${node.x.toFixed(0)},${node.y.toFixed(0)})\n`;
node.children.forEach(c => {
out += this.dumpSceneGraph(c as Container, depth + 1);
});
return out;
}
}
```

> 💡 **Always label your PixiJS objects.** Set `sprite.label = 'spinButton'` on every interactive component. The label is your CSS selector equivalent — it's how tests find components without hardcoding pixel coordinates.

---

## 04 — Label Your Components in Game Code

Before writing a single test, make sure every component that needs to be tested has a `.label` assigned. Here's how a slot game scene should look:

```typescript
// src/scenes/SlotScene.ts

import { Container, Sprite, Text } from 'pixi.js';

export class SlotScene extends Container {
constructor() {
super();
this.label = 'SlotScene';
this.buildUI();
}

private buildUI() {
// ── 5 Reels ────────────────────────────────────
for (let i = 0; i < 5; i++) {
const reel = new Container();
reel.label = `reel_${i}`; // ← labelled: findAllByPrefix('reel_')
reel.x = 200 + i * 160;
reel.y = 150;
this.addChild(reel);
}

// ── Spin Button ────────────────────────────────
const spinBtn = new Sprite();
spinBtn.label = 'spinButton'; // ← labelled
spinBtn.x = 580;
spinBtn.y = 580;
spinBtn.interactive = true;
spinBtn.cursor = 'pointer';
spinBtn.on('pointerdown', () => this.onSpin());
this.addChild(spinBtn);

// ── Balance Text ───────────────────────────────
const balanceText = new Text({ text: 'Balance: $1,000.00' });
balanceText.label = 'balanceDisplay'; // ← labelled
balanceText.x = 50;
balanceText.y = 650;
this.addChild(balanceText);

// ── Win Banner (hidden until win) ──────────────
const winBanner = new Container();
winBanner.label = 'winBanner'; // ← labelled
winBanner.visible = false;
this.addChild(winBanner);

// ── Loading Overlay ────────────────────────────
const loader = new Container();
loader.label = 'loadingOverlay'; // ← labelled
this.addChild(loader);
}
}
```

---

## 05 — Test Side: The PixiPage Helper

Now that the bridge is in place, wrap Playwright's `Page` with game-aware methods so your test files stay clean and readable.

```typescript
// tests/helpers/PixiPage.ts

import { Page } from '@playwright/test';
import type { SpriteInfo } from '../../src/testing/GameBridge';

export class PixiPage {
constructor(private page: Page) {}

/** Wait for PixiJS app and bridge to be fully ready */
async waitForGame(): Promise<void> {
await this.page.waitForFunction(() =>
typeof window.__GAME_BRIDGE__ !== 'undefined' &&
window.__PIXI_APP__?.stage !== undefined
, { timeout: 10_000 });
}

/** Find a sprite by label — returns serialisable info */
async findSprite(label: string): Promise<SpriteInfo | null> {
return this.page.evaluate((lbl) =>
window.__GAME_BRIDGE__.getSpriteInfo(lbl)
, label);
}

/** Assert a sprite exists — throws if not found */
async expectSprite(label: string): Promise<SpriteInfo> {
const info = await this.findSprite(label);
if (!info) throw new Error(`Sprite '${label}' not found in scene graph`);
return info;
}

/** Read a PixiJS Text node's content */
async getTextContent(label: string): Promise<string | null> {
return this.page.evaluate((lbl) =>
window.__GAME_BRIDGE__.getTextContent(lbl)
, label);
}

/** Fire a PixiJS event on a named sprite */
async triggerEvent(label: string, event: string): Promise<boolean> {
return this.page.evaluate(({ lbl, evt }) =>
window.__GAME_BRIDGE__.triggerEvent(lbl, evt)
, { lbl: label, evt: event });
}

/** Click a sprite by computing its canvas coordinates */
async clickSprite(label: string): Promise<void> {
const info = await this.expectSprite(label);
const canvas = this.page.locator('canvas');
const box = await canvas.boundingBox();
if (!box) throw new Error('Canvas element not found');

// Convert PixiJS scene coords → viewport coords
const scaleX = box.width / 1280;
const scaleY = box.height / 720;
const clickX = box.x + (info.x + info.width / 2) * scaleX;
const clickY = box.y + (info.y + info.height / 2) * scaleY;

await this.page.mouse.click(clickX, clickY);
}

/** Wait until a sprite becomes visible */
async waitForSprite(label: string, timeout = 5000): Promise<void> {
await this.page.waitForFunction((lbl) => {
const info = window.__GAME_BRIDGE__?.getSpriteInfo(lbl);
return info?.visible === true;
}, label, { timeout });
}

/** Dump full scene graph — add to failing tests for debugging */
async dumpSceneGraph(): Promise<string> {
return this.page.evaluate(() =>
window.__GAME_BRIDGE__.dumpSceneGraph()
);
}
}
```

---

## 06 — Waiting for Animations

Games have animations. Tests must wait for them before asserting. Never assert immediately after triggering a spin.

```typescript
// tests/helpers/waitForAnimation.ts

import { Page } from '@playwright/test';

/** Wait for the game to return to idle state */
export async function waitForIdle(
page: Page,
timeout = 15_000
): Promise<void> {
await page.waitForFunction(() =>
window.__GAME_BRIDGE__?.getGameState() === 'idle'
, { timeout });
}

/** Wait until a Text node shows an expected value */
export async function waitForText(
page: Page,
label: string,
expectedText: string,
timeout = 5_000
): Promise<void> {
await page.waitForFunction(
({ lbl, txt }) =>
window.__GAME_BRIDGE__?.getTextContent(lbl) === txt,
{ lbl: label, txt: expectedText },
{ timeout }
);
}

/** Wait until a sprite property reaches a specific value */
export async function waitForSpriteProperty(
page: Page,
label: string,
property: string,
expected: unknown,
timeout = 5_000
): Promise<void> {
await page.waitForFunction(
({ lbl, prop, val }) => {
const node = window.__GAME_BRIDGE__?.findByLabel(lbl);
return (node as any)?.[prop] === val;
},
{ lbl: label, prop: property, val: expected },
{ timeout, polling: 100 }
);
}
```

---

## 07 — Interaction Methods: Which to Use

| Method | How | Tests | Use When |
|---|---|---|---|
| Mouse click on canvas | `page.mouse.click(x, y)` | Full stack | Testing real pointer input pipeline |
| PixiJS event emit | `bridge.triggerEvent(label, 'pointerdown')` | Logic only | No coordinate math needed |
| Direct state mutation | `page.evaluate(() => gameState.balance = 999)` | State setup | Edge-case test setup |
| Keyboard event | `page.keyboard.press('Space')` | Full stack | Shortcuts, accessibility |

> ⚠️ Prefer `triggerEvent()` for unit-style tests and `clickSprite()` for integration tests that need to verify the full pointer event pipeline including hit testing.

---

## 08 — Full Slot Game Test Suite

Here is a complete real-world test file for a PixiJS slot game. It covers load verification, spin flow, animation waiting, and win condition testing.

```typescript
// tests/slot-game.spec.ts

import { test, expect } from '@playwright/test';
import { PixiPage } from './helpers/PixiPage';
import { waitForIdle } from './helpers/waitForAnimation';

test.describe('Slot Game — What The Fish', () => {

test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:5173');
const pixi = new PixiPage(page);
await pixi.waitForGame();
});

// ── Initial Load ─────────────────────────────────────────────

test('renders the canvas element', async ({ page }) => {
await expect(page.locator('canvas')).toBeVisible();
});

test('spin button is visible', async ({ page }) => {
const pixi = new PixiPage(page);
const spin = await pixi.expectSprite('spinButton');

expect(spin.visible).toBe(true);
expect(spin.alpha).toBeGreaterThan(0);
});

test('all 5 reels are present in the scene graph', async ({ page }) => {
const reels = await page.evaluate(() =>
window.__GAME_BRIDGE__
.findAllByPrefix('reel_')
.map(r => ({ label: (r as any).label, x: r.x }))
);

expect(reels).toHaveLength(5);
expect(reels.map(r => r.label)).toEqual([
'reel_0', 'reel_1', 'reel_2', 'reel_3', 'reel_4'
]);
});

test('loading overlay disappears after assets load', async ({ page }) => {
const pixi = new PixiPage(page);
await pixi.waitForSprite('loadingOverlay');

// Actually wait for it to become hidden
await page.waitForFunction(() =>
window.__GAME_BRIDGE__.getSpriteInfo('loadingOverlay')?.visible === false
, { timeout: 10_000 });

const overlay = await pixi.findSprite('loadingOverlay');
expect(overlay?.visible).toBe(false);
});

// ── Spin Flow ────────────────────────────────────────────────

test('spin button triggers reels to spin', async ({ page }) => {
const pixi = new PixiPage(page);
await pixi.triggerEvent('spinButton', 'pointerdown');

const state = await page.evaluate(() =>
window.__GAME_BRIDGE__.getGameState()
);
expect(state).toBe('spinning');
});

test('spin completes and returns to idle state', async ({ page }) => {
const pixi = new PixiPage(page);
await pixi.triggerEvent('spinButton', 'pointerdown');
await waitForIdle(page, 15_000);

const finalState = await page.evaluate(() =>
window.__GAME_BRIDGE__.getGameState()
);
expect(finalState).toBe('idle');
});

test('balance changes after spin completes', async ({ page }) => {
const pixi = new PixiPage(page);

const before = await page.evaluate(() =>
window.__GAME_BRIDGE__.getBalance()
);

await pixi.triggerEvent('spinButton', 'pointerdown');
await waitForIdle(page);

const after = await page.evaluate(() =>
window.__GAME_BRIDGE__.getBalance()
);

expect(after).not.toBe(before);
});

test('spin button is disabled while spinning', async ({ page }) => {
const pixi = new PixiPage(page);
await pixi.triggerEvent('spinButton', 'pointerdown');

const btnAlpha = await page.evaluate(() => {
const node = window.__GAME_BRIDGE__.findByLabel('spinButton');
return (node as any)?.alpha;
});

expect(btnAlpha).toBeLessThan(1); // dimmed = disabled
});

// ── Win Condition ────────────────────────────────────────────

test('win banner appears after a winning spin', async ({ page }) => {
const pixi = new PixiPage(page);

// Force a win so test is deterministic — not relying on RNG
await page.evaluate(() =>
window.__GAME_BRIDGE__.forceWin({ multiplier: 5 })
);

await pixi.triggerEvent('spinButton', 'pointerdown');
await waitForIdle(page);

const banner = await pixi.findSprite('winBanner');
expect(banner?.visible).toBe(true);
});

test('balance display updates with win amount', async ({ page }) => {
const pixi = new PixiPage(page);

await page.evaluate(() =>
window.__GAME_BRIDGE__.forceWin({ multiplier: 10 })
);
await pixi.triggerEvent('spinButton', 'pointerdown');
await waitForIdle(page);

const balanceText = await pixi.getTextContent('balanceDisplay');
expect(balanceText).toContain('Balance:');
expect(balanceText).not.toBe('Balance: $1,000.00'); // must have changed
});
});
```

---

## 09 — Visual Snapshot Testing

Playwright can screenshot the canvas and compare it to a baseline on every run. This catches visual regressions that no assertion can.

```typescript
// tests/visual.spec.ts

import { test, expect } from '@playwright/test';

test('game renders correctly at idle state', async ({ page }) => {
await page.goto('http://localhost:5173');
await page.waitForFunction(() =>
window.__GAME_BRIDGE__?.getGameState() === 'idle'
);

await expect(page.locator('canvas')).toHaveScreenshot('game-idle.png', {
threshold: 0.05, // 5% pixel difference allowed
maxDiffPixels: 500
});
});

test('win banner visual appearance', async ({ page }) => {
await page.goto('http://localhost:5173');
await page.waitForFunction(() => window.__GAME_BRIDGE__ !== undefined);

await page.evaluate(() => {
window.__GAME_BRIDGE__.forceWin({ multiplier: 5 });
window.__GAME_BRIDGE__.triggerEvent('spinButton', 'pointerdown');
});

await page.waitForFunction(() =>
window.__GAME_BRIDGE__.getSpriteInfo('winBanner')?.visible === true
);

await expect(page.locator('canvas')).toHaveScreenshot('game-win.png', {
threshold: 0.05
});
});
```

---

## 10 — Playwright Config & CI Setup

```typescript
// playwright.config.ts

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html'], ['junit', { outputFile: 'results.xml' }]],

use: {
baseURL: 'http://localhost:5173',
headless: true,
screenshot: 'only-on-failure',
video: 'on-first-retry',
trace: 'on-first-retry',
viewport: { width: 1280, height: 720 },
},

projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],

webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
timeout: 60_000,
reuseExistingServer: !process.env.CI,
},
});
```

```yaml
# .github/workflows/game-tests.yml

name: PixiJS Game Tests

on:
push:
branches: [main, develop]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

- name: Build game
run: npm run build

- name: Run tests
run: npx playwright test
env:
CI: true

- name: Upload report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30

- name: Upload screenshots on failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-screenshots
path: test-results/
```

---

## 11 — Common Pitfalls & Fixes

| Pitfall | Symptom | Fix |
|---|---|---|
| Returning PixiJS object from evaluate | Circular reference error | Extract plain values: `{ x, y, visible }` |
| Clicking before game loads | Nothing happens | Call `waitForGame()` before any interaction |
| Asserting mid-animation | Wrong state seen | Use `waitForIdle()` before asserting result |
| No labels on sprites | `findByLabel()` returns null | Set `sprite.label = 'name'` in game code |
| Wrong canvas coordinates | Click fires in wrong place | Account for `boundingBox()` offset + scale ratio |
| Bridge in production | Security / cheating risk | Gate with `import.meta.env.MODE !== 'production'` |
| Random outcomes in tests | Flaky win/lose tests | Use `forceWin()` / `forceLose()` test hooks |

---

## Assertion Quick Reference

| What to check | Code |
|---|---|
| Sprite exists | `await pixi.expectSprite('spinButton')` |
| Sprite is visible | `expect(sprite.visible).toBe(true)` |
| Sprite alpha (enabled) | `expect(sprite.alpha).toBeGreaterThan(0.5)` |
| Text content | `expect(await pixi.getTextContent('balanceDisplay')).toContain('$')` |
| Game state | `expect(await page.evaluate(() => window.__GAME_BRIDGE__.getGameState())).toBe('idle')` |
| Reel count | `expect(reels).toHaveLength(5)` |
| Visual snapshot | `await expect(page.locator('canvas')).toHaveScreenshot('name.png')` |
| Canvas rendered | `await expect(page.locator('canvas')).toBeVisible()` |

---

> 💡 **Debug tip:** When a test fails and you can't figure out why, add this line to your test: `console.log(await pixi.dumpSceneGraph())`. It prints the full PixiJS scene tree with labels and coordinates — the equivalent of browser DevTools "Inspect Element" for canvas games.

---

*Playwright · PixiJS v8 · TypeScript 5 · Node 20+ · June 2026*

Promote this post

Share & amplify

LinkedIn post generator

Generate a polished, professional LinkedIn post from this article. Edit before posting.

Comments (0)

Sort:
Sign in to join the discussion.