Chapter 17

Scoring System

Track distance and coin scores, format them for display, persist high scores, and connect everything through events for a responsive, satisfying scoring experience.

Why Scoring Matters

Score is the heartbeat of an infinite runner. It is the single number that answers the question every player asks: "How well did I do?" Without a visible, responsive score, the game feels aimless. With one, every meter traveled and every coin collected becomes meaningful.

Our scoring system tracks two distinct types of points:

  • Distance Score: Points earned simply by staying alive and running forward. This accumulates automatically based on how far the player has traveled.
  • Coin Score: Points earned by collecting coins. Each coin has a base value, which can be modified by the Score Multiplier power-up.

The total score is the sum of both. We also track coins as a separate currency (for potential shop purchases later) and persist the all-time high score using PlayerPrefs.

The ScoreData Structure

Before building the manager, let us define a data structure that holds all scoring information. Using a struct keeps this lightweight and value-typed, which is perfect for passing score snapshots around via events.

ScoreData.csC#
namespace InfiniteRunner.Gameplay
{
    /// <summary>
    /// A snapshot of all current scoring data. Passed through events
    /// so UI and other systems can display the latest information.
    ///
    /// We use a struct (not a class) because:
    /// 1. It is small and contains only value types.
    /// 2. It is passed by value, so receivers get their own copy.
    /// 3. No heap allocation - no garbage collection pressure.
    /// </summary>
    public struct ScoreData
    {
        /// <summary>
        /// Points earned from distance traveled.
        /// </summary>
        public int DistanceScore;

        /// <summary>
        /// Points earned from collecting coins (after multiplier).
        /// </summary>
        public int CoinScore;

        /// <summary>
        /// Total score (DistanceScore + CoinScore).
        /// </summary>
        public int TotalScore;

        /// <summary>
        /// Total number of individual coins collected this run.
        /// Separate from CoinScore because multipliers affect points
        /// but not the coin count.
        /// </summary>
        public int CoinsCollected;

        /// <summary>
        /// Distance traveled in meters this run.
        /// This is the raw distance, not the score derived from it.
        /// </summary>
        public float DistanceTraveled;

        /// <summary>
        /// The all-time high score (loaded from persistent storage).
        /// </summary>
        public int HighScore;

        /// <summary>
        /// Whether the current TotalScore has beaten the HighScore.
        /// Used to show "NEW!" in the game over screen.
        /// </summary>
        public bool IsNewHighScore;

        /// <summary>
        /// The currently active score multiplier (1 = normal).
        /// </summary>
        public int CurrentMultiplier;
    }
}
Struct vs. Class

A struct in C# is a value type stored on the stack, while a class is a reference type stored on the heap. For small data containers that are frequently created and passed around (like score snapshots fired every frame), structs avoid garbage collection overhead. However, structs should be small (under ~16 bytes ideally, though our ScoreData is larger for readability). For game jams this is perfectly fine; for extreme optimization you might split it into separate events.

Distance Scoring

Distance score accumulates automatically as the player runs. There are two common approaches:

Approach 1: Time-Based (Simpler)

Multiply elapsed time by the current speed to estimate distance. This is simpler but can drift from the player's actual position if speed changes frequently.

Time-based distanceC#
// Each frame, add distance based on current speed
float distanceThisFrame = currentSpeed * Time.deltaTime;
totalDistanceTraveled += distanceThisFrame;

Approach 2: Position-Based (More Accurate)

Track the player's actual Z position delta each frame. This is more accurate if the player can slow down, speed up, or stop.

Position-based distanceC#
// Track how far the player actually moved this frame
float deltaZ = playerTransform.position.z - lastZPosition;
if (deltaZ > 0) // Only count forward movement
{
    totalDistanceTraveled += deltaZ;
}
lastZPosition = playerTransform.position.z;

We will use Approach 1 (time-based) because our player always moves at a constant forward speed determined by the game, and origin shifting (Chapter 14) can reset the player's Z position, which would confuse the position-based approach.

Converting Distance to Score

Raw distance in meters is not very exciting as a score number. We convert it using a multiplier so the score feels larger and more satisfying:

