This was a big refactor day. After three days of chasing initialization race conditions (scripts running before assets exist, backpack code running before the player spawns, duplicate execution in production), it was clear that patching individual cases wasn’t going to scale. We needed a real solution.

The problem

Every asset in the framework needs certain things to exist before it can initialize:

  • The Scoreboard needs the GUI layer to be ready
  • The RoastingStick needs the Backpack subsystem to be running
  • The Orchestrator needs all game assets to be registered

We’d been solving this ad-hoc — a WaitForChild here, a repeat wait() until there, a _G guard at the top. It worked, barely, and every new asset meant a new potential race condition. The re-entry guards from yesterday were the last straw.

The solution: Stage-Wait

We built a boot system with named stages that execute in a guaranteed order:

  1. Core — Framework internals
  2. Subsystems — Player, Backpack, GUI
  3. Assets — All gameplay assets
  4. Ready — Game is fully initialized

Every asset declares which stage it needs to wait for, and the System ensures that stage is complete before calling the asset’s init(). No more guessing. No more WaitForChild.

The key difference from ad-hoc waiting: stages are barriers, not timeouts. A stage doesn’t complete until every module in that stage has finished initializing. If the Player subsystem takes 2 seconds to set up, every asset waiting on Subsystems blocks until it’s done. No race, guaranteed.

This pattern is common in server frameworks (think Spring’s @DependsOn or systemd’s After=), but we hadn’t seen it applied to Roblox game initialization. Most Roblox games either use flat scripts (every script is independent) or a single entry point with manual ordering. The stage system gives us dependency resolution without requiring scripts to know about each other.

The migration

The bulk of the day was migrating every existing asset to use the new system. One by one: RoastingStick, Dispenser, ZoneController, TimedEvaluator, GlobalTimer, Scoreboard, LeaderBoard, Orchestrator, MessageTicker. Each one got a waitForStage declaration and had its manual waiting logic removed.

It was methodical, not glamorous — nine modules, same pattern each time. But after the migration, we deleted about 40 lines of scattered WaitForChild and repeat wait() hacks, removed every _G re-entry guard, and the game booted cleanly every time. In Studio, in production, in Team Test. No more “it works on my machine.”

We also added a configurable Debug logging system at the end of the day — because when you’re debugging boot order, you need to see exactly what’s initializing and when. Each stage logs its start and completion, and individual modules can opt into verbose logging with a config flag.