Skip to content

Migrating from Phaser ​

Phaser is a game framework: scenes, a loader, input, physics, tweens, audio, cameras and a renderer. spryte is only the renderer. So the first question isn't how to port your sprites, it's which parts of Phaser you're replacing. This guide covers the drawing side: the model, the same game in both, a mapping table, and what you'll need from elsewhere. If you'd rather measure first, try it on one effect.

What you're replacing ​

PhaserWith spryte
WebGL renderer, game objects, cameras (drawing), blend modes, masks, render textures, shadersspryte
Game loop (game.loop, update(time, delta))Your own requestAnimationFrame loop, or debuff's Game
Scenes and the Scene ManagerYour own state, or debuff's scenes
Loader (this.load.image, atlases, audio)Image.decode(), createImageBitmap, or debuff's assets
Input, Arcade or Matter physics, tweens, audio, timersYour own code, a library of your choice, or debuff's packages

If your game leans heavily on Arcade physics, tweens and the Scene Manager, moving everything is a bigger job than a renderer swap. Start with the part that's slow (usually bullets or particles) and try it first.

The mental model ​

Phaser (retained)spryte (immediate)
this.add.image(...) once in create(), then change its properties in update().Each frame, call draw.sprite(image, x, y, options) for everything visible.
The scene's display list remembers what to draw.Your game state is the only source of truth. Nothing to keep in sync.
destroy() objects, or pool them with Group and setActive(false).Stop drawing it. There's nothing to pool.
Draw order comes from the display list and setDepth.Draw order is call order.

The gain is per-object work: a Phaser game object is updated, culled and batched each frame, while spryte writes a few numbers per sprite. The trade is that anything the display list did for you (hit areas, depth sorting, containers) is now yours.

Side by side ​

Phaser 4 (the same code runs in Phaser 3):

ts
import Phaser from "phaser";

class Main extends Phaser.Scene {
  bunnies: Phaser.GameObjects.Image[] = [];

  preload() {
    this.load.image("bunny", "/bunny.png");
  }

  create() {
    for (let i = 0; i < 1000; i++) {
      this.bunnies.push(this.add.image(Math.random() * 320, Math.random() * 180, "bunny"));
    }
  }

  update() {
    for (const b of this.bunnies) b.rotation += 0.05;
  }
}

new Phaser.Game({
  type: Phaser.WEBGL,
  width: 320,
  height: 180,
  backgroundColor: "#111",
  pixelArt: true,
  scene: Main,
});

spryte:

ts
import { createRenderer } from "spryte";

const canvas = document.querySelector("canvas")!;
canvas.width = 320;
canvas.height = 180;
const draw = createRenderer(canvas, { smooth: false }); // like pixelArt: true
draw.resize(canvas.width, canvas.height);

const bunny = new Image();
bunny.src = "/bunny.png";
await bunny.decode();

// Your state is plain data. spryte never sees it until you draw.
const bunnies = Array.from({ length: 1000 }, () => ({ x: Math.random() * 320, y: Math.random() * 180, r: 0 }));
// one reused options object: drawing allocates nothing
const look = { rotation: 0 };

