Multi-Metric Scoring System
A dynamic scoring model that tracks any number of named metrics through a single service, returning immutable snapshots the game rules never have to own.
What the system is for
Most games need more than one number to describe performance. A shooter tracks kills, accuracy and time. A platformer tracks coins, lives lost and best-lap time. A rhythm game tracks notes hit, combo and a weighted final score. Every project reinvents the same wiring: a static manager, a pile of public floats and a HUD that reads them directly.
Serenity's Score aggregate replaces that wiring with a proper service. Metrics are registered by name at startup and mutated through typed operations. The service never knows what kills or accuracy mean to the game — it only knows how to add, subtract, multiply, divide, set and reset a named value and return the result as an immutable snapshot.
The Unity problem
Score singletons break the moment a game needs two scoring contexts — a per-level score and a total run score, for example — or when a feature like combo multipliers needs to apply across several metrics at once. Public floats on a MonoBehaviour make HUDs write directly to game state, which turns any refactor into a manual search-and-replace across every scene and every script that ever called `ScoreManager.Instance.kills++`.
Persistence is the other failure point. Saving a high score means reaching into whatever shape the singleton happens to have at that moment. There is no stable contract, no clear moment of capture and no way to compare two sessions without duplicating the reading logic across the save and the leaderboard screen.
How Serenity approaches it
Serenity exposes scoring through IScoreService. Each metric is registered with a ScoreKey — an immutable string identifier — and a ScoreMetricKind that describes how the value should be interpreted: Integer, Float, Percentage or Time. Once registered, the metric can be changed through Add, Subtract, Multiply, Divide, Set or ResetKey, and the whole model can be cleared with ResetAll. Any operation returns nothing; the service owns the state.
The snapshot contract is the central design point. Calling GetSnapshot returns a ScoreSnapshot: an immutable struct that holds a read-only dictionary of every current ScoreValue keyed by ScoreKey. HUDs, leaderboard screens and the persistence layer all receive the same snapshot. ScoreRecord captures best, worst, average and session count for a metric across sessions, with IScoreRepository providing the persistence port so the storage strategy never leaks into the domain.
Metrics no longer have to be registered from code. A UnityScoreSettings asset lists Key/Kind rows through the Score Settings wizard (Tools ▸ Serenity ▸ Create ▸ Score ▸ Score Settings), and the InstallScore Initialization Pipeline task registers every declared row at boot — additive to, never a replacement for, IScoreService.Register calls from your own glue. Score now actually boots as part of the standard pipeline instead of only existing when a test constructs it directly.
Two building blocks close the gap between a moving Transform and a scored run. UnityTransformDistanceScoreMeter feeds a declared metric from how far a Transform has travelled — a per-axis mask decides what counts, Score Per Unit scales the award, and a Max Delta Per Frame guard re-seeds instead of paying out a teleport or respawn. AddScoreSignal authors a Key/Delta pair as a no-code inbound signal, so any SignalEmitterComponent-style trigger can award a fixed amount to a metric with zero gameplay glue.
Score and Leaderboard meet at a boot-ordering guarantee and a component. InstallScore always registers metrics before InstallLeaderboard runs, so a leaderboard never asks for a ScoreKey that doesn't exist yet, and UnityLeaderboardScoreSubmitter wraps the submit-current-score flow into a parameterless SubmitNow() call — no hand-wired UnityLeaderboardScoreBridge construction needed from a Button click or a SignalReactionComponent.
How it fits into Serenity
Score lives in the Serenity.Score namespace and follows the same layered structure as every other Serenity aggregate. The Domain layer defines the value objects ScoreKey, ScoreValue and ScoreSnapshot, the entity ScoreRuntime, the enum ScoreMetricKind and the record type ScoreRecord. The Application layer exposes IScoreService and the persistence port IScoreRepository. The Infrastructure layer provides UnityScoreService and UnityScoreRepository as the concrete implementations. ScoreInstaller and UnityScoreInstaller register everything through the initialization pipeline.
Score cooperates with the Game Session aggregate, which triggers ResetAll at the start of each session and calls GetSnapshot at the end so the result can be persisted through IScoreRepository. The Event Dispatcher carries the snapshot to any HUD component that subscribed to score-changed signals, so the HUD never holds a reference to the service.
Combo has no landing page of its own, so its contract lives here: IComboService (AddCombo, BreakCombo, GetSnapshot) tracks a single running count of consecutive kills for the session, dispatching ComboIncreasedSignal and ComboBrokenSignal through the glue that calls it. The service applies no rules of its own about when the combo rises or breaks — that judgment call belongs to the consuming game layer — but the count it produces is exactly what feeds a score multiplier: game glue typically calls Multiply on the relevant ScoreKey using the current combo, scaling the points a kill is worth without Score or Combo coupling to each other directly.
Practical workflow
- Define your ScoreKeys as constants or a static class so every system references the same identifiers.
- Register each metric at session start by calling IScoreService.Register with the appropriate ScoreMetricKind, or declare Key/Kind rows on a UnityScoreSettings asset and let InstallScore register them at boot.
- Fire Add, Subtract, Multiply, Divide or Set from game logic in response to gameplay events — or drop a UnityTransformDistanceScoreMeter on a moving object, or wire AddScoreSignal to a trigger, for zero-code scoring.
- Call IComboService.AddCombo() on a consecutive kill and BreakCombo() on player damage or timeout, dispatching ComboIncreasedSignal / ComboBrokenSignal alongside each call so the HUD and Score can react.
- Let the Combo glue call Multiply on the target metric key using CurrentCombo when a combo multiplier is active.
- Subscribe to the score-changed event through the Event Dispatcher to update the HUD without polling.
- Call GetSnapshot at the end of a session or checkpoint and pass it to IScoreRepository.Save for persistence, or call UnityLeaderboardScoreSubmitter.SubmitNow() to save the snapshot and submit it to a board in one step.
What you get
- Service interface IScoreService with Add, Subtract, Multiply, Divide, Set, ResetKey and ResetAll
- Named metric keys through the ScoreKey value object — any string identifier, immutable and hashable
- Metric kind enum ScoreMetricKind: Integer, Float, Percentage and Time
- ScoreValue combining a float amount with its kind in a single immutable struct
- ScoreSnapshot: immutable read-only dictionary of all current metrics, safe to pass across systems
- ScoreRecord for persistence: best, worst, average and session count per metric key
- Persistence port IScoreRepository with Save, TryLoadLatest, LoadAll and Clear
- UnityScoreInstaller registers the service through the standard Serenity initialization pipeline, wired via the InstallScore pipeline task
- Declarative metrics through UnityScoreSettings (Key/Kind rows) and the Score Settings wizard — no hand-written Register calls required
- UnityTransformDistanceScoreMeter scene component: per-axis mask, Score Per Unit scaling and a teleport/respawn guard for distance-based scoring
- AddScoreSignal for constant-delta, no-code score awards from any signal-emitting trigger
- Combo aggregate: IComboService with AddCombo, BreakCombo and GetSnapshot; ComboIncreasedSignal and ComboBrokenSignal as the public event surface; combo count typically drives a score multiplier via Multiply
When to use this
- Games that track two or more independent scoring dimensions such as kills, time, accuracy or combo.
- Projects that need a stable snapshot contract so HUDs, save files and leaderboards all read the same data.
- Codebases that want the combo or session system to feed multipliers into scoring without coupling directly to a singleton.
- Designers who want distance-based or signal-triggered scoring configured on a component or an asset instead of written in code.
- Any project that has outgrown a static score manager and needs a replaceable, testable service behind an interface.
Related systems
Use Serenity when you want scoring to be a proper domain service — one that speaks in named keys and immutable snapshots, stays ignorant of game rules, boots automatically alongside the combo counter that multiplies it, and integrates cleanly with session lifecycle, the leaderboard and the HUD through the Event Dispatcher.
English
Español
Català