---
name: game-engines
description: Choosing and driving a game engine — Phaser 3.90, PIXI 7, Matter 0.20, Howler — and the removed APIs that throw. The URLs themselves are in vendor-shelf
when: Building a game, a physics toy or an arcade prototype, or deciding whether an engine is worth it at all
---

# Using a game engine

Read this **with** `game-prototype` (the craft: loop, input, state, collision)
and `game-feel` (making it look finished). This one is the plumbing: how to
reach an engine, and the places where the API you remember is the wrong major
version.

## Do you want one?

**Yes** for an arcade game, a platformer, a shooter — many entities plus
physics — and *always* for stacking, ragdolls or joints: never hand-write a
rigid-body solver.

**No** for a toy, a visualiser, one mechanic, a jam-sized idea. A working
300-line canvas game beats a beautiful Phaser game that never loads.

---

## The URLs live in `vendor-shelf`

**Read `vendor-shelf` for the actual `<script src>` lines.** It has all
seventeen libraries, the CSP rule that blocks every CDN, and the per-library
version traps. Phaser, PIXI, Matter and Howl are on that shelf; this file is
what to do once one of them is on the page.

⚠️ Do not paraphrase a URL from memory. A version typo 404s silently and looks
exactly like a game with a logic bug.

---

# The version traps

Each of these is a case where the API in memory is a **different major version**
from the one at the URL above. None of them produces an error that says "wrong
version".

## Phaser 3.90 — the default for an arcade game

```js
const game = new Phaser.Game({
  type: Phaser.AUTO, width: 800, height: 600,
  parent: 'app',                         // ⚠️ or it appends wherever it likes
  backgroundColor: '#12141c',
  pixelArt: true,                        // for pixel art — see game-feel
  physics: { default: 'arcade', arcade: { gravity: { y: 900 }, debug: false } },
  scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH },
  scene: [BootScene, GameScene],         // classes, not one big object
});
```

### ⚠️⚠️ `createEmitter` was REMOVED and throws — this is the single biggest one

Phaser 3.60 rewrote particles, and the pre-3.60 shape is what almost everyone
writes from memory. On 3.90 it throws, verbatim:

> `Error: createEmitter removed. See ParticleEmitter docs for info`

```js
// ⛔ pre-3.60 — throws the error above
this.add.particles('spark').createEmitter({ speed: 200, lifespan: 400 });

// ✅ 3.60+ — add.particles IS the emitter, and takes x, y, texture, config
const burst = this.add.particles(0, 0, 'spark', {
  speed: { min: 60, max: 220 }, lifespan: 420, quantity: 12,
  scale: { start: 1, end: 0 }, blendMode: 'ADD', emitting: false,
});
burst.explode(14, x, y);                 // one-shot at a point
```

⚠️ **And a particle needs a texture** — with no art you get
`Error: Particle has no texture frame`. Make one in `create()`; it is four
lines and it also gives you every other primitive:

```js
const g = this.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xffffff, 1).fillCircle(4, 4, 4);
g.generateTexture('spark', 8, 8);        // now 'spark' is a real texture key
g.destroy();
```

### The rest, in the order they bite

- ⚠️⚠️ **`update(time, delta)` — `delta` is MILLISECONDS.** `x += speed * delta`
  runs the game **sixty times too fast**. Use `delta / 1000`, or call
  `setVelocityX()` and let arcade physics integrate for you.
- ⚠️ **You need no art at all.** `this.add.rectangle(x, y, w, h, 0x44ccff)` plus
  `this.physics.add.existing(rect)` is a physics-enabled primitive. A game of
  rectangles is a real game; a game waiting on sprites is not.
- **Held vs one-shot:** `cursors.left.isDown` is held;
  `Phaser.Input.Keyboard.JustDown(spaceKey)` is the edge. Confusing them is why
  the jump fires every frame. WASD in one call:
  `this.keys = this.input.keyboard.addKeys('W,A,S,D')`.
- **Grounded** is `sprite.body.blocked.down` (or `.touching.down` against
  another body) — never a flag you maintain yourself.
- `physics.add.collider(a, b)` blocks; `overlap(a, b, cb)` detects without
  blocking. Pickups need `overlap`; floors need `collider`.
- ⚠️ **The HUD scrolls away with the camera** unless you pin it:
  `text.setScrollFactor(0)` and a high `setDepth(1000)`. This is the fix; a
  second camera is not needed for a score readout.
- **Camera follow**, once the level is bigger than the screen:
  ```js
  this.cameras.main.setBounds(0, 0, levelW, levelH);
  this.physics.world.setBounds(0, 0, levelW, levelH);
  this.cameras.main.startFollow(player, true, 0.08, 0.08);  // lerp, not snap
  this.cameras.main.setDeadzone(120, 80);                   // no jitter when idle
  ```
  ⚠️ Camera bounds and **world** bounds are separate. Setting only the camera
  lets the player walk out of the level while the view stops.
- **Scenes are the state machine.** `this.scene.start('Game')`,
  `this.scene.pause()` / `resume()`, `this.scene.launch('Pause')` to run a
  pause overlay *over* a paused scene, and `this.scene.restart()` — which
  re-runs `create` and is the correct reset. Do not hand-write a `reset()` that
  has to remember every mutable field.
- ⚠️ Arcade bodies are **axis-aligned rectangles**. `setRotation` turns the
  sprite and not its body. Rotated collision means Matter, not arcade.
