🗺️ 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.
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.
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 Concept | Where it appears in Pixel Quest |
|---|---|
| Grid | The parent component that defines tile size and cell layout |
| Tilemap | The actual layer that stores and renders painted tiles |
| Tile Palette | The editor window where you organise and select tiles to paint with |
| Tilemap Renderer | Controls how a Tilemap layer draws its tiles (sort order, material) |
| Tilemap Collider 2D | Automatically generates colliders from every painted tile on a layer |
| Composite Collider 2D | Merges adjacent tile colliders into a single, efficient mesh |
| Sorting Layers | Controls which tilemap layer renders on top of another |
| PlayerPrefs | Saves and loads volume, colour theme, and difficulty between sessions |
| Settings UI | Slider, Toggle, and Dropdown wired to a SettingsManager singleton |
The Four Activities
| Activity | What you do | Key Concept |
|---|---|---|
| 🗺️ Paint the World | Use the Tile Palette to paint floor, wall, and decoration layers | Tilemap, Palette, Brush tools |
| 🧱 Add Collision | Apply Tilemap Collider 2D to the walls layer only | Tilemap Collider 2D, Composite Collider 2D |
| 🎮 Move the Player | Script a 2D character that collides with the tilemap walls | Rigidbody2D, MovePosition |
| ⚙️ Settings Panel | Build and wire a settings menu that saves preferences | PlayerPrefs, UI Slider, Toggle, Dropdown |
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).
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.
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.
-
1Create 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.
-
2Confirm 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.
-
3Install 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.
-
4Add 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.
-
5Download 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):
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.
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.
The Four Core Components
Creating the Grid and Tilemap Layers
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.
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.
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
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):
-
1Drag 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.
-
2Save 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. -
3Repeat for every tile sprite
Drag in your Wall sprite, Decoration sprite, and Water sprite one by one. Each creates a
.assetfile 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.
-
1Create a Rule Tile asset
Right-click in Project window → Create → 2D → Tiles → Rule Tile. Name it WallRuleTile. Save it to
Assets/Art/Tiles/. -
2Configure 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.
-
3Add the Rule Tile to the palette
Drag the WallRuleTile asset into the Tile Palette window like any other tile.
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.
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
| Tool | Shortcut | What it does |
|---|---|---|
| ✏️ Paint Brush | B | Click or click-drag to paint the selected tile. The most-used tool. |
| 🪣 Fill | G | Flood-fills a connected area with the selected tile — like the paint bucket in Photoshop. |
| ⬜ Box Fill | U | Click and drag to fill a rectangular region. Useful for large floor areas. |
| 🔴 Eraser | D | Removes tiles. Hold Shift while using the Paint Brush as a shortcut to the eraser. |
| 👆 Select | S | Selects a region to move or copy. |
| 🔍 Pick | I | Click a tile already in the scene to select it as the active tile — like the eyedropper tool. |
Step 3A — Set the Active Tilemap Layer
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.
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:
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 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.
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.
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
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
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.
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.
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
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 // ───────────────────────────────────────────────────────────────────────────── // 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); } } }
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.
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.
// 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; } }
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.
// 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); } }
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.
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.
// 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; } }
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.
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)
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.
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
| Type | Set | Get | Use it for |
|---|---|---|---|
| float | PlayerPrefs.SetFloat("key", value) | PlayerPrefs.GetFloat("key", default) | Volume (0.0–1.0), player speed multiplier |
| int | PlayerPrefs.SetInt("key", value) | PlayerPrefs.GetInt("key", default) | Difficulty level (0/1/2), toggles (0/1) |
| string | PlayerPrefs.SetString("key", value) | PlayerPrefs.GetString("key", default) | Player name, language code ("en", "fr") |
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.
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:
// 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."); }
Inspector Wiring Checklist
All scripts are written and the UI is built. Now connect them.
Player GameObject
Main Camera
AudioManager
SettingsManager
Wiring UI Events
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.
Testing Your Game
Test 1 — Tilemap and Collision
Test 2 — Camera Follow
Test 3 — Settings Panel
Common Issues
| Problem | Likely Cause | Fix |
|---|---|---|
| Player walks through walls | Walls Tilemap missing Tilemap Collider 2D, or "Used By Composite" not ticked, or player has no Rigidbody 2D | Check 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 Renderers | Edit → Project Settings → Tags and Layers → Sorting Layers. Then assign each Tilemap Renderer component the correct Sorting Layer. |
| Camera not following player | Target field in CameraFollow is not assigned | Select Main Camera → CameraFollow component → drag Player GameObject into Target field |
| Settings not saving between Play sessions | PlayerPrefs.Save() not being called, or key string typo | Ensure each OnXxxChanged method calls PlayerPrefs.Save(). Use const string keys to prevent typos. |
| Slider values reset to 0 on Play | LoadAllSettings() not running, or AudioManager.Instance is null when SettingsManager.Start() calls it | Make sure AudioManager Awake() runs before SettingsManager Start(). Both singletons must exist in the scene. |
| Tiles not showing in Tile Palette window | Sprites not sliced correctly (Sprite Mode is Single not Multiple), or PPU mismatch | Select sprite sheet → Sprite Mode: Multiple → Slice → Grid by Cell Size → Apply. Check all tiles use the same Pixels Per Unit. |
| Pink/magenta tiles | URP/Built-in pipeline mismatch on imported sprites or materials | Select the sprite → Inspector → change Texture Type to Sprite (2D and UI) → Apply |
Extension Challenges
Finished the core tutorial? These extensions progressively deepen your understanding of both the Tilemap system and UI best practices.
| ⭐ Difficulty | Challenge | New Concept |
|---|---|---|
| ⭐ | Add a Water tile layer with a blue animated tint using a custom Material | Tilemap Material, custom shader tint |
| ⭐ | Add a "Master Volume" slider that scales both music and SFX simultaneously | AudioListener.volume, PlayerPrefs float |
| ⭐⭐ | Implement a Rule Tile for walls so corners automatically use the correct sprite variant | Rule Tile, neighbour conditions |
| ⭐⭐ | Add an Animated Tile for water (looping frame animation) using the 2D Tilemap Extras package | AnimatedTile, AnimationSpeed field |
| ⭐⭐ | Save the player's last known position to PlayerPrefs and restore it when the game launches | PlayerPrefs.SetFloat for X and Y, transform.position on Start |
| ⭐⭐⭐ | Add a minimap that renders the entire tilemap at a small scale in the HUD corner | Second Camera, Render Texture, UI RawImage |
| ⭐⭐⭐ | Implement a "fog of war" layer — a black Tilemap that gets erased as the player explores | Tilemap.SetTile(null), trigger zone detection |
| ⭐⭐⭐⭐ | Create a runtime map editor — allow the player to paint tiles during gameplay using mouse/touch input | Tilemap.SetTile(), Camera.ScreenToWorldPoint, WorldToCell |
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!