| name | phaser-plus |
| description | Use when building Phaser 4 games with @toolcase/phaser-plus — scene lifecycle, feature registry, object pooling, flow events/timers/jobs, layer + camera management, perspective2d (isometric/grid), GLSL shader effects, particle VFX (ParticleFeature), A* pathfinding (NavMesh), and the Tweakpane in-game debugger. |
phaser-plus — API Reference
Unified runtime layer on top of Phaser 4. Adds opinionated scene lifecycle, registries (features, services, pool), event/job flow, layer-per-camera rendering, perspective2d, shader effects, A*, and a debugger feature. Single import surface:
import {
Engine, Scene, GameObject, GameObjectComponent, Events,
Feature, FeatureRegistry, ServiceRegistry,
Layer, ObjectLayer, HTMLFeature, SplitScreen,
GameObjectPool,
Flow,
Structs,
LogLevel,
Debugger, Panel,
PerformancePanel, MemoryPanel, TimelinePanel, InputPanel, AudioPanel, NetPanel, SaveStatePanel,
ConsoleCommands, HotReload, RemoteDebugger,
Scene2D, World, GameObject2D, Grid,
Effect, EffectManager, installEffects, EFFECT_REGISTRY,
NavMesh, PathFinder, Path, PATH_FOUND, PATH_FAILED,
TilemapNavMesh, TilemapFeature,
CameraDirector, EASE_LINEAR, EASE_IN_OUT, EASE_OUT, SHOT_DONE,
ScreenShake, CameraFlash, DialogCameraCue, ParallaxLayer, LetterboxFeature,
InputFeature, ACTION_PRESS, ACTION_RELEASE, ACTION_HOLD,
InputBuffer, GestureRecognizer,
GESTURE_TAP, GESTURE_DOUBLE_TAP, GESTURE_LONG_PRESS, GESTURE_SWIPE, GESTURE_PINCH,
GamepadFeature, GAMEPAD_CONNECTED, GAMEPAD_DISCONNECTED, GAMEPAD_BUTTON_DOWN, GAMEPAD_BUTTON_UP,
MAPPING_XBOX, MAPPING_PS, MAPPING_SWITCH, MAPPING_STANDARD,
VirtualJoystick, VIRTUAL_AXIS_X, VIRTUAL_AXIS_Y,
STATE_ENTER, STATE_EXIT, STATE_TRANSITION,
SUCCESS, FAILURE, RUNNING,
REPLAY_FRAME, REPLAY_END,
AudioFeature, AUDIO_MUSIC_START, AUDIO_MUSIC_END, AUDIO_BUS_CHANGE,
SaveService, PersistenceFeature, SAVE_DONE, LOAD_DONE, SAVE_DELETED,
LocalStorageBackend, IndexedDBBackend, MemoryBackend,
ParticleFeature, PARTICLE_BURST, PARTICLE_STREAM_START, PARTICLE_STREAM_STOP,
NetFeature, LoopbackTransport, WebSocketTransport,
NET_CONNECTED, NET_DISCONNECTED, NET_RTT_UPDATE, NET_ENTITY_STATE
} from '@toolcase/phaser-plus'
import type { Transport } from '@toolcase/phaser-plus'
Peers: phaser@4.x, @toolcase/base@5.x, @toolcase/logging@5.x. Optional peers: react / react-dom >=18.
Table of Contents
- Quick Start
- Engine & Scene
- GameObject
- Features
- GameObjectPool
- Flow
- Debugger
- Perspective2D
- Effects
- AI / Pathfinding
- Events constants
- Flow extensions — StateMachine, BehaviorTree, Replay
- Cinema — Camera, Shake, Parallax, Letterbox
- Input — actions, buffer, gamepad, gestures, joystick
- Audio — bus mixer, music, SFX pool, spatial, ducking
- Particles — ParticleFeature (VFX registry + pool)
- Net — NetFeature, Transport, entity sync
- Worked examples
- Cross-library integration
- Theming / styling surfaces
- Cheat sheet
Quick Start
import { Game, AUTO } from 'phaser'
import { Scene, ObjectLayer, GameObject, installEffects } from '@toolcase/phaser-plus'
class Player extends GameObject {
onCreate() {
const sprite = this.scene.add.sprite(0, 0, 'hero')
this.add(sprite)
}
}
class GameScene extends Scene {
onInit() {
this.pool.register('player', Player)
}
onCreate() {
const layer = this.features.register('main', ObjectLayer)
layer.add<Player>('player', 100, 100)
}
onUpdate(time, delta) { }
}
const game = new Game({
type: AUTO,
width: 960,
height: 540,
dom: { createContainer: true },
scene: [GameScene]
})
installEffects(game)
Key contract: subclass Scene, override beforeInit / onInit / onLoad / onCreate / onUpdate / onDestroy. Don't override Phaser's init/preload/create/update — they are taken.
Engine & Scene
Engine
Per-Game singleton wrapping a LoggerFactory and a ServiceRegistry. Attached to game.engine and shared across all Scenes. Created automatically on first Scene init.
| Member | Type | Description |
|---|
services | ServiceRegistry | Game-wide DI container |
setLogLevel(level) | this | level is a LogLevel enum value |
getLogger(scope?) | Logger | Scoped logger from @toolcase/logging |
(There is a version string field on Engine, but it is hardcoded and currently stale — do not rely on it to reflect the installed package version.)
Scene
Base scene with built-in registries. Extends Phaser.Scene.
| Member | Type | Description |
|---|
engine | Engine | Per-scene engine handle |
services | ServiceRegistry | Same instance as engine.services |
features | FeatureRegistry | Scene-lifetime features |
pool | GameObjectPool | Pooled GameObject factory |
flow | FlowEngine | Events/timers/jobs/collisions |
payload | Record<string, any> (getter) | Data passed via goTo(key, data) |
Lifecycle hooks (override in subclass):
| Hook | When |
|---|
beforeInit() | First in init(), before onInit |
onInit() | After registries created |
onLoad() | Phaser preload phase |
onCreate() | Phaser create phase |
onUpdate(time, delta) | Per frame, after features + GameObjects, before flow |
onDestroy() | Scene shutdown / goTo |
Navigation:
this.goTo('NextScene', { score: 42 })
this.restart({ retry: true })
this.pause(); this.resume()
GameObject
Phaser Container with stable id, lifecycle hooks, lazy EffectManager, absolute-position helper, and an opt-in component bag.
class Bullet extends GameObject {
onCreate() {
const sprite = this.scene.add.sprite(0, 0, 'bullet')
this.add(sprite)
}
onUpdate(time, delta) {
this.x += 4
}
onDestroy() {}
}
| Member | Type |
|---|
id | string (unique) |
effects | EffectManager (lazy getter) |
absolute | Phaser.Math.Vector2 (cached world position) |
getAbsolute(out?) | Computes world position into out |
add(child | children) | Adds children, calls onAdd(parent) if defined |
remove(child, destroy?) | Removes; optional destroy |
removeAll(destroy?) | |
addComponent<T>(cls) | Attach a GameObjectComponent subclass (idempotent) |
getComponent<T>(cls) | Return the component, or null |
removeComponent(cls) | Call onDestroy() and detach |
onCreate / onAdd / onUpdate / onRemove / onDestroy | Override hooks |
onUpdate only ticks when the GameObject is a direct child of the scene. Nested children must be ticked manually.
GameObjectComponent
Reusable, composable behaviors attached to GameObject without inheritance. Each component is a zero-arg class; owner is injected before the first lifecycle call.
import { GameObjectComponent, GameObject } from '@toolcase/phaser-plus'
class HealthComponent extends GameObjectComponent {
hp = 100
maxHp = 100
onCreate() {
this.hp = this.maxHp
}
onUpdate(_time: number, _delta: number) {
if (this.hp <= 0) this.owner.destroy()
}
}
class MoverComponent extends GameObjectComponent {
speed = 200
onUpdate(_time: number, delta: number) {
this.owner.x += this.speed * (delta / 1000)
}
}
class Enemy extends GameObject {
onCreate() {
const health = this.addComponent(HealthComponent)
health.maxHp = 200
this.addComponent(MoverComponent)
}
}
Components participate in the owner's lifecycle:
| Hook | When |
|---|
onCreate() | After GameObject.onCreate() (at pool obtain, or immediately when addComponent() is called on a live object) |
onUpdate(time, delta) | Every frame (via doUpdate) |
onDestroy() | On removeComponent(), or when the owner is destroyed |
Features
Feature
Base class for scene-lifetime modules. Constructor (scene, key) is called by FeatureRegistry.register.
| Hook | Purpose |
|---|
onCreate() | Setup |
onUpdate(time, delta) | Per-frame |
preDestroy() | Before destroy (allows feature removal during destroy chain) |
onDestroy() | Cleanup |
emit(event, ...args) | Dispatch on the registry's broadcast bus |
FeatureRegistry
features.register<T extends Feature>(key: string, FeatureClass): T
features.get<T>(key): T | null
features.has(key): boolean
features.destroy(key): void
features.destroyAll(): void
features.keys: string[]
features.size: number
Registry doubles as an event bus (Broadcast). Listen with features.on('layer_depth_update', fn).
ServiceRegistry
Lazy singleton DI, game-lifetime.
services.bind(MyService, () => new MyService(...))
services.provide(MyService, instance)
services.resolve<T>(MyService): T
services.has(MyService): boolean
services.dispose(MyService): void
services.disposeAll(): void
Layer
Feature that owns a dedicated Phaser.Camera and a root GameObject container. Use one layer per visual plane (background, world, UI).
| Member | Description |
|---|
container: GameObject | Root container |
camera (getter) | Owned camera |
cameraFilter (getter) | Bitmask excluding other cameras |
list / count | Children of container |
visible (get/set) | Toggles container visibility |
depth (get/set) | Camera + container depth (resorts cameras) |
setBackgroundColor(color) | |
centerOn(x, y) | Camera-centered scrolling |
getByName<T>(name) | Lookup child by .name |
clear() | Remove children without destroying |
const bg = this.features.register('bg', Layer)
bg.setBackgroundColor('#0f172a').depth = 0
ObjectLayer
Layer extension that spawns/releases through scene.pool.
const game = this.features.register('game', ObjectLayer)
const enemy = game.add<Enemy>('enemy', x, y)
game.remove(enemy)
game.clear()
HTMLFeature
Feature providing a DOM <div> overlaid on the canvas (Phaser DOM container). Requires dom: { createContainer: true } in game config.
class Hud extends HTMLFeature {
onCreate() {
this.node.innerHTML = '<div class="hud">…</div>'
}
}
Use ReactFeature (extends HTMLFeature) to mount a React tree into the DOM overlay:
import { ReactFeature } from '@toolcase/phaser-plus'
class Hud extends ReactFeature {
onCreate() {
this.render(<HudComponent />)
}
}
render(element) is safe to call before react-dom has loaded — the latest element is buffered and flushed once the root is ready. Use renderAsync(element) when you need to await the initial mount. Call unmount() to tear down the React tree early (also called automatically on feature destroy).
SplitScreen
Two-camera follow with adaptive single↔split mode based on point distance.
const split = this.features.register('split', SplitScreen)
split.setSplitThresholds(400, 320)
split.follow(playerA.position, playerB.position)
split.unfollow()
split.cameras exposes { ui, A, B }.
GameObjectPool
scene.pool — pre-allocated GameObject factories.
this.pool.register<Bullet>('bullet', Bullet,
null,
(obj) => obj.setActive(false).setVisible(false)
)
const b = this.pool.obtain<Bullet>('bullet')
this.pool.release(b)
this.pool.count('bullet')
this.pool.instances
this.pool.dispose()
Pooled objects gain a poolable: true flag and release() closure attached by the underlying ObjectPool. Don't manually destroy a pooled GameObject — release it.
Flow
FlowEngine
Auto-created on each Scene (scene.flow). Holds four typed processors.
| Processor | Field | Purpose |
|---|
EventProcessor | flow.events | Named one-shot events |
TimeEventProcessor | flow.timer | Repeating interval timers |
JobProcessor | flow.jobs | Cooperative long-running tasks |
CollisionEventProcessor | null | flow.physics | Matter collisions (only if scene.matter exists) |
flow.active = false pauses all processors. flow.addProcessor(eventType, ProcessorClass) plugs in custom processors.
Event
Named, optionally delayed, pay-loaded event.
class DamageEvent extends Flow.Event<{ amount: number }> {
onFire(payload) {
player.hp -= payload.amount
}
}
scene.flow.events.add('damage', DamageEvent)
scene.flow.events.trigger('damage', { amount: 10 }, 0.5)
scene.flow.events.triggerNow('damage', { amount: 10 })
scene.flow.events.triggerFn(() => console.log('tick'), 1)
scene.flow.events.replace('damage', NewDamageEvent)
scene.flow.events.remove('damage')
scene.flow.events.has('damage')
scene.flow.events.keys
TimeEvent
Recurring timer; onFire(times) receives the iteration count.
class SpawnTick extends Flow.TimeEvent {
onFire(times) { spawnEnemy() }
}
scene.flow.timer.add('spawn', SpawnTick, 2, 0)
scene.flow.timer.remove('spawn')
Job
Cooperative long-running task. onUpdate(time) returning true signals completion.
class FadeJob extends Flow.Job<{ target: Phaser.GameObjects.Sprite }> {
onCreate() { this.t = 0 }
onUpdate(time) {
this.t += 0.016
this.payload.target.alpha = 1 - this.t
return this.t >= 1
}
onComplete() {}
onTerminate(error?) {}
}
scene.flow.jobs.run(FadeJob, { target: sprite })
scene.flow.jobs.maxJobsPerFrame = 4
scene.flow.jobs.queuedJobs
Throwing inside onUpdate calls onTerminate(error) and removes the job.
CollisionEvent
Matter physics listener. Auto-bound when scene.matter is available.
class HitEvent extends Flow.CollisionEvent {
onEnter(bodyA, bodyB, event) {}
onExit(bodyA, bodyB, event) {}
}
scene.flow.physics?.add('hit', HitEvent)
scene.flow.physics?.setCollision('bullet', 'enemy', 'hit')
Collisions are routed by Matter body label. add(name, EventClass) registers the handler; setCollision(labelA, labelB, name) (symmetric) binds a label pair to it, so onEnter / onExit fire when bodies with those labels touch.
| Method | Purpose |
|---|
add(name, EventClass) | Register a CollisionEvent subclass under name (calls its onCreate) |
setCollision(labelA, labelB, name) | Bind a body-label pair to a registered event (both directions) |
removeCollision(labelA, labelB) | Unbind a label pair |
remove(name) | Tear down a registered event (calls its onDestroy) |
has(name) | Whether an event name is registered |
Flow sugar — Timer, Parallel, throttle, debounce
Common scheduling without subclassing Job/TimeEvent/Event.
import { Flow } from '@toolcase/phaser-plus'
const fire = Flow.Timer.delay(scene, 0.4, () => sword.play('swing'))
fire.cancel()
const tick = Flow.Timer.interval(scene, 1, count => hud.setSeconds(count))
tick.pause(); tick.resume(); tick.cancel()
Flow.Timer.sequence(scene, [
{ delay: 0.0, run: () => intro.show() },
{ delay: 1.5, run: () => intro.hide() },
{ delay: 0.2, run: () => boss.spawn() }
])
const handle = Flow.Parallel.run(2, [
done => loadAtlas('hero', done),
done => loadAtlas('mob', done),
done => loadAtlas('fx', done)
], () => scene.goTo('main'))
handle.remaining
const onResize = Flow.throttle(layout, 100)
const onSearch = Flow.debounce(query, 300, { leading: false })
Returned handles share cancel() / pause() / resume() (when applicable). Parallel.run(N, tasks, onAll) caps concurrency at N (use Infinity-style 0 to run all at once); each task takes a done callback.
Tween & Timeline
Property interpolation driven by the same flow.doUpdate clock as events, timers, and jobs.
Flow.Tween — static helper for one-shot tweens.
import { Flow } from '@toolcase/phaser-plus'
const handle = Flow.Tween.to(scene, box, { x: 400, alpha: 0.5 }, {
ms: 600,
ease: 'easeOutCubic',
delay: 200,
loop: true,
yoyo: true,
onComplete: () => console.log('done'),
onUpdate: (progress, target) => {}
})
handle.pause()
handle.resume()
handle.cancel()
handle.progress
handle.completed
handle.paused
Or access the processor directly: scene.flow.tween(target, to, opts) returns the same TweenHandle.
Flow.Timeline — fluent builder for sequenced + parallel tweens.
const tl = new Flow.Timeline(scene)
tl.to(box, { x: 400 }, { ms: 500, ease: 'easeOutCubic' })
.wait(200)
.parallel(sub => {
sub.to(box, { alpha: 0 }, { ms: 300, ease: 'easeIn' })
.to(icon, { scaleX: 0 }, { ms: 300, ease: 'easeIn' })
})
.stagger([a, b, c], { scaleY: 1 }, { ms: 400, ease: 'easeOutBack' }, 100)
.call(() => sfx.play('whoosh'))
const handle = tl.play(() => console.log('sequence done'))
TimelineHandle API:
handle.seek(ms)
handle.restart()
handle.pause()
handle.resume()
handle.cancel()
handle.elapsed
handle.total
handle.progress
handle.completed
handle.paused
Easing — all built-in easing functions available as Flow.EASE record and string keys:
| Key | Shape |
|---|
linear | constant |
easeIn / easeOut / easeInOut | quadratic |
easeInCubic / easeOutCubic / easeInOutCubic | cubic |
easeInBack / easeOutBack / easeInOutBack | overshoot |
bounce / bounceIn / bounceOut | bounce |
elastic | elastic snap |
import { Flow } from '@toolcase/phaser-plus'
Flow.EASE.easeOutBack(0.8)
Flow.resolveEase('easeOutBack')
TweenProcessor — the FlowProcessor backing tweens and timelines. Accessible as scene.flow.tweens.
scene.flow.tweens.cancelAll()
scene.flow.tweens.pauseAll()
scene.flow.tweens.resumeAll()
scene.flow.tweens.onLog = (time, type, name) => {}
TimelinePanel.bindTimeline(handle) — attach a scrub UI to a TimelineHandle inside an existing TimelinePanel.
const dbg = this.features.register('debugger', Debugger).setExpanded()
const panel = dbg.addPanel('timeline', TimelinePanel, 'My Timeline')
const handle = tl.play()
panel.bindTimeline(handle)
Demo: tween-timeline — TweenTimelineDemo.js.
TimelinePanel.bindReplay(recorder) — wire a ReplayRecorder to this panel. Adds RP State / RP Frame readouts, a scrub slider (seeks the frame cursor during playback via recorder.seekTo), and Record / Stop / Replay buttons. Each REPLAY_FRAME event is also appended to the history log so it appears in the Window readout.
const dbg = this.features.register('debugger', Debugger).setExpanded()
const panel = dbg.addPanel('timeline', TimelinePanel, 'Replay Timeline')
const recorder = this.features.register('replay', Flow.ReplayRecorder)
panel.bindReplay(recorder)
Demo: replay-recorder-timeline — ReplayRecorderTimelineDemo.js.
Debugger
HTMLFeature exposing Tweakpane panels. Requires dom: { createContainer: true }.
import { Debugger, MemoryPanel } from '@toolcase/phaser-plus'
const dbg = this.features.register('debugger', Debugger)
dbg.setExpanded(true)
dbg.inspect(someGameObject)
dbg.addPanel('memory', MemoryPanel)
dbg.removePanel('memory')
dbg.getPanel<MemoryPanel>('memory')
Built-in panels (always present, cannot be removed): inspector, overview, flow, layer, gameObject. Extra registered panels available for addPanel: PerformancePanel, MemoryPanel, TimelinePanel, InputPanel, AudioPanel, NetPanel, SaveStatePanel.
Built-in tools (also Panel subclasses): ConsoleCommands, HotReload, RemoteDebugger.
Panel (custom)
Subclass to add your own folder.
class MyPanel extends Panel {
draw() {
this.base.addBinding(state, 'fps', { readonly: true, label: 'FPS' })
this.base.addBlade({ view: 'separator' })
this.base.addButton({ title: 'Reload' }).on('click', () => location.reload())
}
doUpdate() { }
dispose() {}
}
dbg.addPanel('mine', MyPanel, 'My Panel')
this.base is a Tweakpane folder. The essentials plugin is registered, so blades like fpsgraph, text, separator are available.
Perspective2D
Isometric / projected-2D rendering layer. Use Scene2D for an out-of-the-box scene with world (World) + ui (ObjectLayer) preset, or compose manually.
Scene2D
class IsoScene extends Scene2D {
onCreate() {
this.world.projection = Structs.Matrix2.createISO(64)
this.world.register('tile', Tile)
this.world.add<Tile>('tile', 0, 0)
}
}
Protected fields: this.world: World, this.ui: ObjectLayer.
World
Layer with projection matrix and depth-sort.
| Member | Description |
|---|
projection: Matrix2 | World→screen 2×2 transform |
register<T>(key, GameObject2DClass) | Pool-backed registration |
add<T>(key, x, y): T | null | Spawn at world (x, y) |
remove<T>(obj) | Release back to pool |
clear() | Release all |
debug(flag?, colors?): this | Toggle isometric grid overlay |
GameObject2D
GameObject with projection-aware position via transform/pivot.
class Tile extends GameObject2D {
onCreate() { this.add(this.scene.add.image(0, 0, 'tile')) }
}
const t = world.add<Tile>('tile', 4, 2)
t.setTransform(5, 3)
Use setTransform / setTransformX / setTransformY to move in world space — they apply the World's projection automatically.
addTag / removeTag / removeTags exist on the base GameObject2D as no-op stubs (they just return this) — they are override hooks for subclasses, not a working tag system in the base class.
Grid
Visualization overlay (used internally by world.debug(true)):
const grid = new Grid(this)
grid.setProjection(Structs.Matrix2.createISO(64))
grid.setColors(0x334155, 0x64748b)
Matrix2
2×2 transform matrix (extends Float32Array). Constructor and helpers:
const iso = Structs.Matrix2.createISO(64)
const custom = Structs.Matrix2.create(64, 32, 30, -30)
iso.translate(worldX, worldY, outVec)
iso.adjoint
iso.inverse
iso.determinant
iso.setValues(v00, v01, v10, v11)
SpatialHash
Uniform-grid broad-phase spatial hash. O(1) amortised insert/remove; query cost scales with cells touched. Best for objects of roughly uniform size (cellSize ≈ 2× average object diameter). Backed by Spatial.SpatialHash from @toolcase/base.
import type { SpatialPoint, SpatialRect } from '@toolcase/phaser-plus'
const hash = new Structs.SpatialHash<Enemy>(64)
hash.insert(enemy, { x: enemy.x, y: enemy.y, width: 32, height: 32 })
hash.update(enemy, { x: enemy.x, y: enemy.y, width: 32, height: 32 })
hash.remove(enemy)
const nearby = hash.query({ x: px - 200, y: py - 200, width: 400, height: 400 })
const closest = hash.nearest({ x: px, y: py }, 300)
hash.size
hash.clear()
Quadtree
Recursive quad-partition tree. O(log n) insert; query prunes by bounding box; nearest uses branch-and-bound. Best for non-uniform object density. Backed by Spatial.Quadtree from @toolcase/base.
const qt = new Structs.Quadtree<Enemy>(
{ x: 0, y: 0, width: 4096, height: 4096 },
8,
8
)
qt.insert(enemy, { x: enemy.x, y: enemy.y, width: 32, height: 32 })
qt.update(enemy, { x: enemy.x, y: enemy.y, width: 32, height: 32 })
qt.remove(enemy)
const nearby = qt.query({ x: px - 200, y: py - 200, width: 400, height: 400 })
const closest = qt.nearest({ x: px, y: py }, 300)
qt.size
qt.clear()
Choosing between the two:
SpatialHash — uniform object sizes, high throughput, frequent moves.
Quadtree — varying object sizes or sparse density; culling and AI perception over irregular distributions.
Demo: broad-phase example scene (examples/src/phaser-plus/scenes/BroadPhaseDemo.js) — 260 moving bodies re-indexed in both a SpatialHash and a Quadtree every frame; the cursor drives a range query() (highlighting overlapping bodies) and a nearest() lookup (ringed + connected to the cursor), with S switching which structure answers. Registered under category Structs.
Vec2
Immutable 2D vector. All operations return a new Vec2; Vec2.ZERO and Vec2.ONE are shared singletons.
const a = new Structs.Vec2(3, 4)
const b = new Structs.Vec2(1, 2)
a.add(b)
a.subtract(b)
a.scale(2)
a.dot(b)
a.length
a.lengthSq
a.normalize()
a.lerp(b, 0.5)
a.rotate(Math.PI)
a.negate()
a.distanceTo(b)
a.equals(b)
a.toArray()
AABB
Axis-aligned bounding box for broad-phase collision checks and region queries. Mutable — set / reset update in place; clone makes a copy.
const box = new Structs.AABB(10, 20, 100, 80)
box.left; box.right; box.top; box.bottom
box.centerX; box.centerY
box.contains(50, 50)
box.intersects(otherBox)
box.containsRect(otherBox)
box.set(0, 0, 200, 200)
box.reset()
box.copyFrom(otherBox)
const b2 = Structs.AABB.from({ x: 0, y: 0, width: 64, height: 64 })
const b3 = Structs.AABB.fromCenter(cx, cy, width, height)
Transform
Mutable 2D transform snapshot (position, rotation, scale). Useful for pooled objects that need to cache or diff state without allocating new objects.
const t = new Structs.Transform()
t.set(100, 200, Math.PI / 4, 1.5, 1.5)
t.x; t.y; t.rotation; t.scaleX; t.scaleY
t.applyTo(sprite)
t.copyFrom(other)
t.clone()
t.reset()
Easing
Namespace of named easing functions ((t: number) => number, t ∈ [0, 1]). Sourced from @toolcase/base; identical to the keys available via Flow.EASE. Use directly when you need an easing function outside of a Tween/Timeline context.
import type { EasingFn } from '@toolcase/phaser-plus'
Structs.Easing.easeOutCubic(0.5)
Structs.Easing.easeInBack(0.8)
Structs.Easing.easeOutBounce(1)
const ease: EasingFn = Structs.Easing.cubicBezier(0.25, 0.1, 0.25, 1)
ease(0.5)
Families available: Sine, Quad, Cubic, Quart, Quint, Expo, Circ, Back, Elastic, Bounce — each in easeIn, easeOut, easeInOut variants, plus cubicBezier.
Random
Seeded, deterministic PRNG (mulberry32). Reproducible across platforms — pass the same seed to replay a sequence.
const rng = new Structs.Random(42)
rng.next()
rng.int(1, 6)
rng.float(0.5, 1.5)
rng.bool(0.3)
rng.pick(['a', 'b', 'c'])
rng.shuffle([1, 2, 3, 4])
rng.weighted([
{ item: 'common', weight: 70 },
{ item: 'rare', weight: 25 },
{ item: 'epic', weight: 5 },
])
Effects
GLSL filters attached per-GameObject via obj.effects. Backed by Phaser 4 filters.internal.
Bootstrap
import { installEffects } from '@toolcase/phaser-plus'
installEffects(game)
Use
import { GrayScaleEffect, OutlineEffect, FireEffect } from '@toolcase/phaser-plus'
const fx = obj.effects.add(GrayScaleEffect)
obj.effects.add(OutlineEffect, { thickness: 2, color: 0xff0000 })
obj.effects.add(FireEffect)
obj.effects.list
obj.effects.remove(fx)
obj.effects.clear()
Custom effect
import { Effect } from '@toolcase/phaser-plus'
class MyEffect extends Effect {
static KEY = 'my-effect'
static FRAGMENT = `precision mediump float;
uniform sampler2D uMainSampler;
varying vec2 outTexCoord;
void main() { gl_FragColor = texture2D(uMainSampler, outTexCoord); }`
intensity = 1
applyUniforms(programManager, time) {
programManager.setUniform('uIntensity', this.intensity)
}
}
obj.effects.add(MyEffect, { intensity: 0.5 })
Built-in shader registry (EFFECT_REGISTRY)
Color: GrayScaleEffect, NegativeEffect, PosterizeEffect, ThresholdEffect, ColorRGBEffect, ColorEffect, HSVEffect, ColorChangeEffect, SepiaEffect, MetalFXEffect, GoldFXEffect, GoldenFXEffect, IcedFXEffect, SandFXEffect, StoneFXEffect, WoodFXEffect.
Procedural: NoiseEffect, NoiseAnimatedEffect, BloodEffect, BurningFXEffect, FireEffect, FireAdditiveEffect, SmokeEffect, FrozenEffect, IceEffect, LightningEffect, LightningBoltEffect, PlasmaRainbowEffect, PlasmaShieldEffect.
Distortion: BlackHoleEffect, TwistEffect, DistortionEffect, DistortionAdditiveEffect, WaveEffect, MysticDistortionEffect, MysticDistortionAdditiveEffect, HeatEffect, JellyEffect, JellyAutoMoveEffect, LiquidEffect, LiquifyEffect, SlimEffect.
Dissolve: DesintegrationFXEffect, DestroyedFXEffect, CompressionFXEffect, PixelEffect, Pixel8BitsBlackEffect, Pixel8BitsCommodoreEffect, Pixel8BitsGameboyEffect.
Mask: CircleFadeEffect, ClippingEffect, EnergyBarEffect, GhostEffect, FourGradientsEffect, AdditiveEffect, TeleportationEffect, CartoonEffect.
Lighting: OutlineEffect, PatternEffect, PatternAdditiveEffect, EdgeColorEffect, BlurEffect, SharpenEffect, GrassFXEffect, GrassMultiFXEffect, HologramEffect, Hologram2Effect, Hologram3Effect, ShinyReflectEffect, SkyCloudEffect, WaterAndBackgroundEffect, WaterAndBackgroundDeluxeEffect, WaterfallEffect.
Effects Gallery demo (effects-gallery)
A searchable 4-column grid with a live shader thumbnail per EFFECT_REGISTRY key, a family filter bar (Color / Procedural / Distortion / Dissolve / Mask / Lighting), a DOM search input, and a right-side preview panel with per-uniform − / + tweak controls. All 73 built-in effects are browsable in one place without touching the keyboard.
AI / Pathfinding
Cooperative A* on a user-defined grid mesh.
NavMesh (extend)
class TileMesh extends NavMesh {
constructor(private map: Phaser.Tilemaps.Tilemap) { super() }
isBlocked(x, y) { return this.map.getTileAt(x, y)?.collides ?? true }
cost(x, y) { return this.map.getTileAt(x, y)?.properties.cost ?? 1 }
}
PathFinder (Feature)
const pathFinder = this.features.register('pathfinder', PathFinder)
pathFinder.setMesh(new TileMesh(map))
pathFinder.budgetMs = 2
const path = pathFinder.findPath(sx, sy, ex, ey, 5000)
path.on(PATH_FOUND, (waypoints: Waypoint[]) => {
})
path.on(PATH_FAILED, (reason: string) => {
})
Each findPath mints a fresh Path backed by an @toolcase/base AStar stepped cooperatively — budgetMs caps the per-frame slice. Heuristic is octile (8-connectivity, diagonal squeeze through two blocked orthogonals is rejected).
TilemapFeature — tilemap loader + NavMesh bridge
Feature that loads Tiled (.tmj) or LDtk (.ldtk) maps, renders tile layers, wires arcade-physics colliders, and auto-generates a TilemapNavMesh from walkable tile IDs.
import { TilemapFeature, TilemapNavMesh, PathFinder, PATH_FOUND } from '@toolcase/phaser-plus'
class GameScene extends Scene {
onLoad() {
const tilemap = this.features.register('tilemap', TilemapFeature)
tilemap.loadTiled('level1', 'assets/level1.tmj')
this.load.image('tileset', 'assets/tileset.png')
}
onCreate() {
const tilemap = this.features.get('tilemap')
tilemap.create('level1')
tilemap.addTileset('tileset', 'tileset')
tilemap.createLayer('Ground', 'tileset')
tilemap.buildColliders('Walls', 'tileset')
const navMesh = tilemap.buildNavMesh({ walkable: [1, 2], layer: 'Ground' })
const pf = this.features.register('finder', PathFinder)
pf.setMesh(navMesh)
const path = pf.findPath(0, 0, 20, 12)
path.on(PATH_FOUND, (waypoints) => moveAgent(waypoints))
}
}
From inline 2D array (no file load needed)
const MAP = [
[1,1,1,1],
[1,0,0,1],
[1,0,0,1],
[1,1,1,1],
]
tilemap.createFromData(MAP, 32, 32)
const navMesh = tilemap.buildNavMesh({ walkable: [0], layer: 0 })
LDtk support
tilemap.loadLDtk('world', 'assets/world.ldtk')
const raw = this.cache.json.get('world')
Tiled object layer → pooled ObjectLayer
Pre-register the pool key in onInit, then let toObjectLayer spawn every Tiled object entity:
this.pool.register('chest', ChestObject)
const objectLayer = tilemap.toObjectLayer('entities', 'Pickups', 'chest')
Retrieve raw Tiled objects
const objects = tilemap.getObjects('Enemies')
TilemapFeature API
| Method | Returns | Description |
|---|
loadTiled(key, url) | this | Queue a Tiled JSON map load (call before load phase ends) |
loadLDtk(key, url) | this | Queue an LDtk JSON load |
create(key) | this | Build tilemap from Phaser cache (call in onCreate) |
createFromData(data, tw, th) | this | Build tilemap from inline 2D number array |
addTileset(name, textureKey) | Tileset | Register a tileset image against the map |
createLayer(layerName, tilesetName?) | TilemapLayer | Render a tile layer |
buildColliders(layerName, tilesetName?) | TilemapLayer | Render + enable collision by collides: true property |
buildNavMesh(options) | TilemapNavMesh | Generate NavMesh from walkable tile IDs |
toObjectLayer(featureKey, objectLayerName, poolKey) | ObjectLayer | Spawn a pooled ObjectLayer from a Tiled object layer |
getObjects(objectLayerName) | TilemapObject[] | Return raw Tiled object definitions |
tilemap | Tilemap | null | Direct access to the underlying Phaser Tilemap |
BuildNavMeshOptions: { walkable: number[], layer?: number | string }.
TilemapNavMesh
NavMesh subclass constructed automatically by TilemapFeature.buildNavMesh, or manually:
import { TilemapNavMesh } from '@toolcase/phaser-plus'
const navMesh = new TilemapNavMesh(map, [1, 2], 'Ground')
isBlocked(x, y) returns true when the tile is absent, empty (index === -1), or its index is not in the walkable set. cost(x, y) reads the tile's cost custom property (defaults to 1).
Events constants
Events namespace re-exports string constants used internally on the FeatureRegistry bus and elsewhere. Most-used:
| Constant | Emitted on | Args |
|---|
LAYER_DEPTH_UPDATE | features (registry) | — |
import { Events } from '@toolcase/phaser-plus'
this.features.on(Events.LAYER_DEPTH_UPDATE, () => {})
Flow extensions — StateMachine, BehaviorTree, Replay
Flow.StateMachine, Flow.BT.*, and Flow.ReplayRecorder are scene-lifetime Features. Register through scene.features.register(...).
StateMachine — finite state machine
Generic context C, fluent definition.
import { Flow } from '@toolcase/phaser-plus'
interface AICtx { hp: number, target: any | null }
class EnemyAI extends Flow.StateMachine<AICtx> {
onCreate() {
this
.setContext({ hp: 100, target: null })
.addState('idle', { onUpdate: ctx => ctx.target && this.fire('see_target') })
.addState('chase', { onEnter: () => sprite.play('run') })
.addState('attack', { onEnter: swing, onExit: clearWindup })
.addState('flee', { onEnter: () => sprite.play('panic') })
.addTransition('idle', 'chase', 'see_target')
.addTransition('chase', 'attack', null, c => distance(c) < 1)
.addTransition(null, 'flee', null, c => c.hp < 20)
.setStart('idle')
super.onCreate()
}
}
const ai = scene.features.register('enemy.ai', EnemyAI)
ai.fire('see_target')
ai.go('flee')
ai.is('attack')
ai.current
from === null is a global transition (any source). Listen for transitions:
import { STATE_ENTER, STATE_EXIT, STATE_TRANSITION } from '@toolcase/phaser-plus'
ai.on(STATE_TRANSITION, (from, to) => log.info('fsm', { from, to }))
BehaviorTree
Composed of BT.Sequence, BT.Selector, BT.Parallel, BT.Action, BT.Condition, BT.Inverter, BT.Repeater, BT.AlwaysSucceed, BT.AlwaysFail. Run via Flow.BehaviorTreeProcessor.
import { Flow } from '@toolcase/phaser-plus'
const { Sequence, Selector, Action, Condition } = Flow.BT
const tree = new Selector([
new Sequence([
new Condition(ctx => ctx.blackboard.lowHP === true),
new Action(ctx => { drinkPotion(ctx); return 'success' })
]),
new Sequence([
new Condition(ctx => ctx.blackboard.target !== null),
new Action(ctx => attack(ctx))
]),
new Action(_ctx => { wander(); return 'success' })
])
const proc = scene.flow.addProcessor(Flow.BehaviorTreeProcessor.EVENT_TYPE, Flow.BehaviorTreeProcessor)
proc.add('boss', tree, { lowHP: false, target: null }, 0.1)
proc.get('boss')!.blackboard.target = player
Status values: 'success' | 'failure' | 'running'. running keeps the cursor in Sequence/Selector.
ReplayRecorder
Deterministic input capture/playback (assumes deterministic sim). The recorder ticks a fixed-rate frame accumulator on onUpdate; per frame it snapshots the inputs staged via setInput(key, value) and emits REPLAY_FRAME. Inputs reset each frame.
import { Flow, REPLAY_FRAME, REPLAY_END } from '@toolcase/phaser-plus'
const replay = scene.features.register('replay', Flow.ReplayRecorder)
const session = replay.record(42, 60)
scene.flow.events.add('input', class extends Flow.Event<{ action: string }> {
onFire(payload) { replay.setInput('action', payload.action) }
})
const recorded = replay.stop()
replay.play(recorded!)
replay.on(REPLAY_FRAME, (tick, inputs) => apply(inputs))
replay.on(REPLAY_END, session => log.info('replay finished', session))
During playback read the active frame's inputs with replay.readInput(key). replay.state is 'idle' | 'recording' | 'playing'; replay.tick is the current frame index.
replay.seekTo(frameIndex) — jump the playback cursor to the given frame during 'playing' state. No-op otherwise. Used by TimelinePanel.bindReplay for the RP Scrub slider.
See replay-recorder-timeline (ReplayRecorderTimelineDemo.js) for a full example wiring bindReplay.
Landing demo: flow-landing (FlowLandingDemo.js) — one scene covering the complete Flow surface: Flow.Event (delayed trigger), Flow.TimeEvent (recurring interval), Flow.Job (cooperative countdown), Flow.Timer sugar, Flow.Tween (yoyo), Flow.Timeline (sequence + parallel), and a StateMachine FSM with three states and signal transitions. Use the right-side panel buttons to exercise each system interactively.
Cinema — Camera, Shake, Parallax, Letterbox
CameraDirector
Queues "shots" — follow / pan / zoom / fit-bounds / spline. One shot active at a time; SHOT_DONE fires per shot.
import { CameraDirector, EASE_IN_OUT, SHOT_DONE } from '@toolcase/phaser-plus'
const director = scene.features.register('camera', CameraDirector)
director.setCamera(scene.cameras.main)
director.queue({ type: 'follow', target: player, lerp: 0.1 })
director.queue({ type: 'zoom', zoom: 2, duration: 0.6, ease: EASE_IN_OUT })
director.queue({
type: 'fit-bounds',
x: 0, y: 0, width: 1024, height: 768,
duration: 0.8, padding: 32
})
director.queue({
type: 'spline',
points: [{ x: 0, y: 0 }, { x: 200, y: 100 }, { x: 400, y: 0 }],
duration: 4, loop: true
})
director.on(SHOT_DONE, shot => log.debug('shot done', shot.type))
director.skip()
director.clear()
Easing constants exported: EASE_LINEAR, EASE_IN_OUT, EASE_OUT. Custom easing is any (t: number) => number.
ScreenShake
Trauma-based. Multiple sources stack; each decays at its own rate. Sources can be random-noise (default) or deterministic sine.
import { ScreenShake } from '@toolcase/phaser-plus'
const shake = scene.features.register('shake', ScreenShake)
shake.setMaxOffset(20).setMaxAngle(0.04).setExponent(2)
shake.add(0.6, 1.5)
shake.impact(0.8)
shake.rumble(0.35, 2)
shake.sine(0.5, 12, 0.5)
shake.clear()
| Method | Mode | Use for |
|---|
add(trauma, decay) | random | generic falloff |
impact(trauma = 0.8) | random | hit-stop, weapon impact |
rumble(intensity, durationSec = 1) | random | engines, sustained tension |
sine(intensity, freqHz = 12, durationSec = 1) | sine | predictable wobbles, vehicle idle |
trauma / intensity is [0..1]; the visual effect is trauma^exponent. Random and sine sources can be active simultaneously and overlay.
CameraFlash
Fullscreen color overlay with hold + fade. Multiple flashes blend (weighted RGB, summed alpha clamped to 1).
import { CameraFlash } from '@toolcase/phaser-plus'
const flash = scene.features.register('flash', CameraFlash)
flash.flash(0xffffff, 0.25)
flash.flash(0xff2244, 0.6, 0.05)
flash.setDepth(1100)
flash.clear()
flash(color, durationSec, holdSec = 0) queues a flash; the overlay reaches color immediately, holds at full alpha for holdSec, then fades over durationSec.
DialogCameraCue
Frame-and-dim feature for NPC moments / dialog beats. Four screen-locked dim panels mask everything outside the focus rect, with optional inner vignette and optional camera blur post-FX. Re-focus(...) while open to retarget without flicker.
import { DialogCameraCue } from '@toolcase/phaser-plus'
const cue = scene.features.register('dialog', DialogCameraCue)
cue.setDimColor(0x000000).setDimAlpha(0.6).setDefaultFade(0.3)
cue.focus({ x: 320, y: 180, width: 480, height: 280 }, { vignette: true })
cue.focus({ x: 0, y: 0, width: 1280, height: 200 }, { fadeSec: 0.4, blur: true })
cue.focus(null)
cue.clear()
cue.isOpen()
focus(frame, opts?) — frame is screen-space { x, y, width, height } (scrollFactor = 0) or null to dim the full viewport. opts.fadeSec overrides default fade; opts.vignette adds a soft inner ring inside the frame; opts.blur toggles camera.postFX.addBlur if available.
| Method | Effect |
|---|
focus(frame, opts?) | open or retarget the cue |
clear() | fade out & release blur |
setDimColor(color) / setDimAlpha(0..1) | tune the mask |
setDepth(depth) | render layer (default 1090, vignette renders at depth+1) |
setDefaultFade(sec) | default in/out duration |
ParallaxLayer
A Layer whose camera scrolls at a factor of the reference camera. Optional auto-tiling for infinite backgrounds.
import { ParallaxLayer } from '@toolcase/phaser-plus'
const sky = scene.features.register('sky', ParallaxLayer)
sky.setFactor(0.2).setReference(scene.cameras.main)
sky.depth = -10
sky.container.add(scene.add.image(0, 0, 'sky'))
const trees = scene.features.register('trees', ParallaxLayer)
trees.setFactor(0.6).setAutoTile(512, 512)
trees.addTiled(scene.add.image(0, 0, 'tree-band'))
LetterboxFeature
Aspect-ratio mask that draws bars whenever the viewport doesn't match the target aspect. Re-layouts automatically on scale.resize.
import { LetterboxFeature } from '@toolcase/phaser-plus'
const letterbox = scene.features.register('letterbox', LetterboxFeature)
letterbox.setAspect(21 / 9)
letterbox.setBarColor(0x000000)
letterbox.setBarDepth(1000)
setAspect(ratio) is the toggle: passing a value matching the viewport hides the bars. setBarColor(0xRRGGBB) retints, setBarDepth(depth) re-layers.
Landing demo: cinema-landing (CinemaLandingDemo.js) — one scene covering the full Cinema surface: CameraDirector (pan / zoom / fit-bounds / follow / spline shots), ScreenShake (impact / rumble / sine), CameraFlash (white hit, red damage), ParallaxLayer (three depth layers using generated graphics), LetterboxFeature (21:9 toggle), and DialogCameraCue (focus + vignette). All triggered from an HTML panel; no texture assets needed.
Input — actions, buffer, gamepad, gestures, joystick
InputFeature — action mapping
Bind each game action to one or more sources (key / mouse / gamepad button / axis / virtual). Emits ACTION_PRESS / ACTION_RELEASE / ACTION_HOLD.
import { InputFeature, ACTION_PRESS, ACTION_RELEASE } from '@toolcase/phaser-plus'
const input = scene.features.register('input', InputFeature)
input.bind('jump', [{ type: 'key', code: 'SPACE' }, { type: 'gamepad', button: 0 }])
input.bind('shoot', [{ type: 'mouse', button: 0 }, { type: 'gamepad', button: 7 }])
input.bind('move-x', [{ type: 'axis', axis: 0, threshold: 0.2 }])
input.on(ACTION_PRESS, (action: string) => {
if (action === 'jump') player.jump()
})
input.on(ACTION_RELEASE, action => stopAction(action))
input.isPressed('shoot')
input.holdMs('shoot')
InputBuffer — combo / leniency window
Records last N actions inside a sliding window. Use to forgive 100ms-late jumps or detect combos.
import { InputBuffer } from '@toolcase/phaser-plus'
const buffer = scene.features.register('input.buffer', InputBuffer)
buffer.setCapacity(32).setWindow(150)
input.on(ACTION_PRESS, action => buffer.push(action))
function tryDoubleJump(now: number) {
if (player.airborne && buffer.consume('jump', 250, now)) {
player.doubleJump()
}
}
consume() removes the matching entry; peek() keeps it. windowMs defaults from setWindow.
GamepadFeature
Higher-level gamepad polling — rumble, deadzones, multi-pad routing, per-pad mapping presets (MAPPING_XBOX / MAPPING_PS / MAPPING_SWITCH / MAPPING_STANDARD).
import { GamepadFeature, MAPPING_PS } from '@toolcase/phaser-plus'
const gamepad = scene.features.register('gamepad', GamepadFeature)
gamepad.setDefaultDeadZone(0.15)
gamepad.setDefaultMapping('xbox')
gamepad.setMapping(0, MAPPING_PS)
if (gamepad.isConnected(0)) {
const { x, y, rx, ry } = gamepad.axes(0)
gamepad.rumble(0, 0.6, 0.2, 200)
}
Emits GAMEPAD_CONNECTED / GAMEPAD_DISCONNECTED / GAMEPAD_BUTTON_DOWN / GAMEPAD_BUTTON_UP on the feature bus.
GestureRecognizer
Tap / double-tap / long-press / swipe / pinch from touch input. Event names are exported constants — use them, not raw strings.
import {
GestureRecognizer,
GESTURE_TAP, GESTURE_DOUBLE_TAP, GESTURE_LONG_PRESS,
GESTURE_SWIPE, GESTURE_PINCH
} from '@toolcase/phaser-plus'
const gestures = scene.features.register('gestures', GestureRecognizer)
gestures.on(GESTURE_SWIPE, ({ direction, length }) => {
if (direction === 'left' && length > 80) player.dash(-1)
})
gestures.on(GESTURE_DOUBLE_TAP, ({ x, y }) => placeMarker(x, y))
gestures.on(GESTURE_PINCH, ({ scale }) => camera.setZoom(camera.zoom * scale))
VirtualJoystick
On-screen analog stick + buttons (mobile/touch). Wire to an InputFeature to drive virtual bindings; the joystick also feeds VIRTUAL_AXIS_X / VIRTUAL_AXIS_Y.
import { VirtualJoystick, InputFeature, VIRTUAL_AXIS_X, VIRTUAL_AXIS_Y } from '@toolcase/phaser-plus'
const input = scene.features.register('input', InputFeature)
const stick = scene.features.register('stick', VirtualJoystick)
stick.setInput(input)
stick.setJoystickConfig({ size: 160, placement: 'left', bottomOffset: 48, sideOffset: 48, deadZone: 0.2 })
stick.addButton({ id: 'jump', label: 'A', placement: 'right' })
scene.events.on('update', () => {
const { x, y } = stick.vector
player.x += x * speed
player.y += y * speed
})
Buttons set virtual booleans (input.setVirtual(id, …)); pair with an { type: 'virtual', id } binding on InputFeature.bind('jump', [...]).
Landing demo: input-landing (InputLandingDemo.js) — one scene covering the full Input surface: InputFeature action map (WASD + Space, ACTION_PRESS / ACTION_RELEASE / hold timer), InputBuffer (ring buffer for leniency-window combo detection), GestureRecognizer (tap, double-tap, long-press, swipe, pinch), GamepadFeature (connect/disconnect, per-button events). Move the square with WASD/arrows; press Space three times within 600 ms for the triple-jump combo. Live readouts in the right-side HTML panel.
Worked examples
Searchable effects gallery (all 73 shaders)
import { Scene, EFFECT_REGISTRY, EffectManager, HTMLFeature } from '@toolcase/phaser-plus'
const FAMILIES = [
{ name: 'Color', start: 0, count: 16, color: 0x3b82f6 },
{ name: 'Procedural', start: 16, count: 13, color: 0xf59e0b },
{ name: 'Distortion', start: 29, count: 13, color: 0x10b981 },
{ name: 'Dissolve', start: 42, count: 7, color: 0xef4444 },
{ name: 'Mask', start: 49, count: 8, color: 0x8b5cf6 },
{ name: 'Lighting', start: 57, count: 16, color: 0xf472b6 },
]
class SearchFeature extends HTMLFeature {
onInput = null
onCreate() {
this.node.innerHTML = `<input type="text" placeholder="search…" data-q style="position:absolute;top:9px;left:480px;">`
this.node.querySelector('[data-q]').addEventListener('input', e => this.onInput?.(e.target.value))
}
}
const sprite = scene.add.sprite(cx, cy, 'objects', 'turtle_left')
const mgr = new EffectManager(sprite)
const fx = mgr.add(EFFECT_REGISTRY[0])
fx.amount = 0.5
mgr.clear()
See examples/src/phaser-plus/scenes/EffectsGalleryDemo.js for the full paginated grid implementation with family filter chips, mouse-wheel scrolling, and per-uniform −/+ controls.
Side-scroller mini scene (Layer + Pool + Flow + Effects)
import { Scene, Layer, ObjectLayer, GameObject, Flow, OutlineEffect } from '@toolcase/phaser-plus'
class Bullet extends GameObject {
onCreate() { this.add(this.scene.add.sprite(0, 0, 'bullet')) }
onUpdate() { this.x += 10; if (this.x > 1000) (this as any).release() }
}
class Hero extends GameObject {
onCreate() {
this.add(this.scene.add.sprite(0, 0, 'hero'))
this.effects.add(OutlineEffect, { thickness: 2, color: 0xffeb3b })
}
onUpdate(_t: number, delta: number) {
if (cursors.left.isDown) this.x -= delta * 0.4
if (cursors.right.isDown) this.x += delta * 0.4
}
}
class StageScene extends Scene {
onInit() {
this.pool.register('bullet', Bullet)
this.pool.register('hero', Hero)
}
onLoad() {
this.load.image('hero', 'hero.png')
this.load.image('bullet', 'bullet.png')
}
onCreate() {
const bg = this.features.register('bg', Layer)
bg.setBackgroundColor('#0f172a').depth = 0
const game = this.features.register('game', ObjectLayer)
game.depth = 10
game.add<Hero>('hero', 100, 300)
const scene = this
this.flow.timer.add('shoot', class extends Flow.TimeEvent {
onFire() {
const b = scene.pool.obtain<Bullet>('bullet')
b.setPosition(120, 300)
game.container.add(b)
}
}, 0.25)
this.flow.events.add('boss', class extends Flow.Event<{ name: string }> {
onFire(payload) { spawnBoss(payload.name) }
})
this.flow.events.trigger('boss', { name: 'Wyrm' }, 5)
}
}
Isometric world with pathfinding
import { Scene2D, Structs, NavMesh, PathFinder, PATH_FOUND, GameObject2D } from '@toolcase/phaser-plus'
class Tile extends GameObject2D {
onCreate() { this.add(this.scene.add.image(0, 0, 'iso-tile')) }
}
class GridMesh extends NavMesh {
constructor(private blocked: Set<string>) { super() }
isBlocked(x: number, y: number) { return this.blocked.has(`${x},${y}`) }
cost(_x: number, _y: number) { return 1 }
}
class IsoScene extends Scene2D {
onCreate() {
this.world.projection = Structs.Matrix2.createISO(64)
this.world.register('tile', Tile)
for (let x = 0; x < 16; x++)
for (let y = 0; y < 16; y++) this.world.add<Tile>('tile', x, y)
const mesh = new GridMesh(new Set(['3,3', '3,4', '4,3']))
const pf = this.features.register('pf', PathFinder)
pf.setMesh(mesh)
pf.budgetMs = 2
const path = pf.findPath(0, 0, 10, 10)
path.on(PATH_FOUND, points => moveAlong(points))
}
}
Multiplayer scene with debugger + remote logger
import { Scene, Debugger, MemoryPanel, NetPanel, LogLevel } from '@toolcase/phaser-plus'
class ArenaScene extends Scene {
onInit() {
this.engine.setLogLevel(LogLevel.DEBUG)
}
onCreate() {
const dbg = this.features.register('debug', Debugger)
dbg.addPanel('memory', MemoryPanel)
dbg.addPanel('net', NetPanel)
dbg.setExpanded(true)
}
}
AI mob with StateMachine + flow events
class GoblinAI extends Flow.StateMachine<{ player: any, hp: number }> {
onCreate() {
this
.setContext({ player: scene.player, hp: 60 })
.addState('patrol', { onUpdate: patrol })
.addState('alert', { onEnter: alert })
.addState('combat', { onUpdate: combat })
.addTransition('patrol', 'alert', 'sees-player')
.addTransition('alert', 'combat', null, ctx => distance(ctx) < 4)
.addTransition(null, 'patrol', null, ctx => ctx.player === null)
.setStart('patrol')
super.onCreate()
}
}
scene.features.register('goblin.ai', GoblinAI)
scene.flow.events.add('sees-player', class extends Flow.Event {
onFire() { (scene.features.get('goblin.ai') as GoblinAI).fire('sees-player') }
})
Cross-library integration
@toolcase/serializer for netcode
import Serializer from '@toolcase/serializer'
const wire = new Serializer('arena.v1')
wire.define('Snapshot', [
{ key: 'tick', type: 'uint32', rule: 'required' },
{ key: 'pos', type: 'bytes', rule: 'required' }
])
class NetFeature extends Feature {
onCreate() {
this.scene.flow.events.add('tx', class extends Flow.Event<{ tick: number, pos: Uint8Array }> {
onFire(p) { ws.send(wire.encode('Snapshot', p)) }
})
}
}
@toolcase/web-components HUD overlay
HTMLFeature hosts the HUD; bind values per-frame.
import { HTMLFeature } from '@toolcase/phaser-plus'
import { register } from '@toolcase/web-components'
register()
class Hud extends HTMLFeature {
onCreate() {
this.node.innerHTML = '<tc-health-bar id="hp" value="100" max="100"></tc-health-bar>'
}
onUpdate() {
;(this.node.querySelector('#hp') as any).value = this.scene.player.hp
}
}
@toolcase/base pool & state
import { State } from '@toolcase/base'
interface World { score: number, wave: number }
const world = new State<World>({ score: 0, wave: 1 })
scene.engine.services.provide(State as any, world)
world.on('state.score', s => log.info('score', s))
Theming / styling surfaces
phaser-plus is a runtime layer; most "look" lives inside Phaser GameObjects (textures, tints, alpha, pipelines). A few surfaces are HTML/CSS-themable:
| Surface | How to style |
|---|
| Phaser GameObjects | Use Phaser's native API: setTint(0xRRGGBB), setAlpha, setBlendMode, custom shaders via Effect. |
Effect shaders | The 73 built-in effects accept numeric color parameters in 0xRRGGBB form — pass game-themed palette values (e.g. from @toolcase/base Color, where keys resolve as Color.RED / Color.BLUE etc., or Color.toNumber('blue') for the 0xRRGGBB number) to keep visuals consistent across scenes. |
Cinema overlays | CameraFlash.flash(color, ...), LetterboxFeature.setBarColor, DialogCameraCue.setDimColor — all accept 0xRRGGBB. |
HTMLFeature content | Plain DOM inside this.node. Style with regular CSS / SCSS or by mounting @toolcase/web-components (tc-* web components) — they expose a full --tc-* / --bs-* variable layer documented in their SKILL.md. |
Debugger panels | Tweakpane folders; restyle with Tweakpane's own CSS variables on the panel container. |
Color discipline tip: the 0xRRGGBB literals used by setTint / shader effects / cinema overlays are easy to scatter. Consolidate them into a constants module (or the Color palette from @toolcase/base — Color.toNumber('blue') yields the 0xRRGGBB number) and pass references — that way one change retones every scene.
Audio — bus mixer, music, SFX pool, spatial, ducking
AudioFeature is a scene-lifetime Feature that owns a master bus plus four default named buses (music, sfx, ui, ambience), each with independent volume / mute / pan. It handles music playback with crossfade, pooled fire-and-forget SFX with polyphony limits, spatial pan+attenuation by world position, and per-bus ducking. It wires directly into AudioPanel via bindDebugger.
AudioFeature
import { AudioFeature, AUDIO_MUSIC_START, AUDIO_MUSIC_END, AUDIO_BUS_CHANGE } from '@toolcase/phaser-plus'
const audio = scene.features.register('audio', AudioFeature)
Default buses created automatically: 'music', 'sfx', 'ui', 'ambience'. Add custom buses with addBus(name).
Master
audio.setMasterVolume(0.8)
audio.setMasterMute(true)
audio.getMasterVolume()
audio.isMasterMute()
Per-bus controls
audio.setVolume('music', 0.6)
audio.setMute('sfx', true)
audio.setPan('ui', -0.3)
const bus = audio.getBus('music')
Music (one track per bus, with crossfade)
audio.play('music', 'theme', true)
audio.crossfadeTo('music', 'boss_theme', 1200)
audio.stopMusic('music', 600)
SFX (pooled fire-and-forget, polyphony limit)
Register SFX keys once (pre-allocates maxVoices sound instances for voice stealing):
audio.registerSfx('jump', 4)
audio.registerSfx('explosion', 6)
audio.playSfx('sfx', 'jump')
Spatial audio (world-space pan + attenuation)
audio.setListener(camera.scrollX + width / 2, camera.scrollY + height / 2)
audio.playSpatial('sfx', 'sfx_ping', worldX, worldY, 300)
playSpatial uses the pool registered with registerSfx. Requires registerSfx before calling.
Ducking
Temporarily lower a bus (e.g. duck music while a VO plays):
audio.duck('music', 0.15, 1200, 600)
Multiple duck calls on the same bus restart the hold/restore cycle.
Debugger panel binding
Binds every bus to AudioPanel sliders so the in-game debugger reflects live bus state:
const dbg = scene.features.register('debugger', Debugger)
dbg.addPanel('audio', AudioPanel, 'Audio')
const audio = scene.features.register('audio', AudioFeature)
audio.bindDebugger(dbg)
Event constants
| Constant | Payload | Meaning |
|---|
AUDIO_MUSIC_START | (busName, key) | A track started on a bus |
AUDIO_MUSIC_END | (busName) | A track stopped on a bus |
AUDIO_BUS_CHANGE | (busName, bus) | Bus sliders changed via panel |
import { AUDIO_MUSIC_START } from '@toolcase/phaser-plus'
scene.features.on(AUDIO_MUSIC_START, (bus, key) => hud.showNowPlaying(key))
Full wiring example
import { Scene, Debugger, AudioPanel, AudioFeature, AUDIO_MUSIC_START } from '@toolcase/phaser-plus'
class GameScene extends Scene {
onInit() {
this.pool.register('bullet', Bullet)
}
onLoad() {
this.load.audio('music_main', 'assets/music_main.ogg')
this.load.audio('music_battle', 'assets/music_battle.ogg')
this.load.audio('sfx_shot', 'assets/sfx_shot.wav')
}
onCreate() {
const dbg = this.features.register('debugger', Debugger)
dbg.addPanel('audio', AudioPanel, 'Audio')
const audio = this.features.register('audio', AudioFeature)
audio.registerSfx('sfx_shot', 6)
audio.setListener(this.cameras.main.midPoint.x, this.cameras.main.midPoint.y)
audio.bindDebugger(dbg)
audio.play('music', 'music_main', true)
this.features.on(AUDIO_MUSIC_START, (bus, key) => console.log('now playing', key))
this.input.on('pointerdown', p => {
audio.playSpatial('sfx', 'sfx_shot', p.worldX, p.worldY, 600)
})
}
onUpdate() {
const audio = this.features.get('audio')
const mid = this.cameras.main.midPoint
audio.setListener(mid.x, mid.y)
}
}
Custom buses
Add extra buses beyond the four defaults:
audio.addBus('voice')
audio.play('voice', 'npc_bark', false)
audio.duck('music', 0.2, 3000, 500)
Remove when done:
audio.removeBus('voice')
Persistence — SaveService, PersistenceFeature, backends
Durable save-slot system backed by a pluggable SaveBackend. Stores versioned JSON envelopes and applies a migration chain when loading older saves.
SaveService (ServiceRegistry singleton)
import { SaveService, LocalStorageBackend, MemoryBackend, IndexedDBBackend } from '@toolcase/phaser-plus'
import type { SaveSchema, SaveConfig } from '@toolcase/phaser-plus'
Construct via engine.services.bind:
scene.services.bind(SaveService, () => new SaveService({
backend: new LocalStorageBackend('my-app'),
namespace: 'game',
schema: {
version: 2,
migrations: {
1: (data) => ({ ...data, playerName: 'Hero' })
}
}
}))
new SaveService() (zero-arg) defaults to LocalStorageBackend('phaser-plus'), namespace 'save', schema version 1.
| Method | Returns | Description |
|---|
save(slotId, data) | Promise<void> | Serialize data as a versioned envelope |
load<T>(slotId) | Promise<T | null> | Load + run migrations; null when slot missing |
delete(slotId) | Promise<void> | Remove a slot |
list() | Promise<SaveEntry[]> | All saved entries in this namespace |
has(slotId) | Promise<boolean> | Check if a slot exists |
setSchema(schema) | this | Replace schema at runtime |
dispose() | void | Calls backend.dispose(); invoked automatically by ServiceRegistry |
SaveEntry: { id: string, version: number, savedAt: number }.
SaveBackend interface
interface SaveBackend {
save(key: string, value: string): Promise<void>
load(key: string): Promise<string | null>
delete(key: string): Promise<void>
keys(): Promise<string[]>
dispose(): void
}
Three built-in backends:
| Class | Storage | Use for |
|---|
LocalStorageBackend(appPrefix?) | localStorage | Default; up to ~5 MB |
IndexedDBBackend(dbName?) | IndexedDB | Large saves, binary blobs |
MemoryBackend() | Map<string, string> | Tests; volatile |
PersistenceFeature
Scene-lifetime Feature that resolves SaveService from engine.services and forwards calls onto it with feature-bus events.
import { PersistenceFeature, SAVE_DONE, LOAD_DONE, SAVE_DELETED } from '@toolcase/phaser-plus'
this.services.bind(SaveService, () => new SaveService({ namespace: 'game', schema }))
const persistence = this.features.register('persistence', PersistenceFeature)
await persistence.save('slot-1', gameState)
const data = await persistence.load<GameState>('slot-1')
await persistence.delete('slot-1')
const entries = await persistence.list()
this.features.on(SAVE_DONE, (slotId, data) => hud.refresh())
this.features.on(LOAD_DONE, (slotId, data) => applyState(data))
this.features.on(SAVE_DELETED, (slotId) => hud.refresh())
persistence.service exposes the raw SaveService for direct access.
Migration chain (v1 → v2 → v3)
Each key in migrations is the source version to migrate FROM. The chain runs iteratively until data._version === schema.version:
const schema: SaveSchema = {
version: 3,
migrations: {
1: (d) => ({ ...d, playerName: 'Hero' }),
2: (d) => ({ ...d, highScore: 0 })
}
}
A save made with version 1 passes through both migrators in order. Saves already at version 3 are returned unchanged.
SaveStatePanel — debugger panel for save slots
SaveStatePanel is a Panel subclass that lists all slots from a bound SaveService, shows per-slot metadata, previews serialized data content, and lets you force-load or delete any slot from the in-game debugger.
import { Debugger, SaveStatePanel, SaveService, PersistenceFeature } from '@toolcase/phaser-plus'
this.services.bind(SaveService, () => new SaveService({ namespace: 'game', schema }))
const dbg = this.features.register('debugger', Debugger).setExpanded()
const savePanel = dbg.addPanel('save', SaveStatePanel, 'Save State')
const persistence = this.features.register('persistence', PersistenceFeature)
savePanel.bind(persistence.service)
Panel controls:
- Slots (readonly) — comma-separated IDs of all saved slots in the namespace
- Slot ID (editable) — type a slot ID to target; auto-filled with the first slot on
bind
- Version / Saved (readonly) — schema version and ISO timestamp of the targeted slot
- Data (readonly) — up to 120 chars of
JSON.stringify of the slot's data content
- Refresh — re-query
service.list() and update slot list + metadata
- Inspect — load the targeted slot's data and populate the Data preview
- Force Load — load the targeted slot and emit
load on the panel
- Delete Slot — delete the targeted slot and auto-refresh the list
Listening to panel events:
savePanel.on('load', (slotId, data) => {
scene.applyState(data)
})
savePanel.on('delete', (slotId) => {
console.log('deleted', slotId)
})
Keep the panel in sync when saves happen outside it:
import { SAVE_DONE, SAVE_DELETED } from '@toolcase/phaser-plus'
scene.features.on(SAVE_DONE, () => savePanel.refresh())
scene.features.on(SAVE_DELETED, () => savePanel.refresh())
Demo: save-state-panel example scene (SaveStatePanelDemo.js).
Worked example — save/load with tc-save-slot-list
import {
Scene, HTMLFeature, SaveService, PersistenceFeature, LocalStorageBackend,
LOAD_DONE, SAVE_DONE, SAVE_DELETED
} from '@toolcase/phaser-plus'
const SCHEMA: SaveSchema = {
version: 2,
migrations: { 1: (d) => ({ ...d, playerName: 'Hero' }) }
}
class HUD extends HTMLFeature {
onCreate() {
this.node.innerHTML = '<tc-save-slot-list id="slots" mode="load"></tc-save-slot-list>'
const list = this.node.querySelector('#slots')
list.onLoad = id => this.scene.features.get('p')?.load(id)
list.onSave = id => this.scene.features.get('p')?.save(id, this.scene.state)
list.onDelete = id => this.scene.features.get('p')?.delete(id)
}
}
class GameScene extends Scene {
onInit() {
this.services.bind(SaveService, () => new SaveService({
backend: new LocalStorageBackend('my-game'),
namespace: 'game',
schema: SCHEMA
}))
}
onCreate() {
const p = this.features.register('p', PersistenceFeature)
this.features.register('hud', HUD)
this.features.on(LOAD_DONE, (id, data) => applyState(data))
this.features.on(SAVE_DONE, () => refreshSlots())
}
}
Assets — AssetFeature (declarative manifest loader)
AssetFeature is a scene-lifetime Feature that wraps Phaser's loader with a declarative AssetManifest, per-bundle lazy loading, aggregated progress events, exponential-backoff retry (via @toolcase/base retry), and a preload-then-swap path for hot asset reloads.
AssetFeature
import {
AssetFeature,
ASSET_PROGRESS,
ASSET_LOAD_COMPLETE,
ASSET_LOAD_ERROR
} from '@toolcase/phaser-plus'
import type { AssetManifest } from '@toolcase/phaser-plus'
const assets = scene.features.register('assets', AssetFeature)
Manifest definition
const MANIFEST: AssetManifest = {
bundles: {
ui: {
images: [
{ key: 'logo', url: 'assets/logo.png' }
],
atlases: [
{ key: 'icons', textureUrl: 'assets/icons.png', atlasUrl: 'assets/icons.json' }
]
},
game: {
images: [
{ key: 'tileset', url: 'assets/tileset.png' }
],
audio: [
{ key: 'bgm', url: ['assets/bgm.ogg', 'assets/bgm.mp3'] }
],
fonts: [
{ key: 'hud-font', textureUrl: 'assets/hud-font.png', fontDataUrl: 'assets/hud-font.xml' }
]
}
}
}
assets.define(MANIFEST)
Loading bundles
await assets.load('ui')
await assets.load('ui', 'game')
await assets.load()
load() skips keys that are already loaded — safe to call repeatedly. Throws if define() has not been called or if a previous load() is still in progress.
Aggregated progress
ASSET_PROGRESS fires with a number 0..1 representing how many files from the current load() call have completed (successful files only; denominator is the number of pending files at the start of the call).
scene.features.on(ASSET_PROGRESS, (progress: number) => {
loadingScreen.progress = progress
})
scene.features.on(ASSET_LOAD_COMPLETE, (bundleNames: string[]) => {
loadingScreen.remove()
})
scene.features.on(ASSET_LOAD_ERROR, (error: Error) => {
console.error('asset load failed', error.message)
})
Retry
assets.retries (default 3) controls how many retry attempts are made when any file in a batch fails. Retry uses exponential backoff starting at 500 ms (factor: 2). On each retry only the files that have not yet successfully loaded are re-queued.
assets.retries = 2
has(key)
Returns true if the asset was successfully loaded in any previous load() call.
if (assets.has('bgm')) {
audio.play('music', 'bgm', true)
}
Hot-reload (preload-then-swap)
reload(key) re-fetches a single asset from its original URL under a temporary key, then swaps it into the live texture / audio / bitmap-font cache without restarting the scene. Supported types: image, atlas, font, audio.
await assets.reload('icons')
Any Phaser.GameObjects.Image already referencing the key picks up the new texture automatically on next render.
API summary
| Member | Description |
|---|
define(manifest) | Set the active AssetManifest; returns this |
load(...bundles) | Promise<void> — lazy-load named bundles (all if no args) |
has(key) | boolean — whether the key was successfully loaded |
reload(key) | Promise<void> — preload-then-swap one asset in-place |
retries | number (default 3) — retry attempts on batch failure |
Event constants
| Constant | Payload | Fired when |
|---|
ASSET_PROGRESS | (progress: number) | File completed; progress is 0..1 over the current batch |
ASSET_LOAD_COMPLETE | (bundleNames: string[]) | All bundles in the load() call finished |
ASSET_LOAD_ERROR | (error: Error) | Retries exhausted; load() also throws |
Full wiring example
import {
Scene, HTMLFeature, AssetFeature,
ASSET_PROGRESS, ASSET_LOAD_COMPLETE, ASSET_LOAD_ERROR
} from '@toolcase/phaser-plus'
import type { AssetManifest } from '@toolcase/phaser-plus'
const MANIFEST: AssetManifest = {
bundles: {
ui: { atlases: [{ key: 'icons', textureUrl: 'icons.png', atlasUrl: 'icons.json' }] },