SvitsGames

Phaser drops pointerup if you release off-canvas

Short version: Phaser emits pointerup only when pointer.upElement is the game canvas, and pointerupoutside in every other case. If you listen to the first alone, a drag that ends past the edge of the canvas doesn’t fail — it freezes. The handler that would have resolved the gesture is also the handler that clears its visual state, so neither happens.

Bind both events to one handler. It’s two lines, and Phaser’s own source comment at that branch says it is deliberately dispatching the outside case rather than discarding it — so the event you want is already being sent, under a name you haven’t subscribed to.

The symptom: a stroke that froze instead of failing

Cook Line asks you to draw a stroke across a grid of tiles to plate an order. Press, drag across tiles, release, and the path either pays or it doesn’t.

Sometimes it did neither. The tiles stayed popped up in their selected state, the ribbon connecting them stayed drawn on screen, and nothing resolved. The game hadn’t crashed — the timer kept running, orders kept expiring — it just sat there holding a gesture that was already over. Pressing again cleared it and everything worked normally from there.

That last detail is what makes it miserable to reproduce. The next press wipes the evidence, so by the time you’ve noticed something looked wrong and gone looking, the state is gone. It reads as a rendering glitch rather than a dropped event.

The routing: one comparison decides the event name

Phaser 4.1.0 decides between the two events with a single equality check, at the end of InputPlugin.processUpEvents:

//  If they released outside the canvas, but pressed down inside it, we'll still dispatch the event.
if (!_eventData.cancelled && this.isActive())
{
    if (pointer.upElement === this.manager.game.canvas)
    {
        this.emit(Events.POINTER_UP, pointer, currentlyOver);
    }
    else
    {
        this.emit(Events.POINTER_UP_OUTSIDE, pointer);
    }
}

upElement is set straight from the DOM event’s target — this.upElement = event.target in Pointer.up. So it isn’t asking whether the pointer is over the canvas, or whether the gesture began there. It’s asking which element the browser considered the target of that specific pointerup.

Read the comment on the first line. Phaser is not losing the event and it isn’t being careless: it knows you pressed inside and released outside, and it deliberately dispatches something. It just dispatches something with a different name, and you have to have subscribed to that name.

This is a reasonable design. pointerup genuinely means “released on the canvas,” and plenty of code wants exactly that. The cost is that the more common case on desktop gets the longer name, and nothing tells you the short name has a condition attached.

Why it freezes rather than drops

A dropped event usually means an action doesn’t happen. Here it meant the action didn’t happen and the interface stayed mid-action, which is a worse failure and worth understanding structurally.

Our handler does both jobs:

private handlePointerUp(): void {
  if (!this.acceptingInput || this.selectedTiles.length === 0) return;
  // A stray tap isn't a failed attempt — clear it silently. The reject buzz is
  // reserved for a deliberate 2+ tile path that matched nothing.
  if (this.selectedTiles.length < 2) { this.clearSelection(); return; }
  this.onSelectionResolved(this.selectedTiles.map(/* ... */));
}

Every exit from a stroke runs through this function. Resolving the path, and returning the tiles to their unselected look, are the same code path — because in every case anyone had thought about, a stroke ends exactly once and both things should happen together.

So when the function never runs, both halves hang. selectedTiles still holds the tiles, so they stay popped and the ribbon stays drawn. And the only other thing that touches that array is handlePointerDown, which calls clearSelection() before starting a new stroke — which is precisely why the next press appears to fix it.

The lesson generalises past Phaser: if one function both commits a transaction and tears down the UI for it, then any path that skips the function leaves the UI asserting something the model doesn’t believe. Those two jobs look like they belong together right up until an event routes around them.

For the player, none of that is visible. What they experience is a stroke they drew and paid attention to that simply didn’t count, while the clock ran.

The fix: bind both to one handler

scene.input.on('pointerup', this.handlePointerUp, this);
scene.input.on('pointerupoutside', this.handlePointerUp, this);

Same handler, no branching. The handler doesn’t need to know which event brought it, because the decision — resolve the stroke — is the same either way.

Resolving rather than cancelling is also the honest reading of the interaction. The ribbon already told the player green or red before they let go, so resolving on release gives them what the screen promised. Cancelling because the cursor crossed an invisible boundary would be the surprising behaviour.