Distance to score conversionC#
// 1 meter = 10 points by default
// At 10 m/s speed, player earns 100 points per second from distance alone
int distanceScore = Mathf.FloorToInt(totalDistanceTraveled * distanceMultiplier);

Coin Scoring

Each coin has a base value (set on the Coin component, typically 1). When collected, the coin fires GameEvents.OnCoinCollected with its value. The ScoreManager receives this, applies any active score multiplier, and adds the result to the coin score.

Coin score calculationC#
// When a coin is collected:
// coinValue = the base value from Coin.cs (usually 1)
// currentMultiplier = 1 normally, 2 with Score Multiplier power-up
int pointsEarned = coinValue * pointsPerCoin * currentMultiplier;
totalCoinScore += pointsEarned;
totalCoinsCollected += coinValue; // Raw count, unaffected by multiplier
Separating Coin Count from Coin Score

We track CoinsCollected (the raw number of coins) separately from CoinScore (the points earned from coins). This is important because if you later add a coin shop, you want the currency to be the actual coin count, not the inflated score. A player who collects 50 coins with a 2x multiplier earns 100 coin points but still only has 50 coins to spend.

Score Display Formatting

A score of "1234567" is hard to read at a glance. "1,234,567" is much clearer. C# makes this easy with string formatting:

Score formattingC#
/// <summary>
/// Formats a score integer with comma separators.
/// Examples: 0 -> "0", 1234 -> "1,234", 1234567 -> "1,234,567"
/// </summary>
public static string FormatScore(int score)
{
    return score.ToString("N0");
}

/// <summary>
/// Formats distance as meters with one decimal place.
/// Examples: 123.4 -> "123.4m", 2500.0 -> "2,500.0m"
/// </summary>
public static string FormatDistance(float distance)
{
    return distance.ToString("N1") + "m";
}
C# Format Strings

"N0" is a standard numeric format string. The N means "number" (with comma separators), and the 0 means zero decimal places. "N1" gives one decimal place. These automatically use the system's culture settings for the separator character, but in most cases you will see commas.

The Complete ScoreManager

Now let us bring everything together into the ScoreManager. This is the central class that tracks all scoring, responds to game events, and notifies the UI.

Step 1: Create the Script

  1. Navigate to Assets/Scripts/Gameplay in the Project window.
  2. Right-click > Create > C# Script and name it ScoreManager.
  3. Open it in your code editor and replace the contents with the code below.

Step 2: The Full ScoreManager Script

ScoreManager.csC#
using UnityEngine;
using InfiniteRunner.Core;

namespace InfiniteRunner.Gameplay
{
    /// <summary>
    /// Central scoring system that tracks distance score, coin score,
    /// total score, and high score persistence. Communicates entirely
    /// through events for decoupled architecture.
    ///
    /// Attach this to a persistent manager GameObject in the scene.
    /// </summary>
    public class ScoreManager : MonoBehaviour
    {
        // ---------------------------------------------------------------
        // Singleton
        // ---------------------------------------------------------------

        public static ScoreManager Instance { get; private set; }

        // ---------------------------------------------------------------
        // Inspector Fields
        // ---------------------------------------------------------------

        [Header("Distance Scoring")]
        [Tooltip("How many score points per meter traveled. " +
                 "Higher values make the score grow faster.")]
        [SerializeField] private float distanceMultiplier = 10f;

        [Header("Coin Scoring")]
        [Tooltip("Base points awarded per coin value unit. " +
                 "A coin with value 1 earns this many points.")]
        [SerializeField] private int pointsPerCoin = 10;

        [Header("Update Rate")]
        [Tooltip("How often (in seconds) the score update event fires. " +
                 "Lower values = smoother UI updates but more events.")]
        [SerializeField] private float updateInterval = 0.1f;

        // ---------------------------------------------------------------
        // PlayerPrefs Keys
        // ---------------------------------------------------------------

        // Constants for PlayerPrefs keys. Using constants prevents typos.
        private const string HIGH_SCORE_KEY = "InfiniteRunner_HighScore";
        private const string TOTAL_COINS_KEY = "InfiniteRunner_TotalCoins";

        // ---------------------------------------------------------------
        // Private State
        // ---------------------------------------------------------------

