Scenes, game state and timing
Stages and the state manager
A Stage is a screen. Register it against a state id, then change to it:
import { Stage, state } from "melonjs";
class PlayScreen extends Stage {
onResetEvent(app) { /* build the scene */ }
onDestroyEvent(app) { /* tear it down */ }
}
state.set(state.PLAY, new PlayScreen()); // register first
state.change(state.PLAY); // then switch
Built-in ids: state.LOADING, state.MENU, state.READY, state.PLAY,
state.GAMEOVER (no underscore), state.GAME_END, state.SCORE,
state.CREDITS, state.SETTINGS, state.DEFAULT. state.USER (100) is the
base for your own ids — const CUTSCENE = state.USER + 1.
Build scene content in onResetEvent, not the constructor. onResetEvent
runs on every entry to the state; the constructor runs once. Scene content
created in the constructor exists once and is not rebuilt when you return.
onResetEvent(app) receives the Application — use that rather than importing
the global game.
Extra arguments to state.change are forwarded:
state.change(state.PLAY, false, { level: 2 }); // → onResetEvent(app, { level: 2 })
The second argument is forceChange: pass true to switch immediately instead
of on a deferred tick, which is what you want when changing state right after a
preload completes. It is ignored when a transition is configured — a transition
always drives the switch from its own completion callback.
state.change throws Undefined Stage for state '<id>' if nothing was
registered for that id, and state.set throws if the second argument is not a
Stage instance. Neither fails quietly.
Persistent objects across levels
if (typeof this.HUD === "undefined") {
this.HUD = new Container(); // construct once (or UIBaseElement)
}
this.HUD.isPersistent = true; // survives level changes
this.HUD.floating = true; // screen coordinates, not world
app.world.addChild(this.HUD, 100); // explicit z — see melonjs-renderables
Transitions and pausing
state.transition("fade", "#FFFFFF", 250);
state.transition("mask", "#000", 500, new Ellipse(0, 0, 1, 1)); // shape required
state.pause(); // freeze update, keep drawing
state.resume();
app.freeze(150); // brief hit-stop (proxies state.freeze)
state.transition is global and sticky — once set it applies to every later
state.change. The "mask" form needs its fourth argument, an Ellipse or
Polygon; without one it warns and falls back to a direct switch.
A Stage's own update() is NOT paused
This one is easy to get wrong and hard to see. Application.update calls both
of these unconditionally:
this.isDirty = this.world.update(this.updateDelta);
this.isDirty = state.current().update(this.updateDelta) || this.isDirty;
Only Container.update consults the pause — it reads state.isPaused() and
skips every child that is not updateWhenPaused. So world children (entities,
sprites, meshes, a GLTFModel) freeze correctly, while the Stage subclass's
own update() keeps running.
A game that keeps its logic in entities pauses for free and never notices. A
game that drives the simulation from Stage.update — common for an endless
runner, where one object moves and the world scrolls past it — keeps simulating
through pauseOnBlur with its world frozen. The signature is bizarre and very
misleading: the scenery scrolls on, the player character stands still in world
space and slides off the screen, and it all snaps back on focus. It reads as a
culling or animation bug, not a pause bug.
Guard it explicitly:
update(dt) {
super.update(dt);
if (state.isPaused()) {
return true; // world children are already frozen for you
}
// …simulation…
}
Worth knowing that pauseOnBlur (default true) fires on a window blur, not
only on tab visibilitychange — so clicking another window on the same screen
triggers it while the canvas is still fully visible.
The update loop
There is no loop you own. Logic goes in update(dt) overrides, or a
GAME_UPDATE subscription for scene-level animation:
event.on(event.GAME_UPDATE, () => { /* per frame */ });
update(dt) must return true when the object changed and needs redrawing.
Returning nothing is falsy and the object can appear frozen while its state moves.
dt is milliseconds since the last frame and is the right basis for motion —
the engine already paces it. Do not reach for performance.now().
A common idiom for a per-frame game controller with nothing to draw is a
Renderable(0, 0, 1, 1) with alwaysUpdate = true and an empty draw().
Timers: use the engine's, not the window's
import { timer } from "melonjs";
timer.setTimeout(fn, 1000);
const id = timer.setInterval(fn, 500);
timer.clearInterval(id); // in the Stage's onDestroyEvent
Stage has only onResetEvent and onDestroyEvent — there is no
onActivateEvent / onDeactivateEvent on a stage. Those two are Renderable
hooks; put per-renderable teardown there instead.
Engine timers are pause-aware. window.setTimeout keeps firing while the
game is paused, which produces enemies spawning behind a pause menu. Pass
false as the third argument — timer.setInterval(fn, 500, false) — for one
that keeps running through a pause, which is what a pause-menu animation or a
countdown that should not freeze needs. (That argument was ignored by
setInterval before 20.3.)
Tweens
import { Tween, pool } from "melonjs";
const t = pool.pull("Tween", sprite.pos) // registered name is "Tween"
.to({ x: 300 }, { duration: 500 }) // options object, not a number
.easing(Tween.Easing.Quadratic.Out)
.onComplete(() => { /* … */ })
.start(); // ← without this, nothing happens
Four traps:
- A tween without
.start()(orautoStart: truein theto()options) silently does nothing. to()'s second argument is an options object ({ duration, easing, yoyo, repeat, delay, repeatDelay, interpolation, autoStart }). Passing a bare number is not a duration — it is read for a.durationproperty, finds none, and the tween silently runs the default 1000 ms.- Tweens freeze during
state.pause()/freeze()unlesstween.updateWhenPaused = true— which is how you get an effect that decays through a hit-stop. - Stop a tween before destroying its target, or
onUpdatefires against a dead renderable.
Guard teardown-adjacent callbacks
Callbacks can fire while a stage is being torn down, when the current state is already something else:
if (!state.isCurrent(state.PLAY)) return;
Without the guard, a "children emptied" handler can reset the wrong stage.
Persistence
import { save } from "melonjs";
save.add({ hiscore: 0 }); // once; idempotent, loads any stored value
save.hiscore = 1200; // plain assignment writes to localStorage
In TypeScript, keep what add() returns. The namespace carries a
[key: string]: unknown index for keys registered anywhere, so reading
save.hiscore off it gives you unknown. add() hands back the same
namespace typed with the keys you just registered, which is the handle to
keep:
const store = save.add({ hiscore: 0, lives: 3 });
store.hiscore = 1200; // number, no cast
if (score > store.hiscore) { /* ... */ }
Chained calls accumulate, so save.add({ a: 0 }).add({ b: "" }) is typed with
both.
Symptom → cause
| symptom | cause |
|---|---|
| scene builds once and is empty on return | content created in the constructor, not onResetEvent |
Undefined Stage for state 'N' thrown |
no state.set for that id first |
| a tween finishes in 1000 ms whatever you pass | to() takes { duration }, not a number |
| object frozen while its state updates | update() not returning true |
| timers fire behind a pause menu | window.setTimeout instead of timer.setTimeout |
| the world freezes on blur but the game keeps advancing | simulation lives in Stage.update, which the pause does not gate — only Container.update checks state.isPaused() |
| the player character is left behind and scrolls off after a lost window | same cause: the stage advanced the camera while its world children were paused |
| a tween does nothing | .start() never called |
| effect stops during a hit-stop | tween needs updateWhenPaused = true |
| crash after a stage switch | callback ran during teardown — guard with state.isCurrent |
| HUD scrolls away with the camera | missing floating = true |
Related skills
melonjs-getting-started— the Application and the first scenemelonjs-renderables—floating, draw order, update/draw contracts