- **Bullets: pool, do not allocate.** `const bullets = this.physics.add.group({
  maxSize: 40 })`, then `bullets.get(x, y, 'bullet')` — it returns `null` when
  the pool is full, which is the correct backpressure. `killAndHide(b)` returns
  one to the pool.

## PIXI 7.4.3 — a renderer, not a game framework

⚠️⚠️ **v8's startup is the one models emit, and this shelf is v7.** v8 code
throws `app.init is not a function`; the fix is not a shim, it is writing v7:

```js
// ✅ 7.4.3 — SYNCHRONOUS constructor, and the canvas is `app.view`
const app = new PIXI.Application({
  width: 800, height: 600, background: 0x101018,
  antialias: true, resolution: devicePixelRatio, autoDensity: true,  // HiDPI
});
document.getElementById('app').appendChild(app.view);
```

- ⚠️ **`app.ticker.add((delta) => …)` — `delta` is in FRAMES, not ms** (1.0 at
  60fps). For real time use `app.ticker.deltaMS`. The same 60× bug as Phaser's,
  in the opposite direction.
- `PIXI.Loader` is gone in v7; assets load through `await PIXI.Assets.load(url)`.
  v6 code fails with "Loader is not a constructor".
- Sprites you generate: `PIXI.Texture.from(canvas)` from an offscreen canvas is
  the no-asset route, the same trick as Phaser's `generateTexture`.
- Thousands of same-texture sprites belong in a `PIXI.ParticleContainer`, which
  drops per-sprite tinting and filters in exchange for a batched draw.
- PIXI has **no physics, no input, no collision, no scenes.** You still write
  all of `game-prototype`. Reach for it when you want tens of thousands of
  sprites, not when you want a game framework.

## Matter 0.20 — real rigid-body physics

```js
const { Engine, Runner, Bodies, Composite, Body, Events } = Matter;
const engine = Engine.create();
Composite.add(engine.world, [
  Bodies.rectangle(400, 590, 800, 20, { isStatic: true }),
  Bodies.circle(400, 100, 20, { restitution: 0.8 }),
]);
Runner.run(Runner.create(), engine);
```

- ⚠️ **`Engine.run()` still WORKS here — it is a deprecated alias of
  `Runner.run`, not a removal.** It logs a deprecation line and runs. So do not
  spend a round "fixing" it after reading a tutorial; the real decision is the
  next bullet.
- ⚠️⚠️ **Pick ONE clock.** `Runner.run` starts its own rAF loop. If you also
  call `Engine.update(engine, …)` from your own loop, every body integrates
  twice and gravity looks doubled. Either let the Runner drive it, or skip the
  Runner entirely and call `Engine.update(engine, 1000 / 60)` inside your own
  fixed-timestep `update()` — which is what makes the simulation deterministic
  and replayable (`game-prototype` A1).
- Collisions are **events, not return values**:
  `Events.on(engine, 'collisionStart', (e) => e.pairs.forEach(p => …))`, where
  each pair has `bodyA` / `bodyB`. Tag your bodies with `label` or a custom
  `plugin` field at creation so the handler can tell what hit what.
- ⚠️ Bodies are positioned by their **centre**, not their top-left corner. A
  ground rectangle at `y = height` is half below the screen.
- Static scenery is `{ isStatic: true }`. Forgetting it is why the floor falls.
- Move a body with `Body.setVelocity` / `applyForce`, never by assigning
  `body.position` — writing position teleports it past the solver and you get
  tunnelling and stuck pairs.
- Matter simulates; it does not draw your game. `Matter.Render` is a **debug**
  view — ship your own rendering from `body.position` and `body.angle`.
- Resting bodies twitch by design (`slop`); enable `engine.enableSleeping = true`
  for stacks that should settle and stay settled.

## Howler 2.2.4 — audio

`new Howl({ src: ['sfx.mp3'], volume: 0.4 })`. It performs the autoplay-unlock
dance for you, which is the whole reason to use it over raw WebAudio — see
`game-prototype` B5 for why an unlocked context matters and what the failure
looks like.

- ⚠️ `src` must be an **array**, and a URL with no file extension needs
  `format: ['mp3']` — otherwise it silently plays nothing.
- One `Howl` per sound, reused. Constructing one per shot re-decodes the file
  every time and stutters.
- `sprite: { jump: [0, 300], hit: [400, 250] }` cuts many effects out of one
  file — one request instead of a dozen. `s.play('jump')` plays a slice.
- `Howler.volume(0)` is a global mute in one line. Ship the mute key.
- ⚠️ It needs **audio files**, and generating those is a separate problem. With
  no assets, the WebAudio oscillator in `game-prototype` B5 gives you jump,
  pickup and death for zero bytes and zero requests — prefer it for a prototype.

---

## ⚠️ Prove the engine actually loaded — the step that gets skipped

A game that failed to load and a game with a logic bug look identical. So check
the specific thing, in this order:

1. Open it in a real browser (`playtest`, or `check-the-site-you-built`).
2. Read the console. `Phaser is not defined`, or `Cannot read properties of
   undefined` on line 1 of your script, is the **script tag** — not your game.
3. Check the network list for the engine URL. **404** = a wrong `/vendor/` path
   (re-copy it from the block above). **Blocked by CSP** = you reached for a
   CDN. They produce the same blank screen and need opposite fixes.
4. `console.log(Phaser.VERSION)` — it prints `3.90.0`. If you are writing
   against a different major, stop and re-read the section above.
5. Only then debug your game.

⭐ Say in your handover **which engine and which version** you built against.
The next person to edit the file will otherwise write for the version they
remember, and every trap on this page will happen again.
