COIT20271 · Week 7 Lab Tutorial

🗺️ Pixel Quest

Build a tile-based 2D world — paint environments with the Tilemap system, add physics collision, and wire up a full Settings UI using PlayerPrefs.

2D Tilemap System
Tile Palette
Tilemap Collider 2D
Sorting Layers
PlayerPrefs
Settings UI
4 Scripts
Introduction

What You'll Build

In this tutorial you will build Pixel Quest — a 2D top-down adventure game where the entire world is constructed from tiles. You will use Unity's Tilemap system to paint a multi-layered environment, add physical collision so the player cannot walk through walls, then build a fully functional Settings Menu that persists player preferences (volume, colour theme, difficulty) across game sessions using PlayerPrefs.

🎯
Learning Goal

By the end you will understand how the Tilemap, Grid, Palette, and Collider components work together — and how to build a settings panel that remembers user choices, which is a feature present in virtually every commercial game.

Week 7 Concepts in Action

Lecture ConceptWhere it appears in Pixel Quest
GridThe parent component that defines tile size and cell layout
TilemapThe actual layer that stores and renders painted tiles
Tile PaletteThe editor window where you organise and select tiles to paint with
Tilemap RendererControls how a Tilemap layer draws its tiles (sort order, material)
Tilemap Collider 2DAutomatically generates colliders from every painted tile on a layer
Composite Collider 2DMerges adjacent tile colliders into a single, efficient mesh
Sorting LayersControls which tilemap layer renders on top of another
PlayerPrefsSaves and loads volume, colour theme, and difficulty between sessions
Settings UISlider, Toggle, and Dropdown wired to a SettingsManager singleton

The Four Activities

ActivityWhat you doKey Concept
🗺️ Paint the WorldUse the Tile Palette to paint floor, wall, and decoration layersTilemap, Palette, Brush tools
🧱 Add CollisionApply Tilemap Collider 2D to the walls layer onlyTilemap Collider 2D, Composite Collider 2D
🎮 Move the PlayerScript a 2D character that collides with the tilemap wallsRigidbody2D, MovePosition
⚙️ Settings PanelBuild and wire a settings menu that saves preferencesPlayerPrefs, UI Slider, Toggle, Dropdown
⏱️
Time Estimate

This tutorial covers one lab session plus independent work. In lab: Project setup, Tilemap system, Palette, Collision (Sections 1–5). After lab: Player script, Settings UI, PlayerPrefs (Sections 6–8).

Step 0

Project Setup

Unlike previous weeks, this project uses a 2D Core template. The 2D template preconfigures the render pipeline, camera, and physics settings for 2D gameplay — saving considerable setup time.

⚠️
Start with a 2D Project — not 3D

