๐ Lesson 1.5: Mini-Project โ A Game Bootstrapper with a Service Container
Time to make the whole module click into one thing you'll reuse in every serious project: a bootstrapper. It's the game's front door โ a tiny init scene whose only job is to stand up the systems the game needs, in the right order, before anything gameplay-related runs. You'll build a service container (Lesson 1.2), register IAudioService, ISaveService, and IInputService behind interfaces, initialize them in order and asynchronously with Awaitable (Lesson 1.3), and only then load the main scene. It sits in its own assembly (Lesson 1.1) and is a textbook composition root. This is the pattern studios open a codebase with.
๐ฏ What You'll Build
By the end of this build-along, you will have:
- A reusable
ServiceContainerthat registers and resolves services behind interfaces - Three services โ
IAudioService,ISaveService,IInputServiceโ each with an asyncInitializeAsync - A
BootstrapperMonoBehaviourin a dedicated init scene that wires and initializes everything in a defined order - An async boot sequence that awaits each service, then loads the main scene โ with cancellation wired to the destroy token
- A clear picture of how architecture, DI, and async combine into a real startup flow
Estimated Time: 75 minutes ยท Prerequisite: All of Module 1 โ assemblies (1.1), DI (1.2), Awaitable (1.3), patterns (1.4)
In This Lesson
Why a Bootstrapper?
Without a bootstrapper, initialization scatters. Each system wakes up in its own Awake/Start, in an order Unity decides (and you fight with the Script Execution Order settings), each grabbing its own dependencies via singletons. Load order bugs, "the save system wasn't ready yet" null references, and half-initialized state follow.
A bootstrapper fixes this by making startup explicit and ordered. One object, running first in a dedicated scene, does the whole dance: create the services, initialize them in the sequence they depend on, and hand control to the game only when everything is ready. It's the runtime embodiment of the composition root from Lesson 1.2 โ the single place that knows concrete types and wires the object graph.
๐ The init-scene pattern
The init scene (or "boot scene") is a nearly empty scene containing just the bootstrapper. It's the first scene in Build Settings, so it always runs before gameplay. Because services are created here and marked DontDestroyOnLoad, they survive the load into the main scene โ giving you a clean, deterministic startup every run.
The Boot Sequence at a Glance
Before the code, the shape of what we're building. The bootstrapper walks left to right: assemble the container, register services behind interfaces, initialize them asynchronously in order, then load the main scene.
Step 1: The Container & Interfaces
Start with the container. This is the Lesson 1.2 container, trimmed to what the bootstrapper needs โ register a shared instance, resolve it later.
using System;
using System.Collections.Generic;
namespace Studio.Core
{
public class ServiceContainer
{
private readonly Dictionary<Type, object> _services = new();
public void Register<TInterface>(TInterface instance)
=> _services[typeof(TInterface)] = instance;
public TInterface Resolve<TInterface>()
{
if (_services.TryGetValue(typeof(TInterface), out var s))
return (TInterface)s;
throw new InvalidOperationException(
$"No service registered for {typeof(TInterface).Name}");
}
}
}
Now the three service contracts. Each has an async InitializeAsync so startup work (loading an audio bank, reading a save file, binding input) can happen without blocking, and takes a CancellationToken so a cancelled boot unwinds cleanly.
using System.Threading;
using UnityEngine;
namespace Studio.Core
{
public interface IGameService
{
Awaitable InitializeAsync(CancellationToken token);
}
public interface IAudioService : IGameService
{
void Play(string clipId);
void SetVolume(float volume);
}
public interface ISaveService : IGameService
{
void Save(string key, string value);
string Load(string key);
}
public interface IInputService : IGameService
{
Vector2 MoveAxis { get; }
bool JumpPressed { get; }
}
}
A shared IGameService base gives every service the same InitializeAsync shape, which lets the bootstrapper treat them uniformly. All of this lives in Studio.Core โ the bottom assembly from Lesson 1.1 that everything else depends down on.
Step 2: The Service Implementations
Now concrete implementations. They're deliberately simple โ the point is the shape, especially how each InitializeAsync does real async work and honors the token. Note the audio and save services do a brief background hop or frame-yield to model genuine startup latency.
using System.Threading;
using UnityEngine;
using Studio.Core;
namespace Studio.Services
{
public class AudioService : IAudioService
{
public async Awaitable InitializeAsync(CancellationToken token)
{
// Simulate loading an audio bank off the main thread.
await Awaitable.BackgroundThreadAsync();
System.Threading.Thread.Sleep(50); // stand-in for real I/O
await Awaitable.MainThreadAsync();
token.ThrowIfCancellationRequested();
Debug.Log("[Audio] ready");
}
public void Play(string clipId) => Debug.Log($"[Audio] play {clipId}");
public void SetVolume(float volume) { /* set mixer volume */ }
}
public class FileSaveService : ISaveService
{
public async Awaitable InitializeAsync(CancellationToken token)
{
// Simulate reading a save file across a couple of frames.
await Awaitable.NextFrameAsync(token);
await Awaitable.NextFrameAsync(token);
Debug.Log("[Save] ready");
}
public void Save(string key, string value)
=> PlayerPrefs.SetString(key, value);
public string Load(string key)
=> PlayerPrefs.GetString(key, string.Empty);
}
public class InputService : IInputService
{
public Vector2 MoveAxis { get; private set; }
public bool JumpPressed { get; private set; }
public async Awaitable InitializeAsync(CancellationToken token)
{
// Enable the Input System action maps here, then yield once.
await Awaitable.NextFrameAsync(token);
Debug.Log("[Input] ready");
}
}
}
These sit in a Studio.Services assembly that references Studio.Core โ services depend down on the interfaces, exactly the dependency direction from Lesson 1.1.
๐ก Order matters, and it's explicit. Because eachInitializeAsyncisawaited one after another, save can safely assume audio is up, and input can assume both are. If save depended on audio's mixer being ready, this ordering guarantees it. Ordering initialization is one of the main reasons a bootstrapper exists.
Step 3: The Bootstrapper
The centerpiece. It's a MonoBehaviour in the init scene that creates the container, registers the services, initializes them in order with Awaitable, exposes the container to the rest of the game, and loads the main scene. It also survives the load (DontDestroyOnLoad) and threads the destroy token through every await.
using System.Threading;
using UnityEngine;
using UnityEngine.SceneManagement;
using Studio.Core;
using Studio.Services;
namespace Studio.Boot
{
public class Bootstrapper : MonoBehaviour
{
[SerializeField] private string _mainSceneName = "Main";
// The composed container, made available to the rest of the game.
public static ServiceContainer Services { get; private set; }
async void Awake()
{
// This object (and its services) must survive the scene load.
DontDestroyOnLoad(gameObject);
try
{
await BootAsync(destroyCancellationToken);
}
catch (System.OperationCanceledException)
{
// Expected if the app quits mid-boot โ swallow quietly.
}
catch (System.Exception e)
{
Debug.LogException(e); // surface real boot failures
}
}
private async Awaitable BootAsync(CancellationToken token)
{
var container = new ServiceContainer();
// 1) Create the concrete services.
var audio = new AudioService();
var save = new FileSaveService();
var input = new InputService();
// 2) Register each behind its interface.
container.Register<IAudioService>(audio);
container.Register<ISaveService>(save);
container.Register<IInputService>(input);
// 3) Initialize IN ORDER โ each finishes before the next starts.
await audio.InitializeAsync(token);
await save.InitializeAsync(token);
await input.InitializeAsync(token);
// 4) Publish the container, then hand off to the game.
Services = container;
Debug.Log("[Boot] all services ready โ loading main scene");
await SceneManager.LoadSceneAsync(_mainSceneName);
}
}
}
Read the four numbered blocks against Figure 1 โ they're the same four stages. Everything downstream now reaches its dependencies through Bootstrapper.Services.Resolve<T>(), or (better) gets them injected by a scene-level installer that reads from that container. A gameplay object, for instance:
using UnityEngine;
using Studio.Core;
using Studio.Boot;
public class Player : MonoBehaviour
{
private IAudioService _audio;
void Start()
{
// Pull the ready-made service the bootstrapper composed.
_audio = Bootstrapper.Services.Resolve<IAudioService>();
}
void Footstep() => _audio.Play("footstep");
}
โ ๏ธ async void Awake โ the sanctioned exception
Awake must return void, so this is one of the legitimate async void entry points from Lesson 1.3 โ and note it does the right thing: the entire body is wrapped in try/catch so no exception escapes silently, and the real work lives in an awaitable BootAsync that can be awaited and cancelled. Never let the boot's exceptions vanish; a failed startup you can't see is the worst kind of bug.
Step 4: Scenes & Running It
Wire it up in the editor:
- Create two scenes:
Boot(nearly empty) andMain(your gameplay scene). - In
Boot, add an empty GameObject named Bootstrapper and attach theBootstrappercomponent. Set Main Scene Name toMain. - Open File โธ Build Settings and add both scenes. Drag
Bootto the top so it loads first (index 0). AddMainbelow it. - Put the scripts in their assemblies: container and interfaces in
Studio.Core, implementations inStudio.Services, the bootstrapper inStudio.Boot(referencing both). This is the Lesson 1.1 layout in miniature. - Press Play from the
Bootscene. Watch the Console:[Audio] readyโ[Save] readyโ[Input] readyโ[Boot] all services ready, then the Main scene loads.
The ordered log is the proof: services come up one at a time, in the sequence you declared, and the game only starts once they're all ready. Stop Play mid-boot and the OperationCanceledException path unwinds it cleanly instead of resuming into a torn-down app.
โ Checkpoint
If your Console shows the four ready-messages in order followed by the Main scene loading, you've built a working composition root with ordered async initialization. That's a genuine, shippable startup architecture โ not a toy.
What You Built & How to Extend It
You assembled every Module 1 idea into one artifact: a bootstrapper that is a composition root (1.2), lives in layered assemblies (1.1), initializes services with Awaitable and a cancellation token (1.3), and is the seed for patterns like an event bus (1.4). The services are swappable behind interfaces, the startup order is explicit, and gameplay code stays ignorant of concrete types.
Ways to grow it
- A loading screen with progress. Have
BootAsyncreport progress (e.g. anIProgress<float>or an event per service) and drive a progress bar in the Boot scene โ trivial now that init is sequential and awaited. - Parallel where safe. Services with no interdependency can init concurrently:
await Awaitable.WhenAll(...)-style patterns (or await several started operations together) shorten boot time while keeping the ordered ones ordered. - Adopt a real DI framework. Swap the hand-rolled container for VContainer: register in a
LifetimeScope, let it auto-inject into MonoBehaviours, and the bootstrapper shrinks. The concepts map one-to-one. - Event-driven readiness. Publish a
ServicesReadyevent on the bus from Lesson 1.4 so any system can react to boot completion without the bootstrapper knowing about it. - Graceful failure. If a service's
InitializeAsyncthrows (corrupt save, missing audio bank), catch it per-service and fall back to a safe default rather than aborting the whole boot.
๐ Module 1, complete. You can now structure a large project, wire it with dependency injection, control time with async/await, apply scaling patterns, and stand it all up from a clean bootstrapper. That's the architectural foundation the rest of Unity Advanced builds performance, rendering, netcode, and tooling on top of.
Hands-on Challenge
๐๏ธ Exercise 1: Add a fourth service with a progress bar
Objective: Extend the boot flow and surface its progress.
- Add an
IContentService : IGameServicewhoseInitializeAsyncyields several frames to simulate loading content. - Register and initialize it in the bootstrapper, in order after input.
- Report progress: after each service completes, update a UI
Sliderin the Boot scene (e.g. 1/4, 2/4, โฆ). HaveBootAsynctake anIProgress<float>and callReportafter each await.
โ Approach
Pass an IProgress<float> into BootAsync; after each await service.InitializeAsync(token), call progress.Report((float)done / total). Because the services init sequentially, the progress values are naturally monotonic. A Progress<float> created on the main thread marshals its callback back to the main thread, so updating the Slider from it is safe.
๐๏ธ Exercise 2: Make boot cancellable and prove it
Add a "cancel boot" path: create a linked CancellationTokenSource (linked to destroyCancellationToken) and cancel it from a debug key. Confirm that cancelling mid-boot (e.g. during the save service's frame-yields) stops the sequence and does not load the main scene, and that the OperationCanceledException is caught quietly rather than logged as an error.
โ Expected behavior
Because each InitializeAsync passes the token into its awaits and/or calls ThrowIfCancellationRequested(), cancelling throws OperationCanceledException at the next suspension point. That unwinds BootAsync before LoadSceneAsync is reached, so the main scene never loads. The bootstrapper's dedicated catch (OperationCanceledException) swallows it โ cancellation is expected, not an error โ while a genuine failure still hits the Debug.LogException branch.
๐ฏ Quick Quiz
Question 1: Why does the bootstrapper live in its own init scene set first in Build Settings?
Question 2: The services are initialized with sequential awaits rather than all at once. What does that guarantee?
Question 3: Why is async void Awake acceptable here when the course warns against async void?
Question 4: How does gameplay code get the audio service after boot?
Summary
๐ Key Takeaways
- A bootstrapper is the game's front door โ a composition root in a first-loaded init scene that stands up systems in a deterministic order.
- It creates a container, registers services behind interfaces (
IAudioService,ISaveService,IInputService), and publishes the container for the rest of the game. - Services are initialized in order with
await service.InitializeAsync(token), so later services can rely on earlier ones โ without blocking the frame loop. - Cancellation flows through
destroyCancellationToken;async void Awakeis the sanctioned entry point, wrapped in try/catch with the real work in an awaitableBootAsync. - The whole thing sits in layered assemblies and is easily extended with progress bars, parallel init, a real DI framework, or event-driven readiness.
๐ What's Next?
Module 1 gave you an architecture that scales. Now we make sure it's fast. Module 2 opens the performance track: in Lesson 2.1: The Profiler in Depth, you'll learn to measure before you optimize โ reading the Profiler window frame by frame to find where time actually goes, so every optimization that follows is aimed at a real bottleneck.
๐ You have a front door
A bootstrapper that composes, orders, and awaits your services, then hands off to the game. Reuse it in every project โ it's the shape a professional Unity codebase starts from.