Reusable Startup
A bootstrap scene initializes app settings, services, optional modules, and the first gameplay scene before the game starts.
Car Dodger is a mobile-first arcade dodger built in Unity. The player avoids incoming traffic, survives as long as possible, and competes for high scores.
The project focuses on clean game flow, mobile readiness, reusable infrastructure, and fast iteration. Startup, scene loading, gameplay, scoring, difficulty, pooling, and UI behaviour are split into smaller systems instead of one large manager.
I used Car Dodger to build more than a one-off game. The project validates reusable Unity infrastructure that can be moved into future projects, especially the Mobile Bootstrap and Scene Flow packages.
A bootstrap scene initializes app settings, services, optional modules, and the first gameplay scene before the game starts.
Vehicles and effects are pooled and reused to reduce allocation spikes and garbage collection pressure during gameplay.
Gameplay events, scoring rules, and difficulty changes are handled through focused systems instead of direct script dependencies.
These embedded snippets show the engineering decisions behind the project: reusable startup architecture, editor tooling, mobile-conscious spawning, and decoupled score and difficulty systems.
A persistent AppRoot builds service registrations, creates a startup plan, runs phases in order, and then enters the first gameplay scene.
private async void Awake()
{
if (startupStarted)
return;
startupStarted = true;
DontDestroyOnLoad(gameObject);
ServiceRegistry services = BuildDefaultServices();
IReadOnlyList<IMobileBootstrapModule> resolvedModules = ResolveModules();
RegisterModuleServices(resolvedModules, services);
StartupContext context = new(this, config, services);
StartupPlan plan = BuildPlan(resolvedModules);
StartupCoordinator coordinator = new();
await coordinator.RunAsync(plan, context);
}
private StartupPlan BuildPlan(IReadOnlyList<IMobileBootstrapModule> resolvedModules)
{
StartupPlan plan = new();
plan.Add(StartupPhase.CriticalBoot, new ConfigureApplicationStep());
plan.Add(StartupPhase.CriticalBoot, new InitializeSaveStep());
plan.Add(StartupPhase.CriticalBoot, new LoadSettingsStep());
plan.Add(StartupPhase.RequiredServices, new InitializeAudioStep());
plan.Add(StartupPhase.RequiredServices, new ApplySettingsStep());
if (config.LoadFirstSceneOnStartup)
plan.Add(StartupPhase.EnterGame, new LoadFirstSceneStep());
return plan;
}
The editor window tracks important scenes, exposes additive open/close controls, and can force Play Mode to start from the bootstrap scene.
private void DrawPlayModeSection()
{
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
EditorGUILayout.LabelField("Play Mode", EditorStyles.boldLabel);
EditorGUI.BeginChangeCheck();
bool playFromSpecificScene = EditorGUILayout.Toggle(
"Play From Specific Scene",
config.PlayFromSpecificScene);
SceneAsset playModeStartScene = (SceneAsset)EditorGUILayout.ObjectField(
"Start Scene",
config.PlayModeStartScene,
typeof(SceneAsset),
false);
if (EditorGUI.EndChangeCheck())
{
Undo.RecordObject(config, "Update Scene Flow Play Mode");
config.PlayFromSpecificScene = playFromSpecificScene;
config.PlayModeStartScene = playModeStartScene;
EditorUtility.SetDirty(config);
SceneFlowEditorUtility.ApplyPlayModeStartScene(config);
}
EditorGUILayout.EndVertical();
}
private void SetPlayModeStartScene(SceneAsset sceneAsset)
{
Undo.RecordObject(config, "Set Scene Flow Start Scene");
config.PlayFromSpecificScene = true;
config.PlayModeStartScene = sceneAsset;
EditorUtility.SetDirty(config);
SceneFlowEditorUtility.ApplyPlayModeStartScene(config);
}
Vehicles are prewarmed and reused. The spawner requests objects from the pool, checks lane safety, applies spawn state, and avoids unnecessary runtime allocation.
public void WarmPool(GameObject prefab, int count)
{
string key = prefab.name;
if (!poolDictionary.ContainsKey(key))
poolDictionary[key] = new Queue<GameObject>();
for (int i = 0; i < count; i++)
{
GameObject obj = Instantiate(prefab);
obj.name = key;
obj.SetActive(false);
poolDictionary[key].Enqueue(obj);
}
}
private void SpawnVehicleAtLane(Transform sp)
{
GameObject prefab = vehicles[Random.Range(0, vehicles.Length)];
GameObject obj = ObjectPooler.Instance.SpawnFromPool(
prefab,
sp.position,
prefab.transform.rotation);
Vehicle vehicle = obj.GetComponent<Vehicle>();
if (vehicle == null)
return;
float speed = Random.Range(minVehicleSpeed, maxVehicleSpeed);
vehicle.ApplySpawnState(speed);
}
private void EnsurePoolPrewarmed()
{
if (hasPrewarmed || ObjectPooler.Instance == null || vehicles == null)
return;
for (int i = 0; i < vehicles.Length; i++)
ObjectPooler.Instance.WarmPool(vehicles[i], warmPerPrefab);
hasPrewarmed = true;
}
Gameplay systems publish events without knowing who consumes them. Score rules and difficulty stages react to those signals through dedicated managers.
public static void Publish(in GameplayEvent gameplayEvent)
{
if (listeners.Count == 0)
return;
isDispatching = true;
for (int i = 0; i < listeners.Count; i++)
listeners[i].OnGameplayEvent(in gameplayEvent);
isDispatching = false;
FlushPendingChanges();
}
public void OnGameplayEvent(in GameplayEvent gameplayEvent)
{
if (!TryGetRule(gameplayEvent.Type, out ScoreRule rule))
return;
scoreManager.AddPoints(
rule.points,
rule.reason,
rule.presentation,
gameplayEvent.WorldPosition,
rule.presentation == ScoreChangePresentation.PopupToScore,
gameplayEvent.Intensity);
}
private void ApplyToSpawners(DifficultyStage stage)
{
foreach (var spawner in spawners)
{
if (!spawner)
continue;
spawner.SetVehicleSpeedRange(stage.minVehicleSpeed, stage.maxVehicleSpeed);
spawner.SetWaveInterval(stage.spawnIntervalSeconds);
}
}