        // Running totals for this play session.
        private float distanceTraveled;
        private int distanceScore;
        private int coinScore;
        private int coinsCollected;
        private int highScore;
        private int totalCoinsEver; // Lifetime coin count (for a future shop).

        // The current multiplier (1 normally, 2+ with power-up).
        private int currentMultiplier = 1;

        // Whether scoring is currently active (only during gameplay).
        private bool isScoring;

        // Timer for throttling score update events.
        private float updateTimer;

        // Current player speed (received from the game systems).
        private float currentSpeed;

        // Flag to track if we beat the high score this run.
        private bool isNewHighScore;

        // ---------------------------------------------------------------
        // Unity Lifecycle
        // ---------------------------------------------------------------

        private void Awake()
        {
            // Singleton pattern.
            if (Instance != null && Instance != this)
            {
                Destroy(gameObject);
                return;
            }
            Instance = this;

            // Load persisted data.
            LoadHighScore();
        }

        private void OnEnable()
        {
            // Subscribe to game events.
            GameEvents.OnGameStarted += HandleGameStarted;
            GameEvents.OnGameOver += HandleGameOver;
            GameEvents.OnCoinCollected += HandleCoinCollected;
            GameEvents.OnMultiplierChanged += HandleMultiplierChanged;
            GameEvents.OnSpeedChanged += HandleSpeedChanged;
        }

        private void OnDisable()
        {
            // Unsubscribe to prevent memory leaks.
            GameEvents.OnGameStarted -= HandleGameStarted;
            GameEvents.OnGameOver -= HandleGameOver;
            GameEvents.OnCoinCollected -= HandleCoinCollected;
            GameEvents.OnMultiplierChanged -= HandleMultiplierChanged;
            GameEvents.OnSpeedChanged -= HandleSpeedChanged;
        }

        private void Update()
        {
            if (!isScoring) return;

            // Accumulate distance based on current speed.
            UpdateDistanceScore();

            // Throttle score update events to avoid overwhelming the UI.
            updateTimer += Time.deltaTime;
            if (updateTimer >= updateInterval)
            {
                updateTimer = 0f;
                BroadcastScoreUpdate();
            }
        }

        // ---------------------------------------------------------------
        // Distance Scoring
        // ---------------------------------------------------------------

        /// <summary>
        /// Called every frame to accumulate distance traveled and
        /// convert it into score points.
        /// </summary>
        private void UpdateDistanceScore()
        {
            // Calculate distance traveled this frame.
            // Speed is in units/second, deltaTime is in seconds,
            // so the product is units (meters) traveled this frame.
            float distanceThisFrame = currentSpeed * Time.deltaTime;
            distanceTraveled += distanceThisFrame;

            // Convert total distance to score points.
            // FloorToInt ensures we only count whole points.
            int newDistanceScore = Mathf.FloorToInt(
                distanceTraveled * distanceMultiplier * currentMultiplier
            );

            // Only update if the score actually changed.
            if (newDistanceScore != distanceScore)
            {
                distanceScore = newDistanceScore;
                CheckHighScore();
            }
        }

        // ---------------------------------------------------------------
        // Coin Scoring
        // ---------------------------------------------------------------

        /// <summary>
        /// Called when the player collects a coin. Adds points
        /// based on coin value, points-per-coin rate, and multiplier.
        /// </summary>
        /// <param name="coinValue">The base value of the collected coin.</param>
        private void HandleCoinCollected(int coinValue)
        {
            // Track raw coin count (unaffected by multiplier).
            coinsCollected += coinValue;

            // Calculate points with multiplier applied.
            int points = coinValue * pointsPerCoin * currentMultiplier;
            coinScore += points;

            // Check if this pushed us past the high score.
            CheckHighScore();

            // Fire an immediate score update so the UI responds instantly
            // to coin collection (don't wait for the throttled update).
            BroadcastScoreUpdate();

            Debug.Log($"[ScoreManager] Coin collected! " +
                      $"+{points} points (x{currentMultiplier}). " +
                      $"Total coins: {coinsCollected}");
        }

        // ---------------------------------------------------------------
        // High Score
        // ---------------------------------------------------------------

