Quick answer: Loading screens race because at least three producers (scene graph, asset manager, UI animation) complete on different ticks and threads. Replace ad-hoc flags with a single explicit state machine, await every producer, add a deadline, and inject artificial delay per producer to reproduce the bug deterministically.
Loading-screen bugs are the most embarrassing flavor of race condition. They happen in front of a freshly-installed player, on a hero animation, with nothing else on screen to hide the glitch. The symptoms read like a haunted house: the player-character spawns under the floor, the HUD pops in two frames late, the skybox flashes black for a single frame. These are almost always the same bug — the loading screen unblocked before one of its upstream producers was actually ready. This post walks through how to find the race, how to structure your code to prevent the next one, and how to build a reproducer.
List Every Producer
Start by writing down every async event your loading screen waits on. For a typical 3D game this is longer than you think:
Scene-graph ready (the scene tree is instantiated but children’s _ready may not have all run), asset-manager ready (textures, meshes, and shader variants are GPU-resident), audio-bank loaded (Wwise or FMOD report the bank streaming in), savegame parsed (the player’s last position is decoded), localization ready (the string table for the current locale is in memory), and UI animation complete (the spinner fade-out finished on tick N+k, not tick N). Miss any one of these and you have a race.
The common failure mode is waiting only on scene_loaded. The scene is loaded; the assets bound to the scene are still streaming. You proceed, and the player sees gray textures for two seconds while the streaming catches up.
Model an Explicit State Machine
Replace every loose boolean flag with a single state object. The object tracks each producer’s status (pending, ready, or failed) and only advances when all required producers report ready.
class LoadingState:
def __init__(self, producers):
self.producers = {p: "pending" for p in producers}
self.started_at = time.monotonic()
def report(self, name, status):
assert name in self.producers, f"unknown producer {name}"
self.producers[name] = status
log.info("loading.report", producer=name, status=status)
def is_ready(self):
return all(s == "ready" for s in self.producers.values())
def stragglers(self):
return [n for n, s in self.producers.items() if s != "ready"]
Two properties fall out of this shape. First, every producer is named, which means your logs tell you exactly which one held up the handoff. Second, adding a new producer is one-line safe: you list it in the constructor, you assert in report, and the compiler catches you if you forget to wire it up.
Await All, Not One
The calling code becomes a single await-all. In async frameworks this is Promise.all or asyncio.gather; in a game engine it is a yield-until-state-ready loop on the main tick.
async def load_level(level_id):
state = LoadingState(["scene", "assets", "audio", "save", "ui"])
await asyncio.gather(
load_scene(level_id, state),
load_assets(level_id, state),
load_audio_bank(level_id, state),
parse_savegame(state),
play_ui_intro(state),
)
assert state.is_ready(), f"stragglers: {state.stragglers()}"
start_gameplay()
Do not let any producer skip reporting. The most common bug I see is a producer that returns synchronously on the fast path and forgets to call state.report(name, "ready"). The handoff waits forever. Add a test that runs every producer and asserts the state is ready after each one.
Add a Deadline
Real hardware misbehaves. A player’s disk is spinning at 30 MB/s, their audio middleware hit a streaming error, their shader cache is cold. Your loading screen cannot wait forever. Attach a deadline to the whole operation (30 seconds for most games) and when it expires, log every straggler and ship anyway with fallback assets.
Fallback assets means: a low-resolution texture stand-in, silent audio, default UI. The player sees a slightly less polished game instead of an infinite spinner. Every fallback emits a telemetry event so you can see in aggregate which producers time out most often — that is the list of subsystems to optimize next sprint.
Reproduce the Race On Purpose
Races hide on fast machines. To reproduce them, inject artificial delay. Add a developer flag per producer: --slow-assets=800ms, --slow-scene=200ms, --slow-audio=5s. Run through every permutation of which producer finishes last. If the game misbehaves for any order, you have a dependency you did not declare.
This technique also works in CI. Run a matrix job where each run has a different producer slowed down. It catches regressions where someone adds a new producer but does not wire it into the await-all.
Log the Timeline
The single most useful debugging aid for loading-screen races is a structured timeline log. Every producer reports start, progress, and completion with a monotonic timestamp. When a player reports a bad load, their log looks like this:
# loading.timeline player=abc123 level=forest
t=0.000 scene start
t=0.004 assets start
t=0.004 audio start
t=0.018 save start
t=0.412 scene ready
t=0.418 ui start
t=0.610 save ready
t=0.780 ui ready
t=2.100 assets ready # slow producer on this run
t=2.100 audio ready
t=2.101 start_gameplay
One glance and you can tell which producer held up the handoff. Ship this log format in your telemetry so you can triage remote reports without asking the player for a repro.
“We had a one-frame pop-in on the HUD that nobody could catch locally. Adding the producer timeline showed the UI animation had completed two frames before the HUD widgets were added to the tree. One line fix — wait on widgets-in-tree, not just UI-animation-finished.”
Related Issues
For a general background on engine-level load flow, read common bug report mistakes and how to avoid them. For how to capture the timeline from a live game, see best practices for game error logging.
If you cannot name every producer your loading screen waits on, you have a race — you just have not seen it yet.