You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Two related changes to how a level load is scheduled and observed. Both are additive: no existing call form changes behaviour, and no shipped type changes.
1. The deferral is a 2011 artefact
level.load() stops the loop and defers the real work:
That comment and its timer trace back to 2011-07-08 (v0.9.0), where it read "pause the game loop to avoid some silly side effects". Promises are ES2015 and were not practical until roughly 2016; queueMicrotask arrived in 2018. So the timer was not chosen over a microtask — it was the only deferral primitive that existed. There is no macrotask semantic here worth preserving.
The deferral itself is still needed.renderable/trigger.js:228 calls level.load() from a trigger handler, inside the game loop, potentially mid-iteration over the very container safeLoadLevel then calls .reset() on and destroys. state.stop() sets a flag but does not unwind the current frame. A microtask drains when the JS stack empties — the end of the rAF callback containing update and draw — so it unwinds the frame exactly as the timer does.
Swapping it also fixes a latent issue: setTimeout is clamped to ≥1000 ms in background tabs in every major browser, so a level load queued as the tab hides sits behind that clamp. A microtask has no clamp. The only thing the macrotask buys — yielding to the browser to paint once before the heavy work — is worth nothing here, because state.stop() has already halted the loop and level.load() does not switch to the loading state.
2. level.loadAsync()
Once the deferral is promise-shaped internally, the completion point exists and can simply be returned:
load() keeps return true and its boolean type; loadAsync() returns that same promise instead of discarding it.
Why a sibling rather than changing load()'s return. A Promise is truthy, so if (me.level.load("x")) would survive — but build/level/level.d.ts declares function load(levelId: string, options?: {…}): boolean, and this package ships its types. A TypeScript game doing const ok: boolean = me.level.load("x") stops compiling. reload(), next() and previous() delegate to load() and carry the same declaration. There is no single convention for this across asset-loading APIs — libraries designed Promise-first return the promise from load() directly, while those with an established non-Promise return add a sibling — and we are in the second situation. loader.preload() is already Promise-returning, so the engine ends up with both shapes, which is the price of not breaking typed consumers.
The engine is already working around not having it.trigger.js:202 sequences fade-out → load → fade-in by monkey-patching the caller's own callback:
With a promise the wrap-and-restore disappears and the user's onLoaded is left alone:
constonComplete=async()=>{awaitlevel.loadAsync(gotolevel,settings);// re-read AFTER the load: `game.reset()` reassigns `app.viewport`// (application.ts:785), which is exactly why the wrapped callback// above reads it inside the callback rather than capturing itconstvp=app.viewport;vp.addCameraEffect(newFadeEffect(vp,{direction: "out", … }));};
That detail is load-bearing: the callback this replaces re-reads app.viewport at trigger.js:174-175 with the comment "re-read viewport after game.reset reassigns it". A refactor that captures the viewport before the load reintroduces a stale-viewport bug, so the promise form has to re-read it too — it removes the callback interception, not the need to read late.
And it completes the game-side sequence, which is already half promise-shaped since #1512:
The engine's preloader does not load levels and should not: loader.js and loadingscreen.js have no level references at all. Assets first, level after, by game code.
Internal callers of level.load() are exactly two, both in renderable/trigger.js (lines 202 and 228), and both ignore the return value today.
Failures should reject rather than throw asynchronously, so await can catch them. The existing synchronous throw for an unknown level id should stay synchronous — that is a programmer error, not a load failure.
Two related changes to how a level load is scheduled and observed. Both are additive: no existing call form changes behaviour, and no shipped type changes.
1. The deferral is a 2011 artefact
level.load()stops the loop and defers the real work:That comment and its timer trace back to 2011-07-08 (v0.9.0), where it read "pause the game loop to avoid some silly side effects". Promises are ES2015 and were not practical until roughly 2016;
queueMicrotaskarrived in 2018. So the timer was not chosen over a microtask — it was the only deferral primitive that existed. There is no macrotask semantic here worth preserving.The deferral itself is still needed.
renderable/trigger.js:228callslevel.load()from a trigger handler, inside the game loop, potentially mid-iteration over the very containersafeLoadLevelthen calls.reset()on and destroys.state.stop()sets a flag but does not unwind the current frame. A microtask drains when the JS stack empties — the end of the rAF callback containing update and draw — so it unwinds the frame exactly as the timer does.Swapping it also fixes a latent issue:
setTimeoutis clamped to ≥1000 ms in background tabs in every major browser, so a level load queued as the tab hides sits behind that clamp. A microtask has no clamp. The only thing the macrotask buys — yielding to the browser to paint once before the heavy work — is worth nothing here, becausestate.stop()has already halted the loop andlevel.load()does not switch to the loading state.2.
level.loadAsync()Once the deferral is promise-shaped internally, the completion point exists and can simply be returned:
load()keepsreturn trueand itsbooleantype;loadAsync()returns that same promise instead of discarding it.Why a sibling rather than changing
load()'s return. A Promise is truthy, soif (me.level.load("x"))would survive — butbuild/level/level.d.tsdeclaresfunction load(levelId: string, options?: {…}): boolean, and this package ships its types. A TypeScript game doingconst ok: boolean = me.level.load("x")stops compiling.reload(),next()andprevious()delegate toload()and carry the same declaration. There is no single convention for this across asset-loading APIs — libraries designed Promise-first return the promise fromload()directly, while those with an established non-Promise return add a sibling — and we are in the second situation.loader.preload()is already Promise-returning, so the engine ends up with both shapes, which is the price of not breaking typed consumers.The engine is already working around not having it.
trigger.js:202sequences fade-out → load → fade-in by monkey-patching the caller's own callback:With a promise the wrap-and-restore disappears and the user's
onLoadedis left alone:That detail is load-bearing: the callback this replaces re-reads
app.viewportattrigger.js:174-175with the comment "re-read viewport after game.reset reassigns it". A refactor that captures the viewport before the load reintroduces a stale-viewport bug, so the promise form has to re-read it too — it removes the callback interception, not the need to read late.And it completes the game-side sequence, which is already half promise-shaped since #1512:
Scope notes
loader.jsandloadingscreen.jshave no level references at all. Assets first, level after, by game code.level.load()are exactly two, both inrenderable/trigger.js(lines 202 and 228), and both ignore the return value today.awaitcan catch them. The existing synchronousthrowfor an unknown level id should stay synchronous — that is a programmer error, not a load failure.