        /// <summary>
        /// Checks whether the current total score exceeds the high score.
        /// Sets the isNewHighScore flag if it does.
        /// </summary>
        private void CheckHighScore()
        {
            int totalScore = GetTotalScore();

            if (totalScore > highScore)
            {
                highScore = totalScore;
                isNewHighScore = true;
            }
        }

        /// <summary>
        /// Saves the high score and lifetime coin count to PlayerPrefs.
        /// Called when the game ends.
        /// </summary>
        private void SaveHighScore()
        {
            PlayerPrefs.SetInt(HIGH_SCORE_KEY, highScore);
            PlayerPrefs.SetInt(TOTAL_COINS_KEY, totalCoinsEver + coinsCollected);
            PlayerPrefs.Save(); // Force immediate write to disk.

            Debug.Log($"[ScoreManager] Saved high score: {highScore}");
        }

        /// <summary>
        /// Loads the high score and lifetime coin count from PlayerPrefs.
        /// Called once during Awake().
        /// </summary>
        private void LoadHighScore()
        {
            // GetInt returns 0 if the key does not exist (first time playing).
            highScore = PlayerPrefs.GetInt(HIGH_SCORE_KEY, 0);
            totalCoinsEver = PlayerPrefs.GetInt(TOTAL_COINS_KEY, 0);

            Debug.Log($"[ScoreManager] Loaded high score: {highScore}, " +
                      $"lifetime coins: {totalCoinsEver}");
        }

        // ---------------------------------------------------------------
        // Event Handlers
        // ---------------------------------------------------------------

        /// <summary>
        /// Called when a new game starts. Resets all per-run scores.
        /// </summary>
        private void HandleGameStarted()
        {
            // Reset per-run data.
            distanceTraveled = 0f;
            distanceScore = 0;
            coinScore = 0;
            coinsCollected = 0;
            currentMultiplier = 1;
            isNewHighScore = false;
            updateTimer = 0f;

            // Begin scoring.
            isScoring = true;

            // Send initial score update (all zeros).
            BroadcastScoreUpdate();

            Debug.Log("[ScoreManager] Scoring started.");
        }

        /// <summary>
        /// Called when the game ends. Saves the high score and
        /// sends a final score update.
        /// </summary>
        private void HandleGameOver()
        {
            // Stop scoring.
            isScoring = false;

            // Final high score check.
            CheckHighScore();

            // Persist the high score.
            SaveHighScore();

            // Send one final score update with all final values.
            BroadcastScoreUpdate();

            // Fire a dedicated game over score event with the final data.
            GameEvents.OnFinalScoreCalculated?.Invoke(GetScoreData());

            Debug.Log($"[ScoreManager] Game over! Final score: " +
                      $"{FormatScore(GetTotalScore())}. " +
                      $"New high score: {isNewHighScore}");
        }

        /// <summary>
        /// Called when the score multiplier changes (power-up).
        /// </summary>
        private void HandleMultiplierChanged(int newMultiplier)
        {
            currentMultiplier = Mathf.Max(1, newMultiplier);
            Debug.Log($"[ScoreManager] Multiplier changed to " +
                      $"x{currentMultiplier}");
        }

        /// <summary>
        /// Called when the player's forward speed changes.
        /// We need this to calculate distance-based scoring.
        /// </summary>
        private void HandleSpeedChanged(float newSpeed)
        {
            currentSpeed = newSpeed;
        }

        // ---------------------------------------------------------------
        // Broadcasting
        // ---------------------------------------------------------------

        /// <summary>
        /// Fires a score update event with the current scoring data.
        /// UI systems listen for this to refresh their displays.
        /// </summary>
        private void BroadcastScoreUpdate()
        {
            ScoreData data = GetScoreData();
            GameEvents.OnScoreUpdated?.Invoke(data);
        }

        // ---------------------------------------------------------------
        // Public API
        // ---------------------------------------------------------------

        /// <summary>
        /// Returns the current total score (distance + coins).
        /// </summary>
        public int GetTotalScore()
        {
            return distanceScore + coinScore;
        }

