Skip to content

Level: add level.loadAsync(), and replace the 2011-era setTimeout deferral with a microtask #1646

Description

@obiot

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:

if (wasRunning) {
	state.stop();
	setTimeout(() => {
		safeLoadLevel(levelId, options, true);
	});
}

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:

function scheduleLoad(levelId, options, restart) {
	return Promise.resolve().then(() => safeLoadLevel(levelId, options, restart));
}

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:

const userOnLoaded = settings.onLoaded;
settings.onLoaded = function (levelId) {
	vp.addCameraEffect(new FadeEffect(vp, { direction: "out",}));  // reveal
	if (typeof userOnLoaded === "function") userOnLoaded.call(this, levelId);
};
const onComplete = () => { level.load(gotolevel, settings); };

With a promise the wrap-and-restore disappears and the user's onLoaded is left alone:

const onComplete = async () => {
	await level.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 it
	const vp = app.viewport;
	vp.addCameraEffect(new FadeEffect(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:

await loader.preload(assets);    // already awaitable
await level.loadAsync("map1");

Scope notes

  • 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.
  • Related: Renderer: shader and pipeline compilation stalls the first frames of a 3D scene #1644 wants a completion point to await a renderer warm-up at. That is a consumer of this, not a reason for it — the warm-up also works synchronously without any of this.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions