Your First Complete Game Loop: States, Scenes, UI, Audio & Builds
What turns a level into a game you can hand to someone: a title screen, a run with states (ready, playing, paused, won, lost), objects that outlive a scene, scene loading behind a fade, pausing that really stops everything, uGUI menus and a HUD with TextMeshPro, sound effects, music and an Audio Mixer with volume settings, PlayerPrefs for settings and a best time, and a Windows build. The guided build finishes Lantern Cliffs from T09 as a complete small game, with sounds made by a script so you need no assets.
- Study time
- 10–14 hours
- Reading time
- 194 min read · 17,175 words
- Prerequisites
- T05 · Scripting in Unity: Lifecycle, Serialization, Coroutines & Awaitable, T07 · Input System, Movement & Cameras (Cinemachine), T09 · 2D Development: Sprites, Tilemaps, 2D Physics, 2D Animation & Lights
- Courses merged
- 9 course files
- Your progress
- —
- Run a game as a small state machine (ready, playing, paused, won, lost) where one method decides what each state turns on and off
- Keep scene loading, music and settings alive across scenes with a bootstrapped persistent object, without duplicate singletons or stale statics
- Load scenes by name, by index, in the background with a progress bar and additively, and pass data between them
- Pause a game properly: time, physics, animation, sound and player input, while menus keep working
- Build menus and a HUD with uGUI: Canvas Scaler, anchors, TextMeshPro, buttons, sliders, keyboard and gamepad navigation
- Play sound effects and music through an Audio Mixer, expose its volumes and drive them from sliders in decibels
- Store settings and a best time with PlayerPrefs, and know why saves need something else
- Make a Windows build with Build Profiles and Player Settings, read its log, and know what differs between the Editor and a build
A level is not yet a game. A game has a way in (a title screen), a beginning that waits for the player, an end that says whether they won, a way to stop for a moment, a way to try again, sound, settings that are remembered, and a file someone else can run. None of these is hard on its own. The difficulty is that they all touch each other: pausing has to stop the clock, the sound and the controls while the menu keeps working, and loading a scene must not destroy the music or create a second copy of anything.
This chapter covers all of those parts for Unity 6.3, and then puts them together by finishing Lantern Cliffs, the 2D platformer from T09 · 2D Development: Sprites, Tilemaps, 2D Physics, 2D Animation & Lights, as a complete small game with a title screen, lives, a clock, a pause menu, music, sound effects, volume settings, a best time and a Windows build. Every script compiles against Unity 6.3 with no warnings and is in the sandbox under Assets/Handbook/T10. You need T05 · Scripting in Unity: Lifecycle, Serialization, Coroutines & Awaitable's lifecycle and Awaitable, T07 · Input System, Movement & Cameras (Cinemachine)'s action maps, and the Lantern Cliffs project from T09 · 2D Development: Sprites, Tilemaps, 2D Physics, 2D Animation & Lights. The sections before the guided build are general: they work the same way in 3D.
The big picture#
A game runs two loops at very different speeds. The frame loop is Unity's: about sixty times a second it reads input, steps physics, calls every Update, animates and draws. You met it in T05 · Scripting in Unity: Lifecycle, Serialization, Coroutines & Awaitable. The game loop is yours, and it runs at the speed of the player: title screen, play, win or lose, try again. A frame lasts 16 milliseconds; one turn of the game loop lasts minutes.
The parts of this chapter map onto that picture:
- States are the boxes of the game loop inside one scene: ready, playing, paused, won, lost. One component owns them and decides what each one turns on and off.
- Scenes are the bigger boxes: the title screen and each level. Loading one destroys the last, so anything that must last for the whole game (music, settings, the fader that hides loading) lives on an object that outlives scenes.
- Time is how a state reaches into the frame loop. Pausing doesn't stop the frame loop, because the pause menu still has to be drawn and clicked. It stops the clock that physics, animation and your game code read.
- UI shows the states (a HUD while playing, a panel for each of the others), and its buttons are how the player moves between them.
- Audio tells the player what happened without them having to look, and an Audio Mixer gives them control of how loud it is.
- PlayerPrefs remembers the few things that must survive quitting: the volume settings and the best time.
- Builds turn the project into a program that runs without Unity, which is where several differences from Play mode show up for the first time.
Game states#
"Is the game running?" sounds like a yes-or-no question, so the first version of most games has a bool: isGameOver. Then the game needs a pause, and a second bool appears, then a countdown before play starts, then a win screen. Four bools can be combined in sixteen ways, and only five of those combinations mean anything. The others (paused and game over, won and lost at once) are bugs waiting for the right sequence of key presses.
The fix is to name the states the game can actually be in, as an enum, and keep exactly one of them current:
public enum RunState { Ready, Playing, Paused, Won, Lost }
public RunState State { get; private set; }A variable that holds one state out of a fixed set, plus rules for moving between them, is a finite state machine. It can't be half paused, because State has one value. Lantern Cliffs' run has five states:
One method changes state#
The important rule is that the state changes in exactly one place. Lantern Cliffs' CliffsGame has a private Enter(RunState next), and everything that wants a different state (a timer, a key press, a button, the last flask) calls a public method that ends up there. Enter sets everything that depends on the state, from scratch, every time:
| State | Player controls | Time.timeScale | Sound | Panel |
|---|---|---|---|---|
| Ready | off | 1 | playing | "Get ready" |
| Playing | on | 1 | playing | none (the HUD) |
| Paused | off | 0 | paused, except music and menus | pause menu |
| Won | off | 1 | playing, win jingle | result and time |
| Lost | off | 1 | playing, lose jingle | reason, retry |
Setting everything, rather than changing only what differs from the previous state, is what makes this robust. If Enter only turned the controls back on when leaving Paused, a path you hadn't thought of (Paused straight to Lost, say, through a timer) would leave them off. When every state sets every setting, the order in which states happen can't matter.
using System;
using Handbook.T09;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
namespace Handbook.T10
{
public enum RunState { Ready, Playing, Paused, Won, Lost }
// Runs one attempt at the level: a moment to get ready, then play until every flask
// is in (won) or the keeper runs out of lives (lost). It owns the clock, pausing,
// the end panels and the best time.
public class CliffsGame : MonoBehaviour
{
public const string BestTimeKey = "LanternCliffs.BestTime";
[SerializeField] FlaskCounter flasks;
[SerializeField] KeeperLives lives;
[SerializeField] float readyTime = 1.5f;
[SerializeField] string titleScene = "Title";
[Header("Panels")]
[SerializeField] MenuPanel readyPanel;
[SerializeField] MenuPanel pausePanel;
[SerializeField] MenuPanel wonPanel;
[SerializeField] MenuPanel lostPanel;
[SerializeField] TMP_Text wonText;
[SerializeField] TMP_Text lostText;
[Header("Sounds")]
[SerializeField] AudioSource jingles; // win and lose, through the Effects group
[SerializeField] AudioClip winSound;
[SerializeField] AudioClip loseSound;
public RunState State { get; private set; }
public float Elapsed { get; private set; }
public event Action<RunState> StateChanged;
InputAction pause;
InputActionMap player;
float readyUntil;
void Awake()
{
pause = InputSystem.actions.FindAction("UI/Pause", throwIfNotFound: true);
player = InputSystem.actions.FindActionMap("Player", throwIfNotFound: true);
}
void OnEnable()
{
flasks.Changed += OnFlasksChanged;
lives.Changed += OnLivesChanged;
}
void OnDisable()
{
flasks.Changed -= OnFlasksChanged;
lives.Changed -= OnLivesChanged;
}
void Start()
{
readyUntil = Time.time + readyTime;
Enter(RunState.Ready);
}
void Update()
{
switch (State)
{
case RunState.Ready:
if (Time.time >= readyUntil) Enter(RunState.Playing);
break;
case RunState.Playing:
Elapsed += Time.deltaTime;
if (pause.WasPressedThisFrame()) Enter(RunState.Paused);
break;
case RunState.Paused:
if (pause.WasPressedThisFrame()) Enter(RunState.Playing);
break;
}
}
// Whatever state the scene ends in, the next scene gets normal time, sound and controls.
void OnDestroy()
{
Time.timeScale = 1f;
AudioListener.pause = false;
player?.Enable();
}
// ---- Called by buttons and by other components ----
public void Pause()
{
if (State == RunState.Playing) Enter(RunState.Paused);
}
public void Resume()
{
if (State == RunState.Paused) Enter(RunState.Playing);
}
public void Lose(string reason)
{
if (State != RunState.Playing) return;
lostText.text = reason;
Enter(RunState.Lost);
}
public void Restart() => GameSystems.Instance.Scenes.Load(SceneManager.GetActiveScene().name);
public void QuitToTitle() => GameSystems.Instance.Scenes.Load(titleScene);
// ---- The state machine ----
void Enter(RunState next)
{
RunState previous = State;
State = next;
// Only Playing gives the player control. Only Paused stops time and sound.
if (next == RunState.Playing) player.Enable();
else player.Disable();
Time.timeScale = next == RunState.Paused ? 0f : 1f;
AudioListener.pause = next == RunState.Paused;
SetPanel(readyPanel, next == RunState.Ready);
SetPanel(pausePanel, next == RunState.Paused);
SetPanel(wonPanel, next == RunState.Won);
SetPanel(lostPanel, next == RunState.Lost);
if (next == RunState.Won) FinishWon();
if (next == RunState.Lost) jingles.PlayOneShot(loseSound);
if (next != previous) StateChanged?.Invoke(next);
}
static void SetPanel(MenuPanel panel, bool open)
{
if (open && !panel.IsOpen) panel.Show();
else if (!open && panel.IsOpen) panel.Hide();
}
void FinishWon()
{
jingles.PlayOneShot(winSound);
float best = PlayerPrefs.GetFloat(BestTimeKey, float.PositiveInfinity);
if (Elapsed < best)
{
PlayerPrefs.SetFloat(BestTimeKey, Elapsed);
PlayerPrefs.Save(); // write it now, in case the game crashes later
wonText.text = $"The lamp will burn all night.\nTime {TimeText.Format(Elapsed)}: a new best!";
}
else
{
wonText.text = $"The lamp will burn all night.\nTime {TimeText.Format(Elapsed)}, best {TimeText.Format(best)}";
}
}
void OnFlasksChanged(int collected, int total)
{
if (State == RunState.Playing && total > 0 && collected == total) Enter(RunState.Won);
}
void OnLivesChanged(int left)
{
if (left == 0) Lose("The keeper is too battered to go on.");
}
}
}Read it in three parts:
Updateonly checks what can end the current state. Ready waits for its time to run out; Playing counts the clock and watches the Pause action; Paused watches the Pause action to resume. Won and Lost wait for a button, so they check nothing.- The public methods are guarded.
Pausedoes nothing unless the game is Playing, andLosedoes nothing unless it's Playing, so a button pressed twice, or a crab that bites after the last flask, can't break the sequence. The guards are the arrows of the diagram written as code. - Other objects find out through an event.
StateChangedfires after each change. The run doesn't know who listens; music, a muffling effect or analytics can all subscribe later without editing it. T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory explains the pattern.
When an enum and a switch aren't enough#
An enum with one Enter method suits a handful of states with simple rules, which covers the flow of most small games. It gets awkward when each state has a lot of its own code and data (an enemy with patrol, chase, search and flee behaviours), when states nest (a "combat" state with its own sub-states), or when designers want to edit the graph. Then each state becomes its own class with Enter, Tick and Exit, or you reach for a hierarchical state machine or a behaviour tree. T15 · State Machines, Hierarchical FSMs & Behaviour Trees builds those. Don't start there: a game flow that fits in one screen of code is easier to read as one screen of code.
Objects that outlive a scene#
Loading a scene in the normal way destroys every object in the current one. That is usually what you want: the next level starts clean, and restarting a level by loading it again resets every crab, flask and counter without any code. But a few things must survive. Music that restarts on every scene load sounds broken. The fade that hides a scene change needs to exist both before and after it. Settings have to be applied once, not by every scene.
DontDestroyOnLoad#
Object.DontDestroyOnLoad(gameObject) moves an object into a special scene that is never unloaded. During Play you can see it in the Hierarchy as a scene named DontDestroyOnLoad. Its children go with it. It works only on root objects: called on a child, it logs a warning and does nothing, so give persistent objects a root of their own.
The classic way to use it is a manager placed in the first scene that calls DontDestroyOnLoad on itself in Awake and puts itself in a static Instance property, so other scripts can find it. This is the singleton pattern. It has a well-known trap: if the scene that contains the manager is loaded again (the player returns to the title screen), a second manager is created, and now two of them play music. Every tutorial's fix is a duplicate check:
void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject); // a copy from a scene loaded again
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}It works, but it has two weaknesses. The manager exists only if you pressed Play in the scene that contains it: start from a level to test it, and Instance is null. And the duplicate does exist for one frame, so its Awake and OnEnable run, which matters if they start things.
Creating it from code, before any scene#
A sturdier way is to create the persistent object from code before the first scene loads. A static method marked with [RuntimeInitializeOnLoadMethod] runs automatically when the game starts, in builds and in Play mode, whatever scene you started in. With the load type BeforeSceneLoad it runs after the first scene's objects are loaded but before any of their Awake methods, so the persistent object is ready before anything in the scene asks for it.
using UnityEngine;
namespace Handbook.T10
{
// The one object that lives for the whole run of the game: scene loading, music and the
// volume settings. It's a prefab in a Resources folder, created by code before the first
// scene loads, so it exists whichever scene you press Play in.
public class GameSystems : MonoBehaviour
{
[SerializeField] SceneFader scenes;
[SerializeField] MusicPlayer music;
[SerializeField] Volumes volumes;
public static GameSystems Instance { get; private set; }
public SceneFader Scenes => scenes;
public MusicPlayer Music => music;
public Volumes Volumes => volumes;
// With domain reload off, a static keeps its value from the last Play session,
// when it pointed at an object that has since been destroyed. Clear it first.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ClearInstance() => Instance = null;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void Create()
{
var prefab = Resources.Load<GameSystems>("GameSystems");
if (prefab == null)
{
Debug.LogWarning("GameSystems: no GameSystems prefab in a Resources folder, so none was created.");
return;
}
Instance = Instantiate(prefab);
Instance.name = prefab.name;
DontDestroyOnLoad(Instance.gameObject);
}
}
}The method loads a prefab named GameSystems from a folder named Resources, instantiates it, and marks it DontDestroyOnLoad. There's exactly one, there's always one, and no scene contains it, so no scene load can duplicate it. The prefab holds the scene fader, the music player and the volume settings, each a component that does one job; GameSystems only creates them and hands them out.
The first [RuntimeInitializeOnLoadMethod], at SubsystemRegistration (the earliest point there is), sets Instance back to null. That's for the Universal 2D template, which turns domain reload off, so static fields keep their values between Play sessions. Without it, Instance would still point at last session's object, now destroyed, until Create replaced it. T05's section on statics explains the problem; this is its standard fix.
The other common shape is a boot scene: scene 0 of the build holds the persistent objects and immediately loads the title screen. It has the same advantages in a build, and you can see and edit its objects in a scene. In the Editor, though, you have to start Play from the boot scene, or set EditorSceneManager.playModeStartScene from an editor script so Play always begins there. Either shape is fine. Pick one and use it for every persistent object, so there's one answer to "where does this live?".
What should persist, and what shouldn't#
Make as little persistent as you can. Lantern Cliffs keeps only scene loading, music and volume settings in GameSystems. The run itself (the clock, the lives, the flask count) lives in the level scene, on ordinary components. That way Restart is just "load this scene again": everything that belongs to one attempt is destroyed and recreated, and there's no reset code to forget a field in. A persistent "game manager" that holds the score, the lives and the level state has to be reset by hand for every restart and every new level, and each forgotten field is a bug that only shows on the second attempt.
Scenes and loading#
The scene list#
A build only contains the scenes in its scene list, and you can load only those. In Unity 6 the list lives in File > Build Profiles, in the Scene List section: drag scenes in from the Project window, or open a scene and click Add Open Scenes. Untick a scene to leave it out of builds without removing it. The platform profiles share one list; a build profile you add with Add Build Profile can override it, so a demo profile can ship fewer levels. From code, EditorBuildSettings.scenes returns the active profile's list.
The list's order matters in two ways. The scene at index 0 is the one a build starts with, so put the title screen (or the boot scene) there. And each scene's position is its build index, which you can load by number. In the Editor, Play starts in whatever scene is open, whatever its index, which is why bootstrapping from code is so convenient during development.
Loading a scene#
using UnityEngine;
using UnityEngine.SceneManagement;
namespace Handbook.T10
{
// The simplest scene changes, for buttons to call: by name, the next one in the
// scene list, or the current one again.
public class SceneSwitcher : MonoBehaviour
{
public void LoadByName(string sceneName) => SceneManager.LoadScene(sceneName);
public void LoadNext()
{
int next = SceneManager.GetActiveScene().buildIndex + 1;
if (next < SceneManager.sceneCountInBuildSettings)
SceneManager.LoadScene(next);
else
SceneManager.LoadScene(0); // after the last level, back to the first scene
}
public void Reload() => SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}
}SceneManager.LoadScene takes a scene's name (its file name without .unity), its path, or its build index. Names are easy to read and break when you rename a scene; indices survive renames and break when you reorder the list. Neither is checked by the compiler, so keep scene names in one place (a constant, a serialized field) rather than scattered through scripts.
What happens when you call it, in order:
- Nothing yet. The scene loads in the next frame, not during the call, so the rest of this frame's code still runs, in the old scene. Code written after
LoadScenecan't use the new scene. - Every object in the old scene gets
OnDisableandOnDestroy, except those markedDontDestroyOnLoad. - The new scene's objects are created, and get
AwakeandOnEnable;Startfollows before their firstUpdate.
The whole load then happens within one frame, so the game freezes while it reads the scene. For a small scene that's a hitch you won't notice; for a big one it's a frozen screen for a second or more, which is what background loading is for.
Loading in the background#
SceneManager.LoadSceneAsync starts the same load and returns at once with an AsyncOperation. The scene loads over several frames while the current scene keeps running, so a fade, a spinner or a progress bar can animate. You can await it, poll its progress and isDone, or subscribe to completed.
It can also be held back. Set allowSceneActivation to false and the scene loads but doesn't switch: it waits until you set it to true. While it waits, progress stops at 0.9, not 1. The last tenth is the switch itself: the new scene's objects waking up and the old scene unloading.
A loading screen uses both: load in the background with a bar, then wait for the player to press a key before switching, so they can read a tip.
using System;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
namespace Handbook.T10
{
// A loading scene: loads the next scene in the background with a progress bar,
// then waits for a key press before switching to it.
public class LoadingScreen : MonoBehaviour
{
[SerializeField] string sceneToLoad = "Cliffs";
[SerializeField] Image progressFill; // Image Type Filled, Fill Method Horizontal
[SerializeField] TMP_Text prompt;
async Awaitable Start()
{
try
{
AsyncOperation loading = SceneManager.LoadSceneAsync(sceneToLoad);
loading.allowSceneActivation = false; // load, but don't switch yet
// Held back like this, progress stops at 0.9: the last tenth is the switch itself.
while (loading.progress < 0.9f)
{
progressFill.fillAmount = loading.progress / 0.9f;
await Awaitable.NextFrameAsync(destroyCancellationToken);
}
progressFill.fillAmount = 1f;
prompt.text = "Press any key";
while (!AnyPress())
await Awaitable.NextFrameAsync(destroyCancellationToken);
loading.allowSceneActivation = true; // this scene unloads as the new one starts
}
catch (OperationCanceledException)
{
}
}
// Either device may be missing, so check each one before asking it anything.
static bool AnyPress() =>
(Keyboard.current != null && Keyboard.current.anyKey.wasPressedThisFrame)
|| (Gamepad.current != null && (Gamepad.current.buttonSouth.wasPressedThisFrame
|| Gamepad.current.startButton.wasPressedThisFrame));
}
}It divides progress by 0.9 so the bar fills completely. Start is an async Awaitable method, which Unity allows (T05 · Scripting in Unity: Lifecycle, Serialization, Coroutines & Awaitable), and each wait passes destroyCancellationToken, so the method stops cleanly if the loading scene is destroyed first. Use this scene by loading it normally from the title screen: it's small, so it appears at once, and it loads the big scene in the background.
LoadSceneAsync still does some work on the main thread, mostly when the new scene's objects wake up, so a big scene can hitch at activation even when loaded in the background. Splitting a level into several smaller scenes loaded additively, and doing expensive set-up over several frames, keeps that hitch small.
Several scenes at once#
By default a load is LoadSceneMode.Single: it replaces every loaded scene. With LoadSceneMode.Additive the new scene is added beside the ones already loaded, and SceneManager.UnloadSceneAsync removes one later. Studios use this to split a game into layers: a scene for the systems, one for the HUD, one for the level geometry, one for the lighting, each loaded and unloaded on its own. Big worlds stream in pieces this way as the player moves.
With several scenes loaded, one of them is the active scene (SceneManager.SetActiveScene). New objects created with Instantiate go into it, and its lighting settings (skybox, ambient light) are the ones used. You can drag several scenes into the Hierarchy in the Editor to edit them together. Unity won't unload the last loaded scene; there must always be one.
Scene events, and passing data on#
SceneManager.sceneLoaded fires after each scene loads, with the scene and the mode; sceneUnloaded and activeSceneChanged are its partners. A persistent object can subscribe to react to every load, for example to find the new level's spawn point. Unsubscribe in OnDestroy, as with any event a longer-lived object raises.
Data doesn't pass from one scene to the next by itself, because the objects that held it are destroyed. The ways to carry it:
| Way | Good for | Watch out for |
|---|---|---|
| A field on a persistent object | The current level, the chosen character, the score across levels | Resetting it when a new game starts |
| A static field | A small value from a menu, such as the selected difficulty | Survives between Play sessions with domain reload off (T05 · Scripting in Unity: Lifecycle, Serialization, Coroutines & Awaitable) |
| A ScriptableObject asset | Shared settings and data that several scenes reference (T13 · ScriptableObjects & Data-Driven Architecture) | In the Editor, changes made in Play mode stay in the asset |
| PlayerPrefs or a save file | Anything that must survive quitting | Disk writes; PlayerPrefs suits settings, not saves (T16 · Saving & Loading: Serialization, Save Architecture & Versioning) |
Lantern Cliffs passes nothing between scenes except the best time, which has to survive quitting anyway, so it goes through PlayerPrefs.
Hiding the change: a fader#
A cut from one scene to another looks abrupt, and for one frame you may see the new scene before its Start methods have arranged it. A fade to black hides both. The fader has to live on a persistent object, because it starts in the old scene and finishes in the new one:
using System;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace Handbook.T10
{
// Loads scenes behind a fade to black. It counts unscaled time, so it also works
// from a paused game, where Time.timeScale is 0.
public class SceneFader : MonoBehaviour
{
[SerializeField] CanvasGroup curtain; // a full-screen black Image on its own Canvas
[SerializeField] float fadeTime = 0.35f;
public bool Busy { get; private set; }
void Awake()
{
curtain.alpha = 0f;
curtain.blocksRaycasts = false;
}
// For buttons and events, which need a void method. The work is in LoadAsync.
public async void Load(string sceneName)
{
try
{
await LoadAsync(sceneName);
}
catch (OperationCanceledException)
{
// Play mode ended or the game quit during the fade.
}
}
public async Awaitable LoadAsync(string sceneName)
{
if (Busy) return;
Busy = true;
curtain.blocksRaycasts = true; // no clicks get through while the scene changes
await Fade(1f);
AsyncOperation loading = SceneManager.LoadSceneAsync(sceneName);
if (loading == null)
Debug.LogError($"SceneFader: '{sceneName}' isn't in the scene list of the active build profile.");
else
await Awaitable.FromAsyncOperation(loading, destroyCancellationToken);
await Fade(0f);
curtain.blocksRaycasts = false;
Busy = false;
}
async Awaitable Fade(float target)
{
float from = curtain.alpha;
for (float t = 0f; t < fadeTime; t += Time.unscaledDeltaTime)
{
curtain.alpha = Mathf.Lerp(from, target, t / fadeTime);
await Awaitable.NextFrameAsync(destroyCancellationToken);
}
curtain.alpha = target;
}
}
}The curtain is a full-screen black Image on its own Canvas, drawn above every other UI, with a Canvas Group to fade it. LoadAsync fades to black, loads the scene in the background and awaits it with Awaitable.FromAsyncOperation, then fades back. While the curtain is up it blocks clicks, so the player can't press a button in a scene that's about to vanish, and Busy ignores a second load requested during the first. The fade counts Time.unscaledDeltaTime, because "Quit to title" is pressed from the pause menu, where scaled time has stopped; with deltaTime the fade would never finish.
Load is an async void wrapper so buttons and events can call it; the wrapper catches the cancellation that happens if Play mode ends mid-fade, which would otherwise be logged as an error. If the scene isn't in the scene list, LoadSceneAsync logs an error and returns null instead of an operation, so the fader says which scene was missing and fades back in rather than leaving the screen black.
Time and pausing#
Every game tutorial pauses with one line, Time.timeScale = 0, and it's the right line. But it's only a third of a pause. It stops the clock that most of the game reads; it doesn't stop sound, and it doesn't stop input. A real pause has three parts, and CliffsGame.Enter sets all three together:
if (next == RunState.Playing) player.Enable();
else player.Disable();
Time.timeScale = next == RunState.Paused ? 0f : 1f;
AudioListener.pause = next == RunState.Paused;What timeScale stops#
Time.timeScale multiplies game time. At 1 it runs normally, at 0.5 at half speed, at 0 not at all. Unity keeps running frames at 0: Update is still called every frame and the screen still draws, which is why a pause menu can work at all. What changes is the time that things read:
| Thing | At timeScale 0 | To keep it running |
|---|---|---|
Update, LateUpdate | Still called; Time.deltaTime is 0 | Use Time.unscaledDeltaTime |
FixedUpdate and physics | Not called; bodies freeze in place | Nothing: a paused game shouldn't simulate |
Time.time | Stops | Time.unscaledTime, Time.realtimeSinceStartup |
| Animator | Stops (Update Mode Normal) | Update Mode Unscaled Time |
| Particle systems | Stop | Main module's useUnscaledTime |
| Coroutines | yield return null continues; WaitForSeconds never ends | WaitForSecondsRealtime |
Invoke, InvokeRepeating | Wait forever | A coroutine or Awaitable on unscaled time |
| Audio | Keeps playing | To stop it: AudioListener.pause |
| Input System actions | Keep firing (in the default update mode) | To stop them: disable the action map |
| uGUI | Keeps working; button transitions use unscaled time | Nothing needed |
| Audio Mixer snapshots | Transitions stop (Update Mode Normal) | Mixer Update Mode Unscaled Time |
Read the table as a checklist. Your own scripts are the main thing to check: a script that moves with deltaTime stops by itself, but one that counts frames, or uses Time.unscaledDeltaTime for something in the world, keeps going. Scripts that must run in the pause menu (the fader, the music's fades, a menu animation) use unscaled time on purpose, and should say so in a comment.
Pausing sound#
Audio runs on its own clock, so a paused game keeps playing every sound. Setting AudioListener.pause to true pauses every Audio Source in the game at once, and setting it back to false resumes them where they stopped. Sources whose ignoreListenerPause is true carry on: that's what you want for music and for the menu's own click sounds. The Audio Source Inspector doesn't show this setting, so it's set from code: MusicPlayer sets it in Awake, and Exercise 1 does the same for button sounds.
The alternative is to keep sound running and change the mix instead: muffle the music with a low-pass filter and duck the effects. That's a mixer snapshot, which Exercise 6 builds. Many games combine both: effects pause, music carries on but muffled.
Pausing input#
With time stopped, the keeper can't move, because CliffRunner moves through physics. But actions still fire, so a jump pressed during the pause is buffered and happens the moment play resumes, and a script that reacts to an action straight away (opening a map, firing) works in the pause. The fix is T07's action-map switch: CliffsGame disables the whole Player map in every state except Playing, so Ready, Paused, Won and Lost all ignore movement and jumping without each script having to check.
That raises a question: if the Player map is off, what turns the pause off? The Pause action lives in the UI map, which stays enabled, alongside the actions the Event System uses to navigate menus. In T07 · Input System, Movement & Cameras (Cinemachine)'s Exercise 6 the pause key read the keyboard directly to get round the same problem; an action in the UI map does the job with rebindable controls and any device.
Slow motion#
Values between 0 and 1 give slow motion: 0.25 for a dramatic moment, 0.05 for a near-freeze on a big hit. Physics needs one extra line: it still steps every Time.fixedDeltaTime of game time, so at 0.25 it runs a quarter as many steps per real second, and moving bodies stutter. Scale the step with the time, and restore both afterwards:
Time.timeScale = 0.25f;
Time.fixedDeltaTime = 0.02f * Time.timeScale; // 0.02 is the default stepRigidbody interpolation (T08 · 3D Physics: Rigidbodies, Colliders, Raycasts & Character Controllers) also smooths it. Values above 1 speed the game up, which is handy for testing a long level; past about 2, physics takes bigger steps than it was tuned for.
UI with uGUI#
Unity has two systems for game UI. uGUI (Unity UI, the com.unity.ugui package) builds UI from GameObjects with components: a Canvas, Images, Text, Buttons, laid out with RectTransforms in the Scene view. UI Toolkit builds it from UXML documents styled with USS, a web-like approach, edited in the UI Builder. Unity is investing in UI Toolkit, and it's what the Editor's own windows use. uGUI is mature, fully supported in Unity 6, used by most tutorials, courses and Asset Store packages, and works directly with the things you already know (GameObjects, the Animator, prefabs, materials). This chapter uses uGUI. T20 · UI: uGUI and UI Toolkit, HUDs, Menus & Data Binding covers both in depth, including when to choose UI Toolkit.
The Canvas#
Every uGUI element lives under a Canvas. Create one with GameObject > UI (Canvas) > Canvas, or let Unity create it when you add the first element from the same menu, which offers Image, Raw Image, Panel, Text, Button, Dropdown and Input Field (the last four in TextMeshPro versions), Toggle, Slider, Scrollbar, Scroll View and Event System, with the old text-based controls under Legacy. The new Canvas comes with a Canvas Scaler and a Graphic Raycaster (which finds what the pointer is over), and an Event System object appears beside it if the scene has none.
The Canvas's Render Mode decides where the UI is drawn:
- Screen Space - Overlay: drawn on top of everything, after the cameras, at the screen's resolution. The usual choice for HUDs and menus. With several overlay canvases, Sort Order decides which is on top; higher draws later.
- Screen Space - Camera: drawn by a chosen camera at a set distance in front of it, so post-processing and the camera's settings apply to it, and 3D objects can appear in front of it.
- World Space: a flat object in the scene with a position and size in world units, for health bars over enemies, signs and in-world screens.
Fitting every screen: the Canvas Scaler#
The Canvas Scaler decides how big UI is on different screens. A Canvas made from the menu starts at Constant Pixel Size, which keeps every element the same number of pixels: a button that looks right on a 1080p monitor is half as big on a 4K one and too big on a small window. For games, switch it to Scale With Screen Size:
- Reference Resolution: the screen size you design at; 1920 × 1080 is a good choice. UI is laid out in these units, and scaled to the real screen.
- Screen Match Mode Match Width Or Height with Match between 0 (scale with the width) and 1 (with the height). At 0.5 it blends both, so a wider or taller screen than the reference neither squashes nor overflows much. Landscape games often use 0.5 or lean towards height; portrait phone games lean towards width.
The scaler's default reference is 800 × 600 with Match 0, a leftover from an older era; change both when you switch mode. Test the result by picking different aspect ratios from the Game view's resolution menu, including a very wide one and a 4:3 one.
RectTransform and anchors#
UI elements have a RectTransform instead of a Transform: a rectangle with a size, a pivot (the point it rotates and scales around, from (0, 0) at the bottom left to (1, 1) at the top right) and anchors. Anchors say which point of the parent rectangle the element is attached to, so they decide what happens when the parent changes size:
When an element's anchors are together (Min equals Max), the Inspector shows Pos X, Pos Y, Width and Height: a fixed-size rectangle at an offset from that anchor. When they're apart, it shows Left, Right, Top and Bottom instead: distances from the anchors, so the element stretches. The quickest way to set them is the Anchor Presets square at the top left of the RectTransform: click a preset to set the anchors, hold Shift to set the pivot too, and Alt to move the element there as well. Shift+Alt with the bottom-right "stretch both ways" preset makes an element fill its parent, which is how a full-screen panel or the fader's curtain is made.
Use the Scene view's 2D mode and the Rect tool (T) to edit UI; press F with the Canvas selected to frame it. An overlay Canvas looks enormous in the Scene view next to a 2D level, because one UI unit is one pixel of the reference resolution; that's expected.
Layout groups#
Positioning ten menu buttons by hand is tedious, and adding an eleventh means moving them all. Vertical Layout Group, Horizontal Layout Group and Grid Layout Group arrange their children for you, with spacing, padding and alignment; reorder children in the Hierarchy and the layout follows. A Layout Element on a child overrides its preferred or minimum size, and a Content Size Fitter makes a rectangle grow to fit its content, such as a text box that fits its text. Layout costs a little every time the layout changes, so use groups for menus and lists, not for HUD elements that change every frame.
Images#
An Image draws a sprite (or a plain colour with no sprite) tinted by its Color. Its Image Type matters more than it looks:
- Simple stretches the sprite to the rectangle; Preserve Aspect keeps its shape, and Set Native Size resizes the rectangle to the sprite.
- Sliced uses the sprite's 9-slice borders (set in the Sprite Editor, T09 · 2D Development: Sprites, Tilemaps, 2D Physics, 2D Animation & Lights), so a panel or button background stretches without stretching its corners.
- Tiled repeats the sprite to fill the rectangle.
- Filled shows part of the sprite: horizontally, vertically or radially, by
fillAmountfrom 0 to 1. Health bars, cooldown clocks and the loading bar above use it.
A Raw Image draws a texture rather than a sprite, which is what you need for a render texture, such as a minimap camera's output or a webcam image.
Text with TextMeshPro#
Text in Unity 6 is TextMeshPro, built into the uGUI package. Instead of drawing letters from a bitmap at one size, it uses a signed distance field (SDF) font atlas: a texture that stores, for each pixel, how far it is from the edge of a letter. The shader turns that distance into a sharp edge at any size, and the same data gives outlines, glows and drop shadows from the material, without extra textures.
- The first time you add a TextMeshPro object, Unity offers to import the TMP Essential Resources (the default font, shaders and settings). Accept: nothing renders without them. You can also do it later from Window > TextMeshPro > Import TMP Essential Resources.
- To use your own font, import the
.ttfor.otffile and make a font asset from it: select it and choose Assets > Create > TextMeshPro > Font Asset > SDF, or open Window > TextMeshPro > Font Asset Creator for full control over the characters, atlas size and render mode. For pixel fonts, the same menu's Bitmap font asset keeps letters' hard pixels instead of smoothing them. - Text supports rich text tags:
<b>,<i>,<color=#ffcc66>,<size=150%>, and<sprite=0>for icons from a sprite asset. - In code, the type to reference is
TMP_Text, the base class of both the UI version (TextMeshProUGUI) and the world-space one (TextMeshPro). It's in theTMPronamespace.
Setting text to a new string is fine for things that change rarely: the result panel, a dialogue line. For numbers that change often, SetText takes a format with up to several numeric arguments and writes them into the text without creating a new string, so a clock that updates ten times a second makes no garbage:
clockText.SetText("{0}:{1:00}.{2}", minutes, seconds, tenths);
flaskText.SetText("{0}/{1}", collected, total);Buttons, sliders and the other controls#
uGUI's interactive controls all derive from Selectable, which gives them an Interactable tick box, a Transition (how they look when normal, highlighted, pressed, selected and disabled: Color Tint, Sprite Swap or Animation) and Navigation (below). Each control then raises a UnityEvent when used:
| Control | Event | Typical use |
|---|---|---|
| Button | onClick | Menu actions: Play, Resume, Quit |
| Toggle | onValueChanged(bool) | On/off settings; a Toggle Group makes radio buttons |
| Slider | onValueChanged(float) | Volumes, sensitivity, brightness |
| Dropdown (TMP) | onValueChanged(int) | Resolution, quality level, language |
| Input Field (TMP) | onValueChanged, onEndEdit | Names, seeds, chat |
| Scrollbar, Scroll View | onValueChanged | Long lists, credits |
You can wire these events in the Inspector: click + under the event, drag in an object, and pick a component's method from the drop-down. The method must be public and return void, with no parameter or one parameter of a simple type (int, float, string, bool or an Object reference), which the Inspector then asks you to fill in. For Slider, Toggle and Dropdown, the drop-down also has a Dynamic section: methods listed there receive the control's current value instead of a fixed one.
Or wire them in code, with AddListener and RemoveListener, which keeps the connection visible in the script and survives renaming the method:
using UnityEngine;
using UnityEngine.UI;
namespace Handbook.T10
{
// Connects a Slider to one of the volume settings in GameSystems.
[RequireComponent(typeof(Slider))]
public class VolumeSlider : MonoBehaviour
{
[SerializeField] string parameter = "MusicVolume";
Slider slider;
void Awake()
{
slider = GetComponent<Slider>();
slider.minValue = 0f;
slider.maxValue = 1f;
}
void OnEnable()
{
// Without notify: showing the saved value isn't a change to save again.
slider.SetValueWithoutNotify(GameSystems.Instance.Volumes.Get(parameter));
slider.onValueChanged.AddListener(OnMoved);
}
void OnDisable() => slider.onValueChanged.RemoveListener(OnMoved);
void OnMoved(float value) => GameSystems.Instance.Volumes.Set(parameter, value);
}
}Inspector wiring is quick and lets designers rearrange menus without code; but the Inspector stores the method's name as a string, so renaming the method silently breaks the button. Code wiring is safer and searchable. A reasonable split: Inspector wiring for menu buttons that call one obvious method, code for anything that passes values or must not break. Whichever you choose, choose one per control: a button wired in both places runs its method twice.
Note SetValueWithoutNotify in VolumeSlider. Setting slider.value from code raises onValueChanged, exactly as if the player had moved it. Here that would save the value that was just loaded, which is harmless; elsewhere it can cause loops (a value change that sets the value) or play a sound when a menu opens. Toggles, dropdowns and input fields have the same method.
Keyboard and gamepad navigation#
A game played with a gamepad must be playable with a gamepad all the way, menus included. The Event System keeps track of one selected control, moves the selection when the player presses a direction, and presses the selected control on Submit. The arrows come from each control's Navigation setting: Automatic (the nearest control in that direction, which is right for most menus), Horizontal, Vertical, None, or Explicit, where you pick the neighbour for each direction. Click Visualize under Navigation to see the arrows in the Scene view.
Navigation needs a starting point. If nothing is selected, pressing a direction does nothing, and a gamepad player sees a menu they can't use. The Event System has a First Selected field, but it only applies when the scene starts. For panels that appear later, select a control from code when the panel opens, with SetSelectedGameObject on EventSystem.current. MenuPanel below does this every time it's shown.
The Event System's input module is Input System UI Input Module when the project uses the Input System. Out of the box it uses the package's own default UI actions (Navigate, Submit, Cancel, Point, Click and so on); you can point its Actions Asset at your project's actions to share bindings and rebinding with the rest of the game. Either way it doesn't use the Player map, so disabling that map during a pause leaves the menus working.
Showing and hiding panels#
The obvious way to hide a menu is SetActive(false). It works, but it has costs: activating a big panel rebuilds its layout and Canvas, scripts on it get OnDisable and OnEnable (and lose any state they don't save), and you can't fade it. A Canvas Group on the panel's root controls the whole panel instead:
- Alpha: the opacity of everything under it, for fades.
- Interactable: whether its controls can be used and selected.
- Blocks Raycasts: whether it catches clicks or lets them through to what's behind it.
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace Handbook.T10
{
// Shows and hides a group of UI with a Canvas Group, and selects its first button
// so a keyboard or gamepad can use it straight away.
[RequireComponent(typeof(CanvasGroup))]
public class MenuPanel : MonoBehaviour
{
[SerializeField] bool startOpen;
[SerializeField] Selectable firstSelected;
CanvasGroup group;
public bool IsOpen { get; private set; }
void Awake()
{
group = GetComponent<CanvasGroup>();
Apply(startOpen);
}
// The Event System may not be ready during Awake, so the first selection waits for Start.
void Start()
{
if (IsOpen) SelectFirst();
}
public void Show()
{
Apply(true);
SelectFirst();
}
public void Hide() => Apply(false);
void Apply(bool open)
{
IsOpen = open;
group.alpha = open ? 1f : 0f;
group.interactable = open; // buttons can't be pressed while hidden
group.blocksRaycasts = open; // and clicks pass through to what's behind
}
void SelectFirst()
{
if (firstSelected != null && EventSystem.current != null)
EventSystem.current.SetSelectedGameObject(firstSelected.gameObject);
}
}
}A hidden MenuPanel is invisible, can't be clicked, can't be selected by navigation, and its scripts keep running. Showing it selects its first control. The first selection on scene start waits for Start, because the Event System might not have run its own OnEnable yet when the panel's Awake runs.
For panels that are rarely shown and expensive to keep (a big options menu with a list of graphics settings), SetActive is still reasonable: the cost moves to the moment it opens. For panels that open and close during play, such as a pause menu, the Canvas Group is better.
A HUD that changes only when it must#
A HUD shows game state: flasks, lives, the clock. The simplest HUD sets its texts in Update every frame, which rebuilds the Canvas every frame and, with string formatting, creates garbage every frame for text that changes a few times a minute. Lantern Cliffs' HUD reacts to events instead: FlaskCounter.Changed from T09 · 2D Development: Sprites, Tilemaps, 2D Physics, 2D Animation & Lights and KeeperLives.Changed from this chapter. The clock is the one thing that changes continuously; it redraws when the tenth of a second it shows changes, not every frame.
using Handbook.T09;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Handbook.T10
{
// The on-screen counters. Each one changes only when what it shows changes:
// the flasks and lives through events, the clock once per tenth of a second.
public class HudView : MonoBehaviour
{
[SerializeField] FlaskCounter flasks;
[SerializeField] KeeperLives lives;
[SerializeField] CliffsGame game;
[SerializeField] TMP_Text flaskText;
[SerializeField] TMP_Text clockText;
[SerializeField] Image[] lifeIcons;
int shownTenths = -1;
void OnEnable()
{
flasks.Changed += ShowFlasks;
lives.Changed += ShowLives;
}
void OnDisable()
{
flasks.Changed -= ShowFlasks;
lives.Changed -= ShowLives;
}
void Update()
{
int tenths = Mathf.FloorToInt(game.Elapsed * 10f);
if (tenths == shownTenths) return;
shownTenths = tenths;
// TextMeshPro's SetText formats numbers without making a new string each time.
clockText.SetText("{0}:{1:00}.{2}", tenths / 600, tenths % 600 / 10, tenths % 10);
}
void ShowFlasks(int collected, int total) => flaskText.SetText("{0}/{1}", collected, total);
void ShowLives(int left)
{
for (int i = 0; i < lifeIcons.Length; i++)
lifeIcons[i].enabled = i < left;
}
}
}The HUD reads the game but never changes it, and the game doesn't know the HUD exists. You could delete the HUD and the game would still be won and lost the same way, or build a second HUD for a different layout without touching the game code. That separation, between the model (the state of the game) and the view (how it's shown), is the most useful habit in UI code.
The lives are shown as icons: HudView switches off each Image beyond the lives left, using the Image's enabled so the layout doesn't move. The clock's format and the reason it uses whole tenths are in the next section's maths.
Showing a time#
Formatting a time looks trivial: minutes, then seconds with one decimal. The obvious code formats the leftover seconds with {0:00.0}. The trouble is rounding: 59.97 seconds of leftover rounds to "60.0", so the clock shows 0:60.0 before it shows 1:00.0. Rounding down to whole tenths first and then using only whole numbers avoids it, and it matches what a stopwatch shows: you haven't reached 1:00.0 until you have. The program below compares the two, along with the volume maths from the audio section:
using System;
// Two small calculations behind T10's settings and HUD: turning a volume slider into
// decibels, and showing a time without the 0:60.0 bug. Pure C#, so it runs without Unity.
static class Program
{
static void Main()
{
Console.WriteLine("Volume slider to mixer decibels (20 x log10)");
foreach (float slider in new[] { 1f, 0.8f, 0.5f, 0.25f, 0.1f, 0.01f, 0.0001f })
Console.WriteLine($"slider {slider,6:0.0000}: {Decibels(slider),6:0.0} dB");
Console.WriteLine();
Console.WriteLine("A slider sent straight to the fader (0 to 1 mapped to -80 to 0 dB)");
foreach (float slider in new[] { 1f, 0.75f, 0.5f, 0.25f })
Console.WriteLine($"slider {slider:0.00}: {-80f + 80f * slider,6:0.0} dB, {Math.Pow(10, (-80 + 80 * slider) / 20.0) * 100,7:0.000}% of full signal");
Console.WriteLine();
Console.WriteLine("Showing a time");
foreach (float t in new[] { 9.95f, 59.97f, 83.47f, 600.04f })
Console.WriteLine($"{t,6:0.00} s naive {Naive(t),-7} correct {Correct(t)}");
}
static float Decibels(float slider) => MathF.Log10(MathF.Max(slider, 0.0001f)) * 20f;
// Minutes, then the leftover seconds rounded by the format string: 59.97 rounds up to "60.0".
static string Naive(float seconds) => $"{(int)(seconds / 60)}:{seconds % 60:00.0}";
// Round down to whole tenths first, then use only integer maths.
static string Correct(float seconds)
{
int tenths = (int)MathF.Floor(seconds * 10f);
return $"{tenths / 600}:{tenths % 600 / 10:00}.{tenths % 10}";
}
}Volume slider to mixer decibels (20 x log10)
slider 1.0000: 0.0 dB
slider 0.8000: -1.9 dB
slider 0.5000: -6.0 dB
slider 0.2500: -12.0 dB
slider 0.1000: -20.0 dB
slider 0.0100: -40.0 dB
slider 0.0001: -80.0 dB
A slider sent straight to the fader (0 to 1 mapped to -80 to 0 dB)
slider 1.00: 0.0 dB, 100.000% of full signal
slider 0.75: -20.0 dB, 10.000% of full signal
slider 0.50: -40.0 dB, 1.000% of full signal
slider 0.25: -60.0 dB, 0.100% of full signal
Showing a time
9.95 s naive 0:10.0 correct 0:09.9
59.97 s naive 0:60.0 correct 0:59.9
83.47 s naive 1:23.5 correct 1:23.4
600.04 s naive 10:00.0 correct 10:00.0The naive version is wrong in two ways: it shows 0:60.0, and at 9.95 seconds it shows 0:10.0 while the minutes say 0, so the clock jumps ahead of time. The correct version, used by TimeText.Format and by the HUD, never does either. (9.95 shows as 0:09.9 because the float nearest to 9.95 is a hair below it.)
using UnityEngine;
namespace Handbook.T10
{
public static class TimeText
{
// 83.47 seconds -> "1:23.4". It rounds down to the tenth first and then uses only
// whole numbers, so 59.97 seconds shows as 0:59.9, never as 0:60.0.
public static string Format(float seconds)
{
int tenths = Mathf.FloorToInt(seconds * 10f);
return $"{tenths / 600}:{tenths % 600 / 10:00}.{tenths % 10}";
}
}
}Audio#
Sound does a lot of a game's communicating. A jump that makes a sound feels like it happened; a pickup with a rising chime feels like a reward; a hurt sound tells the player they were hit before they see it. Unity's audio has four parts, and the fourth is the one beginners skip:
- An Audio Clip is the sound data: a WAV, OGG, MP3 or AIFF file imported as an asset.
- An Audio Source is a component that plays clips, from a position in the world.
- An Audio Listener is the ears. There's one, on the Main Camera by default, and every Source is heard from its position.
- An Audio Mixer is an asset with channels, called groups, that Sources play into: one for music, one for effects, and so on. It's where volumes, effects and the player's settings live.
Importing clips#
Select a clip to see its import settings. Three decide most of the cost:
| Setting | Options | Use |
|---|---|---|
| Load Type | Decompress On Load, Compressed In Memory, Streaming | Short, frequent effects: Decompress On Load (instant start, more memory). Long clips: Streaming (read from disk as they play, little memory). Medium ones: Compressed In Memory. |
| Compression Format | PCM, Vorbis, ADPCM (and others on some platforms) | Music and voice: Vorbis with a Quality around 70%. Short effects: ADPCM (cheap to decode, about 3.5 times smaller than PCM). PCM only for the shortest, most frequent sounds. |
| Force To Mono | On or off | On for 3D sound effects: a stereo clip played in 3D loses its stereo anyway. |
The format you import doesn't matter much: Unity re-encodes every clip with these settings. Import WAV or AIFF when you have them, so nothing is compressed twice. Preload Audio Data loads the clip with the scene; untick it for clips used rarely, and they load the first time they play. Each platform can override the settings, so the phone build can use lower quality than the desktop one.
The Audio Source#
The Audio Source's main settings:
- The first field holds what it plays: an Audio Clip, or an Audio Random Container (below). Unity 6.3's Inspector labels it Audio Generator; older tutorials show Audio Clip or Audio Resource. In code,
cliptakes a clip andresourcetakes either. - Output: the mixer group it plays into. Empty means straight to the listener, bypassing the mixer and the player's volume settings: always fill it in.
- Play On Awake starts it when the object is enabled, which suits ambient loops; untick it for sources your scripts play. Loop repeats the clip.
- Volume (0 to 1) and Pitch (1 is normal; it also changes speed). Priority, from 0 (most important) to 256, decides which sources are cut when too many play at once; the default is 128, and music should be lower, around 0 to 64.
- Spatial Blend: 0 is 2D (heard the same wherever the source is), 1 is 3D (panned and faded by its position relative to the listener). Under 3D Sound Settings, Min Distance and Max Distance with a rolloff curve decide how volume falls off.
Unity plays a limited number of sounds at once. In Edit > Project Settings > Audio, Max Real Voices (32 by default) are actually heard; up to Max Virtual Voices (512) more are tracked silently and take over when a real voice stops. Past those limits, the lowest-priority, quietest sounds are dropped. Thirty-two is plenty for a small game, but a machine gun that plays a new shot every frame can use them up.
Ways to play a sound#
Play()plays the source's own clip from the start, stopping it first if it was playing. Right for music and loops, wrong for effects that can overlap: a second jump sound would cut off the first.PlayOneShot(clip, volumeScale)plays a clip on top of whatever the source is already playing, using its settings (output, spatial blend, pitch). Many one-shots can overlap on one source. You can't stop one individually:Stop()stops them all. This is the usual way to play effects.PlayClipAtPoint(clip, position), a static method ofAudioSource, creates a temporary object with a 3D source at a point, plays the clip once, and destroys the object afterwards. Handy for a sound that must outlive its object (an explosion of an enemy that's destroyed this frame). But it has no way to choose a mixer group, so the sound skips your Effects volume. A small pool of your own sources routed to Effects is better in a real game.
The keeper's sounds are one source and three clips:
using Handbook.T09;
using UnityEngine;
namespace Handbook.T10
{
// The keeper's sound effects, played through one AudioSource with a little random
// pitch, so the hundredth jump doesn't sound exactly like the first.
[RequireComponent(typeof(AudioSource))]
public class KeeperSounds : MonoBehaviour
{
[SerializeField] AudioClip jump;
[SerializeField] AudioClip flask;
[SerializeField] AudioClip hurt;
[SerializeField] float pitchVariation = 0.08f;
AudioSource source;
CliffRunner runner;
FlaskCounter counter;
KeeperHurt keeperHurt;
void Awake()
{
source = GetComponent<AudioSource>();
runner = GetComponent<CliffRunner>();
counter = GetComponent<FlaskCounter>();
keeperHurt = GetComponent<KeeperHurt>();
}
void OnEnable()
{
runner.Jumped += OnJumped;
counter.Changed += OnFlasksChanged;
keeperHurt.Hurt += OnHurt;
}
void OnDisable()
{
runner.Jumped -= OnJumped;
counter.Changed -= OnFlasksChanged;
keeperHurt.Hurt -= OnHurt;
}
void OnJumped() => Play(jump);
void OnHurt() => Play(hurt);
void OnFlasksChanged(int collected, int total)
{
if (collected > 0) Play(flask); // not for the count sent at the start
}
void Play(AudioClip clip)
{
source.pitch = 1f + Random.Range(-pitchVariation, pitchVariation);
source.PlayOneShot(clip);
}
}
}Hearing the same sample repeated a hundred times sounds mechanical, so each play gets a slightly different pitch, up to 8% either way. The pitch belongs to the source, so it also changes a one-shot that is still ringing; with sounds this short you can't hear it, but for long sounds, give each its own source or use an Audio Random Container.
The sounds subscribe to events that T09 · 2D Development: Sprites, Tilemaps, 2D Physics, 2D Animation & Lights's scripts raise: CliffRunner.Jumped, FlaskCounter.Changed and KeeperHurt.Hurt. Neither side knows the other in any other way. The flask sound skips the first Changed, which FlaskCounter sends from Start with nothing collected, so the HUD can show 0/5.
Where should a sound live? On the thing that makes it. The tide makes the splash, so the splash is on the tide, and it plays whatever falls in:
using UnityEngine;
namespace Handbook.T10
{
// Plays a sound when a rigidbody enters this trigger: the splash of the tide.
// The sound belongs to the thing that makes it, not to whatever falls in.
[RequireComponent(typeof(AudioSource))]
public class TriggerSound : MonoBehaviour
{
[SerializeField] AudioClip clip;
[SerializeField] float minimumGap = 0.2f; // one splash, even if two colliders enter
AudioSource source;
float lastPlayed = float.NegativeInfinity;
void Awake() => source = GetComponent<AudioSource>();
void OnTriggerEnter2D(Collider2D other)
{
if (other.attachedRigidbody == null || Time.time - lastPlayed < minimumGap) return;
lastPlayed = Time.time;
source.PlayOneShot(clip);
}
}
}Music#
Music has different needs from effects: one track at a time, looping, continuing across scene loads, fading rather than cutting, and still playing in the pause menu. That's a small component on the persistent GameSystems object:
using System;
using UnityEngine;
namespace Handbook.T10
{
// Plays one music track at a time and keeps it going across scene loads. Asking for the
// track that is already playing does nothing, so the music never restarts by accident.
[RequireComponent(typeof(AudioSource))]
public class MusicPlayer : MonoBehaviour
{
[SerializeField] float fadeTime = 1f;
AudioSource source;
int request; // the newest Play call wins
void Awake()
{
source = GetComponent<AudioSource>();
source.playOnAwake = false;
source.loop = true;
source.ignoreListenerPause = true; // keeps playing in the pause menu
}
public async void Play(AudioClip clip)
{
if (clip == source.clip && source.isPlaying) return;
int mine = ++request;
try
{
if (source.isPlaying)
await FadeTo(0f, mine);
if (mine != request) return;
source.clip = clip;
if (clip == null)
{
source.Stop();
return;
}
source.volume = 0f;
source.Play();
await FadeTo(1f, mine);
}
catch (OperationCanceledException)
{
}
}
// The fade uses the source's own volume. The player's volume setting lives in the
// mixer, so the two never fight.
async Awaitable FadeTo(float target, int mine)
{
float from = source.volume;
for (float t = 0f; t < fadeTime; t += Time.unscaledDeltaTime)
{
if (mine != request) return; // a newer track took over
source.volume = Mathf.Lerp(from, target, t / fadeTime);
await Awaitable.NextFrameAsync(destroyCancellationToken);
}
source.volume = target;
}
}
}Asking for the track that's already playing does nothing, so two scenes that want the same track keep it going straight through the load. A new track fades the old one out, then the new one in. Fades use unscaled time, so a track change from the pause menu still completes, and the request counter makes a newer call win if two arrive during a fade. The fade works on the source's own volume; the player's music volume is in the mixer, so the two never fight.
Each scene says what it wants with one small component; the title screen and the level both ask for the same track in Lantern Cliffs, so it plays without a break:
using UnityEngine;
namespace Handbook.T10
{
// Put one in each scene to say which track it wants. Scenes that want the same
// track keep it playing straight through the load.
public class SceneMusic : MonoBehaviour
{
[SerializeField] AudioClip music;
void Start()
{
if (GameSystems.Instance != null)
GameSystems.Instance.Music.Play(music);
}
}
}The Audio Mixer#
Open Window > Audio > Audio Mixer (Ctrl+8). The window has four sections on the left: Mixers (the mixer assets in the project), Snapshots, Groups and Views, and a strip of faders on the right. Create a mixer with + beside Mixers, or from the Project window's Create menu under Audio. It starts with one group, Master. Right-click Master and choose Add child group to add groups under it: Music and Effects for most games, with Voice, Ambience or UI as needed. A group's output goes to its parent, so Master is everything.
Each group has a fader in decibels, effects (click Add Effect at the bottom of the strip, or in the Inspector: Lowpass, Highpass, Echo, Compressor, SFX Reverb, Duck Volume and others), and Mute, Solo and Bypass buttons. Point Audio Sources' Output at a group, and that group's fader and effects apply to them.
Exposing a volume to scripts
A script can't reach a group's fader directly. You expose it as a named parameter first:
- Select the
Musicgroup. In the Inspector, right-click the word Volume and choose Expose 'Volume (of Music)' to script. An arrow appears beside it. - In the Audio Mixer window, open Exposed Parameters at the top right. The new parameter is called "MyExposedParam". Double-click it and rename it
MusicVolume. - Do the same for Effects, naming it
EffectsVolume.
Now mixer.SetFloat("MusicVolume", -10f) sets the Music fader to −10 dB. The name is a string, so a typo fails silently: SetFloat returns false if no parameter has that name, which is worth checking once while you build.
Sliders and decibels
A mixer fader works in decibels, from −80 dB (silent) to 0 dB (unchanged) and up to +20 dB. A settings slider works from 0 to 1. The tempting conversion is a straight line, −80 at 0 and 0 at 1, and it's wrong. Decibels are already logarithmic: every −20 dB is a tenth of the signal. The LoopMaths output above shows the result: a straight-line slider at 75% sends −20 dB, a tenth of full signal; at 50%, −40 dB, a hundredth, which is nearly silent. The whole useful range is squashed into the top quarter of the slider.
The right conversion is 20 × log10(slider): 1 gives 0 dB, 0.5 gives −6 dB (half the signal), 0.1 gives −20 dB. Zero can't go into a logarithm, so the slider's bottom is clamped to 0.0001, which is −80 dB:
using UnityEngine;
using UnityEngine.Audio;
namespace Handbook.T10
{
// The player's volume settings: values from 0 to 1, stored in PlayerPrefs and sent to
// the Audio Mixer's exposed parameters in decibels.
public class Volumes : MonoBehaviour
{
[SerializeField] AudioMixer mixer;
[SerializeField] string[] parameters = { "MusicVolume", "EffectsVolume" };
[SerializeField] float defaultVolume = 0.8f;
const float Quietest = 0.0001f; // -80 dB, the bottom of a mixer fader
// Many users report that mixer values set in Awake are lost, so this waits for Start.
void Start()
{
foreach (string parameter in parameters)
Apply(parameter, Get(parameter));
}
public float Get(string parameter) => PlayerPrefs.GetFloat(Key(parameter), defaultVolume);
public void Set(string parameter, float volume)
{
PlayerPrefs.SetFloat(Key(parameter), volume);
Apply(parameter, volume);
}
// Loudness is heard on a log scale: halving the signal is about -6 dB, a tenth is -20 dB.
void Apply(string parameter, float volume) =>
mixer.SetFloat(parameter, Mathf.Log10(Mathf.Max(volume, Quietest)) * 20f);
static string Key(string parameter) => "Volume." + parameter;
}
}Volumes stores the slider's 0-to-1 value, not decibels, so the slider shows what the player set. It applies the saved values in Start, because a value set in Awake doesn't stick: in a Unity 6.3 test, SetFloat in Awake returned true and read back correctly, but by Start the parameter was back at the snapshot's value. A value set in Start stays. VolumeSlider, in the UI section, connects a slider to it.
Snapshots
A snapshot is a saved state of every setting in the mixer. The mixer starts with one; click + beside Snapshots to add more, change the settings while each is selected, and blend between them from code with snapshot.TransitionTo(seconds). A "Paused" snapshot that lowers the music and puts a low-pass filter on it is the classic use; others are underwater, a boss fight, or a quiet moment for dialogue. Exercise 6 builds the paused one.
PlayerPrefs: settings and small records#
PlayerPrefs stores small values under string keys, and keeps them between runs of the game. It has three types: int, float and string.
PlayerPrefs.SetFloat("Volume.MusicVolume", 0.8f);
float music = PlayerPrefs.GetFloat("Volume.MusicVolume", 0.8f); // default if missing
bool seen = PlayerPrefs.HasKey("TutorialSeen");
PlayerPrefs.DeleteKey("TutorialSeen");
PlayerPrefs.Save(); // write to disk nowThe Get methods return the default you pass when the key doesn't exist, so code reads naturally on the first run. Values are written to disk when the game quits normally, or when you call Save; CliffsGame saves straight after a new best time, so a crash later doesn't lose it.
Where they live depends on the platform. On Windows, in the registry under HKEY_CURRENT_USER\Software\ followed by the Company Name and Product Name from Player Settings; in the Editor, under Software\Unity\UnityEditor\ and the same names, so Play mode and builds keep separate values. On macOS, in a .plist file in ~/Library/Preferences. On the web, in the browser's IndexedDB storage, up to 1 MB. To start fresh in the Editor, use Edit > Clear All PlayerPrefs.
Prefix keys with something meaningful (Volume., LanternCliffs.) and keep each key in one constant, like CliffsGame.BestTimeKey, which both the game and the title screen read.
Making a build#
A build is your game as a program that runs without Unity: on Windows, an .exe with a _Data folder and Unity's player files beside it. Build early and often. Several kinds of bug exist only in builds, and finding one the week before a deadline is much worse than finding it on day two.
Build Profiles#
Open File > Build Profiles. The Platforms list shows every platform; the ones you can build for have their build support module installed, through Unity Hub's Installs page and Add modules. Select Windows (on a Mac, macOS) and, if it isn't active, click Switch Platform, which reimports assets for that platform. Check the Scene List, then Build (asks for a folder) or Build And Run (builds, then starts it).
The profile's options include Development Build, which you want for test builds: it shows a "Development Build" label in the corner, lets the Profiler connect to the running game (Autoconnect Profiler does so at start), and with Script Debugging lets your IDE's debugger attach. Release builds leave it off: they're smaller, faster, and strip debug code.
Player Settings#
Edit > Project Settings > Player, also opened from the profile's Player Settings button, holds what the built program says about itself:
- Company Name and Product Name: the window title, the folders the game saves to, and the PlayerPrefs location. The template's company is
DefaultCompany; change both before anything is saved for real, because changing them later moves the save locations. - Version: shown nowhere by default, but readable from
Application.version, and worth putting on the title screen of test builds so bug reports say which build they came from. New projects start at 1.0. - Default Icon, and under Resolution and Presentation, Fullscreen Mode (the 2D template uses Fullscreen Window, a borderless window at the desktop's resolution) and the size used for windowed mode (1920 × 1080). Unity remembers a player's last screen mode and size between runs.
- Splash Image: the logo shown at start. In Unity 6 the "Made with Unity" splash is optional on every plan, including Personal; untick Show Splash Screen to start straight into the game.
When the build misbehaves: the logs#
A build has no Console, but it still writes every Debug.Log, warning, error and exception to a log file. On Windows it's Player.log in this folder:
%USERPROFILE%\AppData\LocalLow\<Company Name>\<Product Name>\(On macOS, ~/Library/Logs/ followed by the company and product names.) The previous run's log is kept as Player-prev.log. Open the log first when a build does something the Editor didn't: the error is usually there, with a stack trace. A development build can also show errors on screen, and connect the Editor's Console to the running player.
What differs between Play mode and a build#
- Editor-only code is gone. Anything in an
Editorfolder, inside#if UNITY_EDITOR, or using theUnityEditornamespace isn't in the build. UsingUnityEditorin a runtime script outside#if UNITY_EDITORmakes the build fail with compile errors that Play mode never showed. - Only listed scenes and referenced assets ship. A scene missing from the Scene List can't be loaded; an asset loaded only by a string path, outside a Resources folder, isn't there.
- The game starts at scene 0, not at whatever scene you had open.
Application.Quitworks, and is ignored in the Editor;TitleMenu.Quithandles both.- Domain reload settings don't apply: every run of a build starts with fresh statics, so a bug caused by stale statics in the Editor won't reproduce in the build, and the other way round.
- Timing is different. A build runs faster than Play mode, without the Editor's overhead, so frame-rate bugs (movement without
deltaTime) show up as things going too fast, and the frame rate can run into the hundreds unless VSync orApplication.targetFrameRatelimits it. - Files and folders differ.
Application.dataPathpoints into the build's data folder, which may be read-only. Write files toApplication.persistentDataPath, which is the same LocalLow folder as the log.
Builds from code#
An editor script can make a build with BuildPipeline.BuildPlayer, which takes the scenes, the output path, the target platform and options, and returns a BuildReport with the result, the size, the time and every error. Teams use this for one-click builds with version numbers stamped in, and for build machines that build every commit. Exercise 7 writes a menu item that does this. T50 · Shipping: Build Profiles, Platforms, Web, Steam & Release Checklist covers shipping properly: platforms, the web, Steam, and a release checklist.
Guided build: Lantern Cliffs, the whole night#
At the end of T09 · 2D Development: Sprites, Tilemaps, 2D Physics, 2D Animation & Lights, Lantern Cliffs is a level: the keeper can run, jump, collect flasks and be knocked about by crabs, but nothing starts, nothing ends, and it's silent. In this build it becomes a game. A title screen with the best time leads into the cliffs; a moment to get ready, then the clock starts; three lives, lost to crabs or the sea; a pause menu with volume settings; a result when the oil is in or the keeper gives up; music that carries on across scenes; and a Windows build you can give to a friend. You need no audio files: a script writes them.
Open the LanternCliffs project from T09. The build uses T09's scripts as they are, plus SoundMaker (an editor script), GameSystems, SceneFader, MusicPlayer, SceneMusic, Volumes, VolumeSlider, CliffsGame, KeeperLives, HudView, MenuPanel, TitleMenu, TimeText, KeeperSounds and TriggerSound. Most of their code is in the sections above; this build says where each one goes. Copy them all into _Project/Scripts now (SoundMaker into _Project/Scripts/Editor). CliffsGame throws an exception until step 2 is done, which is deliberate: it can't work without the Pause action.
Step 1 · Making the sounds#
using System;
using System.IO;
using UnityEditor;
using UnityEngine;
namespace Handbook.T10.EditorTools
{
// Writes Lantern Cliffs' sound effects and music as WAV files into Assets/_Project/Audio,
// built from simple waves, so the project needs no audio assets. Run it once from the menu.
public static class SoundMaker
{
const int Rate = 44100; // samples per second
const string Folder = "Assets/_Project/Audio";
static readonly string[] Effects = { "Jump", "Flask", "Hurt", "Splash", "Click", "Win", "Lose" };
enum Wave { Square, Triangle, Noise }
static readonly System.Random noise = new System.Random(7); // the same sounds every run
[MenuItem("Tools/Lantern Cliffs/Make Sounds")]
static void MakeAll()
{
Directory.CreateDirectory(Folder);
Write("Jump", Jump(), 0.8f);
Write("Flask", Flask(), 0.8f);
Write("Hurt", Hurt(), 0.8f);
Write("Splash", Splash(), 0.8f);
Write("Click", Click(), 0.6f);
Write("Win", Win(), 0.8f);
Write("Lose", Lose(), 0.8f);
Write("Night", Music(), 0.6f);
AssetDatabase.Refresh();
foreach (string effect in Effects)
Configure(effect, music: false);
Configure("Night", music: true);
Debug.Log($"Wrote {Effects.Length + 1} sounds to {Folder}");
}
static float[] Jump()
{
float[] b = Buffer(0.18f);
Tone(b, 0f, 0.18f, 260f, 620f, Wave.Square, 1f); // a quick slide up
return b;
}
static float[] Flask()
{
float[] b = Buffer(0.45f);
Tone(b, 0f, 0.12f, Hz(83), Hz(83), Wave.Triangle, 1f); // B5
Tone(b, 0.08f, 0.37f, Hz(88), Hz(88), Wave.Triangle, 1f); // E6, a fourth higher
return b;
}
static float[] Hurt()
{
float[] b = Buffer(0.3f);
Tone(b, 0f, 0.3f, 420f, 90f, Wave.Square, 0.8f); // a slide down
Tone(b, 0f, 0.12f, 0f, 0f, Wave.Noise, 0.6f); // with a crunch at the start
return b;
}
static float[] Splash()
{
float[] b = Buffer(0.6f);
Tone(b, 0f, 0.6f, 0f, 0f, Wave.Noise, 1f);
Smooth(b, 0.12f); // soften the hiss into a rush
return b;
}
static float[] Click()
{
float[] b = Buffer(0.04f);
Tone(b, 0f, 0.04f, 1500f, 1500f, Wave.Square, 1f);
return b;
}
static float[] Win()
{
float[] b = Buffer(1f);
int[] notes = { 72, 76, 79, 84 }; // C5 E5 G5 C6
for (int i = 0; i < notes.Length; i++)
{
float length = i == notes.Length - 1 ? 0.65f : 0.16f;
Tone(b, i * 0.11f, length, Hz(notes[i]), Hz(notes[i]), Wave.Triangle, 1f);
}
return b;
}
static float[] Lose()
{
float[] b = Buffer(1.1f);
Tone(b, 0f, 0.25f, Hz(67), Hz(67), Wave.Square, 0.6f); // G4
Tone(b, 0.22f, 0.25f, Hz(64), Hz(64), Wave.Square, 0.6f); // E4
Tone(b, 0.44f, 0.65f, Hz(60), Hz(55), Wave.Square, 0.6f); // C4, sagging
return b;
}
// Eight bars at 96 beats a minute, exactly 20 seconds, so it loops without a seam:
// a triangle bass on each chord's root and a quiet square arpeggio above it.
static float[] Music()
{
const float beat = 60f / 96f;
int[][] chords =
{
new[] { 57, 60, 64 }, new[] { 53, 57, 60 }, new[] { 48, 52, 55 }, new[] { 55, 59, 62 }, // Am F C G
new[] { 57, 60, 64 }, new[] { 53, 57, 60 }, new[] { 55, 59, 62 }, new[] { 52, 56, 59 }, // Am F G E
};
int[] pattern = { 0, 1, 2, 3, 2, 1, 0, 1 }; // 3 = the root an octave up
float[] b = Buffer(chords.Length * 4 * beat);
for (int bar = 0; bar < chords.Length; bar++)
{
int[] chord = chords[bar];
float barStart = bar * 4 * beat;
for (int half = 0; half < 2; half++)
{
float bass = Hz(chord[0] - 12);
Tone(b, barStart + half * 2 * beat, 2 * beat, bass, bass, Wave.Triangle, 0.9f);
}
for (int step = 0; step < pattern.Length; step++)
{
int note = pattern[step] == 3 ? chord[0] + 12 : chord[pattern[step]];
note += 12;
Tone(b, barStart + step * beat / 2, beat / 2, Hz(note), Hz(note), Wave.Square, 0.18f);
}
}
return b;
}
// ---- The synthesiser ----
static float[] Buffer(float seconds) => new float[Mathf.RoundToInt(seconds * Rate)];
// MIDI note number to frequency: note 69 is A4, 440 Hz, and each step is a semitone.
static float Hz(int note) => 440f * Mathf.Pow(2f, (note - 69) / 12f);
// Adds one note to the buffer: a wave whose pitch slides from one frequency to another,
// with a 5 ms fade-in (no click) and a fade-out to silence at the end.
static void Tone(float[] buffer, float start, float length, float fromHz, float toHz, Wave wave, float volume)
{
int first = Mathf.RoundToInt(start * Rate);
int count = Mathf.RoundToInt(length * Rate);
double phase = 0;
for (int i = 0; i < count && first + i < buffer.Length; i++)
{
float t = (float)i / count;
phase += Mathf.Lerp(fromHz, toHz, t) / Rate;
float envelope = Mathf.Min(1f, i / (0.005f * Rate)) * (1f - t);
buffer[first + i] += Shape(wave, phase) * volume * envelope;
}
}
static float Shape(Wave wave, double phase)
{
double p = phase - Math.Floor(phase); // 0 to 1 through one cycle
switch (wave)
{
case Wave.Square: return p < 0.5 ? 1f : -1f;
case Wave.Triangle: return (float)(4.0 * Math.Abs(p - 0.5) - 1.0);
default: return (float)(noise.NextDouble() * 2.0 - 1.0);
}
}
// A one-pole low-pass filter: each sample moves only part of the way towards the input.
static void Smooth(float[] buffer, float amount)
{
float y = 0f;
for (int i = 0; i < buffer.Length; i++)
{
y += (buffer[i] - y) * amount;
buffer[i] = y;
}
}
// A 16-bit mono WAV file, scaled so its loudest sample reaches the given peak.
static void Write(string name, float[] samples, float peak)
{
float loudest = 0f;
foreach (float s in samples)
loudest = Mathf.Max(loudest, Mathf.Abs(s));
float gain = loudest > 0f ? peak / loudest : 0f;
using var file = new BinaryWriter(File.Create($"{Folder}/{name}.wav"));
int dataBytes = samples.Length * 2;
file.Write("RIFF".ToCharArray());
file.Write(36 + dataBytes);
file.Write("WAVEfmt ".ToCharArray());
file.Write(16); // size of the format block
file.Write((short)1); // PCM
file.Write((short)1); // one channel
file.Write(Rate);
file.Write(Rate * 2); // bytes per second
file.Write((short)2); // bytes per sample frame
file.Write((short)16); // bits per sample
file.Write("data".ToCharArray());
file.Write(dataBytes);
foreach (float s in samples)
file.Write((short)Mathf.RoundToInt(s * gain * short.MaxValue));
}
// Music is long: stream it from disk, compressed. Effects are short and frequent:
// decompress them once when they load, so they start instantly.
static void Configure(string name, bool music)
{
var importer = (AudioImporter)AssetImporter.GetAtPath($"{Folder}/{name}.wav");
AudioImporterSampleSettings settings = importer.defaultSampleSettings;
settings.loadType = music ? AudioClipLoadType.Streaming : AudioClipLoadType.DecompressOnLoad;
settings.compressionFormat = music ? AudioCompressionFormat.Vorbis : AudioCompressionFormat.ADPCM;
settings.quality = 0.7f;
importer.defaultSampleSettings = settings;
importer.SaveAndReimport();
}
}
}- Choose Tools > Lantern Cliffs > Make Sounds. Eight WAV files appear in
_Project/Audio:Jump,Flask,Hurt,Splash,Click,Win,Loseand the music,Night. - Select
Nightand look at its import settings: Load Type Streaming, Compression Format Vorbis, Quality 70. SelectJump: Decompress On Load, ADPCM. The script'sConfigureset them, following the import table above. Click the play button in the Inspector's preview to hear each one.
The sounds are built from three waves: square (buzzy, for the jump and hurt), triangle (soft, for the flask chime and the bass) and noise (for the splash and the crunch of a hit). The music is eight bars of Am, F, C, G, Am, F, G, E at 96 beats a minute: a triangle bass and a quiet square arpeggio. Edit the notes and run the menu again to change them.
Step 2 · A Pause action#
- Double-click
Assets/Settings/InputSystem_Actionsto open the actions editor. These are the project-wide actions (T07 · Input System, Movement & Cameras (Cinemachine)). - Select the UI map. In the Actions column, click +, name the action
Pause, and leave its type as Button. - Give it two bindings: Escape [Keyboard] and Start [Gamepad]. Use the Listen button in the path picker and press the key, or find them under Keyboard and Gamepad.
- Close the editor; it saves automatically if Auto-Save is ticked, otherwise click Save Asset.
The action goes in the UI map, not the Player map, because CliffsGame turns the Player map off while paused, and the Pause action has to be able to unpause. CliffsGame finds it with FindAction and throwIfNotFound: true, so a missing or misspelt action is a clear exception at start, not a pause key that silently does nothing.
Step 3 · The mixer#
- Open Window > Audio > Audio Mixer. Click + beside Mixers and save the mixer as
_Project/Audio/Main. - Right-click Master in Groups and choose Add child group. Name it
Music. Select Master again and addEffects. - Select
Music. In the Inspector, right-click Volume and choose Expose 'Volume (of Music)' to script. Do the same forEffects. - Open Exposed Parameters at the top right of the window. Rename the two parameters
MusicVolumeandEffectsVolume, matching each one's group.
Rename carefully: the exposed parameter list doesn't say which group each one belongs to until you hover over it, and the names are what Volumes uses. If you swap them, the music slider changes the effects.
Step 4 · The GameSystems prefab#
This prefab is the persistent object from the bootstrap section. It holds the fader, the music and the volume settings, and code creates it before the first scene.
- In the Cliffs scene, create an empty object named
GameSystemsat the root and add theGameSystemscomponent. - With it selected, choose GameObject > UI (Canvas) > Canvas, so the Canvas becomes its child. Name it
Curtain. Set Sort Order to 100, so it covers every other Canvas. Add a Canvas Group andSceneFader, and drag the Canvas Group into the fader's Curtain field. Unity also added an EventSystem at the scene's root; leave it there, because the level's menus will use it. - Right-click
Curtainand add UI (Canvas) > Image, namedBlack. Set its Color to black with full alpha. In the Anchor Presets, Shift+Alt-click the bottom-right preset (stretch in both directions), so it fills the screen. - Add a child of
GameSystemsnamedMusic, with an Audio Source: Output Music, Priority 0. AddMusicPlayer(it unticks Play On Awake and ticks Loop itself). - Add
Volumesto theGameSystemsobject and drag theMainmixer into its Mixer field. The parameter names are alreadyMusicVolumeandEffectsVolume. - On the
GameSystemscomponent, drag in the fader (fromCurtain), the music player and the volumes. - Create the folder
_Project/Resources, dragGameSystemsinto it to make a prefab namedGameSystems, then delete the object from the scene.
Press Play. Nothing looks different, but in the Hierarchy a DontDestroyOnLoad scene appears with GameSystems in it, created by code before the level's objects woke up. Stop, rename the prefab to Systems, and press Play again: the Console warns that no prefab was found, and the game runs without it. Rename it back.
The curtain's Canvas Scaler doesn't matter, because a stretched image fills any screen. The curtain has no Event System of its own on purpose: each scene has one, and two would clash.
Step 5 · The title screen#
- Save the Cliffs scene. In the Project window, select it and press Ctrl+D; rename the copy
Titleand open it. - Delete
Keeper,Start,Tideand thePlacedobject underCliffs. Keep the camera, the Global Light 2D, theCliffstilemaps,Moon,Headlandand the EventSystem. On the Main Camera, removeCameraFollow2D(it has no keeper to follow) and set its position to (10, 8.5, −10), looking at the cliff top under the moon. - Choose GameObject > UI (Canvas) > Canvas and name it
TitleCanvas. On its Canvas Scaler, set UI Scale Mode to Scale With Screen Size, Reference Resolution 1920 × 1080, Match 0.5. - Add UI (Canvas) > Text - TextMeshPro as a child. The TMP Importer window opens: click Import TMP Essentials, then close it. Name the text
GameTitle, type "Lantern Cliffs", Font Size 120, alignment centred, anchored top centre (Shift+Alt-click the top-centre preset), Pos Y −160, Width 1400, Height 160. - Add another text,
BestTime: Font Size 40, centred, anchored top centre, Pos Y −320, Width 1000, Height 60. - Add an empty child
Main(right-click the Canvas, Create Empty), anchored in the middle, Width 420, Height 340, Pos Y −120. AddMenuPanelto it (a Canvas Group comes with it) and tick Start Open. Add a Vertical Layout Group with Spacing 24 and Child Alignment Middle Center, and untick all its Control Child Size and Child Force Expand boxes. - Add three UI (Canvas) > Button - TextMeshPro children to
Main:Play,OptionsandQuit, each 400 × 90, with labels to match and Font Size 44. DragPlayintoMain's First Selected. - Add UI (Canvas) > Panel to the Canvas, named
Options; it stretches over the screen with a translucent Image. AddMenuPanel(Start Open off). Inside it: a title text "Options", two UI (Canvas) > Sliders namedMusicSliderandEffectsSlider(500 × 40, each with a text label to its left), and a buttonBack. AddVolumeSliderto each slider, with ParameterMusicVolumeandEffectsVolume. DragMusicSliderinto the panel's First Selected. - Add
TitleMenutoTitleCanvas. Drag inBestTimeand theQuitbutton; First Level is alreadyCliffs. - Wire the buttons in the Inspector.
Play:TitleMenu.Play.Quit:TitleMenu.Quit.Options: two entries,Main'sMenuPanel.HideandOptions'MenuPanel.Show.Back: the reverse,Options' Hide andMain's Show. - Create an empty
SceneMusicobject with theSceneMusiccomponent, and drag theNightclip into it.
using TMPro;
using UnityEngine;
namespace Handbook.T10
{
// The title screen's buttons, and the best time so far.
public class TitleMenu : MonoBehaviour
{
[SerializeField] string firstLevel = "Cliffs";
[SerializeField] TMP_Text bestTimeText;
[SerializeField] GameObject quitButton;
void Start()
{
float best = PlayerPrefs.GetFloat(CliffsGame.BestTimeKey, -1f);
bestTimeText.text = best < 0f ? "No best time yet" : "Best night: " + TimeText.Format(best);
// A web page can't close itself, so a Quit button there would do nothing.
if (Application.platform == RuntimePlatform.WebGLPlayer)
quitButton.SetActive(false);
}
public void Play() => GameSystems.Instance.Scenes.Load(firstLevel);
public void Quit()
{
#if UNITY_EDITOR
// Application.Quit is ignored in the Editor, so stop Play mode instead.
UnityEditor.EditorApplication.ExitPlaymode();
#else
Application.Quit();
#endif
}
}
}Open File > Build Profiles. In the Scene List, add Title and Cliffs (open each and click Add Open Scenes, or drag them in from the Project window), and drag Title to the top so it's index 0. Remove SampleScene if the template's entry is still there.
Open Title and press Play. The night music fades in, the best time says "No best time yet", and Play is selected: press the arrow keys or a stick to move between the buttons, and Enter or the south button to press one. Open Options and move the music slider: the music changes, and in the Audio Mixer window the Music fader moves with it, in decibels. Stop and press Play again: the slider remembers. Press Play: the screen fades to black and back, into the cliffs, and the music doesn't skip, because both scenes ask for the same track. (The level doesn't work as a game yet; that's next.)
Step 6 · Lives and the keeper's sounds#
using System;
using Handbook.T09;
using UnityEngine;
namespace Handbook.T10
{
// The keeper's lives. A crab hit or a fall into the sea costs one.
[RequireComponent(typeof(KeeperHurt))]
public class KeeperLives : MonoBehaviour
{
[SerializeField] int startingLives = 3;
public int Lives { get; private set; }
// Raised with the lives left, at the start and after every loss.
public event Action<int> Changed;
KeeperHurt hurt;
void Awake()
{
hurt = GetComponent<KeeperHurt>();
Lives = startingLives;
}
void OnEnable() => hurt.Hurt += LoseOne;
void OnDisable() => hurt.Hurt -= LoseOne;
void Start() => Changed?.Invoke(Lives);
// Trigger messages go to both objects, so the keeper hears about the tide
// without the tide knowing anything about lives.
void OnTriggerEnter2D(Collider2D other)
{
if (other.TryGetComponent(out TideReset _)) LoseOne();
}
void LoseOne()
{
if (Lives == 0) return;
Lives--;
Changed?.Invoke(Lives);
}
}
}- Open
Cliffs. Select the Keeper and addKeeperLives. Starting Lives stays at 3. - Add an Audio Source to the Keeper: Output Effects, Play On Awake off, Spatial Blend 0. Add
KeeperSoundsand drag in theJump,FlaskandHurtclips. - Select
Tide. Add an Audio Source (Output Effects, Play On Awake off) andTriggerSoundwith theSplashclip.
Press Play in Cliffs (the bootstrap means you can start in any scene). Jump: a quick rising blip, slightly different each time. Collect a flask: a two-note chime. Walk into a crab: a falling buzz with a crunch. Jump into the sea: a splash, and the keeper is back at the start. Each crab hit and each fall costs a life, but nothing shows it yet, and nothing happens at zero.
KeeperLives hears about the tide through its own OnTriggerEnter2D, because trigger messages go to both objects: the tide resets the keeper and plays its splash, the keeper loses a life, and neither script knows about the other.
Step 7 · The run#
- Create an empty object named
Gameat the root ofCliffs. AddCliffsGame(its code is in the states section). Drag the Keeper into both Flasks and Lives straight away: the component subscribes to their events as soon as it's enabled. - Add an Audio Source to
Gamefor the jingles: Output Effects, Play On Awake off. Drag it into Jingles, and theWinandLoseclips into Win Sound and Lose Sound. - Add a
SceneMusiccomponent toGamewith theNightclip.
Leave the panel fields empty for now, and don't press Play yet: CliffsGame shows a panel in every state, so without them it throws a NullReferenceException at the first state change. Step 8 makes them. The jingles play in Won and Lost, where the listener isn't paused, so their source needs no special setting. Sounds that must play during the pause (button clicks in the pause menu) need ignoreListenerPause set from code, as Exercise 1 does.
Step 8 · The menus#
The run's four panels go on a Canvas of their own, which will sit above the HUD that step 9 adds:
- Create a Canvas named
Menus, Sort Order 10, with the Canvas Scaler set as in step 5 (Scale With Screen Size, 1920 × 1080, Match 0.5). - Ready: a child empty
Ready, stretched over the whole Canvas, withMenuPanel, and a text inside it: "Find the five flasks", Font Size 72, centred. It has no buttons, so it needs no First Selected. - Pause: a UI (Canvas) > Panel named
PausewithMenuPanel. Darken its Image (black, alpha about 60%) so the frozen level shows through. Inside: a title "Paused"; a group with a Vertical Layout Group, as on the title screen, holding three buttons,Resume,RestartandTitle; and the two volume sliders with labels andVolumeSlider, as in the Options panel. DragResumeinto First Selected. - Won: a Panel named
WonwithMenuPanel, a textWonText(Font Size 56, centred, 1400 × 300) and two buttons,PlayAgainandToTitle. First Selected:PlayAgain. - Lost: duplicate
Wonand rename itLost, its textLostTextand its first button's label "Try again". - Wire the buttons to
Game'sCliffsGame:ResumetoResume; every Restart, Play again and Try again button toRestart; every Title button toQuitToTitle. - On
CliffsGame, fill the panel fields with the four panels, and Won Text and Lost Text with the two texts.
Leave Start Open off on all four. CliffsGame opens the right one in Start by entering Ready, and every panel starts hidden by its own Awake, so the order doesn't matter. Press Play in Cliffs: the Ready text, then play; Escape opens the pause menu, and three lives lost bring up the Lost panel. There's no HUD yet, so you're playing blind to the count.
Step 9 · The HUD#
- Create a Canvas named
HUD, with the Canvas Scaler set as in step 5, and leave its Sort Order at 0, below the menus. - Flasks: add an Image named
FlaskIconwith Source ImageProps_2and Preserve Aspect on, 72 × 72, anchored top left with its pivot (Shift+Alt-click the top-left preset), Pos (32, −24). Untick Raycast Target. Beside it, a textFlaskTextanchored the same way at Pos (116, −24), 200 × 72, Font Size 56, aligned left and middle, reading "0/5". - Lives: an empty child
Lives, anchored top right with its pivot, Pos (−32, −24), 260 × 72, with a Horizontal Layout Group (Spacing 12, Child Alignment Middle Right, Control Child Size and Child Force Expand all unticked). Inside, three Images with Source ImageKeeper_0, 72 × 72, Preserve Aspect, Raycast Target off. - Clock: a text
Clock, anchored top centre with its pivot, Pos Y −24, 300 × 72, Font Size 56, centred, reading "0:00.0". - Add
HudViewtoHUD. Drag in the Keeper (as Flasks and Lives),Game,FlaskText,Clock, and the three life icons into Life Icons.
The sprites are 16-pixel art drawn at 72 pixels, a scale of 4.5. They stay sharp because T09's import settings use Point filtering, and their edges may be a pixel uneven at some screen sizes; a size that's a whole multiple of 16 (64 or 80) avoids that, at the cost of the layout's round numbers. Press Play: the clock waits for a second and a half, then runs; the flask count and the lives follow the game, and the clock stops while the pause menu is open.
Step 10 · Play the whole night#
Open Title and press Play. Go through every path of the state diagram:
- Ready to Playing. Press Play. The fade, then "Find the five flasks" for a second and a half, during which the keeper won't move however you press. Then the panel goes, and the clock starts.
- Pause. Press Escape (or Start). The level freezes, mid-jump if you were jumping; sound effects stop, while the music carries on;
Resumeis selected, and the arrow keys or stick move between the buttons and sliders. Turn the effects down and press Escape again: everything continues from exactly where it stopped, quieter. The clock didn't count the pause. - Lost. Walk into crabs, or jump into the sea, three times. "The keeper is too battered to go on", a falling jingle, and
Try againselected. Try again: the scene reloads and everything resets (flasks, crabs, lives, clock), with no reset code anywhere. - Won. Collect all five flasks. The win jingle, the time, "a new best!" the first time. Go to the title: the best time is there. Win again more slowly: the result shows both times, and the best doesn't change.
- Quit to title from the pause menu. The title screen arrives with normal time and sound, because
CliffsGame.OnDestroyput them back.
Then try to break it the way players do: press Escape during the Ready panel (nothing happens), press Escape on the result screen (nothing), collect the last flask and walk into a crab in the same moment (you've won; Lose is ignored outside Playing), and mash Restart (one reload, because the fader is Busy).
Step 11 · The build#
- Open Edit > Project Settings > Player. Set Company Name to your name or studio's, Product Name to
Lantern Cliffs, and Version to0.1. Under Splash Image, untick Show Splash Screen if you like. - Open File > Build Profiles, select Windows (or your platform), and check that Title and Cliffs are in the Scene List with Title first. Tick Development Build for this first build.
- Click Build And Run. Make a folder named
Buildsat the root of the project, besideAssets(not inside it), and choose it. Add/Builds/to your.gitignore: builds don't belong in version control.
The game opens full-screen at your desktop's resolution, with "Development Build" in a corner. Play it through. Alt+F4 quits, and so does the title's Quit button now that it's a real build. Open the log folder from the logs section, using the company and product names you just set, and find Player.log: your Debug.Log lines from T09 ("Oil flasks: 1 of 5") are there. Play it again: the best time you set in the Editor isn't, because builds keep their PlayerPrefs separately.
Step 12 · Break it on purpose, then commit#
- In
MusicPlayer.Awake, comment out theignoreListenerPauseline. Pause: the music stops with everything else. Put it back. - In
SceneFader.Fade, changeTime.unscaledDeltaTimetoTime.deltaTime. Pause and choose Title: the screen fades to nothing and stays black forever, because the fade's clock is stopped. Put it back. - In
CliffsGame.Enter, comment outelse player.Disable();. Now the keeper can run during the Ready panel, and a jump pressed during the pause happens the moment you resume. Put it back. - Delete the EventSystem in
Cliffs. Pause: the menu shows, but nothing in it can be selected or clicked. Undo. - Set the HUD's Canvas Scaler back to Constant Pixel Size and pick different sizes in the Game view's resolution menu (720p, 4K): the HUD shrinks and grows with the screen's pixels. Set it back.
- Clear the Music source's Output on the
GameSystemsprefab. The music slider stops working, because the music no longer goes through the mixer. Set it back to Music. - Untick
Cliffsin the Scene List and press Play on the title. Play logs an error fromSceneFadernaming the scene, and the screen fades back to the title. Tick it again.
Save both scenes and commit. Lantern Cliffs is a complete small game: it starts, ends, pauses, remembers, sounds like something and runs on its own. T11 · Clean Code, SOLID & a C# Style Guide for Unity looks at how its code is written, and T14 · Events, Messaging & Decoupled Systems at the events that hold it together.
Going further#
Games made of many scenes#
Lantern Cliffs has one persistent prefab and one scene per screen. Bigger games often keep a small set of scenes loaded additively at all times: a Systems scene (what GameSystems holds, plus saving and input), a UI scene with every menu, and one or more content scenes that are loaded and unloaded as the player moves through levels. Each team member can edit a different scene without merge conflicts, a level can be tested on its own with the systems scene loaded beside it by an editor script, and switching levels never touches the UI. The cost is that objects in different scenes can't reference each other in the Inspector, so they find each other through events, ScriptableObject channels or a registry (T13 · ScriptableObjects & Data-Driven Architecture, T14 · Events, Messaging & Decoupled Systems).
Loading content on demand#
Scenes in the Scene List and everything they reference are packed into the build. The Addressables package loads assets and scenes by address instead, from the build or from a server, with reference counting so they can be unloaded when nothing uses them. That's how games keep memory low with lots of content, and how they ship content updates without a new build. It replaces most uses of Resources folders. T59 · Big Worlds: Terrain & Vegetation, LOD, Occlusion Culling, Splines Tools, Addressables & Streaming covers it, with the other tools for games with a lot of content.
A full settings menu#
Players expect more than volumes: graphics quality (QualitySettings.SetQualityLevel), resolution and window mode (Exercise 5), VSync and a frame-rate cap, control rebinding (T07 · Input System, Movement & Cameras (Cinemachine)), subtitles, text size, colour-blind options, camera shake and flashing reduction. Keep the values in one settings object, apply them in one place at start, and save them together. Accessibility options are cheap to add early and expensive to add late; T07 · Input System, Movement & Cameras (Cinemachine) lists the input side.
Audio middleware#
Professional audio teams often use FMOD or Wwise instead of Unity's mixer. A sound designer builds "events" in the middleware's own tool (a footstep that varies by surface, music that changes layers with the action) and the game just triggers them by name and sets parameters. It moves sound design out of the Unity project and gives adaptive music, but adds a licence and a second tool. T21 · Audio: Mixers, Sound Design, Adaptive Music, FMOD & Wwise covers the mixer in depth, adaptive music, and both middlewares.
How studios finish the loop#
Studios build the whole loop early, often in the first week, with placeholder art and sound: title, play, win, lose, restart, settings, build. Every later feature is then added to a game that already works end to end, and builds are made continuously, by a build server, from every commit, with the version and commit stamped on the title screen. A "vertical slice" (one level finished to final quality, loop and all) usually comes before the rest of production, because it shows whether the game is fun and how long everything takes. And the bugs this chapter warns about (frozen time after a scene load, a duplicate manager, a menu a gamepad can't reach) are exactly the ones that platform certification and playtesters find first.
Common mistakes & debugging#
The game is frozen, silent or won't respond after a scene change#
timeScaleis still 0. Loading a scene doesn't reset it. Quit to the menu from a pause, and the next scene starts frozen. Reset global state inOnDestroyof the script that changed it, asCliffsGamedoes, or set it when each scene starts.AudioListener.pauseis still true: the next scene is silent except for sources that ignore the pause. Same fix.- An action map is still disabled. The project-wide actions belong to the whole game, not to a scene, so a map disabled for a pause stays disabled in the next scene. Re-enable it when leaving.
- A fade never finishes. A fade or tween that counts
deltaTimestops when time is paused. UseunscaledDeltaTimefor anything that runs in menus.
Menus don't respond#
- No Event System in the scene, or two. Check the Hierarchy during Play, including the DontDestroyOnLoad scene: a persistent object with its own Event System clashes with every scene's.
- Something invisible is on top. A hidden panel that still blocks raycasts (alpha 0 but Blocks Raycasts on), a full-screen Image with Raycast Target ticked, or a curtain left up catches every click. Select the Event System during Play: its Inspector preview shows the object under the pointer.
- Nothing is selected, so the keyboard and gamepad do nothing. Select a control when each panel opens.
- The button calls a method on the prefab asset, not on the object in the scene. Inspector wiring to an asset is allowed and does nothing visible; drag the object from the Hierarchy, not the Project window.
- The Canvas has no Graphic Raycaster, which happens when a Canvas is added as a component by hand rather than from the menu.
Scenes and persistent objects#
- "Scene couldn't be loaded because it has not been added to the build settings": add it to the Scene List in Build Profiles, and check the name's spelling and capitals.
- Two of something (two musics, double sounds, two managers reacting to every event): a manager placed in a scene that's loaded again. Create it from code, or destroy duplicates in
Awake. MissingReferenceExceptionafter a reload: a persistent object kept a reference to something in the old scene, or an old object is still subscribed to a persistent object's event. Unsubscribe inOnDisableorOnDestroy, and look references up again after each load.- A static points at a destroyed object at the start of Play, with domain reload off. Reset statics in a
SubsystemRegistrationmethod.
Sound#
- The volume slider does nothing. The source's Output is empty (it bypasses the mixer), the parameter name doesn't match the exposed one (
SetFloatreturns false), or the value was set inAwake. - Volume jumps to nothing halfway down the slider: a straight-line mapping to decibels. Use
20 × log10. - A sound plays when the scene starts: Play On Awake is ticked on a source a script plays.
- "There are 2 audio listeners in the scene": a second camera brought its own Audio Listener. Remove it from every camera but one.
- Sounds pan oddly or vanish in a 2D game: Spatial Blend is 1 and the camera is far from the sprites. Use 0, or a small blend.
- Effects cut each other off: they use
Playon one source. UsePlayOneShot.
The build#
- The build fails with errors Play mode never showed: a runtime script uses
UnityEditor. Move it to anEditorfolder or wrap it in#if UNITY_EDITOR. - The build behaves differently: read
Player.logfirst. Then suspect the scene it starts in, statics that were stale in the Editor, frame-rate-dependent code, and files written outsidepersistentDataPath. - Pink objects in the build only: a shader or material used only from code was stripped. Reference it from something in a scene, or add it to the always-included shaders in the Graphics settings.
Exercises#
Exercise solutions are in the sandbox under Assets/Handbook/T10/Exercises and Assets/Handbook/T10/Editor.
1. Buttons that click
Give every menu button a click when pressed, and a softer tick when the keyboard or gamepad moves onto it, including in the pause menu.
Solution
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace Handbook.T10
{
// A click when the button is pressed and a softer tick when it's selected with the
// keyboard or a gamepad.
[RequireComponent(typeof(Button))]
public class ButtonSound : MonoBehaviour, ISelectHandler
{
[SerializeField] AudioSource source;
[SerializeField] AudioClip click;
[SerializeField] float selectVolume = 0.4f;
void Awake()
{
// The pause menu pauses the listener, which would silence its own buttons.
// The Inspector doesn't show this setting, so it's set here.
source.ignoreListenerPause = true;
GetComponent<Button>().onClick.AddListener(() => source.PlayOneShot(click));
}
// The Event System calls this when navigation moves onto the button.
public void OnSelect(BaseEventData eventData)
{
// Pointer clicks select too; only keyboard and gamepad moves get the tick.
if (eventData is PointerEventData) return;
source.PlayOneShot(click, selectVolume);
}
}
}Add an Audio Source to each menu Canvas (Output Effects, Play On Awake off) and ButtonSound to each button, with that source and the Click clip. The script sets ignoreListenerPause, because the pause menu pauses the listener and would otherwise silence its own buttons. OnSelect comes from the Event System's ISelectHandler interface; a pointer click also selects the button, so it skips pointer events to avoid a tick and a click together. To save adding it button by button, make one prefab button with the component and use it everywhere.
2. Dawn is coming
Add a second way to lose: the night lasts two minutes, a countdown on the HUD turns orange in the last fifteen seconds, and at dawn the run is lost with its own reason. Don't change CliffsGame.
Solution
using TMPro;
using UnityEngine;
namespace Handbook.T10
{
// A second way to lose: dawn comes before the oil is in. It only reads the game's
// clock and calls Lose, so CliffsGame needs no changes.
public class DawnDeadline : MonoBehaviour
{
[SerializeField] CliffsGame game;
[SerializeField] float nightLength = 120f;
[SerializeField] TMP_Text countdown;
void Update()
{
float left = Mathf.Max(0f, nightLength - game.Elapsed);
countdown.SetText("Dawn in {0}", Mathf.Ceil(left));
countdown.color = left < 15f ? new Color(1f, 0.55f, 0.3f) : Color.white;
if (left <= 0f) game.Lose("Dawn came before the oil was in.");
}
}
}Add a text to the HUD below the clock and DawnDeadline to the HUD, with Game and the text. It reads the run's clock, which only counts while Playing, so the countdown stops in the pause and during Ready by itself. It calls Lose every frame once dawn comes, which is fine: Lose is guarded and does nothing outside Playing. That guard is what makes adding new ways to end a run this easy.
3. Pause when the player looks away
Pause the game automatically when the window loses focus (alt-tab, a notification) or a phone sends the app to the background. It should never unpause by itself.
Solution
using UnityEngine;
namespace Handbook.T10
{
// Pauses the game when the window loses focus (alt-tab, a notification) or the app
// goes into the background on a phone. It never unpauses by itself: the player does.
public class FocusPause : MonoBehaviour
{
[SerializeField] CliffsGame game;
void OnApplicationFocus(bool hasFocus)
{
if (!hasFocus) game.Pause();
}
void OnApplicationPause(bool paused)
{
if (paused) game.Pause();
}
}
}Add it to Game. Unity sends OnApplicationFocus and OnApplicationPause to every MonoBehaviour. Unpausing is left to the player, because a game that resumes the moment focus returns kills them while their hand is still on the mouse. Pause only acts while Playing, so focus changes on the title screen or a result screen do nothing. In the Editor, clicking another window counts as losing focus, which can be annoying while debugging; disable the component when it gets in the way.
4. Duck the music
Make the music dip briefly whenever a sound effect plays, so effects are always heard clearly, and come back up smoothly. No code.
Solution
In the Audio Mixer, select the Music group and click Add Effect > Duck Volume. Then select the Effects group, Add Effect > Send, set its Receive to Music's Duck Volume, and its Send level to 0 dB. Now the Duck Volume on Music listens to the Effects signal and lowers the music when Effects goes over its Threshold. Start with Threshold −30 dB, Ratio 300%, Attack Time 20 ms and Release Time 300 ms, then press Play and jump repeatedly while adjusting them in Play mode (tick Edit in Play Mode at the top of the mixer window to keep the changes). A ducker that bites too hard makes the music pump; aim for a dip you feel more than hear. The player's music volume still works, because it sets the fader, and the ducking is an effect after it.
5. Display settings
Add a resolution drop-down and a full-screen toggle to the Options panel, for desktop builds.
Solution
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Handbook.T10
{
// A resolution dropdown and a full-screen toggle for desktop builds.
// Unity remembers the last screen mode and size itself, between runs.
public class DisplaySettings : MonoBehaviour
{
[SerializeField] TMP_Dropdown resolutionDropdown;
[SerializeField] Toggle fullScreenToggle;
readonly List<Resolution> sizes = new List<Resolution>();
void Start()
{
// Screen.resolutions repeats each size once per refresh rate; keep one of each.
var labels = new List<string>();
int current = 0;
foreach (Resolution r in Screen.resolutions)
{
if (sizes.Exists(s => s.width == r.width && s.height == r.height)) continue;
if (r.width == Screen.width && r.height == Screen.height) current = sizes.Count;
sizes.Add(r);
labels.Add($"{r.width} × {r.height}");
}
resolutionDropdown.ClearOptions();
resolutionDropdown.AddOptions(labels);
resolutionDropdown.SetValueWithoutNotify(current);
fullScreenToggle.SetIsOnWithoutNotify(Screen.fullScreenMode != FullScreenMode.Windowed);
resolutionDropdown.onValueChanged.AddListener(_ => ApplyDisplay());
fullScreenToggle.onValueChanged.AddListener(_ => ApplyDisplay());
}
void ApplyDisplay()
{
Resolution size = sizes[resolutionDropdown.value];
FullScreenMode mode = fullScreenToggle.isOn ? FullScreenMode.FullScreenWindow : FullScreenMode.Windowed;
Screen.SetResolution(size.width, size.height, mode);
}
}
}Add a Dropdown - TextMeshPro and a Toggle to the Options panel and DisplaySettings to the panel. Screen.resolutions lists each size once per refresh rate, so the script keeps one of each. It applies changes straight away and saves nothing: Unity remembers the last mode and size between runs of a build. In the Editor, Screen.resolutions describes the monitor and SetResolution doesn't resize the Game view, so test it in a build. On phones and the web, hide the panel: the screen size isn't the player's choice there.
6. A muffled pause
While the game is paused, make the music sound distant (muffled), blending in and out over a quarter of a second.
Solution
using UnityEngine;
using UnityEngine.Audio;
namespace Handbook.T10
{
// Muffles the music while the game is paused, by blending between two mixer snapshots.
// The mixer's Update Mode must be Unscaled Time: with time stopped, a snapshot
// transition in Normal mode would never move.
public class PauseMuffle : MonoBehaviour
{
[SerializeField] CliffsGame game;
[SerializeField] AudioMixerSnapshot normal;
[SerializeField] AudioMixerSnapshot muffled;
[SerializeField] float blendTime = 0.25f;
void OnEnable() => game.StateChanged += OnStateChanged;
void OnDisable() => game.StateChanged -= OnStateChanged;
// The level unloads with the mixer still muffled if you quit from the pause menu.
void OnDestroy() => normal.TransitionTo(0f);
void OnStateChanged(RunState state) =>
(state == RunState.Paused ? muffled : normal).TransitionTo(blendTime);
}
}In the mixer, add a Lowpass Simple effect to the Music group. Rename the existing snapshot Normal (its cutoff stays at the top of the range). Add a snapshot Muffled, select it, and set the cutoff to about 800 Hz. Select the mixer asset and set its Update Mode to Unscaled Time, or the transition never moves while time is stopped. Add PauseMuffle to Game with both snapshots. It listens to StateChanged, so CliffsGame doesn't change. The snapshots change the filter, not the Music fader: the fader belongs to the volume setting, and once SetFloat has set it, snapshots can't move it. OnDestroy snaps back to Normal, so quitting from the pause menu doesn't leave the title screen muffled.
7. A second level
Add a second cliff level after the first, with a Next button on the Won panel that leads to it, and that hides itself on the last level.
Solution
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
namespace Handbook.T10
{
// On the won panel: go on to the next scene in the scene list, or hide the
// button on the last level. Levels sit after the title scene in the list.
[RequireComponent(typeof(Button))]
public class NextLevelButton : MonoBehaviour
{
int next;
void Awake()
{
next = SceneManager.GetActiveScene().buildIndex + 1;
if (next >= SceneManager.sceneCountInBuildSettings)
{
gameObject.SetActive(false);
return;
}
GetComponent<Button>().onClick.AddListener(LoadNext);
}
void LoadNext()
{
// SceneFader loads by name, so look the name up from the scene's path in the list.
string path = SceneUtility.GetScenePathByBuildIndex(next);
GameSystems.Instance.Scenes.Load(System.IO.Path.GetFileNameWithoutExtension(path));
}
}
}Duplicate Cliffs as Cliffs2, edit the map text in CliffLayout and Paint (move the flasks, crabs and start with the map's letters). Add it to the Scene List after Cliffs. On the Won panel of both scenes, add a button with NextLevelButton and make it First Selected. The button reads the scene list, so a third level needs no code. It loads by name through the fader, so it looks up the name from the scene's path. The best time is still one value for the whole game; to keep one per level, make the key include the scene's name in both CliffsGame and the title screen. Because each level is its own scene with its own CliffsGame, the lives and clock start fresh on each; carrying them from level to level is the moment to move them into a persistent object, with the reset problems the persistent objects section describes.
8. One-click builds
Add a menu item that builds the game for Windows into a folder named after the date and time, stamps that as the version, and reports the size and time in the Console.
Solution
using System;
using System.IO;
using UnityEditor;
using UnityEditor.Build.Reporting;
using UnityEngine;
namespace Handbook.T10.EditorTools
{
// Builds a Windows player into Builds/<version>, with the version stamped from today's
// date, and reports how it went. Uses the shared scene list of the Build Profiles window.
public static class OneClickBuild
{
[MenuItem("Tools/Lantern Cliffs/Build Windows")]
static void BuildWindows()
{
string version = DateTime.Now.ToString("yyyy.MM.dd.HHmm");
PlayerSettings.bundleVersion = version;
string folder = Path.Combine("Builds", version);
var options = new BuildPlayerOptions
{
scenes = EditorBuildSettingsScene.GetActiveSceneList(EditorBuildSettings.scenes),
locationPathName = Path.Combine(folder, PlayerSettings.productName + ".exe"),
target = BuildTarget.StandaloneWindows64,
options = BuildOptions.None,
};
BuildReport report = BuildPipeline.BuildPlayer(options);
BuildSummary summary = report.summary;
if (summary.result == BuildResult.Succeeded)
{
Debug.Log($"Built {version}: {summary.totalSize / (1024 * 1024)} MB in {summary.totalTime.TotalSeconds:0} s");
EditorUtility.RevealInFinder(folder);
}
else
{
Debug.LogError($"Build {summary.result} with {summary.totalErrors} error(s). See the Console.");
}
}
}
}Put it in an Editor folder. GetActiveSceneList takes the ticked scenes of the Scene List, so the menu builds exactly what the Build Profiles window would. The version is a sortable date, so builds list in order and a bug report's version says when it was built; show Application.version on the title screen to see it. The same method can be called from the command line (-executeMethod with -batchmode), which is how build servers build every commit. The BuildReport also lists every file in the build by size, which is the first place to look when a build is unexpectedly large.
Check yourself#
With Time.timeScale set to 0, which of these is still called every frame?
FixedUpdate- A coroutine waiting on
WaitForSeconds(1) UpdateInvokeRepeating
Frames keep running, so Update is called with deltaTime 0. Physics steps, scaled waits and Invoke all depend on game time, which has stopped.
The game is paused with timeScale = 0, but sound effects still play. What stops them?
- Disabling the Player action map
AudioListener.pause = true- Setting every Audio Source's pitch to 0
- Nothing: audio follows
timeScale
Audio runs on its own clock. Pausing the listener pauses every source except those with ignoreListenerPause, such as music and menu clicks.
A loading bar uses LoadSceneAsync with allowSceneActivation = false and never gets past 90%. Why?
- The scene is too big to load in the background
- The bar should use
isDoneinstead - The scene isn't in the Scene List
- The last tenth is activation, which is waiting for
allowSceneActivation
Held back, progress stops at 0.9. Divide by 0.9 for the bar, and set allowSceneActivation to true to finish.
A music manager is placed in the title scene and calls DontDestroyOnLoad on itself. What goes wrong when the player returns to the title screen?
- A second manager is created, and two copies play
- The manager is destroyed
- The music restarts from the beginning
- Nothing
Loading the title scene again creates the manager in it again, beside the persistent one. Destroy duplicates in Awake, or create the manager from code so no scene contains it.
A volume slider at 0.5 should send how many decibels to the mixer?
- −40 dB
- About −6 dB
- −0.5 dB
- −50 dB
20 × log10(0.5) is about −6 dB, half the signal. A straight-line mapping would send −40 dB, a hundredth, which sounds nearly silent.
Why does VolumeSlider use SetValueWithoutNotify when it shows the saved value?
- It's faster than setting
value - Setting
valuedoesn't move the handle - Setting
valueraisesonValueChangedas if the player had moved it - The slider is disabled in
OnEnable
Showing a value isn't a change. With notify, opening a menu would save the setting again, play sounds, or start a loop where a change sets the value.
A gamepad player opens the pause menu and nothing they press does anything, but the mouse works. What's the likely cause?
- No control is selected when the panel opens
- The Canvas is in Screen Space - Overlay
- The Player action map is disabled
timeScaleis 0
Navigation moves the current selection; with nothing selected there's nothing to move. Select the panel's first control when it opens.
Why does Lantern Cliffs keep the lives, the flask count and the clock in the level scene rather than in GameSystems?
- Persistent objects can't hold numbers
- Events don't work across scenes
- It makes the HUD faster
- Restart can then just reload the scene, and everything resets with no reset code
State that belongs to one attempt should be destroyed with it. Persistent state has to be reset by hand, and each forgotten field is a bug on the second attempt.
Which Canvas Scaler mode keeps a HUD the same size relative to the screen on a 720p window and a 4K monitor?
- Constant Pixel Size
- Constant Physical Size
- Scale With Screen Size
- None: use a World Space Canvas
Scale With Screen Size lays the UI out at a reference resolution and scales it to the real screen. Constant Pixel Size, the default, keeps the pixel count, so UI shrinks on bigger screens.
What's the problem with saving a whole game's progress in PlayerPrefs?
- It can only store ints
- It's easily edited, can't save several values atomically, has no versioning, and moves if the product name changes
- It's deleted every time the game quits
- It doesn't work in builds
PlayerPrefs suits settings and small records such as a best time. Saves belong in files, with a format you control (T16).
Cheat sheet#
States
- One enum, one current value
- One
Enter(next)sets everything - Guard public methods by state
- Announce with a
StateChangedevent - Bigger machines: classes per state (T15)
Scenes
- Scene List: File > Build Profiles; index 0 starts
LoadScene: next frame, freezesLoadSceneAsync: background, 0 → 0.9 → 1- Additive +
SetActiveScene+UnloadSceneAsync - Fade with unscaled time
Persistence
DontDestroyOnLoad: root objects only- Create from code at
BeforeSceneLoad - Reset statics at
SubsystemRegistration - Level state stays in the level scene
- Everything in Resources ships
Pausing
Time.timeScale = 0: physics, scaled timeAudioListener.pause = true: sound- Disable the Player map: input
- Pause action in the UI map
- Menus:
unscaledDeltaTime, Unscaled Time - Restore all three in
OnDestroy
uGUI
- Scaler: Scale With Screen Size, 1920 × 1080, 0.5
- Anchors: corner = fixed, split = stretch
- Presets: Shift = pivot, Alt = position
- TMP:
SetText("{0}/{1}", a, b) - Panels: Canvas Group alpha, interactable, raycasts
- Select the first control on open; one Event System
Audio
- Effects: Decompress On Load, ADPCM
- Music: Streaming, Vorbis ~70%
- Effects:
PlayOneShot; music:Play - Always set Output to a mixer group
- dB = 20 × log10(slider), floor 0.0001
SetFloatinStart; overrides snapshots
Prefs and builds
PlayerPrefs: settings, best times, flagsSave()after something important- Set Company and Product names first
- Development Build for testing
- Log: AppData\LocalLow\Company\Product
- Write files to
persistentDataPath
Courses that cover this#
Glossary#
- Game loop
- The cycle a player goes through: title, play, win or lose, try again. Built from scenes and states, on top of Unity's frame loop.
- Finite state machine
- A value that is always exactly one of a fixed set of states, with rules for moving between them.
- Persistent object
- An object that survives scene loads, kept in the DontDestroyOnLoad scene.
- DontDestroyOnLoad
- The call that moves a root object into a scene that is never unloaded.
- Singleton
- A class with exactly one instance, reachable through a static property.
- Bootstrapping
- Creating the game's persistent objects from code, before the first scene, so they exist whichever scene starts.
- Scene List
- The scenes a build contains, in order, set in Build Profiles. Only these can be loaded.
- Build index
- A scene's position in the Scene List; index 0 starts the build.
- AsyncOperation
- The object a background load returns, with its progress, completion and activation control.
- Additive loading
- Loading a scene beside the scenes already loaded instead of replacing them.
- Active scene
- With several scenes loaded, the one new objects go into and whose lighting is used.
- Time scale
- The multiplier on game time; 0 stops physics and scaled time, 1 is normal speed.
- Unscaled time
- Real time, unaffected by the time scale, used by anything that must run in a pause.
- uGUI
- Unity UI: UI built from GameObjects and components under a Canvas.
- Canvas
- The root of a uGUI hierarchy, which decides where and in what order its UI is drawn.
- Canvas Scaler
- The component that sets how big UI is on different screens.
- RectTransform
- A UI element's rectangle: size, pivot and anchors to its parent.
- Anchors
- The points of the parent rectangle a UI element is attached to; apart, they make it stretch.
- TextMeshPro
- Unity's text system, drawing letters from a signed distance field font atlas.
- Signed distance field
- A texture storing each pixel's distance from a shape's edge, so the edge stays sharp at any size.
- Event System
- The scene object that routes pointer, keyboard and gamepad input to UI and tracks the selected control.
- How the selection moves between controls with directions from a keyboard or gamepad.
- Canvas Group
- A component that sets the opacity, interactivity and click-blocking of everything under it.
- UnityEvent
- An event that can be wired in the Inspector, as on a Button's On Click.
- Audio Source
- The component that plays sound, from a position, into a mixer group.
- Audio Listener
- The component that hears; one per scene, usually on the camera.
- Audio Mixer
- An asset of groups, effects and snapshots that sources play through.
- Exposed parameter
- A mixer setting given a name so scripts can set it with
SetFloat. - Decibel
- A logarithmic unit of loudness: −6 dB is about half the signal, −20 dB a tenth.
- Snapshot
- A saved state of a mixer's settings that can be blended to over time.
- One-shot
- A sound played on top of what a source is already playing, with
PlayOneShot. - Ducking
- Lowering one sound automatically while another plays, such as music under effects.
- PlayerPrefs
- Unity's small key–value store for settings, kept between runs.
- Build profile
- A platform and its build settings and scene list, set in File > Build Profiles.
- Player Settings
- The project settings that describe the built program: names, version, icon, screen, splash.
- Development build
- A build with profiling, debugging and on-screen errors enabled, for testing.