๐ 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.
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 inFixedUpdate(M1โ2) - โ Coins are triggers that only react to the
Playertag (M2) - โ Coin stats come from a
CoinDataScriptableObject asset (M3) - โ A coroutine spawns coins on an interval, from an
ObjectPool(M3, M5) - โ A
GameManagersingleton 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
CoinDataassets with different values/colors; have the spawner pick randomly (data-driven design, Lesson 3.1). - Audio: add an
AudioManagerthat subscribes toScoreChangedand plays a "ding" โ a new event subscriber, no manager changes (Lesson 3.2). - Full save: swap
PlayerPrefsfor the JSONSaveManagerand 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
- Unity Learn โ official tutorials and pathways
- Unity Manual & Scripting Reference
- Unity โ architecture & best-practice guides
- Ray's other courses: Introduction to C#, Intermediate C#, Advanced C#.
๐ 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. ๐