Project Details

Loading Car Dodger

Car Dodger

Unity WebGL build

Project information

  • Engine: Unity 6.2
  • Language: C#
  • Render Pipeline: URP
  • Category: Mobile arcade dodger
  • Platform: iOS / Android / WebGL

Project overview

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.

What I Built

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.

Reusable Startup

A bootstrap scene initializes app settings, services, optional modules, and the first gameplay scene before the game starts.

Mobile Runtime Systems

Vehicles and effects are pooled and reused to reduce allocation spikes and garbage collection pressure during gameplay.

Decoupled Gameplay

Gameplay events, scoring rules, and difficulty changes are handled through focused systems instead of direct script dependencies.

Architecture Highlights

  • Bootstrap scene and gameplay scene separation
  • Reusable mobile startup package
  • Scene Flow editor tooling for safer iteration
  • ScriptableObject-driven difficulty settings
  • Event buses for gameplay and score events
  • Object pooling for vehicles and particles
  • Mobile safe-area handling
  • Unity Leaderboards with local fallback

Readable Code Excerpts

These embedded snippets show the engineering decisions behind the project: reusable startup architecture, editor tooling, mobile-conscious spawning, and decoupled score and difficulty systems.

Mobile Bootstrap Package

Reusable startup pipeline

A persistent AppRoot builds service registrations, creates a startup plan, runs phases in order, and then enters the first gameplay scene.

Read AppRoot excerpt
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;
}
Scene Flow Editor Tool

Tooling to reduce iteration mistakes

The editor window tracks important scenes, exposes additive open/close controls, and can force Play Mode to start from the bootstrap scene.

Read SceneFlowWindow excerpt
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);
}
Mobile-Friendly Runtime Systems

Pooling connected to wave spawning

Vehicles are prewarmed and reused. The spawner requests objects from the pool, checks lane safety, applies spawn state, and avoids unnecessary runtime allocation.

Read pooling and spawning excerpt
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;
}
Decoupled Gameplay Logic

Events, score rules, and difficulty updates

Gameplay systems publish events without knowing who consumes them. Score rules and difficulty stages react to those signals through dedicated managers.

Read event and difficulty excerpt
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);
    }
}