Skip to main content

๐Ÿ“ Lesson 5.3: Capstone Project โ€” "Coin Rush"

This is where everything comes together. You'll build Coin Rush, a complete top-down arcade game: roll a ball to grab spawning coins before a timer runs out, beat your saved high score. Every module of this course shows up โ€” components, lifecycle, input, physics, ScriptableObjects, events, coroutines, a manager, UI, save/load, pooling, and a test.

๐ŸŽฏ What You'll Build

A small but complete game loop, wiring together the whole course:

  • A physics-driven player that moves and collects coins (Modules 1โ€“2)
  • Coins defined by a ScriptableObject, spawned in waves by a pooled coroutine spawner (Modules 2โ€“3, 5)
  • A GameManager singleton with a state machine, score, and a countdown, broadcasting via events (Modules 3โ€“4)
  • An event-driven HUD and game-over screen, with a persisted high score (Module 4)
  • A performance pass (pooling, caching) and a unit test for the scoring rule (Module 5)

Estimated Time: 150 minutes

Format: a guided build in five phases. Each references the lesson its ideas came from โ€” treat it as a capstone review as much as a project.

In This Lesson

The Brief & Architecture

Coin Rush: the player rolls a ball around a walled arena. Coins spawn at random spots; touching one adds its value to the score and it despawns. A 30-second countdown ticks down; at zero, the game ends and shows the final score plus the best score ever (saved to disk). Press a key to play again.

Before writing code, picture how the pieces connect. Notice the shape from the whole course: the GameManager broadcasts via events; systems react. Nothing reaches into the manager except deliberate commands.