        /// <summary>
        /// Returns a complete snapshot of all scoring data.
        /// </summary>
        public ScoreData GetScoreData()
        {
            return new ScoreData
            {
                DistanceScore = distanceScore,
                CoinScore = coinScore,
                TotalScore = GetTotalScore(),
                CoinsCollected = coinsCollected,
                DistanceTraveled = distanceTraveled,
                HighScore = highScore,
                IsNewHighScore = isNewHighScore,
                CurrentMultiplier = currentMultiplier
            };
        }

        /// <summary>
        /// Returns the all-time high score.
        /// </summary>
        public int GetHighScore()
        {
            return highScore;
        }

        /// <summary>
        /// Returns the lifetime total coins collected across all runs.
        /// Useful for a shop or upgrade system.
        /// </summary>
        public int GetLifetimeCoins()
        {
            return totalCoinsEver + coinsCollected;
        }

        // ---------------------------------------------------------------
        // Static Formatting Utilities
        // ---------------------------------------------------------------

        /// <summary>
        /// Formats a score integer with comma separators.
        /// Example: 1234567 becomes "1,234,567".
        /// Static so it can be called from anywhere without a reference.
        /// </summary>
        /// <param name="score">The score to format.</param>
        /// <returns>A formatted string with comma separators.</returns>
        public static string FormatScore(int score)
        {
            return score.ToString("N0");
        }

        /// <summary>
        /// Formats a distance value as meters with one decimal place.
        /// Example: 2500.3 becomes "2,500.3m".
        /// </summary>
        /// <param name="distance">The distance in meters.</param>
        /// <returns>A formatted distance string.</returns>
        public static string FormatDistance(float distance)
        {
            return distance.ToString("N1") + "m";
        }
    }
}

Required Event Declarations

The ScoreManager depends on several events. Make sure these are declared in your GameEvents static class:

GameEvents.cs (scoring additions)C#
using System;

namespace InfiniteRunner.Core
{
    public static partial class GameEvents
    {
        // --- Game State ---
        /// <summary>Fired when a new game begins.</summary>
        public static Action OnGameStarted;

        /// <summary>Fired when the player dies and the run ends.</summary>
        public static Action OnGameOver;

        // --- Scoring ---
        /// <summary>Fired periodically with the latest score data.</summary>
        public static Action<ScoreData> OnScoreUpdated;

        /// <summary>Fired once at game over with the final score snapshot.</summary>
        public static Action<ScoreData> OnFinalScoreCalculated;

        // --- Speed ---
        /// <summary>Fired when the player's forward speed changes.</summary>
        public static Action<float> OnSpeedChanged;
    }
}
Events Must Be Declared Before Use

If you try to invoke an event that has not been declared, you will get a compile error. If you invoke an event that has been declared but has no subscribers, the ?. (null-conditional) operator safely does nothing. Always use OnSomeEvent?.Invoke() with the question mark to avoid null reference exceptions.

High Score Persistence with PlayerPrefs

PlayerPrefs is Unity's simplest way to save small amounts of data between play sessions. It stores key-value pairs (like a dictionary) that persist even after the game is closed. It is perfect for high scores and settings, though not suitable for large or complex data (we will build a proper save system in Chapter 23).

How PlayerPrefs Works

PlayerPrefs basicsC#
// Saving a value
PlayerPrefs.SetInt("HighScore", 12345);
PlayerPrefs.SetFloat("MusicVolume", 0.8f);
PlayerPrefs.SetString("PlayerName", "Runner");
PlayerPrefs.Save(); // Force write to disk

// Loading a value (with default if key doesn't exist)
int highScore = PlayerPrefs.GetInt("HighScore", 0);
float volume = PlayerPrefs.GetFloat("MusicVolume", 1.0f);
string name = PlayerPrefs.GetString("PlayerName", "Player");

// Checking if a key exists
bool hasHighScore = PlayerPrefs.HasKey("HighScore");

// Deleting data
PlayerPrefs.DeleteKey("HighScore");  // Delete one key
PlayerPrefs.DeleteAll();             // Delete everything (careful!)
PlayerPrefs Is Not Secure

PlayerPrefs stores data in plain text on the device. On Windows, it is in the registry. On Android, it is in shared preferences XML. Players can easily edit these values to cheat. For a tutorial game this is fine, but for a released game with leaderboards, you would need server-side validation. We will discuss better approaches in Chapter 23 (Save System).