If you open a 3D project and try to add tilemaps, the default camera orientation, physics layers, and lighting will all be wrong. Always create a new project using the 2D (Core) template for this tutorial.

  1. 1
    Create a new 2D project in Unity Hub

    Open Unity Hub → New Project. Select the 2D (Core) template. Name the project PixelQuest. Click Create Project. Wait for Unity to finish setting up the project.

  2. 2
    Confirm the Scene view is in 2D mode

    When you use the 2D (Core) template, the Scene view is already in 2D mode by default — you don't need to press any button. Just check what you see: the Scene view should show a flat front-on view with a 2D camera frame visible (a thin rectangle). If instead you see a 3D-looking perspective with X/Y/Z gizmo arms in the corner, look at the small toolbar at the top of the Scene view panel (not the main Unity toolbar at the very top) — there is a "2D" toggle button there. Click it to switch to 2D mode.

  3. 3
    Install the 2D Tilemap Extras package

    The 2D (Core) template already includes the 2D Tilemap Editor package — you don't need to install it. However, you do need the 2D Tilemap Extras package for Rule Tiles. Go to Window → Package Manager. In the top-left dropdown select Unity Registry. Search for "2D Tilemap Extras". Click Install. Wait for it to finish, then close the Package Manager.

  4. 4
    Add a free tile art pack to your Unity account

    Open your web browser (not inside Unity — the in-editor Asset Store window was removed in Unity 2020). Go to https://assetstore.unity.com and sign in with the same Unity ID you use in Unity Hub. In the search bar, search for "Pixel Dungeon Pack Lite" or any free "Top Down 2D Tileset". On the asset page, click the blue Add to My Assets button. A dialog will pop up — click Open in Unity. Your browser will ask permission to launch Unity; click Open.

  5. 5
    Download and import the pack into your project

    Unity opens the Package Manager window automatically with the asset selected. (If it doesn't open, do it manually: Window → Package Manager → Packages dropdown → My Assets.) Click the Download button. Once it finishes, the button changes to Import. Click Import. A list of files appears — leave everything ticked and click Import at the bottom. The pack's sprites will appear in your Project window.

Folder Structure

Create these folders in your Project window now (right-click → Create → Folder):

Setup Checkpoint

You should have: a 2D project open in 2D Scene view mode, the 2D Tilemap Extras package installed, your tile art pack imported into the project, and the folder structure in place. Press Play — you should see a blue/grey camera background and no compile errors in the Console.

Step 1

Tilemap Architecture

Before creating any tiles, understand how the four core components relate to each other. This is the most important concept in the tutorial.

Grid Cell Size · Cell Gap · Cell Layout Floor Tilemap Tilemap + TilemapRenderer Walls Tilemap + Tilemap Collider 2D Decoration Tilemap No collider needed Sorting Layer: Ground Order: 0 Sorting Layer: Walls Order: 1 Sorting Layer: Deco Order: 2 Composite Collider 2D merges adjacent tile shapes → 1 fast mesh parent → child Tilemap GameObject Collider component

The Four Core Components

🔲
Grid
Root GameObject
The parent that defines the tile coordinate system. All Tilemap child objects inherit its Cell Size (default 1×1 Unity units) and snap to its grid lines automatically.
🗂️
Tilemap
Child Component
Stores the actual tile data — which tile is painted at which grid cell (x, y). One Tilemap = one layer (floor, walls, decorations). You will have multiple Tilemaps under one Grid.
🎨
Tilemap Renderer
Auto-added with Tilemap
Responsible for drawing the tiles on screen. Controls the Sorting Layer and Order in Layer — critical for making walls appear above the floor.
🧱
Tilemap Collider 2D
Physics Component
Reads the Tilemap data and generates a physics shape for every painted tile. Used together with Composite Collider 2D for efficiency — the composite merges hundreds of small shapes into one.

Creating the Grid and Tilemap Layers

💡
Why multiple Tilemaps under one Grid?

Each Tilemap is an independent layer. You can add physics collision to Walls but not Floor. You can control which layer renders on top (sorting). You can paint decorations without overwriting the floor beneath. A single Tilemap for everything would not allow any of this.

Step 2

Creating a Tile Palette

The Tile Palette is the editor window you use to organise and select your tiles before painting them onto a Tilemap. Think of it as an artist's physical palette — you put colours on it, then pick which colour to use on your canvas.

🎨
Palette vs Tile vs Tilemap — the distinction

A Tile is a single asset (sprite + metadata). The Palette is a collection of tiles displayed in a grid for selection. The Tilemap is the layer in your scene that holds the painted result. You create Tiles from sprites, organise them in a Palette, then paint them onto a Tilemap.

Step 2A — Open the Tile Palette Window

📁
Always save the palette to your Palettes folder

Unity will ask where to save the palette asset. Always save into Assets/Art/Palettes/. If you click Cancel, the palette is not saved and your tiles will not persist.

Step 2B — Create Tile Assets from Sprites

You need to convert your sprites into Tile Assets before they can be placed in the palette.

Method A — Drag and Drop (Recommended):

  1. 1
    Drag sprites directly into the Tile Palette window

    In the Project window, select your floor tile sprite. Drag it into the Tile Palette window (the grey grid area, not the toolbar). Unity immediately prompts you to save the new Tile Asset.

  2. 2
    Save the Tile Asset

    Unity opens a save dialog. Navigate to Assets/Art/Tiles/ and click Save. The sprite now appears in the palette as a selectable tile.

  3. 3
    Repeat for every tile sprite

    Drag in your Wall sprite, Decoration sprite, and Water sprite one by one. Each creates a .asset file in your Tiles folder. After all are added, the palette grid should show a preview thumbnail for each tile type.

Method B — Create Tile Asset manually:

Right-click in Project window → Create → 2D → Tiles → Tile. Select the new Tile asset → in the Inspector, drag your sprite into the Sprite field. Drag the Tile Asset into the Palette window.

Step 2C — Adding a Rule Tile (Optional but Recommended)

A Rule Tile automatically selects the correct sprite variant based on neighbouring tiles — for example, it draws a corner piece when a wall tile is surrounded by other walls on two specific sides. This is how professional games achieve seamless tilemap edges.

  1. 1
    Create a Rule Tile asset

    Right-click in Project window → Create → 2D → Tiles → Rule Tile. Name it WallRuleTile. Save it to Assets/Art/Tiles/.

  2. 2
    Configure the rules

    Select the WallRuleTile. In the Inspector click Add Rule. The 3×3 neighbour grid appears. Click the arrows around the centre square: green arrow = must have this tile as neighbour, red X = must NOT have this tile as neighbour. Assign the correct sprite variant for each rule combination. The Rule Tile documentation (Unity Manual → 2D → Tilemaps → Rule Tile) shows detailed examples.

  3. 3
    Add the Rule Tile to the palette

    Drag the WallRuleTile asset into the Tile Palette window like any other tile.

💡
Palette Checkpoint

Your WorldPalette should now show at least four tiles: Floor, Wall (or WallRuleTile), Decoration, and Water. Each tile should preview its sprite when selected. If a tile shows a white square instead of its sprite, the sprite reference in the Tile Asset is missing — select the Tile asset and re-drag the sprite into the Sprite field.

Step 3

Painting the World

With your palette ready, you can now paint tiles onto your Tilemaps using the brush tools in the Tile Palette toolbar. This is the core creative part of the tutorial.

The Tile Palette Toolbar

ToolShortcutWhat it does
✏️ Paint BrushBClick or click-drag to paint the selected tile. The most-used tool.
🪣 FillGFlood-fills a connected area with the selected tile — like the paint bucket in Photoshop.
Box FillUClick and drag to fill a rectangular region. Useful for large floor areas.
🔴 EraserDRemoves tiles. Hold Shift while using the Paint Brush as a shortcut to the eraser.
👆 SelectSSelects a region to move or copy.
🔍 PickIClick a tile already in the scene to select it as the active tile — like the eyedropper tool.

Step 3A — Set the Active Tilemap Layer

🚫
Always check which layer is active before painting

The dropdown at the top of the Tile Palette window shows the Active Tilemap — the layer you will paint onto. If it says "Floor" and you paint wall tiles, they go onto the floor layer (no collision). Always set the active layer to match the tile type you are painting.

🎮
Design tip: Playable spaces

Leave at least 2–3 tiles of open floor between wall segments — a tile is 1 Unity unit wide, and your character will need space to move. Corridors should be at least 3 tiles wide for comfortable navigation.

Step 3B — Using the Pick Tool to Match Existing Tiles

When editing, you often want to match a tile already in the scene. Press I (Pick tool), click on an existing tile in the Scene view — it becomes the active selection in the palette. Switch back to the Brush tool (B) and continue painting with that tile.

Step 3C — Rotating and Flipping Tiles

Some tiles need to be rotated (e.g., a one-sided wall facing different directions). While the Paint Brush is active:

Step 4

Sorting Layers

Without sorting, Unity renders the Tilemaps in an undefined order — walls might appear behind floors. Sorting Layers give you explicit control over render order in 2D.

Creating Sorting Layers

Assigning Sorting Layers to Tilemaps

📐
Sorting Layer vs Order in Layer

Sorting Layer is a named group — a Sprite in the "Player" layer always renders above a Sprite in the "Ground" layer regardless of Order in Layer. Order in Layer is a number that resolves ties within the same Sorting Layer. For different layers, Sorting Layer takes priority.

Visual check

Press Play. Walls should appear on top of the floor tiles. Decorations should appear on top of walls. If a layer is rendering in the wrong order, re-check its Sorting Layer assignment.

Step 5

Tilemap Collision

Right now, a player character would walk through the walls as if they weren't there. We need to add physics collision. The key is that only the Walls layer needs a collider — not the floor or decorations.

Step 5A — Add Tilemap Collider 2D to Walls

🔍
Why are there green outlines around each tile?

The green outlines in the Scene view show the collision shape generated for each tile. By default, Unity creates a separate physics shape per tile. A 100-tile wall = 100 separate physics objects. Composite Collider 2D merges these into 1, which is much more efficient.

Step 5B — Add Composite Collider 2D

Step 5C — Configure the Collider Physics Layer

Collision Checkpoint

Select the Walls Tilemap in the Hierarchy. In the Inspector you should see: Tilemap + Tilemap Renderer + Tilemap Collider 2D (with Used By Composite ticked) + Composite Collider 2D + Rigidbody 2D (Body Type = Static). The composite collider green outline should trace the outer edges of your wall blocks cleanly.

⚠️
Player walks through walls in Play mode?

The player needs a Rigidbody 2D on them as well — collisions only happen when at least one object has a Rigidbody. If the player is just a transform being moved directly, collision detection is bypassed. The PlayerController2D script in the next section uses Rigidbody2D.MovePosition() which is the correct approach.

Script 1 of 4

PlayerController2D.cs

A clean 2D movement script using Rigidbody2D for physics-based collision. We read input each frame and move the character using MovePosition — this works correctly with the Tilemap Collider while keeping movement smooth.

Why Rigidbody2D.MovePosition instead of transform.position?

transform.position = newPos (wrong)

  • Teleports the object each frame
  • Physics engine doesn't see the movement
  • Passes through Tilemap Colliders
  • Camera jitter on fast speeds

rb.MovePosition(newPos) (correct)

  • Physics engine handles the movement
  • Detects collisions during the move
  • Player properly blocked by Tilemap walls
  • Works with Composite Collider 2D
1
PlayerController2D.cs
Attach to: the Player GameObject
MonoBehaviour
Rigidbody2D MovePosition Input.GetAxisRaw FixedUpdate Animator
🎮
Player GameObject Setup (before attaching the script)

Create a player GameObject by dragging your character sprite directly from the Project window into the Hierarchy. Unity creates a GameObject with a Sprite Renderer component already attached and the sprite assigned. Rename it Player. Then add the following components via the Inspector's Add Component button: Rigidbody 2D (set Body Type = Dynamic, Gravity Scale = 0 for top-down movement, tick Constraints → Freeze Rotation Z) and Capsule Collider 2D (resize it to fit the character). Finally, on the Sprite Renderer component, set its Sorting Layer to Player.

PlayerController2D.cs C#
// PlayerController2D.cs
// ─────────────────────────────────────────────────────────────────────────────
// Moves the player character using Rigidbody2D.MovePosition so that the
// physics engine correctly detects collisions with the Tilemap Collider.
//
// Movement is handled in FixedUpdate (runs at a fixed physics rate)
// rather than Update (runs at the display frame rate) for smoother,
// more reliable physics interactions.
// ─────────────────────────────────────────────────────────────────────────────

using UnityEngine;

public class PlayerController2D : MonoBehaviour
{
    // ── Inspector Settings ───────────────────────────────────────────────
    [SerializeField] private float moveSpeed = 5f;

    // ── Cached Components ────────────────────────────────────────────────
    private Rigidbody2D rb;
    private Animator    animator;

    // ── State ────────────────────────────────────────────────────────────
    private Vector2 moveInput;  // raw input direction each frame
    private bool    isMoving;

    // ────────────────────────────────────────────────────────────────────
    // Awake: cache components
    // ────────────────────────────────────────────────────────────────────
    void Awake()
    {
        rb       = GetComponent<Rigidbody2D>();
        animator = GetComponent<Animator>();

        if (rb == null)
            Debug.LogError("PlayerController2D: Rigidbody2D component is missing!");
    }

    // ────────────────────────────────────────────────────────────────────
    // Update: read input every display frame (input polling should be in Update)
    // ────────────────────────────────────────────────────────────────────
    void Update()
    {
        // GetAxisRaw returns -1, 0, or 1 — no smoothing or acceleration.
        // This gives tight, responsive movement suited for tile-based games.
        float h = Input.GetAxisRaw("Horizontal"); // A/D or Left/Right Arrow
        float v = Input.GetAxisRaw("Vertical");   // W/S or Up/Down Arrow

        // Normalise to prevent faster diagonal movement
        moveInput = new Vector2(h, v).normalized;
        isMoving  = moveInput != Vector2.zero;

        UpdateAnimator();
    }

    // ────────────────────────────────────────────────────────────────────
    // FixedUpdate: apply movement through Rigidbody so the physics engine
    // correctly detects collisions with the Tilemap Composite Collider.
    //
    // Time.fixedDeltaTime is used automatically by MovePosition — we do
    // not need to multiply manually here.
    // ────────────────────────────────────────────────────────────────────
    void FixedUpdate()
    {
        Vector2 newPosition = rb.position + moveInput * moveSpeed * Time.fixedDeltaTime;
        rb.MovePosition(newPosition);
    }

    // ────────────────────────────────────────────────────────────────────
    // UpdateAnimator: set animation parameters so the sprite faces the
    // direction of movement. Uses two float parameters and a bool.
    // ────────────────────────────────────────────────────────────────────
    private void UpdateAnimator()
    {
        if (animator == null) return;

        // IsMoving drives the Idle ↔ Walk transition
        animator.SetBool("IsMoving", isMoving);

        // MoveX / MoveY store the last direction for directional walk sprites
        if (isMoving)
        {
            animator.SetFloat("MoveX", moveInput.x);
            animator.SetFloat("MoveY", moveInput.y);
        }
    }
}
🎬
Animator Setup for the Player (optional but recommended)

Create an Animator Controller for the player. Add two states: Idle (default) and Walk. Add a Bool parameter named IsMoving. Add transition Idle → Walk with condition IsMoving = true, and Walk → Idle with IsMoving = false. If you have directional sprites (facing up/down/left/right), add Float parameters MoveX and MoveY and use a Blend Tree inside the Walk state.

Script 2 of 4

CameraFollow.cs

A simple script that keeps the main camera centred on the player. Uses LateUpdate() — which runs after all other Updates — so the camera sees the player's final position each frame, not a mid-frame snapshot.

2
CameraFollow.cs
Attach to: Main Camera
MonoBehaviour
LateUpdate() Vector3.Lerp Camera bounds [SerializeField]
CameraFollow.cs C#
// CameraFollow.cs
// ─────────────────────────────────────────────────────────────────────────────
// Smoothly follows the player target using Lerp.
// LateUpdate() is used because the player moves in FixedUpdate/Update —
// using LateUpdate ensures we always read the player's FINAL position
// for that frame, eliminating one-frame-behind jitter.
// ─────────────────────────────────────────────────────────────────────────────

using UnityEngine;

public class CameraFollow : MonoBehaviour
{
    // ── Inspector Settings ───────────────────────────────────────────────
    [SerializeField] private Transform target;        // the player Transform
    [SerializeField] private float    smoothSpeed = 5f; // higher = snappier follow

    // Camera offset — keep the Z at a negative value (camera is in front of the 2D world).
    [SerializeField] private Vector3 offset = new Vector3(0, 0, -10f);

    // Optional: clamp the camera to map bounds
    // Set these in the Inspector to match your tilemap size.
    [SerializeField] private bool  useBounds  = false;
    [SerializeField] private Vector2 minBounds;
    [SerializeField] private Vector2 maxBounds;

    // ────────────────────────────────────────────────────────────────────
    // LateUpdate: runs after Update and FixedUpdate have both completed.
    // This guarantees we read the player's finalised position.
    // ────────────────────────────────────────────────────────────────────
    void LateUpdate()
    {
        if (target == null) return;

        // Desired camera position: directly above (in Z) the player
        Vector3 desired = target.position + offset;

        // Lerp: gradually move toward the desired position each frame.
        // Time.deltaTime makes the smoothing frame-rate independent.
        Vector3 smoothed = Vector3.Lerp(transform.position, desired, smoothSpeed * Time.deltaTime);

        // Optional: clamp to map boundaries so the camera never shows empty space
        if (useBounds)
        {
            smoothed.x = Mathf.Clamp(smoothed.x, minBounds.x, maxBounds.x);
            smoothed.y = Mathf.Clamp(smoothed.y, minBounds.y, maxBounds.y);
        }

        transform.position = smoothed;
    }
}
Script 3 of 4

AudioManager.cs

A singleton that manages background music and sound effects. The SettingsManager (next script) will call this to adjust the volume when the player moves the volume slider.

3
AudioManager.cs
Attach to: an empty GameObject named "AudioManager"
MonoBehaviour
Singleton AudioSource DontDestroyOnLoad volume control
AudioManager.cs C#
// AudioManager.cs
// ─────────────────────────────────────────────────────────────────────────────
// A persistent singleton that controls background music and SFX volume.
// DontDestroyOnLoad keeps it alive when new scenes are loaded.
// SettingsManager calls SetMusicVolume() and SetSFXVolume() when the
// player adjusts the volume sliders in the Settings panel.
// ─────────────────────────────────────────────────────────────────────────────

using UnityEngine;

public class AudioManager : MonoBehaviour
{
    // ── Singleton ────────────────────────────────────────────────────────
    public static AudioManager Instance { get; private set; }

    // ── Inspector References ─────────────────────────────────────────────
    [SerializeField] private AudioSource musicSource;  // loops background music
    [SerializeField] private AudioSource sfxSource;    // plays one-shot effects

    // ── Default volumes ──────────────────────────────────────────────────
    [SerializeField] [Range(0f,1f)] private float defaultMusicVolume = 0.5f;
    [SerializeField] [Range(0f,1f)] private float defaultSFXVolume   = 0.8f;

    // ────────────────────────────────────────────────────────────────────
    // Awake: set up singleton, persist across scene loads
    // ────────────────────────────────────────────────────────────────────
    void Awake()
    {
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;
        DontDestroyOnLoad(gameObject); // survives scene transitions
    }

    // ────────────────────────────────────────────────────────────────────
    // Start: apply saved volume settings from PlayerPrefs.
    // This runs after SettingsManager.Awake, so saved values are already loaded.
    // ────────────────────────────────────────────────────────────────────
    void Start()
    {
        float savedMusic = PlayerPrefs.GetFloat("MusicVolume", defaultMusicVolume);
        float savedSFX   = PlayerPrefs.GetFloat("SFXVolume",   defaultSFXVolume);

        SetMusicVolume(savedMusic);
        SetSFXVolume(savedSFX);

        if (musicSource != null)
            musicSource.Play();
    }

    // ── Public API ───────────────────────────────────────────────────────

    public void SetMusicVolume(float volume)
    {
        if (musicSource != null)
            musicSource.volume = volume;
    }

    public void SetSFXVolume(float volume)
    {
        if (sfxSource != null)
            sfxSource.volume = volume;
    }

    // Play a one-shot sound effect — footstep, pickup, attack, etc.
    public void PlaySFX(AudioClip clip)
    {
        if (sfxSource != null && clip != null)
            sfxSource.PlayOneShot(clip);
    }
}
Script 4 of 4

SettingsManager.cs

The SettingsManager is the bridge between the Settings UI panel and the game. It reads and writes PlayerPrefs — Unity's built-in key-value store that persists data on disk between game sessions. When the player moves a slider or toggles a setting, this script saves the value immediately.

💾
What is PlayerPrefs?

PlayerPrefs stores data as key-value pairs on the device's local storage (Registry on Windows, plist on Mac, /data on Android). It supports three types: float, int, and string. Data persists across game sessions — the player's volume setting from last week is still there when they launch the game today.

4
SettingsManager.cs
Attach to: an empty GameObject named "SettingsManager"
MonoBehaviour
PlayerPrefs Singleton UI Slider Toggle Dropdown Camera.backgroundColor
SettingsManager.cs C#
// SettingsManager.cs
// ─────────────────────────────────────────────────────────────────────────────
// Manages player settings: music volume, SFX volume, colour theme,
// and difficulty. All settings are saved to PlayerPrefs so they
// persist between game sessions.
//
// Each setting follows the same pattern:
//   1. A public method called by a UI element (slider OnValueChanged etc.)
//   2. The method updates the game immediately (live preview)
//   3. The method saves the value to PlayerPrefs
//   4. On Awake, all saved values are loaded back and applied
// ─────────────────────────────────────────────────────────────────────────────

using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class SettingsManager : MonoBehaviour
{
    // ── Singleton ────────────────────────────────────────────────────────
    public static SettingsManager Instance { get; private set; }

    // ── PlayerPrefs Keys ─────────────────────────────────────────────────
    // Store key strings as constants so a typo doesn't silently break saving.
    private const string KEY_MUSIC      = "MusicVolume";
    private const string KEY_SFX        = "SFXVolume";
    private const string KEY_THEME      = "ColourTheme";
    private const string KEY_DIFFICULTY = "Difficulty";
    private const string KEY_FULLSCREEN = "Fullscreen";

    // ── Inspector References — UI Elements ───────────────────────────────
    // Drag these from your Canvas in the Inspector.
    [Header("Volume Sliders")]
    [SerializeField] private Slider  musicSlider;
    [SerializeField] private Slider  sfxSlider;

    [Header("Theme & Difficulty")]
    [SerializeField] private TMP_Dropdown themeDropdown;
    [SerializeField] private TMP_Dropdown difficultyDropdown;
    [SerializeField] private Toggle      fullscreenToggle;

    [Header("Settings Panel")]
    [SerializeField] private GameObject  settingsPanel;  // the whole settings panel

    // ── Colour Themes ────────────────────────────────────────────────────
    // Index matches the Dropdown option order: 0=Day, 1=Night, 2=Sunset
    [SerializeField] private Color[] themeBackgroundColours = new Color[]
    {
        new Color(0.53f, 0.81f, 0.98f), // Day (sky blue)
        new Color(0.05f, 0.05f, 0.15f), // Night (dark navy)
        new Color(0.9f,  0.4f,  0.1f)   // Sunset (orange)
    };

    // ────────────────────────────────────────────────────────────────────
    // Awake: set up singleton, load and apply all saved settings
    // ────────────────────────────────────────────────────────────────────
    void Awake()
    {
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        Instance = this;
    }

    // ────────────────────────────────────────────────────────────────────
    // Start: apply all saved settings to the UI and the game.
    // This runs after all Awake() calls, so AudioManager.Instance exists.
    // ────────────────────────────────────────────────────────────────────
    void Start()
    {
        LoadAllSettings();

        // Hide the settings panel at game start
        if (settingsPanel != null)
            settingsPanel.SetActive(false);
    }

    // ────────────────────────────────────────────────────────────────────
    // LoadAllSettings: read saved values and apply them to both UI and game
    // ────────────────────────────────────────────────────────────────────
    private void LoadAllSettings()
    {
        // Load music volume — default 0.5 if no saved value exists
        float music = PlayerPrefs.GetFloat(KEY_MUSIC, 0.5f);
        if (musicSlider != null) musicSlider.value = music;
        ApplyMusicVolume(music);

        // Load SFX volume — default 0.8
        float sfx = PlayerPrefs.GetFloat(KEY_SFX, 0.8f);
        if (sfxSlider != null) sfxSlider.value = sfx;
        ApplySFXVolume(sfx);

        // Load colour theme — default 0 (Day)
        int theme = PlayerPrefs.GetInt(KEY_THEME, 0);
        if (themeDropdown != null) themeDropdown.value = theme;
        ApplyColourTheme(theme);

        // Load difficulty — default 1 (Medium)
        int diff = PlayerPrefs.GetInt(KEY_DIFFICULTY, 1);
        if (difficultyDropdown != null) difficultyDropdown.value = diff;

        // Load fullscreen toggle — default true (1)
        bool fs = PlayerPrefs.GetInt(KEY_FULLSCREEN, 1) == 1;
        if (fullscreenToggle != null) fullscreenToggle.isOn = fs;
        ApplyFullscreen(fs);
    }

    // ════════════════════════════════════════════════════════════════════
    // PUBLIC METHODS — called by UI elements via their event system
    // ════════════════════════════════════════════════════════════════════

    // ── Music Volume ─────────────────────────────────────────────────────
    // Wire this to the Music Slider's OnValueChanged event.
    public void OnMusicVolumeChanged(float value)
    {
        ApplyMusicVolume(value);
        PlayerPrefs.SetFloat(KEY_MUSIC, value);
        PlayerPrefs.Save(); // flush to disk immediately
    }

    // ── SFX Volume ───────────────────────────────────────────────────────
    // Wire this to the SFX Slider's OnValueChanged event.
    public void OnSFXVolumeChanged(float value)
    {
        ApplySFXVolume(value);
        PlayerPrefs.SetFloat(KEY_SFX, value);
        PlayerPrefs.Save();
    }

    // ── Colour Theme ─────────────────────────────────────────────────────
    // Wire this to the Theme Dropdown's OnValueChanged event.
    // value: 0 = Day, 1 = Night, 2 = Sunset
    public void OnColourThemeChanged(int value)
    {
        ApplyColourTheme(value);
        PlayerPrefs.SetInt(KEY_THEME, value);
        PlayerPrefs.Save();
    }

    // ── Difficulty ───────────────────────────────────────────────────────
    // Wire this to the Difficulty Dropdown's OnValueChanged event.
    // value: 0 = Easy, 1 = Medium, 2 = Hard
    public void OnDifficultyChanged(int value)
    {
        // Store the difficulty — gameplay scripts can read it via PlayerPrefs
        PlayerPrefs.SetInt(KEY_DIFFICULTY, value);
        PlayerPrefs.Save();
        Debug.Log($"Difficulty set to: {value} (0=Easy, 1=Medium, 2=Hard)");
    }

    // ── Fullscreen Toggle ────────────────────────────────────────────────
    // Wire this to the Fullscreen Toggle's OnValueChanged event.
    public void OnFullscreenToggled(bool value)
    {
        ApplyFullscreen(value);
        PlayerPrefs.SetInt(KEY_FULLSCREEN, value ? 1 : 0);
        PlayerPrefs.Save();
    }

    // ── Settings Panel Open/Close ────────────────────────────────────────
    // Wire the settings gear button's OnClick to this method.
    public void ToggleSettingsPanel()
    {
        if (settingsPanel == null) return;
        bool isOpen = settingsPanel.activeSelf;
        settingsPanel.SetActive(!isOpen);

        // Pause game time while settings are open (optional)
        Time.timeScale = isOpen ? 1f : 0f;
    }

    // ── Reset to Defaults ────────────────────────────────────────────────
    public void ResetToDefaults()
    {
        PlayerPrefs.DeleteAll(); // clear ALL saved preferences
        LoadAllSettings();          // reload defaults into UI and game
        Debug.Log("Settings reset to defaults.");
    }

    // ════════════════════════════════════════════════════════════════════
    // PRIVATE APPLY METHODS — separate from the Save so they can be
    // called on load without re-saving (which would overwrite defaults
    // with 0 on first run).
    // ════════════════════════════════════════════════════════════════════

    private void ApplyMusicVolume(float v)
    {
        if (AudioManager.Instance != null)
            AudioManager.Instance.SetMusicVolume(v);
    }

    private void ApplySFXVolume(float v)
    {
        if (AudioManager.Instance != null)
            AudioManager.Instance.SetSFXVolume(v);
    }

    private void ApplyColourTheme(int index)
    {
        if (index < 0 || index >= themeBackgroundColours.Length) return;
        // Change the camera's background colour — visible in the undrawn areas
        if (Camera.main != null)
            Camera.main.backgroundColor = themeBackgroundColours[index];
    }

    private void ApplyFullscreen(bool value)
    {
        Screen.fullScreen = value;
    }
}
🔑
PlayerPrefs Key Constants — Why?

Notice private const string KEY_MUSIC = "MusicVolume". If you write the key string directly everywhere, a single typo ("MusicVolumn") silently creates a new key that is never read back — the setting appears to save but never loads. Constants make the compiler catch typos for you.

Step 6

Building the Settings UI Panel

The Settings panel is a Canvas panel that appears when the player clicks the gear icon. It contains sliders, a dropdown, a toggle, and buttons — all wired to the SettingsManager.

6A — Canvas Setup

6B — Settings Panel Layout

Build the following UI elements inside SettingsPanel:

6C — Gear/Open Button (in HUD)

Layout checkpoint

The Settings Panel should be visible in the Scene view as a semi-transparent panel with labels, sliders, two dropdowns, a toggle, and two buttons. It should visually match what a player would expect from a settings screen.

Core Concept

PlayerPrefs — How It Works

Before wiring the UI, understand exactly how PlayerPrefs works — this prevents the most common settings bugs.

The Three PlayerPrefs Data Types

TypeSetGetUse it for
floatPlayerPrefs.SetFloat("key", value)PlayerPrefs.GetFloat("key", default)Volume (0.0–1.0), player speed multiplier
intPlayerPrefs.SetInt("key", value)PlayerPrefs.GetInt("key", default)Difficulty level (0/1/2), toggles (0/1)
stringPlayerPrefs.SetString("key", value)PlayerPrefs.GetString("key", default)Player name, language code ("en", "fr")
💾
The "default" parameter matters

When a key has never been saved (first launch), GetFloat("key") would return 0 — meaning music volume would be 0 on first run! Always provide a sensible default as the second argument: PlayerPrefs.GetFloat("MusicVolume", 0.5f) returns 0.5 if the key doesn't exist yet.

When Does PlayerPrefs Save?

PlayerPrefs.Set*() stores data in memory but may not immediately write to disk. Call PlayerPrefs.Save() explicitly to flush to disk. Best practice: call Save() immediately after any Set*() call in your settings methods, as shown in SettingsManager.cs.

⚠️
Never store sensitive data in PlayerPrefs

PlayerPrefs data is stored in plain text on the device — in the Windows Registry and as readable files on Android. Do not store passwords, authentication tokens, or anything sensitive. For game settings (volume, theme, difficulty), it is perfectly appropriate.

Debugging PlayerPrefs

In the Unity Editor, PlayerPrefs is stored in the Registry on Windows. To inspect or clear it during development:

Utility — clearing PlayerPrefs in the Editor C# snippet
// Run this from a temporary script or the ResetToDefaults button to
// clear all saved settings and test first-launch behaviour.

// Delete a single key:
PlayerPrefs.DeleteKey("MusicVolume");

// Delete ALL saved keys (useful for testing first-run defaults):
PlayerPrefs.DeleteAll();

// Check if a key exists before reading it:
if (PlayerPrefs.HasKey("MusicVolume"))
{
    float v = PlayerPrefs.GetFloat("MusicVolume");
    Debug.Log($"Saved music volume: {v}");
}
else
{
    Debug.Log("No saved music volume found — using default.");
}
Step 7

Inspector Wiring Checklist

All scripts are written and the UI is built. Now connect them.

Player GameObject

Main Camera

AudioManager

SettingsManager

Wiring UI Events

🔌
How to wire UI events to scripts

Select the UI element (e.g., MusicSlider). In the Inspector, find the event section (e.g., On Value Changed). Click the + button. Drag the SettingsManager GameObject into the object field. Click the function dropdown → SettingsManager → OnMusicVolumeChanged(float). The slider will now call that method every time its value changes.

Step 8

Testing Your Game

Test 1 — Tilemap and Collision

Test 2 — Camera Follow

Test 3 — Settings Panel

Common Issues

ProblemLikely CauseFix
Player walks through wallsWalls Tilemap missing Tilemap Collider 2D, or "Used By Composite" not ticked, or player has no Rigidbody 2DCheck Walls Tilemap: should have Tilemap Collider 2D (Used By Composite = ticked) + Composite Collider 2D + Rigidbody 2D (Static). Check Player: must have Rigidbody 2D (Dynamic)
Tiles appear all the same layer (no depth)Sorting Layers not created or not assigned to Tilemap RenderersEdit → Project Settings → Tags and Layers → Sorting Layers. Then assign each Tilemap Renderer component the correct Sorting Layer.
Camera not following playerTarget field in CameraFollow is not assignedSelect Main Camera → CameraFollow component → drag Player GameObject into Target field
Settings not saving between Play sessionsPlayerPrefs.Save() not being called, or key string typoEnsure each OnXxxChanged method calls PlayerPrefs.Save(). Use const string keys to prevent typos.
Slider values reset to 0 on PlayLoadAllSettings() not running, or AudioManager.Instance is null when SettingsManager.Start() calls itMake sure AudioManager Awake() runs before SettingsManager Start(). Both singletons must exist in the scene.
Tiles not showing in Tile Palette windowSprites not sliced correctly (Sprite Mode is Single not Multiple), or PPU mismatchSelect sprite sheet → Sprite Mode: Multiple → Slice → Grid by Cell Size → Apply. Check all tiles use the same Pixels Per Unit.
Pink/magenta tilesURP/Built-in pipeline mismatch on imported sprites or materialsSelect the sprite → Inspector → change Texture Type to Sprite (2D and UI) → Apply
Going Further

Extension Challenges

Finished the core tutorial? These extensions progressively deepen your understanding of both the Tilemap system and UI best practices.

⭐ DifficultyChallengeNew Concept
Add a Water tile layer with a blue animated tint using a custom MaterialTilemap Material, custom shader tint
Add a "Master Volume" slider that scales both music and SFX simultaneouslyAudioListener.volume, PlayerPrefs float
⭐⭐Implement a Rule Tile for walls so corners automatically use the correct sprite variantRule Tile, neighbour conditions
⭐⭐Add an Animated Tile for water (looping frame animation) using the 2D Tilemap Extras packageAnimatedTile, AnimationSpeed field
⭐⭐Save the player's last known position to PlayerPrefs and restore it when the game launchesPlayerPrefs.SetFloat for X and Y, transform.position on Start
⭐⭐⭐Add a minimap that renders the entire tilemap at a small scale in the HUD cornerSecond Camera, Render Texture, UI RawImage
⭐⭐⭐Implement a "fog of war" layer — a black Tilemap that gets erased as the player exploresTilemap.SetTile(null), trigger zone detection
⭐⭐⭐⭐Create a runtime map editor — allow the player to paint tiles during gameplay using mouse/touch inputTilemap.SetTile(), Camera.ScreenToWorldPoint, WorldToCell
🎉
You've completed Pixel Quest!

You have built a tile-painted 2D world with multi-layer sorting, physics collision via Composite Collider 2D, a smooth camera follow, and a full Settings UI that persists preferences using PlayerPrefs. These are the exact techniques used in every professional 2D game built in Unity. Well done!