graph TD GM["GameManager (singleton)
state ยท score ยท countdown"] P["PlayerController
(input + physics)"] C["Coin (trigger)
uses CoinData asset"] SP["CoinSpawner
(coroutine + object pool)"] HUD["HUD + GameOver UI"] SV["SaveManager
(high score)"] P -->|"collects"| C C -->|"AddScore(value)"| GM SP -->|"spawns / pools"| C GM -.->|"ScoreChanged / TimeChanged / StateChanged events"| HUD GM -.->|"StateChanged"| SP GM -->|"save/load best"| SV style GM fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style HUD fill:#f3e8ff,stroke:#8b5cf6,stroke-width:2px style C fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

๐Ÿ“– Scene setup (do this first)

Create a Plane floor with four wall Cubes around the edges (each just needs a Collider). Add a Sphere named Player with a Rigidbody, tagged Player. Add empty GameObjects for GameManager, CoinSpawner, and a Canvas for UI. A Coin is a small Cube/Sphere with a trigger collider, saved as a prefab (Lesson 2.3).

Phase 1 โ€” Player & Coins

Modules 1โ€“2: components, lifecycle, input, physics, triggers.

The player is a physics object: read input in Update, apply force in FixedUpdate (Lessons 1.2, 2.1, 2.2). Cache the Rigidbody in Awake (Lessons 1.3, 5.1).

using UnityEngine;

[RequireComponent(typeof(Rigidbody))]
public class PlayerController : MonoBehaviour
{
    [SerializeField] private float moveForce = 12f;

    private Rigidbody body;
    private Vector2 input;

    void Awake() => body = GetComponent<Rigidbody>();   // cache (Lesson 1.3 / 5.1)

    void Update()
    {
        // Read input every frame so nothing is missed (Lesson 1.2).
        input.x = Input.GetAxisRaw("Horizontal");
        input.y = Input.GetAxisRaw("Vertical");
    }

    void FixedUpdate()
    {
        // Apply physics on the fixed step (Lesson 2.2).
        Vector3 dir = new Vector3(input.x, 0f, input.y);
        if (dir.sqrMagnitude > 1f) dir.Normalize();     // no fast diagonals (Lesson 2.1)
        body.AddForce(dir * moveForce);
    }
}

A coin is a trigger (Lesson 2.2): when the Player enters, it awards points and despawns. We'll fill in its data and pooling in Phase 2 โ€” for now, the collision shape of the game:

using UnityEngine;

[RequireComponent(typeof(Collider))]
public class Coin : MonoBehaviour
{
    private CoinData data;               // assigned by the spawner (Phase 2)
    private System.Action<Coin> onCollected;   // how to return to the pool (Phase 2)

    public void Init(CoinData data, System.Action<Coin> onCollected)
    {
        this.data = data;
        this.onCollected = onCollected;
    }

    void OnTriggerEnter(Collider other)
    {
        if (!other.CompareTag("Player")) return;   // only the player (Lesson 2.2)

        GameManager.Instance.AddScore(data.value); // command the manager (Lesson 4.1)
        onCollected?.Invoke(this);                  // back to the pool (Lesson 5.1)
    }
}

โœ… Already using five lessons

[RequireComponent] and caching (1.3), the Update/FixedUpdate split (1.2, 2.2), input and normalization (2.1), triggers and CompareTag (2.2), and a decoupled "return me" callback instead of Destroy (5.1). One little game object, most of Modules 1โ€“2.

Phase 2 โ€” Data & Pooled Spawner

Modules 3 & 5: ScriptableObjects, coroutines, object pooling.

Coin stats live in a ScriptableObject (Lesson 3.1), so designers can make "SilverCoin" and "GoldCoin" assets with different values without touching code:

using UnityEngine;

[CreateAssetMenu(fileName = "CoinData", menuName = "CoinRush/Coin Data")]
public class CoinData : ScriptableObject
{
    public int value = 1;
    public Color color = Color.yellow;
}

The spawner ties together three course ideas: a coroutine spawns on an interval (Lesson 3.3), an object pool reuses coins instead of churning them (Lesson 5.1), and it only runs while the game is in the Playing state (Lesson 4.1):

using System.Collections;
using UnityEngine;
using UnityEngine.Pool;

public class CoinSpawner : MonoBehaviour
{
    [SerializeField] private Coin coinPrefab;
    [SerializeField] private CoinData coinData;
    [SerializeField] private float interval = 0.75f;
    [SerializeField] private Vector2 areaHalfSize = new(8f, 8f);

    private ObjectPool<Coin> pool;

    void Awake()
    {
        pool = new ObjectPool<Coin>(
            () => Instantiate(coinPrefab),
            c => c.gameObject.SetActive(true),
            c => c.gameObject.SetActive(false),
            c => Destroy(c.gameObject),
            defaultCapacity: 30);
    }

    // Started/stopped by the GameManager's state (Phase 3).
    public void BeginSpawning() => StartCoroutine(SpawnLoop());
    public void StopSpawning()  => StopAllCoroutines();

    private IEnumerator SpawnLoop()
    {
        var wait = new WaitForSeconds(interval);   // cached (Lesson 3.3 / 5.1)
        while (true)
        {
            SpawnOne();
            yield return wait;
        }
    }

    private void SpawnOne()
    {
        Coin coin = pool.Get();                    // reused, no GC (Lesson 5.1)
        float x = Random.Range(-areaHalfSize.x, areaHalfSize.x);
        float z = Random.Range(-areaHalfSize.y, areaHalfSize.y);
        coin.transform.position = new Vector3(x, 0.5f, z);
        coin.Init(coinData, ReturnCoin);          // give it its data + return path
    }

    private void ReturnCoin(Coin coin) => pool.Release(coin);
}

โš ๏ธ Reset pooled state

Because coins are reused, always re-set position (and any other state) in SpawnOne/the pool's get callback โ€” a recycled coin remembers where it was. This is the "reset on get" rule from Lesson 5.1. (Random.Range here is only ever called at runtime, so it's fine.)

Phase 3 โ€” The GameManager

Modules 3โ€“4: singleton, state machine, events, coroutines.

The heart of the game. A singleton (Lesson 4.1) that owns the state machine, score, and a countdown coroutine (Lesson 3.3), and broadcasts everything via events (Lesson 3.2) so the UI and spawner react without being referenced:

using System;
using System.Collections;
using UnityEngine;

public enum GameState { Ready, Playing, GameOver }

public class GameManager : MonoBehaviour
{
    public static GameManager Instance { get; private set; }

    [SerializeField] private CoinSpawner spawner;
    [SerializeField] private float roundSeconds = 30f;

    public GameState State { get; private set; } = GameState.Ready;
    public int Score { get; private set; }
    public int TimeLeft { get; private set; }

    // Announcements (Lesson 3.2) โ€” UI subscribes to these.
    public event Action<int> ScoreChanged;
    public event Action<int> TimeChanged;
    public event Action<GameState> StateChanged;

    void Awake()
    {
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        Instance = this;                       // singleton (Lesson 4.1)
    }

    void Start() => SetState(GameState.Ready);

    void Update()
    {
        // Space starts a new round from Ready or GameOver.
        if (Input.GetKeyDown(KeyCode.Space) && State != GameState.Playing)
            StartRound();
    }

    public void AddScore(int amount)
    {
        if (State != GameState.Playing) return;
        Score += amount;
        ScoreChanged?.Invoke(Score);           // broadcast (Lesson 3.2)
    }