It also isn’t an obscure case, for a structural reason rather than a measured one: the gesture is a drag with no upper bound on velocity, and the canvas has an edge. Any stroke whose last tile is on the top row can overshoot it, and on desktop the game is served in an iframe that a player has to actively choose to fullscreen — so the overshoot lands on the portal’s own page rather than on anything of ours. I haven’t instrumented how often that happens in real sessions, so I’ll claim only the shape and not a frequency.

Don’t reach for Pointer Lock to keep the cursor in. It replaces absolute coordinates with deltas and hides the real cursor, and this mechanic is built on absolute samples resolved against tile centres — locking would break the thing it was meant to protect. Honouring the release is the whole fix.

The hidden dependency: input.windowEvents

pointerupoutside only fires because Phaser is listening on the window, not just the canvas. That’s controlled by a config flag that defaults to on, in Config.js:

/**
 * @const {boolean} Phaser.Core.Config#inputWindowEvents - Should Phaser listen
 * for input events on the Window? If you disable this, events like
 * 'POINTER_UP_OUTSIDE' will no longer fire.
 */
this.inputWindowEvents = GetValue(config, 'input.windowEvents', true);

Credit where it’s due — Phaser documents the consequence in the line above the default. But you have to be reading Config.js to find it, and nobody reads Config.js except to turn something off.

Which is the trap. input: { windowEvents: false } looks like a tidy little optimisation: two fewer global listeners, and the game only cares about its own canvas anyway. Set it, and every off-canvas release stops being delivered. Nothing errors. The stroke-freezing bug comes back with no visible connection to the flag you flipped, in a commit whose message says something about reducing listeners.

So the comment in our game config is longer than the config:

// `input.windowEvents` is left at its default of true and must stay there.
// It is what makes Phaser listen for pointer events on the *window* rather
// than only the canvas, and therefore the only reason `pointerupoutside`
// ever fires.

A default you depend on is a dependency. It just doesn’t appear in any file as one, which means the only place it can be recorded is a comment at the point where someone would change it.

Why the fix didn’t propagate from where it already existed

Here’s the part that stings.

SettingsScene had already paired both events, for its volume sliders, so a drag that strayed off the slider track didn’t drop the value:

this.input.on('pointerup', this.handlePointerUp, this);
this.input.on('pointerupoutside', this.handlePointerUp, this);

That landed on 2026-07-28. The same pairing reached GridManager — the core mechanic of the entire game — on 2026-07-30, thirty commits later.

So the knowledge was in the repository the whole time, applied correctly, two days early, in a menu. The primary interaction went without it.

I don’t think that’s carelessness, and I’ve stopped treating it as such. The slider fix was made while thinking about sliders. Nothing about writing it prompts the question “where else does a gesture end?” — the fix felt local because the bug was found locally, and a fix that feels local doesn’t generate a search of the codebase.

What would have caught it isn’t more diligence. It’s the habit of asking, when a fix goes in, whether the thing you just learned is about this component or about the framework. “Sliders should keep tracking off-track” is a component fact. “Phaser splits release events by target element” is a framework fact, and framework facts apply everywhere by default. Those get written down somewhere central or they get rediscovered.

Ours is now a rule in the project notes rather than a comment in one scene, which is the only reason the next scene will get it for free.

What I took from this

Two events with almost the same name, where the shorter one is the special case and the longer one is the default, is a naming pattern worth being suspicious of generally. If an API offers you x and xOutside, find out which one your users actually generate before assuming x is the common path. Here the answer on desktop was the long one.

And a bug that leaves the interface mid-gesture is telling you that commit and cleanup share a code path. That’s usually fine and occasionally exactly the thing that turns a missed event into a frozen screen.

All of this is Phaser 4.1.0 specifically — pinned in the game’s package.json, and every snippet is from the v4.1.0 tag. Re-test before copying it onto a newer version.

The same engine has caught us four times more in other subsystems: the volume setter that looks like a no-op, Scale.RESIZE and device pixels, the canvas sized mid-rotation, and the scene instance reused across restart(). Different shape from this one — those were wrong diagnoses of real evidence, where this was an event we never subscribed to.