PixiJS v8 Development for Starwards
Overview
Starwards uses PixiJS v8 (^8.14.0) for 2D rendering in the browser module. This skill covers both the PixiJS v8 API reference and Starwards-specific patterns.
Core principle: Layered container composition with ticker-driven updates synced to Colyseus state changes.
Table of Contents
-
PixiJS v8 Reference
-
Application
-
Containers & Scene Graph
-
Sprites
-
Graphics (v8 API)
-
Text
-
Textures & Assets
-
Ticker
-
Events / Interaction
-
Performance Tips
-
v8 Migration Guide
-
Starwards Patterns
-
CameraView Application
-
Layer System
-
Graphics Patterns
-
Event Handling
-
Object Pooling
-
Testing with Playwright
PixiJS v8 Reference
Application
The Application class provides an extensible entry point for PixiJS projects.
Async Initialization (v8 Required)
import { Application } from 'pixi.js';
const app = new Application();
await app.init({ width: 800, height: 600, backgroundColor: 0x1099bb, });
document.body.appendChild(app.canvas);
Key Options
Option Type Default Description
width
number
800
Initial width
height
number
600
Initial height
backgroundColor
ColorSource
'black'
Background color
antialias
boolean
Enable anti-aliasing
resolution
number
1
Pixel resolution
resizeTo
Window | HTMLElement
Auto-resize target
preference
'webgl' | 'webgpu'
'webgl'
Renderer type
Containers & Scene Graph
Creating Containers
import { Container } from 'pixi.js';
const container = new Container({ x: 100, y: 100, });
app.stage.addChild(container);
Parent-Child Relationships
-
Children inherit transforms, alpha, visibility from parents
-
Render order: children render in insertion order (later = on top)
-
Use setChildIndex() or zIndex with sortableChildren for reordering
Coordinate Systems
// Local to global const globalPos = obj.toGlobal(new Point(0, 0));
// Global to local const localPos = container.toLocal(new Point(100, 100));
Culling
container.cullable = true; // Enable culling container.cullableChildren = true; // Cull children recursively container.cullArea = new Rectangle(0, 0, 400, 400); // Custom cull bounds
Sprites
Basic Usage
import { Sprite, Assets } from 'pixi.js';
const texture = await Assets.load('bunny.png'); const sprite = new Sprite(texture);
sprite.anchor.set(0.5); // Center anchor sprite.x = 100; sprite.y = 100; sprite.tint = 0xff0000; // Red tint sprite.alpha = 0.8;
Sprite Properties
Property Description
texture
The texture to display
anchor
Origin point (0-1 range)
tint
Color tint
blendMode
Blend mode for compositing
width , height
Size (scales texture)
Graphics (v8 API)
CRITICAL: v8 uses a new fluent API. Build shapes first, then fill/stroke.
v8 Fluent API
import { Graphics } from 'pixi.js';
// Draw shape, then fill/stroke const graphics = new Graphics() .rect(50, 50, 100, 100) .fill(0xff0000) .stroke({ width: 2, color: 'white' });
// Circle with fill and stroke const circle = new Graphics() .circle(100, 100, 50) .fill({ color: 0x00ff00, alpha: 0.5 }) .stroke({ width: 3, color: 0x000000 });
Shape Methods
v7 (OLD) v8 (NEW)
drawRect()
rect()
drawCircle()
circle()
drawEllipse()
ellipse()
drawRoundedRect()
roundRect()
drawPolygon()
poly()
drawStar()
star()
Lines
const lines = new Graphics() .moveTo(0, 0) .lineTo(100, 100) .lineTo(200, 0) .stroke({ width: 2, color: 0xff0000 });
Holes (v8)
const rectWithHole = new Graphics() .rect(0, 0, 100, 100) .fill(0x00ff00) .circle(50, 50, 20) .cut(); // Creates hole
GraphicsContext (Sharing)
import { GraphicsContext, Graphics } from 'pixi.js';
const context = new GraphicsContext() .rect(0, 0, 100, 100) .fill(0xff0000);
const g1 = new Graphics(context); const g2 = new Graphics(context); // Shares same data
Text
Basic Text
import { Text, TextStyle } from 'pixi.js';
const text = new Text({ text: 'Hello World', style: { fontFamily: 'Arial', fontSize: 24, fill: 0xffffff, align: 'center', }, });
TextStyle Properties
Property Description
fontFamily
Font name
fontSize
Size in pixels
fill
Fill color
stroke
Stroke settings
align
Text alignment
wordWrap
Enable word wrapping
wordWrapWidth
Wrap width
BitmapText (Performance)
import { BitmapText } from 'pixi.js';
const bitmapText = new BitmapText({ text: 'Score: 1000', style: { fontFamily: 'MyBitmapFont', fontSize: 32 }, });
Textures & Assets
Loading Assets
import { Assets, Sprite } from 'pixi.js';
// Single asset const texture = await Assets.load('path/to/image.png'); const sprite = new Sprite(texture);
// Multiple assets const textures = await Assets.load(['a.png', 'b.png']);
// With alias await Assets.load({ alias: 'hero', src: 'images/hero.png' }); const heroTexture = Assets.get('hero');
Asset Bundles
Assets.addBundle('game', [ { alias: 'player', src: 'player.png' }, { alias: 'enemy', src: 'enemy.png' }, ]);
const assets = await Assets.loadBundle('game');
Manifest
const manifest = { bundles: [ { name: 'load-screen', assets: [{ alias: 'bg', src: 'background.png' }], }, { name: 'game', assets: [{ alias: 'hero', src: 'hero.png' }], }, ], };
await Assets.init({ manifest }); await Assets.loadBundle('load-screen');
SVGs
// As texture const svgTexture = await Assets.load('icon.svg'); const sprite = new Sprite(svgTexture);
// As Graphics (scalable) const svgContext = await Assets.load({ src: 'icon.svg', data: { parseAsGraphicsContext: true }, }); const graphics = new Graphics(svgContext);
Texture Cleanup
// Unload from cache and GPU await Assets.unload('texture.png');
// Unload from GPU only (keep in memory) texture.source.unload();
// Destroy texture texture.destroy();
Ticker
Basic Usage
import { Ticker, UPDATE_PRIORITY } from 'pixi.js';
// Using app ticker app.ticker.add((ticker) => { sprite.rotation += 0.1 * ticker.deltaTime; });
// One-time callback app.ticker.addOnce((ticker) => { console.log('Called once'); });
// With priority (higher runs first) app.ticker.add(updateFn, null, UPDATE_PRIORITY.HIGH);
Priority Constants
-
UPDATE_PRIORITY.HIGH = 50
-
UPDATE_PRIORITY.NORMAL = 0
-
UPDATE_PRIORITY.LOW = -50
FPS Control
app.ticker.maxFPS = 60; // Cap framerate app.ticker.minFPS = 30; // Clamp deltaTime
Ticker Properties
Property Description
deltaTime
Scaled frame delta
elapsedMS
Raw milliseconds since last frame
FPS
Current frames per second
Events / Interaction
Event Modes
sprite.eventMode = 'static'; // Interactive, non-moving sprite.eventMode = 'dynamic'; // Interactive, moving (receives idle events) sprite.eventMode = 'passive'; // Default, children can be interactive sprite.eventMode = 'none'; // No interaction
Pointer Events
sprite.eventMode = 'static';
sprite.on('pointerdown', (event) => { console.log('Clicked at', event.global.x, event.global.y); });
sprite.on('pointermove', (event) => { /* ... / }); sprite.on('pointerup', (event) => { / ... / }); sprite.on('pointerover', (event) => { / ... / }); sprite.on('pointerout', (event) => { / ... */ });
Hit Area
import { Rectangle, Circle } from 'pixi.js';
sprite.hitArea = new Rectangle(0, 0, 100, 100); // or sprite.hitArea = new Circle(50, 50, 50);
Custom Cursor
sprite.cursor = 'pointer'; sprite.cursor = 'grab'; sprite.cursor = 'url(cursor.png), auto';
Disable Children Interaction
container.interactiveChildren = false; // Skip children hit testing
Performance Tips
Sprites
-
Use spritesheets to minimize texture switches
-
Sprites batch with up to 16 textures per batch
-
Draw order matters for batching efficiency
Graphics
-
Graphics are fastest when not modified after creation
-
Small Graphics (<100 points) batch like sprites
-
Use sprites with textures for complex shapes
Text
-
Avoid updating text every frame (expensive)
-
Use BitmapText for frequently changing text
-
Lower resolution for less memory
Masks
-
Rectangle masks (scissor) are fastest
-
Graphics masks (stencil) are second fastest
-
Sprite masks (filters) are expensive
Filters
-
Release with container.filters = null
-
Set filterArea for known dimensions
-
Use sparingly - each filter adds draw calls
General
-
Enable culling for large scenes: cullable = true
-
Use RenderGroups for static content
-
Set interactiveChildren = false for non-interactive containers
v8 Migration Highlights
Key Changes
- Async Initialization Required
// OLD (v7) const app = new Application({ width: 800 });
// NEW (v8) const app = new Application(); await app.init({ width: 800 });
- Graphics API Changed
// OLD (v7) graphics.beginFill(0xff0000).drawRect(0, 0, 100, 100).endFill();
// NEW (v8) graphics.rect(0, 0, 100, 100).fill(0xff0000);
- Ticker Callback
// OLD (v7) ticker.add((dt) => sprite.rotation += dt);
// NEW (v8) ticker.add((ticker) => sprite.rotation += ticker.deltaTime);
- Application Canvas
// OLD (v7) app.view
// NEW (v8) app.canvas
-
Leaf Nodes Can't Have Children
-
Sprite , Graphics , Mesh etc. can no longer have children
-
Use Container as parent instead
-
getBounds Returns Bounds
// OLD (v7) const rect = container.getBounds();
// NEW (v8) const rect = container.getBounds().rectangle;
Starwards Patterns
CameraView Application
Starwards extends Application for radar/tactical views.
Location: modules/browser/src/radar/camera-view.ts
import { Application, ApplicationOptions, Container } from 'pixi.js';
export class CameraView extends Application { constructor(public camera: Camera) { super(); }
public async initialize( pixiOptions: Partial<ApplicationOptions>, container: WidgetContainer ) { await super.init(pixiOptions);
// Limit FPS to prevent GPU heating
this.ticker.maxFPS = 30;
// Handle resize
container.on('resize', () => {
this.resizeView(container.width, container.height);
});
// Append canvas
container.getElement().append(this.canvas);
}
// Coordinate transformations public worldToScreen = (w: XY) => this.camera.worldToScreen(this.renderer, w.x, w.y); public screenToWorld = (s: XY) => this.camera.screenToWorld(this.renderer, s.x, s.y);
// Layer management public addLayer(child: Container) { this.stage.addChild(child); } }
Key patterns:
-
ticker.maxFPS = 30
-
Prevents excessive GPU usage
-
Coordinate transforms: worldToScreen() , screenToWorld()
-
Layer composition via addLayer()
Layer System
Starwards uses a layer pattern where each layer has a renderRoot Container.
GridLayer Example
Location: modules/browser/src/radar/grid-layer.ts
import { Container, Graphics } from 'pixi.js';
export class GridLayer { private stage = new Container(); private gridLines = new Graphics();
constructor(private parent: CameraView) { this.parent.events.on('screenChanged', () => this.drawSectorGrid()); this.stage.addChild(this.gridLines); }
get renderRoot(): Container { return this.stage; }
private drawSectorGrid() { // Clear and redraw this.gridLines.clear();
// Draw lines using v8 API
this.gridLines
.moveTo(0, screen)
.lineTo(this.parent.renderer.width, screen)
.stroke({ width: 2, color: magnitude.color, alpha: 0.5 });
} }
Pattern:
-
Each layer owns a stage Container
-
Exposes via renderRoot getter
-
Redraws on screenChanged event
-
Uses graphics.clear() before redrawing
Starwards Graphics Patterns
v8 Fluent API Usage
// Drawing selection rectangle const graphics = new Graphics(); graphics .rect(min.x, min.y, width, height) .fill({ color: selectionColor, alpha: 0.2 }) .stroke({ width: 1, color: selectionColor, alpha: 1 });
// Drawing grid lines this.gridLines .moveTo(0, screenY) .lineTo(rendererWidth, screenY) .stroke({ width: 2, color: lineColor, alpha: 0.5 });
Clear and Redraw Pattern
private redraw() { this.graphics.clear(); // ... draw new content }
Starwards Event Handling
Location: modules/browser/src/radar/interactive-layer.ts
Setup
import { Container, FederatedPointerEvent, Rectangle } from 'pixi.js';
export class InteractiveLayer { private stage = new Container();
constructor(private parent: CameraView) { // Set cursor this.stage.cursor = 'crosshair';
// Enable interaction
this.stage.interactive = true;
// Set hit area to full canvas
this.stage.hitArea = new Rectangle(
0, 0,
this.parent.renderer.width,
this.parent.renderer.height
);
// Register events
this.stage.on('pointerdown', this.onPointerDown);
this.stage.on('pointermove', this.onPointerMove);
this.stage.on('pointerup', this.onPointerUp);
// Update hit area on resize
this.parent.events.on('screenChanged', () => {
this.stage.hitArea = new Rectangle(
0, 0,
this.parent.renderer.width,
this.parent.renderer.height
);
});
}
private onPointerDown = (event: FederatedPointerEvent) => { const screenPos = XY.clone(event.global); const worldPos = this.parent.screenToWorld(screenPos); // ... handle interaction }; }
Key patterns:
-
stage.interactive = true enables events
-
stage.hitArea = new Rectangle(...) defines clickable area
-
Update hit area on resize
-
Use event.global for screen coordinates
-
Convert to world with screenToWorld()
Object Pooling
Location: modules/browser/src/radar/texts-pool.ts
Starwards uses iterator-based pooling to reduce GC pressure.
export class TextsPool { private texts: Text[] = [];
constructor(private container: Container) {}
*Symbol.iterator { let index = 0; while (true) { if (index >= this.texts.length) { const text = new Text({ text: '', style: { ... } }); this.texts.push(text); this.container.addChild(text); } const text = this.texts[index]; text.visible = true; yield text; index++; } }
return() { // Hide unused texts for (let i = this.usedCount; i < this.texts.length; i++) { this.texts[i].visible = false; } } }
// Usage const textsIterator = this.textsPoolSymbol.iterator; for (const item of items) { const text = textsIterator.next().value; text.text = item.label; text.x = item.x; text.y = item.y; } textsIterator.return(); // Hide unused
Testing with Playwright
Data Attributes
Add data-id to canvas elements for E2E testing:
this.canvas.setAttribute('data-id', 'Tactical Radar');
Playwright Selectors
// Select canvas by data-id const canvas = page.locator('[data-id="Tactical Radar"]');
// Get attribute values const zoom = await canvas.getAttribute('data-zoom');
RadarDriver Pattern
class RadarDriver { constructor(private canvas: Locator) {}
async getZoom() { return Number(await this.canvas.getAttribute('data-zoom')); }
async setZoom(target: number) { await this.canvas.dispatchEvent('wheel', { deltaY: ... }); } }
Testing Considerations
-
No unit tests for PixiJS components (visual output)
-
Use E2E tests with Playwright
-
Test via data attributes, not rendered pixels
-
Use data-id on Tweakpane panels: page.locator('[data-id="Panel Name"]')
Quick Reference
Task Starwards Pattern
Create layer class MyLayer { stage = new Container(); get renderRoot() { return this.stage; } }
Draw graphics graphics.rect(...).fill({...}).stroke({...})
Redraw graphics.clear(); // then draw
Interactive stage.interactive = true; stage.hitArea = new Rectangle(...)
Events stage.on('pointerdown', handler)
Coords parent.worldToScreen(xy) , parent.screenToWorld(xy)
FPS limit ticker.maxFPS = 30
Test selector page.locator('[data-id="..."]')
Common Pitfalls
Using v7 Graphics API
-
Wrong: beginFill() , drawRect() , endFill()
-
Right: rect().fill().stroke()
Forgetting async init
-
Wrong: new Application({ width: 800 })
-
Right: await app.init({ width: 800 })
Adding children to Sprites
-
v8 leaf nodes can't have children
-
Use Container as parent
Not clearing Graphics
-
Call graphics.clear() before redrawing
Hit area not updated on resize
-
Update stage.hitArea when canvas resizes
SVGs not loading as textures
- Use Texture.from() for SVGs (GitHub issue #8694 workaround)