    private void StartRound()
    {
        Score = 0;
        ScoreChanged?.Invoke(Score);
        SetState(GameState.Playing);
        spawner.BeginSpawning();
        StartCoroutine(Countdown());           // coroutine timer (Lesson 3.3)
    }

    private IEnumerator Countdown()
    {
        TimeLeft = Mathf.CeilToInt(roundSeconds);
        var oneSecond = new WaitForSeconds(1f);
        while (TimeLeft > 0)
        {
            TimeChanged?.Invoke(TimeLeft);
            yield return oneSecond;
            TimeLeft--;
        }
        TimeChanged?.Invoke(0);
        EndRound();
    }

    private void EndRound()
    {
        spawner.StopSpawning();
        SetState(GameState.GameOver);
    }

    private void SetState(GameState newState)
    {
        State = newState;
        StateChanged?.Invoke(newState);
    }
}

โœ… Broadcast out, command in

The manager commands the spawner directly (it owns the round) but announces score, time, and state through events โ€” the exact balance from Lesson 4.1. The HUD (next) never appears here; it just listens. Add a leaderboard, a sound, a particle burst on score โ€” each is a new subscriber, and this class never changes.

Phase 4 โ€” UI & Save

Module 4: event-driven UI and persistence.

The HUD subscribes to the manager's events (Lessons 3.2, 4.2) and updates only on change โ€” no polling, no coupling. It also shows/hides the game-over panel based on state, and persists the high score with PlayerPrefs (Lesson 4.3):

using UnityEngine;
using TMPro;

public class HUD : MonoBehaviour
{
    [SerializeField] private TMP_Text scoreLabel;
    [SerializeField] private TMP_Text timeLabel;
    [SerializeField] private GameObject gameOverPanel;
    [SerializeField] private TMP_Text resultLabel;

    void OnEnable()
    {
        var gm = GameManager.Instance;
        gm.ScoreChanged += OnScore;            // subscribe (Lesson 3.2)
        gm.TimeChanged  += OnTime;
        gm.StateChanged += OnState;
    }

    void OnDisable()
    {
        var gm = GameManager.Instance;
        if (gm == null) return;
        gm.ScoreChanged -= OnScore;            // mirror unsubscribe (Lesson 3.2)
        gm.TimeChanged  -= OnTime;
        gm.StateChanged -= OnState;
    }

    private void OnScore(int score) => scoreLabel.text = $"Score: {score}";
    private void OnTime(int seconds) => timeLabel.text = $"Time: {seconds}";

    private void OnState(GameState state)
    {
        bool over = state == GameState.GameOver;
        gameOverPanel.SetActive(over);
        if (over) ShowResult();
    }

    private void ShowResult()
    {
        int score = GameManager.Instance.Score;
        int best = PlayerPrefs.GetInt("HighScore", 0);   // load (Lesson 4.3)
        if (score > best)
        {
            best = score;
            PlayerPrefs.SetInt("HighScore", best);       // save new record
            PlayerPrefs.Save();
        }
        resultLabel.text = $"Final: {score}\nBest: {best}\n\nPress Space to play again";
    }
}

๐Ÿ’ก Why the null-guard in OnDisable?

When you stop Play mode, objects are destroyed in an undefined order; the GameManager may already be gone when the HUD's OnDisable runs. The if (gm == null) return; guard (Unity's overloaded ==, Lesson 1.3) avoids a teardown error. Small detail, real robustness โ€” exactly the kind of thing Lesson 5.2 teaches you to anticipate.

For a full save game (position, level, inventory) you'd use the JSON SaveManager from Lesson 4.3. For a single high-score integer, PlayerPrefs is the right, simpler tool โ€” choosing correctly between them is the lesson.

Phase 5 โ€” Polish, Test, Verify

Module 5: performance, testing, debugging.

Performance (Lesson 5.1). Do a Profiler pass while playing and watch the GC Alloc column. The design is already allocation-light: coins are pooled, references cached, UI updates only on events, and the wait objects are cached. If a spike appears, trace it โ€” that's the workflow, not a guess.

Testing (Lesson 5.2). The scoring logic is worth locking down. Pull it into a pure C# rule and unit-test it in Edit Mode โ€” no scene, instant feedback:

// Pure logic โ€” no MonoBehaviour, so it's trivially testable (Lesson 5.2).
public class ScoreRules
{
    // Late coins (last 10 seconds) are worth double โ€” a bit of game feel.
    public int Points(int coinValue, int secondsLeft)
        => secondsLeft <= 10 ? coinValue * 2 : coinValue;
}
using NUnit.Framework;