function frame() {
  draw.begin("#111", 320, 180, 1);
  for (const b of bunnies) {
    b.r += 0.05;
    look.rotation = b.r;
    draw.sprite(bunny, b.x, b.y, look); // centred by default, like Phaser's origin 0.5
  }
  draw.end();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

Mapping table ​

Phaser 4spryte
new Phaser.Game({ type: Phaser.WEBGL, width, height })createRenderer(canvas), then draw.resize(w, h) when the canvas's backing size changes
pixelArt: truecreateRenderer(canvas, { smooth: false }), or smooth: false per sprite
backgroundColorThe first argument to draw.begin(clear, width, height, scale)
scale: { mode: FIT, zoom }Size the canvas yourself; the logical size and scale go to draw.begin()
this.load.image(key, url)Any image source: HTMLImageElement (after decode()), ImageBitmap, a canvas
this.add.image(x, y, key) / this.add.sprite(...)draw.sprite(image, x, y, { rotation, scaleX, scaleY, alpha, tint, flipX, flipY })
setOrigin(0, 0)origin: [0, 0] (default is the centre)
setTint(0xff00ff)tint: "#f0f"
setTint(0xffffff).setTintMode(Phaser.TintModes.FILL) (Phaser 3: setTintFill), for hit flashesflash: 1
An atlas frame (this.add.image(x, y, "atlas", "frame"))A Frame: { image, x, y, w, h }. Reuse one object if the rectangle changes per call
Sprite animations (this.anims.create, play)Pick the frame from your own clock and draw it
Blitter, SpriteGPULayer, or particle emitters (this.add.particles)draw.sprites(frame, positions, count, options, { scale, rotation, color }) with typed arrays
Container with x, y, rotation, scaledraw.push(), translate, rotate, scale, draws, draw.pop()
this.cameras.main.scrollX, zoomdraw.push(), scale(zoom), translate(-scrollX, -scrollY), world draws, draw.pop(), then UI
Graphics: fillRect, fillCircle, lineBetweendraw.rect(x, y, w, h, color), draw.circle(x, y, r, color), draw.line(...)
Graphics: fillPoints, fillRoundedRect, strokePointspolygon(draw, points, color), roundRect(draw, x, y, w, h, r, color, lineWidth?), polyline(draw, points, width, color), ring(...)
this.add.nineslice(...)nineSlice(draw, frame, x, y, w, h, insets)
this.add.tileSprite(...) with tilePositionXtiled(draw, frame, x, y, w, h, { offsetX, offsetY, scale })
this.add.text(x, y, text, style) / bitmapTextdraw.text(text, x, y, { font, color, align, baseline }), or a BitmapFont
setBlendMode(Phaser.BlendModes.ADD) (or MULTIPLY, SCREEN, ERASE)draw.blend(mode) before the draws, "normal" after
Camera viewport, or a rectangular maskdraw.clip(x, y, w, h, () => { ... })
filters.internal.addMask(shape) (Phaser 3: createBitmapMask)mask(draw, () => { /* the shape */ }, () => { /* the content */ })
this.add.renderTexture(...), draw(...), render()draw.createTarget(w, h) and draw.into(target, () => { ... })
filters.internal.addBlur(), addColorMatrix() (Phaser 3: preFX)filter(draw, [blur(4), grayscale()], () => { ... })
A custom render node or Shader (Phaser 3: a pipeline)draw.createShader(source) and draw.withShader(shader, () => { ... })
setDepth, this.children.sortSort your data, then draw in that order
setInteractive(), on("pointerdown")Your own hit-testing on your game state (see below)
destroy(), Group poolingStop drawing it. draw.invalidate(image) if an image's pixels changed; draw.dispose() when the whole renderer goes

Bullets and particles: the biggest win ​

A Phaser bullet pool (a Group of Images you recycle) or a particle emitter maps to one draw.sprites call. Keep the state in typed arrays, and spryte uploads them as they are:

ts
const MAX = 50_000;
const pos = new Float32Array(MAX * 2);    // x, y pairs your simulation updates
const scale = new Float32Array(MAX);
const rotation = new Float32Array(MAX);
const color = new Uint32Array(MAX);       // 0xRRGGBBAA
let live = 0;

// each frame, after updating the arrays:
draw.sprites(spark, pos, live, {}, { scale, rotation, color });

On WebGL2 there's no JavaScript loop over the particles at all, and no objects to pool. Phaser's emitters do more out of the box (emit zones, gravity, lifespans and easing), so with spryte that behaviour is a few lines of your own simulation.

What you'll write yourself ​

  • The loop and timing. spryte has no update(time, delta). Use requestAnimationFrame with your own delta, or debuff's Game, which adds a fixed timestep.
  • Hit-testing. Keep bounds in your game state and test the pointer against them. Convert the pointer to logical units with your own scale (the same scale you pass to begin()).
  • Cameras. A camera is a push(), scale(), translate() around the world draws. Follow, lerp, shake and bounds are your own maths, a few lines each.
  • Physics, tweens and audio. spryte doesn't touch them. Keep your own, or use debuff's packages.
  • Text layout. draw.text draws a line with alignment and measure() tells you its size. Word wrap and rich text are up to you.

Try it first ​

Before a rewrite, you can measure spryte on your own game in an afternoon: draw your heaviest effect (bullets, particles) with spryte on a see-through canvas over Phaser's, and compare frame times. This is a way to evaluate, not an architecture we recommend keeping. The transparent option needs spryte 0.2 or later.

ts
// in your scene's create()
const game = this.game.canvas;
const overlay = document.createElement("canvas");
overlay.width = game.width;
overlay.height = game.height;
overlay.style.cssText = "position:absolute;inset:0;pointer-events:none;width:100%;height:100%";
game.parentElement!.append(overlay);

const draw = createRenderer(overlay, { smooth: false, transparent: true });
draw.resize(overlay.width, overlay.height);

// after Phaser has drawn the frame
this.events.on("postrender", () => {
  // transparent clear: Phaser shows through
  draw.begin("#0000", 320, 180, overlay.width / 320);
  draw.sprites(bullet, bulletPositions, bulletCount);
  draw.end();
});

The trade-offs, if you keep it longer: two renderers mean two GPU contexts and two copies of any shared textures; the overlay's size and camera must stay in step with Phaser's scale manager; and spryte always draws on top, so nothing on the overlay can pass behind a Phaser sprite. Once the numbers convince you, move the whole renderer.

Checklist ​

  1. Install: npm install spryte.
  2. Optional: try it first on your heaviest effect and compare frame times.
  3. Decide what replaces Phaser's loop, loader, input and physics: your own code, other libraries, or debuff.
  4. Replace each scene's display list with one draw function, following the mapping table.
  5. Keep bulk state (bullets, particles) in typed arrays and draw it with draw.sprites.
  6. Measure with your own frame timings, or run 2dbench on the devices you care about.

See also: spryte overview, spryte vs Phaser benchmarks, Migrating from PixiJS, API reference, Why does my Phaser game stutter?