PixiJS Ticker: One Update Loop for Motion That Survives a Frame Drop
PixiJS already runs one requestAnimationFrame loop. Register your update logic on its Ticker, drive motion from elapsed time, and know where delta-time stops being enough.
08 May 2026, 01:01 UTC

A sprite that moves sprite.x += 2 every frame looks fine on your 60 Hz laptop and runs about 2.4× faster on a 144 Hz monitor. The usual reaction is to bolt on a second requestAnimationFrame loop with a timestamp — and now you have two loops with independent timing, your update sometimes lands after the render, and you are maintaining a scheduler PixiJS already ships.
The Ticker is that loop, exposed. Register your per-frame logic on it, drive movement from elapsed time rather than frame count, and remove the listener when the object it touches goes away. Two details trip people up: the value you multiply by is a frame-rate multiplier, not milliseconds, and the callback runs on the main thread before rendering, so anything slow in it costs you frames.
What the Ticker actually hands you
The Ticker wraps requestAnimationFrame and runs a list of callbacks once per rendered frame. It is a singleton class, and an Application exposes one as app.ticker. Depending on how the application was constructed, that may or may not be the same instance as Ticker.shared; a listener registered on the wrong one simply never fires.
- Elapsed time. PixiJS reports both a normalized
delta(roughly 1.0 at 60 FPS) and raw milliseconds asdeltaMS. In v5 and later,deltais the multiplier; in v4 it was raw milliseconds. Multiplying a per-frame pixel value bydeltais a common source of motion that is wrong by a factor of ~16. - Priority. Callbacks take a priority number and higher values run earlier in the frame, which lets you order input, simulation, and camera work. The argument list changed across major versions (v7 accepts a context argument before priority), so check the typings for your version instead of copying a snippet.
- Automatic pausing. The ticker stops when the page becomes hidden and resumes when it is visible again, so a background tab does not keep burning CPU on your update logic.
- Rate control.
maxFPS,minFPS,start(), andstop()let you cap or halt the loop without touching your callbacks.
A worked example: constant speed in pixels per second
This runs in the browser after an Application and a Sprite exist. The callback signature shown is the v8 style, where the ticker instance is passed in; in v7 and earlier the callback receives (delta, deltaMS) as arguments instead.
// PixiJS v8, browser. Assumes `app` and `sprite` are already created.
const velocityX = 120; // pixels per second
const MAX_STEP_SECONDS = 1 / 15; // cap one step at ~66 ms of motion
function moveSprite(ticker) {
const seconds = Math.min(ticker.deltaMS / 1000, MAX_STEP_SECONDS);
sprite.x += velocityX * seconds;
if (sprite.x - sprite.width / 2 > app.screen.width) {
sprite.x = -sprite.width / 2; // wrap around
}
}
app.ticker.add(moveSprite);
Two things in that snippet matter more than they look. First, the unit conversion happens once, at the top: velocity is authored in pixels per second, and the frame's contribution is derived from deltaMS. Second, the Math.min cap means a long stall — a garbage collection pause, a heavy layout, a debugger breakpoint — cannot teleport the sprite across the screen in a single step.
Cleanup is manual, and it is where leaks come from
The ticker holds a reference to every registered callback. Destroying a sprite, a scene, or an entire game object does not unregister anything, so the callback keeps running against a dead object and keeps that object's closure alive.
Register a named function so you can remove it later, and call app.ticker.remove(moveSprite) from the same place you tear down the object. An inline arrow function cannot be removed, because you no longer hold a reference to the exact function you passed in.
Where delta-time stops being enough
- It is not a fixed timestep. Frame-rate independent motion is fine for positions and timers, but physics or collision resolution that assumes a constant step will drift and can tunnel through thin colliders at low frame rates. Use an accumulator that consumes fixed-size steps inside the ticker callback.
- PixiJS clamps elapsed time. The reported delta is bounded by the ticker's
minFPSsetting, so a long stall produces a truncated step rather than an enormous one. That clamp is a safety net, not a substitute for your own cap — and it means a stall makes the simulation fall behind real time rather than catch up. - Everything runs on the main thread. Expensive work in a listener delays rendering for that frame. Pathfinding, large sorts, and JSON parsing belong in a worker or spread across frames, not in the update callback.
- Priority is ordering, not scheduling. A high-priority listener still runs to completion before the next one starts; it does not preempt anything.
How to check it actually works
- Log the timing values for a few seconds:
app.ticker.add((t) => console.log(t.deltaMS.toFixed(2), t.FPS.toFixed(1))). At 60 FPS,deltaMSshould sit near 16.7 andFPSnear 60. Property names follow v8; confirm against your version's typings. - Cap the loop with
app.ticker.maxFPS = 30and watch the sprite. It should still cross the screen in roughly the same wall-clock time — screen width divided by 120 seconds in the example above — whiledeltaMSroughly doubles. SetmaxFPSback to0to remove the cap. - Remove the listener and confirm the sprite stops. If it keeps moving, you registered a second callback somewhere, or you registered on
Ticker.sharedrather thanapp.ticker. - If you are unsure which instance you are on, log
app.ticker === Ticker.sharedonce at startup.
The practical rule: one update function per object, velocity expressed in units per second, a cap on the maximum step, and a matching remove call wherever that object is destroyed. That covers the majority of Ticker-related bugs before they reach a device with a different refresh rate than yours.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.