public class ScoreRulesTests
{
    [Test]
    public void EarlyCoin_NormalValue()
    {
        Assert.AreEqual(5, new ScoreRules().Points(5, secondsLeft: 25));
    }

    [Test]
    public void LateCoin_DoubleValue()
    {
        Assert.AreEqual(10, new ScoreRules().Points(5, secondsLeft: 8));
    }
}

Then wire the rule into AddScore (Score += rules.Points(amount, TimeLeft);) โ€” logic in a plain class, MonoBehaviour as glue, exactly the separation from Lesson 5.2.

Debugging (Lesson 5.2). If a coin isn't collected, the checklist writes itself: Is the Player tagged Player? Is the coin's collider a trigger? Does the Player have a Rigidbody (the "one must have a Rigidbody" rule, Lesson 2.2)? Add a Debug.Log($"hit {other.name}", this) in OnTriggerEnter to see what's actually touching. Draw the spawn area with a Gizmo to confirm placement.

โœ… You built a whole game loop

Ready โ†’ Playing โ†’ GameOver, with spawning, collecting, scoring, a timer, UI, a saved best, pooling, and a test. Every module of this course is in there, working together โ€” which is exactly what real Unity development feels like: not one big trick, but many small, well-understood pieces composed cleanly.

Self-Assessment & Quiz

๐Ÿ Build Checklist

Tick these off โ€” each maps to a concept you now own:

  • โ˜ Player rolls with physics; input read in Update, force in FixedUpdate (M1โ€“2)
  • โ˜ Coins are triggers that only react to the Player tag (M2)
  • โ˜ Coin stats come from a CoinData ScriptableObject asset (M3)
  • โ˜ A coroutine spawns coins on an interval, from an ObjectPool (M3, M5)
  • โ˜ A GameManager singleton runs a state machine + countdown and raises events (M3โ€“4)
  • โ˜ The HUD updates from events (not polling) and shows game over (M4)
  • โ˜ The high score persists across sessions (M4)
  • โ˜ Profiler shows near-0 B/frame; a scoring rule has a passing unit test (M5)

If every box is checked, you've demonstrated the entire course in one project. ๐ŸŽ‰

๐Ÿš€ Extend It (optional challenges)

  • Enemies: spawn hazards (also pooled) that end the round on contact โ€” reuse the trigger + state pattern.
  • Coin variety: make Gold and Silver CoinData assets with different values/colors; have the spawner pick randomly (data-driven design, Lesson 3.1).
  • Audio: add an AudioManager that subscribes to ScoreChanged and plays a "ding" โ€” a new event subscriber, no manager changes (Lesson 3.2).
  • Full save: swap PlayerPrefs for the JSON SaveManager and persist a top-5 leaderboard (Lesson 4.3).

๐ŸŽฏ Integration Quiz

Question 1: In Coin Rush, how does the HUD learn the score changed?

Question 2: Why are coins pooled instead of Instantiate/Destroyed each time?

Question 3: A coin's OnTriggerEnter never fires when the player rolls over it. What's the most likely cause?

Course Conclusion

๐ŸŽ“ What You've Learned

Across five modules and fifteen lessons, you went from "what is a MonoBehaviour?" to shipping a complete, well-architected game loop:

  • Module 1 โ€” Unity's C# Model: components, the event lifecycle, and wiring objects together.
  • Module 2 โ€” Gameplay Scripting: input, movement, physics, collisions, spawning, and destroying.
  • Module 3 โ€” Structuring Game Code: ScriptableObject data, decoupled events, and time-based logic with coroutines.
  • Module 4 โ€” Systems and Managers: singletons and state, UI scripting, and saving/loading.
  • Module 5 โ€” Quality and Performance: avoiding GC, pooling, debugging, testing โ€” and this capstone.

๐Ÿงญ The through-line

If one idea unifies the course, it's this: compose small, well-understood pieces and let them communicate cleanly. Components over inheritance, events over hard references, data in assets, logic in testable classes, managers that announce rather than command. Master that mindset and any Unity system โ€” however large โ€” becomes a set of parts you can reason about one at a time.

๐Ÿ“š Where to Go Next

๐Ÿš€ Keep Building

The best next step is to make something small and finish it. Take Coin Rush and add one feature. Then start a fresh idea. Every game you complete teaches more than a dozen tutorials โ€” and you now have the C# scripting foundation to build whatever you imagine.

๐ŸŽ‰ Congratulations โ€” course complete!

You've mastered C# scripting in Unity, from the component model to a shipped game loop. Now go make something amazing. ๐Ÿš€