mcpField
S
starwards-pixijs

starwards-pixijs

github.com/starwardsDevelopment4447 installsUpdated 2d ago
Repository

About starwards-pixijs

A PixiJS v8 reference and practices skill for developers working on Starwards browser rendering. It combines API guidance with Starwards patterns for layers, events, and object pooling.

Key features

  1. 01PixiJS v8 API reference
  2. 02Container and scene-graph organization
  3. 03Asset, texture, and graphics rendering
  4. 04Ticker-driven updates and interaction
  5. 05Starwards layers and object-pooling patterns

Use cases

  1. 01Build browser-based 2D rendering
  2. 02Migrate PixiJS v7 code to v8
  3. 03Develop Starwards radar or tactical views

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/starwards/starwards --skill starwards-pixijs

After installing, type/starwards-pixijsin Claude Code to confirm it loaded.

Details

From the project README

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

  1. PixiJS v8 Reference
  2. Starwards Patterns

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
OptionTypeDefaultDescription
widthnumber800Initial width
heightnumber600Initial height
backgroundColorColorSource'black'Background color
antialiasboolean-Enable anti-aliasing
resolutionnumber1Pixel resolution
resizeToWindow | 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
PropertyDescription
textureThe texture to display
anchorOrigin point (0-1 range)
tintColor tint
blendModeBlend mode for compositing
width, heightSize (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
PropertyDescription
fontFamilyFont name
fontSizeSize in pixels
fillFill color
strokeStroke settings
alignText alignment
wordWrapEnable word wrapping
wordWrapWidthWrap 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
PropertyDescription
deltaTimeScaled frame delta
elapsedMSRaw milliseconds since last frame
FPSCurrent 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
  1. Async Initialization Required
// OLD (v7)
const app = new Application({ width: 800 });

// NEW (v8)
const app = new Application();
await app.init({ width: 800 });
  1. Graphics API Changed
// OLD (v7)
graphics.beginFill(0xff0000).drawRect(0, 0, 100, 100).endFill();

// NEW (v8)
graphics.rect(0, 0, 100, 100).fill(0xff0000);
  1. Ticker Callback
// OLD (v7)
ticker.add((dt) => sprite.rotation += dt);

// NEW (v8)
ticker.add((ticker) => sprite.rotation += ticker.deltaTime);
  1. Application Canvas
// OLD (v7)
app.view

// NEW (v8)
app.canvas
  1. Leaf Nodes Can't Have Children
  • Sprite, Graphics, Mesh etc. can no longer have children
  • Use Container as parent instead
  1. 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.textsPool[Symbol.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

TaskStarwards Pattern
Create layerclass MyLayer { stage = new Container(); get renderRoot() { return this.stage; } }
Draw graphicsgraphics.rect(...).fill({...}).stroke({...})
Redrawgraphics.clear(); // then draw
Interactivestage.interactive = true; stage.hitArea = new Rectangle(...)
Eventsstage.on('pointerdown', handler)
Coordsparent.worldToScreen(xy), parent.screenToWorld(xy)
FPS limitticker.maxFPS = 30
Test selectorpage.locator('[data-id="..."]')

Common Pitfalls

  1. Using v7 Graphics API

    • Wrong: beginFill(), drawRect(), endFill()
    • Right: rect().fill().stroke()
  2. Forgetting async init

    • Wrong: new Application({ width: 800 })
    • Right: await app.init({ width: 800 })
  3. Adding children to Sprites

    • v8 leaf nodes can't have children
    • Use Container as parent
  4. Not clearing Graphics

    • Call graphics.clear() before redrawing
  5. Hit area not updated on resize

    • Update stage.hitArea when canvas resizes
  6. SVGs not loading as textures

    • Use Texture.from() for SVGs (GitHub issue #8694 workaround)

Comments

Markdown supported