COIT20271 — Complete Game Tutorial

💎 Crystal Collector

Build a complete, polished collection game from scratch. Navigate an arena, collect crystals for points, avoid enemies that chase you, and survive to the final buzzer. Includes camera follow, audio, particles, difficulty scaling, and a full game-over/restart loop.

1
Project & Assets

Create a new project and import a free audio pack. The visual style uses Unity primitives with coloured materials — no 3D model imports needed.

  1. Create a new 3D (Built-in Render Pipeline) project named CrystalCollector.
  2. In the Project window, create these folders: Scripts, Materials, Prefabs, Audio.
  3. Import a free audio pack from the Asset Store (search "Free Sound Effects Pack" or "Free Casual Game SFX"). You need clips for: pickup chime, enemy hit/fail, background music loop, and a spawn whoosh. Alternative: download free clips from mixkit.co and drag them into your Audio folder.
  4. Create the following Materials in the Materials folder:
    • Mat_Ground — dark grey (hex #2D2D2D)
    • Mat_Wall — dark teal (hex #1A3A3A)
    • Mat_Player — bright blue (hex #3B82F6)
    • Mat_Crystal — bright green (hex #34D399), set Emission to enabled with colour #34D399 at intensity 1.5
    • Mat_Enemy — bright red (hex #EF4444), set Emission to enabled with colour #EF4444 at intensity 1
Emissive materials To set emission: select the Material, check Emission, click the colour box next to it, and pick the colour + intensity. Emissive objects glow slightly and look striking against the dark ground.
  • Project created with folder structure
  • Audio clips imported
  • 5 materials created with correct colours
2
Arena Build

Build a walled arena so the player and enemies stay within bounds. The dark aesthetic with emissive objects creates a clean, modern look.

  1. Create a Plane at (0, 0, 0). Scale to (3, 1, 3). Apply Mat_Ground. Name it Ground.
  2. Create 4 Cube walls around the arena edges:
    • Wall_North — Position (0, 0.5, 15), Scale (30, 1, 0.5)
    • Wall_South — Position (0, 0.5, -15), Scale (30, 1, 0.5)
    • Wall_East — Position (15, 0.5, 0), Scale (0.5, 1, 30)
    • Wall_West — Position (-15, 0.5, 0), Scale (0.5, 1, 30)
    Apply Mat_Wall to all four.
  3. Create an empty GameObject named Arena. Drag all walls and the ground into it to keep the Hierarchy clean.
  4. Select the Directional Light. Set its colour to a cool white (hex #C8D8E8), intensity to 0.6, and rotation to (50, -30, 0). This creates soft, atmospheric lighting.
  5. Set the scene's ambient light: go to Window → Rendering → Lighting. Under Environment, set Ambient Color to a dark blue-grey (hex #1A1A2E).
  • Ground plane with dark material
  • 4 walls enclosing the arena
  • Lighting configured (dim, atmospheric)
3
Player
  1. Create a Cube at (0, 0.5, 0). Name it Player. Apply Mat_Player.
  2. Add a Rigidbody. Expand Constraints: freeze rotation X, Y, Z.
  3. Add an AudioSource. Uncheck Play On Awake.
  4. Add a Trail Renderer component. Set Time to 0.3, Start Width to 0.3, End Width to 0. Under the colour gradient, set the start to the player's blue colour and the end to transparent. Uncheck Cast Shadows. Set Materials → Element 0 to Unity's Default-Line material.
  5. Set the Player's Tag to Player (this tag already exists by default).
  6. Create PlayerMovement.cs in the Scripts folder and attach it to Player.
PlayerMovement.cs
using UnityEngine; public class PlayerMovement : MonoBehaviour { [SerializeField] private float speed = 8f; private Rigidbody rb; private bool canMove = true; void Awake() { rb = GetComponent<Rigidbody>(); } void FixedUpdate() { if (!canMove) return; float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); Vector3 move = new Vector3(h, 0f, v).normalized; rb.MovePosition(rb.position + move * speed * Time.fixedDeltaTime); } public void DisableMovement() { canMove = false; } }
Why .normalized? Without normalizing, diagonal movement (pressing W+D together) would be faster than cardinal movement because the vector (1,0,1) has a magnitude of ~1.41. Calling .normalized clamps it to magnitude 1 so all directions move at equal speed.
  • Player cube with Rigidbody, rotation frozen
  • Trail Renderer shows a blue trail behind movement
  • Player moves with WASD at equal speed in all directions
4
Camera Follow

A fixed-angle camera that smoothly follows the player, giving a top-down/isometric view of the arena.

  1. Select the Main Camera. Set its position to (0, 14, -8) and rotation to (60, 0, 0). This gives a clear top-down-ish view.
  2. Set the Camera's Clear Flags to Solid Color and Background to dark (#0A0A1A).
  3. Create CameraFollow.cs and attach it to the Main Camera.
  4. In the Inspector, drag the Player object into the target field.
CameraFollow.cs
using UnityEngine; public class CameraFollow : MonoBehaviour { [SerializeField] private Transform target; [SerializeField] private float smoothSpeed = 5f; private Vector3 offset; void Start() { // Store the initial distance between camera and player offset = transform.position - target.position; } void LateUpdate() { Vector3 desiredPos = target.position + offset; transform.position = Vector3.Lerp( transform.position, desiredPos, smoothSpeed * Time.deltaTime); } }
Why LateUpdate? The player moves in FixedUpdate. If the camera also moved in Update, it could read the player's position before or after the physics step, causing jitter. LateUpdate runs after all Update and FixedUpdate calls, so the player's final position is settled.
  • Camera follows the player smoothly
  • Arena is clearly visible from the camera angle
5
Collectibles

Glowing crystals that rotate in place. The player picks them up by moving into them. A spawner keeps a steady supply on the field.

  1. Create a Sphere at (0, 0.5, 0). Scale to (0.5, 0.5, 0.5). Apply Mat_Crystal. Name it Crystal.
  2. Tick Is Trigger on its Sphere Collider.
  3. Add the tag Crystal in Tag Manager and assign it.
  4. Add a Point Light as a child of the Crystal (right-click Crystal → Light → Point Light). Set colour to #34D399, range 3, intensity 1. This makes it glow on the ground.
  5. Create Collectible.cs and attach it to Crystal.
  6. Drag Crystal from Hierarchy into the Prefabs folder to create a Prefab. Delete the original from the scene.
Collectible.cs
using UnityEngine; public class Collectible : MonoBehaviour { [SerializeField] private float rotateSpeed = 90f; [SerializeField] private float bobHeight = 0.2f; [SerializeField] private float bobSpeed = 2f; private float startY; void Start() { startY = transform.position.y; } void Update() { // Rotate transform.Rotate(Vector3.up, rotateSpeed * Time.deltaTime); // Bob up and down float newY = startY + Mathf.Sin(Time.time * bobSpeed) * bobHeight; Vector3 pos = transform.position; pos.y = newY; transform.position = pos; } }
  • Crystal Prefab rotates and bobs
  • Green glow from Point Light visible on ground
  • Prefab saved to Prefabs folder
6
Enemies

Red capsules that chase the player. If one touches the player, game over. They use a simple script that moves toward the player's position each frame.

  1. Create a Capsule at (0, 1, 0). Apply Mat_Enemy. Name it Enemy.
  2. Add a Rigidbody. Freeze rotation X, Y, Z. Set Collision Detection to Continuous.
  3. Tick Is Trigger on its Capsule Collider.
  4. Add the tag Enemy in Tag Manager and assign it.
  5. Add a Point Light as a child. Colour #EF4444, range 2, intensity 0.8.
  6. Create EnemyAI.cs and attach it to the Enemy.
  7. Drag into the Prefabs folder to create a Prefab. Delete the original.
EnemyAI.cs
using UnityEngine; public class EnemyAI : MonoBehaviour { [SerializeField] private float speed = 3f; private Transform player; void Start() { GameObject p = GameObject.FindWithTag("Player"); if (p != null) player = p.transform; } void Update() { if (player == null) return; Vector3 dir = (player.position - transform.position).normalized; dir.y = 0f; // Stay on ground plane transform.position += dir * speed * Time.deltaTime; } }
  • Enemy Prefab with red glow
  • Enemies chase the player when spawned
7
Game Manager

The central script that runs the game: spawns collectibles and enemies, tracks score and time, handles pickup and enemy-hit events, and controls game-over state. Attach it to an empty GameManager GameObject.

  1. Create an empty GameObject named GameManager.
  2. Add an AudioSource to it. Uncheck Play On Awake.
  3. Create GameManager.cs and attach it.
  4. Assign all the Inspector references after creating the UI in the next section.
GameManager.cs
using System.Collections; using TMPro; using UnityEngine; public class GameManager : MonoBehaviour { [Header("Prefabs")] [SerializeField] private GameObject crystalPrefab; [SerializeField] private GameObject enemyPrefab; [Header("Spawning")] [SerializeField] private float crystalInterval = 3f; [SerializeField] private float enemyInterval = 5f; [SerializeField] private float spawnRange = 13f; [SerializeField] private int maxCrystals = 8; [Header("UI")] [SerializeField] private TMP_Text scoreText; [SerializeField] private TMP_Text timerText; [SerializeField] private GameObject gameOverPanel; [SerializeField] private TMP_Text finalScoreText; [Header("Audio")] [SerializeField] private AudioClip pickupClip; [SerializeField] private AudioClip hitClip; [SerializeField] private AudioSource musicSource; [Header("Settings")] [SerializeField] private float gameDuration = 60f; private AudioSource sfxSource; private int score; private float timeRemaining; private bool isGameOver; private int crystalCount; void Awake() { sfxSource = GetComponent<AudioSource>(); } void Start() { Time.timeScale = 1f; score = 0; timeRemaining = gameDuration; isGameOver = false; gameOverPanel.SetActive(false); UpdateUI(); StartCoroutine(SpawnCrystals()); StartCoroutine(SpawnEnemies()); } void Update() { if (isGameOver) return; timeRemaining -= Time.deltaTime; if (timeRemaining <= 0f) { timeRemaining = 0f; EndGame(); } timerText.text = Mathf.CeilToInt(timeRemaining).ToString(); } // ── Called by PlayerCollector ── public void CrystalCollected() { score += 10; crystalCount--; sfxSource.PlayOneShot(pickupClip); UpdateUI(); } public void PlayerHit() { sfxSource.PlayOneShot(hitClip); EndGame(); } void EndGame() { isGameOver = true; musicSource.Stop(); gameOverPanel.SetActive(true); finalScoreText.text = "Final Score: " + score; // Disable player movement FindAnyObjectByType<PlayerMovement>().DisableMovement(); Time.timeScale = 0f; } void UpdateUI() { scoreText.text = "Score: " + score; } public void RestartGame() { Time.timeScale = 1f; UnityEngine.SceneManagement.SceneManager.LoadScene( UnityEngine.SceneManagement.SceneManager.GetActiveScene().name); } // ── Spawners ── IEnumerator SpawnCrystals() { while (!isGameOver) { if (crystalCount < maxCrystals) { Vector3 pos = RandomArenaPos(0.5f); Instantiate(crystalPrefab, pos, Quaternion.identity); crystalCount++; } yield return new WaitForSeconds(crystalInterval); } } IEnumerator SpawnEnemies() { float interval = enemyInterval; while (!isGameOver) { yield return new WaitForSeconds(interval); Vector3 pos = RandomArenaPos(1f); Instantiate(enemyPrefab, pos, Quaternion.identity); // Gradually spawn faster (minimum 1.5s) interval = Mathf.Max(1.5f, interval - 0.2f); } } Vector3 RandomArenaPos(float yHeight) { float x = Random.Range(-spawnRange, spawnRange); float z = Random.Range(-spawnRange, spawnRange); return new Vector3(x, yHeight, z); } }
Difficulty scaling Each time an enemy spawns, the interval decreases by 0.2 seconds (clamped at 1.5s minimum). By the end of a 60-second game, enemies spawn roughly 3× faster than at the start. Students can tune enemyInterval and the reduction rate in the Inspector.
  • GameManager object created with AudioSource
  • Script compiles with no errors
8
UI System

Two layers of UI: a HUD (score + timer always visible) and a Game Over panel that appears on top when the game ends.

  1. Create GameObject → UI → Text - TextMeshPro. Name it ScoreText. Anchor top-left. Pos (20, -20). Font size 32. Text: "Score: 0".
  2. Create another TMP text named TimerText. Anchor top-right. Pos (-20, -20). Font size 32. Text: "60". Set alignment to right.
  3. Create GameObject → UI → Panel. Name it GameOverPanel. Set its Image colour to (0, 0, 0, 200) for a dark semi-transparent overlay.
  4. Inside GameOverPanel, create a TMP text named GameOverTitle. Centre it. Text: "GAME OVER". Font size 60. Bold. White.
  5. Below the title, create another TMP text named FinalScoreText. Text: "Final Score: 0". Font size 32. White.
  6. Create GameObject → UI → Button - TextMeshPro inside the panel. Name it RestartButton. Position below the score. Set the button text to "Play Again".
  7. On the RestartButton's On Click () event, click +, drag the GameManager object into the slot, and select GameManager → RestartGame.
  8. Disable the GameOverPanel (uncheck its checkbox).

Now create the PlayerCollector script — this bridges gameplay events to the GameManager:

PlayerCollector.cs
using UnityEngine; public class PlayerCollector : MonoBehaviour { private GameManager gm; void Awake() { gm = FindAnyObjectByType<GameManager>(); } void OnTriggerEnter(Collider other) { if (other.CompareTag("Crystal")) { gm.CrystalCollected(); Destroy(other.gameObject); } if (other.CompareTag("Enemy")) { gm.PlayerHit(); } } }
  1. Attach PlayerCollector.cs to the Player object.
  2. Now wire up all GameManager Inspector references:
    • crystalPrefab → Crystal prefab from Project window
    • enemyPrefab → Enemy prefab from Project window
    • scoreText → ScoreText from Hierarchy
    • timerText → TimerText from Hierarchy
    • gameOverPanel → GameOverPanel from Hierarchy
    • finalScoreText → FinalScoreText from Hierarchy
    • pickupClip → your pickup audio clip
    • hitClip → your fail/hit audio clip
    • musicSource → (set up in the next section)
  • Score and Timer visible on screen
  • GameOverPanel is disabled and has Restart button wired
  • PlayerCollector attached to Player
  • All GameManager Inspector fields assigned (except musicSource)
9
Audio
  1. Create an empty GameObject named BackgroundMusic. Add an AudioSource.
  2. Assign your music loop clip. Set Play On Awake: ✅, Loop: ✅, Volume: 0.25, Spatial Blend: 0 (2D).
  3. Drag the BackgroundMusic object into the GameManager's musicSource field.
  4. Press Play. Verify: music plays on start, pickup sound on crystal collection, hit sound on enemy contact, music stops on game over.
Audio recap The GameManager uses PlayOneShot() for SFX so sounds overlap cleanly. Background music is on a separate AudioSource with its own volume control. musicSource.Stop() is called on game over. Audio is unaffected by Time.timeScale = 0 — the hit sound still plays after the game freezes.
  • Background music plays and loops
  • Pickup SFX on crystal collection
  • Hit SFX on enemy contact
  • Music stops on game over
10
Polish & Particles

Add a particle burst when crystals are collected. This makes pickups feel satisfying and the game feel polished.

  1. Create a Particle System (GameObject → Effects → Particle System). Name it PickupBurst.
  2. Configure it for a short one-shot burst:
    • Duration: 0.3
    • Looping: ❌ Off
    • Start Lifetime: 0.4
    • Start Speed: 6
    • Start Size: 0.15
    • Start Color: #34D399 (crystal green)
    • Simulation Space: World
    • Play On Awake: ❌ Off
    • Under Emission: Rate over Time = 0, add a single Burst at time 0 with count 20
    • Under Shape: Sphere, Radius 0.3
    • Under Renderer: Material = Default-Particle
  3. Drag PickupBurst into the Prefabs folder. Delete the original from the scene.
  4. Now update PlayerCollector.cs to spawn the particle burst on pickup:
PlayerCollector.cs — updated
using UnityEngine; public class PlayerCollector : MonoBehaviour { [SerializeField] private GameObject pickupEffect; private GameManager gm; void Awake() { gm = FindAnyObjectByType<GameManager>(); } void OnTriggerEnter(Collider other) { if (other.CompareTag("Crystal")) { // Spawn particle burst at crystal position if (pickupEffect != null) { GameObject fx = Instantiate(pickupEffect, other.transform.position, Quaternion.identity); Destroy(fx, 1f); // Clean up after particles finish } gm.CrystalCollected(); Destroy(other.gameObject); } if (other.CompareTag("Enemy")) { gm.PlayerHit(); } } }
  1. Drag the PickupBurst prefab into the pickupEffect field on the Player's PlayerCollector component.
  2. Press Play and collect a crystal — a burst of green particles should fly out.
Final playtest checklist Play the complete game from start to finish. You should experience: background music, smooth camera follow, rotating/bobbing crystals with green glow, enemies chasing you with red glow, particle bursts on pickup, escalating difficulty, game over on hit or timeout, and a working restart button. If all of that works — you've built a complete game.
  • Green particle burst on crystal pickup
  • Particles clean up after 1 second
  • Full game loop works: play → collect → avoid → game over → restart
  • Difficulty increases over time (enemies spawn faster)
  • No console errors or warnings