Building on tutorial 47 (jump and background scroll), this tutorial adds obstacle spawning, collision, scoring, and game over via GameManager, plus automatic obstacle movement and cleanup via ObstacleController. You will practice the singleton pattern, PlayerPrefs, Time.timeScale, SceneManager.LoadScene, and OnBecameInvisible.
Game Structure
| Script | Role |
|---|---|
GameManager |
Singleton that manages the whole scene (spawn, score, game over) |
ObstacleController |
Move obstacles left; destroy them when they leave the screen |
ObstacleController – Move and Auto-Destroy
Attached to each obstacle prefab. Moves left at a constant speed and destroys itself when it goes off-screen.
Full Code
using UnityEngine;
public class ObstacleController : MonoBehaviour
{
/// <summary>Horizontal speed (negative = move left)</summary>
public float moveSpeedX;
private Rigidbody2D rb;
void Start()
{
rb = GetComponent<Rigidbody2D>();
}
void Update()
{
rb.velocity = new Vector2(moveSpeedX, 0);
}
private void OnBecameInvisible()
{
Destroy(gameObject);
}
}
Key Concepts
1. Moving with Rigidbody2D.velocity
rb.velocity = new Vector2(moveSpeedX, 0);
- Setting velocity directly (vs.
AddForce) overwrites the current speed every frame, giving a perfectly constant horizontal movement. - Y = 0: Keeps the obstacle moving horizontally without being affected by gravity. (Alternatively, set Gravity Scale to 0 on the Rigidbody2D.)
- Set
moveSpeedXto a negative value to move left.
2. OnBecameInvisible – Auto-Destroy Off-Screen
private void OnBecameInvisible()
{
Destroy(gameObject);
}
- OnBecameInvisible is called by Unity when this object's Renderer is no longer visible by any camera.
- The counterpart OnBecameVisible fires when it enters any camera's view.
- This removes the obstacle without needing to manually check its position, cleanly preventing memory waste.
- Note: The editor's Scene view counts as a camera. During development, the event may fire later than expected if the Scene view is open. Builds behave correctly.
GameManager – Scene-Wide Control
A singleton accessible anywhere via GameManager.instance. Handles obstacle spawning, scoring, game over, and restart.
Full Code
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using UnityEngine.SceneManagement;
public class GameManager : MonoBehaviour
{
public static GameManager instance;
public Transform[] spawnPoints;
public GameObject[] obstacles;
public float spawnDelay;
private float spawnTimer;
public bool isSpawning;
private int spawnTracker;
public int mainScore;
public TextMeshProUGUI mainScoreText;
public GameObject gameOverPanel;
public TextMeshProUGUI bestScoreText;
public TextMeshProUGUI endScoreText;
public AudioClip explosionSound;
public AudioClip pickupCoinSound;
public AudioSource SE;
void Awake()
{
if (instance != null && instance != this)
{
Destroy(this.gameObject);
}
else
{
instance = this;
}
}
void Start()
{
SE = GetComponent<AudioSource>();
}
void Update()
{
if (isSpawning)
{
spawnTimer += Time.deltaTime;
if (spawnTimer >= spawnDelay)
{
spawnTimer = 0;
SpawnObstacle();
}
}
}
public void GameOver()
{
SE.clip = explosionSound;
SE.Play();
Time.timeScale = 0;
if (mainScore > PlayerPrefs.GetInt("BestScore"))
{
PlayerPrefs.SetInt("BestScore", mainScore);
}
bestScoreText.text = "Best Score : " + PlayerPrefs.GetInt("BestScore").ToString();
endScoreText.text = "Score : " + mainScore.ToString();
gameOverPanel.SetActive(true);
}
public void GameRestart()
{
Time.timeScale = 1;
SceneManager.LoadScene("GameScene");
}
public void ScoreUiUpdate()
{
SE.clip = pickupCoinSound;
SE.Play();
mainScore++;
mainScoreText.text = "Score : " + mainScore.ToString("D5");
}
void SpawnObstacle()
{
spawnTracker = Random.Range(0, obstacles.Length);
switch (spawnTracker)
{
case 0:
Instantiate(obstacles[0], spawnPoints[0].position, Quaternion.identity);
break;
case 1:
Instantiate(obstacles[1], spawnPoints[1].position, Quaternion.identity);
break;
case 2:
Instantiate(obstacles[2], spawnPoints[1].position, Quaternion.identity);
break;
case 3:
Instantiate(obstacles[3], spawnPoints[1].position, Quaternion.identity);
break;
case 4:
Instantiate(obstacles[4], spawnPoints[Random.Range(2, 4)].position, Quaternion.identity);
break;
}
spawnDelay = Random.Range(1f, 3f);
}
}
Key Concepts
1. Singleton – Awake
void Awake()
{
if (instance != null && instance != this)
Destroy(this.gameObject);
else
instance = this;
}
- static instance: Stores the single instance as a static variable so any script can access it via
GameManager.instance. - Awake runs before Start: Safe to read
GameManager.instancefrom any script'sStart. - If a duplicate is created (e.g. on scene reload), it is immediately destroyed, keeping exactly one instance alive.
2. Obstacle Spawning – timer and Instantiate
spawnTimer += Time.deltaTime;
if (spawnTimer >= spawnDelay)
{
spawnTimer = 0;
SpawnObstacle();
}
- Accumulates
Time.deltaTimeeach frame — a simple interval timer without Invoke or coroutines. - After each spawn,
spawnDelay = Random.Range(1f, 3f)randomizes the next interval to avoid predictable patterns.
Instantiate(obstacles[0], spawnPoints[0].position, Quaternion.identity);
- Instantiate(prefab, position, rotation): Creates a copy of the prefab at the given position.
- Quaternion.identity: No rotation — the object spawns facing its default direction.
- A switch-case selects both the obstacle type and its spawn point. The bird (
case 4) picks a random aerial spawn fromspawnPoints[2]orspawnPoints[3].
3. Game Over – Time.timeScale and PlayerPrefs
Time.timeScale = 0;
if (mainScore > PlayerPrefs.GetInt("BestScore"))
PlayerPrefs.SetInt("BestScore", mainScore);
- Time.timeScale = 0: Pauses the game.
Update, physics, and time-based coroutines stop. UI buttons and events still work. - PlayerPrefs: Unity's persistent key-value store. Data survives app restarts.
SetInt/GetIntwork with an int. Good for simple per-device data like high scores or settings.
4. Game Restart – SceneManager.LoadScene
public void GameRestart()
{
Time.timeScale = 1;
SceneManager.LoadScene("GameScene");
}
- Reset timeScale to 1 first: Loading a scene while
timeScaleis 0 keeps the new scene frozen. - SceneManager.LoadScene("name"): Reloads the scene by name. The scene must be registered in File → Build Settings.
- Requires
using UnityEngine.SceneManagement;.
5. Score Display – ToString("D5")
mainScoreText.text = "Score : " + mainScore.ToString("D5");
- "D5" format pads an integer to at least 5 digits with leading zeros (e.g.
3→"00003"). Useful for classic arcade-style score displays.
Connecting to DinoController
DinoController.OnTriggerEnter2D calls GameManager.instance to communicate:
private void OnTriggerEnter2D(Collider2D collision)
{
if (collision.gameObject.CompareTag("Obstacle"))
{
anim.SetTrigger("isDie");
GameManager.instance.GameOver();
}
else if (collision.gameObject.CompareTag("Point"))
{
GameManager.instance.ScoreUiUpdate();
}
}
- CompareTag is faster than
== "Obstacle"and will warn you at runtime if the tag doesn't exist. - Obstacle tag: Set on every obstacle prefab in the Inspector.
- Point trigger: A thin Is Trigger Collider in front of each obstacle, tagged
"Point", increments the score when the dino passes through.
Scene Setup Summary
| Item | Recommendation |
|---|---|
| GameManager object | Empty GameObject with GameManager.cs + AudioSource |
| spawnPoints | 4 empty Transforms: 2 ground, 2 aerial — assign in Inspector |
| obstacles array | 5 prefabs (4 cactus types + bird) — assign in Inspector |
| Obstacle prefabs | Rigidbody2D + Collider2D + ObstacleController; tag = "Obstacle" |
| Score trigger | Is Trigger Collider on each obstacle, tag = "Point" |
| Game over panel | Canvas child — Panel + score texts + restart button; disabled initially |