Why We Call PlayerPrefs.Save()

Unity normally writes PlayerPrefs to disk when the application quits. However, if the game crashes or is force-closed (common on mobile), unsaved data is lost. Calling PlayerPrefs.Save() explicitly forces an immediate write. We call it at game over to ensure the high score is always persisted.

Connecting the ScoreManager to the Game

Let us set up the ScoreManager in the Unity scene and verify everything works.

Step 1: Add ScoreManager to the Scene

  1. In the Hierarchy, find your Managers GameObject (or create one if you do not have it).
  2. Add the ScoreManager component to it.
  3. In the Inspector, set Distance Multiplier to 10 and Points Per Coin to 10.
  4. Set Update Interval to 0.1 (10 updates per second).

Step 2: Verify Event Flow

The scoring system relies on these events being fired by other systems:

  • OnGameStarted — Fired by the GameManager when the player taps Play.
  • OnGameOver — Fired by the GameManager when the player hits an obstacle (and has no shield).
  • OnCoinCollected — Fired by the Coin script when collected (Chapter 16).
  • OnMultiplierChanged — Fired by the PowerUpManager (Chapter 16).
  • OnSpeedChanged — Fired by whatever system controls the player's forward speed (PlayerController or DifficultyManager).

Step 3: Quick Debug Test

To verify the ScoreManager is working before we build the UI, create a temporary debug display:

ScoreDebugDisplay.csC#
using UnityEngine;
using InfiniteRunner.Core;
using InfiniteRunner.Gameplay;

namespace InfiniteRunner.Debug
{
    /// <summary>
    /// Temporary debug script that displays score data on screen
    /// using Unity's OnGUI. Remove this once the proper UI is built.
    /// </summary>
    public class ScoreDebugDisplay : MonoBehaviour
    {
        private ScoreData latestScore;

        private void OnEnable()
        {
            GameEvents.OnScoreUpdated += data => latestScore = data;
        }

        private void OnGUI()
        {
            // Display in the top-left corner.
            GUILayout.BeginArea(new Rect(10, 10, 300, 200));
            GUILayout.Label($"Score: {ScoreManager.FormatScore(latestScore.TotalScore)}");
            GUILayout.Label($"Distance: {ScoreManager.FormatDistance(latestScore.DistanceTraveled)}");
            GUILayout.Label($"Coins: {latestScore.CoinsCollected}");
            GUILayout.Label($"Multiplier: x{latestScore.CurrentMultiplier}");
            GUILayout.Label($"High Score: {ScoreManager.FormatScore(latestScore.HighScore)}");
            if (latestScore.IsNewHighScore)
            {
                GUILayout.Label("*** NEW HIGH SCORE! ***");
            }
            GUILayout.EndArea();
        }
    }
}
OnGUI for Debugging

OnGUI is Unity's old immediate-mode GUI system. It is ugly and slow, but it is perfect for quick debug displays because it requires no setup — just write text to the screen. We will replace this with a proper UI Canvas in Chapter 19.

Chapter Summary

In this chapter, you built a complete scoring system for the infinite runner:

  • ScoreData struct — A lightweight value type that holds all scoring information for passing through events.
  • Distance scoring — Automatically accumulates based on speed and time, converted to points via a multiplier.
  • Coin scoring — Points per coin, affected by the Score Multiplier power-up, with a separate raw coin count.
  • ScoreManager.cs — Singleton manager that tracks everything, responds to events, and broadcasts score updates.
  • High score persistence — Saved and loaded via PlayerPrefs, with immediate disk writes at game over.
  • Score formatting — Static utility methods for displaying scores with comma separators and distances with units.

The scoring system is entirely event-driven. It does not reference the UI, the coin scripts, or the power-up manager directly. This decoupled design means you can change any of those systems without touching the ScoreManager. In the next chapter, we will build the difficulty progression system that gradually increases challenge as the player's score grows.

Save Your Progress

Scoring system tracks distance and coins. Quick commit.

git add .
git commit -m "Add scoring system with distance and coin tracking"

By now, git add . and git commit should feel automatic. That's the goal. Professional developers commit so often they don't even think about it — like saving a document.