Gameplaycom.zoa.progression · v0.1.0

ZOA Progression

Skills that level on XP, perks gated by prerequisites, traits and unlocks, all feeding the stat pipeline.

Progression tracks four different things per entity and each has a different shape. Skills accumulate XP and derive a level from a curve. Perks are ranked purchases gated by skill levels and by other perks. Traits are flags that can be added and removed. Unlocks are flags that only ever go on, and they arrive as a side effect of unlocking a perk.

The one thing all four share is that a perk or trait can carry stat modifiers, and those modifiers reach stat evaluation through IProgressionModifierPipeline exactly the way condition modifiers do through their own pipeline. The two pipelines mirror each other: a project collects modifiers from conditions, progression and whatever else it has, then hands the combined set to StatBlock.Evaluate.

XP storage sits in an IQuantityLedger rather than in the tracker's own dictionaries, because scalar counters are a project-wide concern. Perks, traits and unlocks keep their own state, since a prerequisite chain and a set of flags are not scalars and do not fit a long per entity and id.

Depended on by (1)

How it works

Concepts

Skills and the level curve

An ISkillDefinition is an id, a display name, a description, a maximum level, a level curve and tags. The curve is a list of LevelRequirement pairs of level and total XP required, and the word total matters: each entry is cumulative XP from zero, not the increment over the previous level.

Level resolution walks the curve in order and takes the highest level whose threshold the current XP meets, stopping at the first entry it cannot reach. That means the curve must be authored in ascending XP order, because the walk breaks on the first unmet threshold rather than scanning the whole list. The result is clamped to the definition's MaxLevel.

SkillDefinitionBuilder.WithLinearCurve is the shortcut for the common case: it clears the curve and generates one entry per level up to MaxLevel, where level N requires baseXp times N in total. Author the curve entry by entry with AddLevel when the pacing should not be linear.

AddXp ignores non-positive amounts and silently does nothing for an unregistered skill, which is worth knowing when XP appears to vanish. It reads the old XP, adds through the ledger, recomputes the level, and raises LeveledUp with both the old and the new level when the level actually moved. XP is not clamped at the top of the curve; it keeps accruing past the last entry while the level stops at MaxLevel.

Perks, ranks and gating

An IPerkDefinition carries a MaxRank, a list of effects, skill prerequisites as pairs of skill id and minimum level, a list of required perks, and tags. MaxRank of one is a binary perk; anything higher is a multi-rank perk the player invests in repeatedly.

TryUnlockPerk is one rank per call and it fails, returning false, in four situations: the perk id is not registered, the entity is already at MaxRank, some skill prerequisite is below its minimum level, or some required perk is not held. Only when all of those pass does it increment the rank.

GetAvailablePerks answers the question a skill-tree UI asks: which registered perks are below their max rank and currently satisfy every prerequisite. It is the same gate TryUnlockPerk applies, evaluated across the whole registry, so a tree lights up correctly as the player levels.

Unlocking a perk also processes its Unlock-type effects. Each one adds an unlock id to the entity's set and raises the Unlocked event if it was not already present, so a perk that grants a crafting recipe both raises PerkUnlocked and raises Unlocked for the recipe id. That is the only path that produces unlocks; there is no separate grant method.

How perk effects reach the stat pipeline

A PerkEffect is a tagged union with three shapes, chosen by PerkEffectType. StatModifier carries a StatId, an additive and a multiplier. ConditionGrant carries a ConditionId. Unlock carries a string unlock id. The three static factories, PerkEffect.StatMod, PerkEffect.GrantCondition and PerkEffect.UnlockFlag, are how you build them without setting fields by hand.

CollectModifiers walks the entity's held perks, looks each definition up, and converts only the StatModifier effects. Rank scaling happens at that conversion: the additive is multiplied by the current rank, and a multiplier is scaled around one when rank is above one, so a 1.1 multiplier at rank three becomes 1.3 rather than compounding. That is the same linear-around-one rule the condition system uses for stack scaling, so a designer reading plus ten percent per rank gets what they expect.

Every modifier carries a source string of the form perk: followed by the perk id, so an unexpected stat value can be traced back to the perk behind it. The order field is always zero, so progression modifiers land in the first ordering band and any condition modifier authored with a positive order applies after them.

The single-stat CollectModifiers overload filters during the walk instead of afterwards, so reach for it on a hot path.

Note that the pipeline only converts StatModifier effects. A ConditionGrant effect on a perk is authored data that the integration layer is expected to act on by applying the condition through the condition service; the tracker records the perk but does not apply the condition itself.

The shared scalar ledger

ProgressionTracker keeps per-skill XP in an IQuantityLedger under quantity ids of the form skill.xp. followed by the skill id. RegisterSkill registers that slot with the ledger if it is not already there, using an adapter that takes its display name from the skill definition and sets the initial value to zero with clamping off, which is why XP can run past the end of the curve.

The parameterless constructor allocates a private ledger, which is the shape tests and headless code want. The constructor taking an IQuantityLedger shares one, so a scene that installs a ledger component gets progression XP, currency and objective counters in a single scalar store. The scene installer resolves a registered ledger and passes it through, falling back to a private one when none exists.

GetSkills enumerates the ledger's touched ids for the entity and maps back the ones under the XP prefix, which means it only returns skills the entity has actually accrued XP in. A registered skill the entity has never earned anything in does not appear.

Kill XP out of the box

ZOAProgressionSceneInstaller registers the service and, when its auto-subscribe flag is on, does two more things: it registers a default combat skill under a configurable id, conventionally skill.combat, and subscribes to ObjectiveTriggerBus.EnemyKilled to award a flat XP amount per kill to a configured player entity id.

The package depends on com.zoa.objectives for that subscription alone, not for the mission system, and it gives a kill-the-enemies loop visible level-ups with no wiring at all. Turn the flag off when the project wants its own XP rules, then register skills and subscribe to whatever events actually deserve XP.

The installer honours a pre-existing registration: if a progression service is already registered it adopts that one rather than replacing it, and still applies the kill subscription.

In the editor

Screens

Screenshot pending

/screenshots/progression-wizard.png

The wizard with node type set to Perk and max rank above one, showing the effects list with a stat-modifier row and the skill prerequisites list with a skill id and minimum level.

The Progression Wizard authoring a multi-rank perk.

Screenshot pending

/screenshots/progression-xp-hud.png

Play mode with LevelXpHUDHost active, showing the level label and the partially filled XP track for the combat skill.

The level and XP bar on the HUD.

Setup

Workflow

  1. 01

    Install the service

    Put a ZOAProgressionSceneInstaller in the scene. Leave auto-subscribe on for the default behaviour, where a combat skill is registered and every kill published to the objectives trigger bus awards a flat XP amount to the configured player entity. Turn it off when the project wants its own XP rules.

  2. 02

    Register skills with a curve

    Build each skill with SkillDefinitionBuilder and register it before any XP arrives, because AddXp silently does nothing for an unregistered skill. Reach for WithLinearCurve when the pacing is uniform, and author entries individually when it is not. Remember the thresholds are cumulative and must ascend.

  3. 03

    Author perks and their gates

    Use the Progression Wizard from Tools, ZOA, Advanced, Define, Characters, Progression, Progression Wizard, or build perks in code. Every prerequisite is a hard gate at unlock time: skill levels must be met and required perks must be held. Set MaxRank above one only when the perk is meant to be invested in repeatedly, since rank scales the modifiers.

  4. 04

    Wire the modifier pipeline

    Ask the pipeline for the modifiers, combine them with the condition pipeline's and any other source, then evaluate. Progression modifiers all carry order zero, so any condition modifier that needs to apply after them has to be authored with a positive order.

  5. 05

    Act on condition-grant effects

    The pipeline converts stat modifiers only. If a perk carries a ConditionGrant effect, subscribe to PerkUnlocked and apply that condition through the condition service yourself, ideally with a source string naming the perk so it can be removed cleanly if the perk is ever revoked.

  6. 06

    Drive the tree and the HUD

    A skill tree renders GetAvailablePerks as its purchasable set, since that call applies the same gate TryUnlockPerk does. For the XP bar, add a LevelXpHUDHost and point it at the entity and skill you want shown; it binds lazily and rebinds on level-up.

  7. 07

    Share the ledger

    If the scene installs a quantity ledger, the installer picks it up and progression XP shares storage with currency and objective counters. If you are constructing a tracker yourself and want that unification, pass the shared IQuantityLedger to the constructor rather than using the parameterless one.

Surface

Key types

IProgressionService

service

The single surface across all four progression shapes: skills with XP and levels, ranked perks, toggleable traits, and unlock flags.

  • void RegisterSkill(ISkillDefinition skill) / void RegisterPerk(IPerkDefinition perk)
  • void AddXp(string entityId, SkillId skillId, int xp)
  • int GetXp(...) / int GetLevel(...) / IReadOnlyList<SkillId> GetSkills(string entityId)
  • bool TryUnlockPerk(string entityId, PerkId perkId) / bool HasPerk(...) / int GetPerkRank(...)
  • IReadOnlyList<PerkId> GetUnlockedPerks(...) / GetAvailablePerks(...)
  • bool AddTrait(...) / bool RemoveTrait(...) / bool HasTrait(...) / IReadOnlyList<TraitId> GetTraits(...)
  • bool HasUnlock(string entityId, string unlockId) / IReadOnlyList<string> GetUnlocks(...)
  • event Action<string, SkillId, int, int> LeveledUp
  • event Action<string, PerkId> PerkUnlocked
  • event Action<string, TraitId> TraitAdded, TraitRemoved
  • event Action<string, string> Unlocked

IProgressionModifierPipeline

interface

Aggregates stat modifiers from an entity's perks and traits, with a single-stat overload that filters during the walk. The counterpart to the condition system's pipeline.

  • IReadOnlyList<StatModifier> CollectModifiers(string entityId)
  • IReadOnlyList<StatModifier> CollectModifiers(string entityId, StatId statId)

ISkillDefinition

interface

A skill's static half: id, names, maximum level, the cumulative XP curve, and tags.

  • SkillId Id { get; } / string DisplayName { get; } / string Description { get; }
  • int MaxLevel { get; }
  • IReadOnlyList<LevelRequirement> LevelCurve { get; }
  • IReadOnlyList<string> Tags { get; }

IPerkDefinition

interface

A perk's static half: node type, effects, skill prerequisites as id and minimum level pairs, required perks, tags, and the maximum rank where one means binary.

  • PerkId Id { get; } / ProgressionNodeType NodeType { get; } / int MaxRank { get; }
  • IReadOnlyList<PerkEffect> Effects { get; }
  • IReadOnlyList<(SkillId SkillId, int MinLevel)> Prerequisites { get; }
  • IReadOnlyList<PerkId> RequiredPerks { get; }
  • IReadOnlyList<string> Tags { get; }

ProgressionTracker

class

The default implementation of both the service and the pipeline. XP lives in an IQuantityLedger; perks, traits and unlocks keep their own per-entity state.

  • ProgressionTracker()
  • ProgressionTracker(IQuantityLedger ledger)

PerkEffect

struct

A tagged union of the three things a perk can do: modify a stat, grant a condition, or set an unlock flag. Only the StatModifier shape is converted by the pipeline.

  • PerkEffectType Type; StatId StatId; float Additive; float Multiplier
  • ConditionId ConditionId; string UnlockId
  • static PerkEffect StatMod(StatId statId, float additive, float multiplier = 1f)
  • static PerkEffect GrantCondition(ConditionId conditionId)
  • static PerkEffect UnlockFlag(string unlockId)
  • StatModifier ToStatModifier(string source)

LevelRequirement

struct

One point on the curve: a one-based level number and the total XP required to reach it. Cumulative from zero, not an increment, and the list must ascend.

  • int Level; int XpRequired

ProgressionNodeType

enum

Skill for XP-levelled abilities, Perk for purchasable buffs, Proficiency for weapon or equipment category mastery, Unlock for a binary grant, and Trait for a permanent characteristic that usually carries a trade-off.

SkillId

struct

A trimmed, case-insensitive id with implicit conversion both ways. PerkId and TraitId are the same shape for their own domains.

  • string Value
  • bool IsEmpty { get; }

SkillDefinitionBuilder

class

Fluent construction of a skill definition. WithLinearCurve generates the whole curve at once for the common case where level N costs baseXp times N in total.

  • WithDescription(string) / WithMaxLevel(int) / AddTag(string)
  • AddLevel(int level, int xpRequired)
  • WithLinearCurve(int baseXp)
  • ISkillDefinition Build()

PerkDefinitionBuilder

class

Fluent construction of a perk definition, with shorthand for each effect kind and for each gate.

  • WithNodeType(ProgressionNodeType) / WithMaxRank(int) / WithDescription(string)
  • AddStatMod(string statId, float additive, float mult = 1f)
  • GrantsCondition(string conditionId) / Unlocks(string unlockId) / AddEffect(PerkEffect)
  • RequiresSkill(string skillId, int minLevel) / RequiresPerk(string perkId)
  • IPerkDefinition Build()

DefaultSkills

class

The shipped skill vocabulary in two groups: general skills such as Gunsmithing, Medical, Engineering, Survival, Stealth, Athletics and Demolitions, and weapon proficiencies from PistolProf through HeavyProf.

DefaultPerks

class

The shipped perk ids: SteadyAim, FastReload, ThickSkin, LightFooted, Scavenger, CombatMedic, Demolitions, Sprinter, AdrenalineRush and SilentKill.

DefaultTraits

class

The shipped trait ids: Hardy, GlassCannon, NightOwl, HeavyHitter, Nimble and Resourceful.

ZOAProgressionSceneInstaller

component

Registers the service, shares a registered quantity ledger when one exists, and optionally registers a default combat skill wired to award flat XP from the objectives kill bus. Awake delegates to a public EnsureRegistered.

  • IProgressionService Service { get; }
  • void EnsureRegistered()

LevelXpHUDElement

class

A UI Toolkit level and XP bar bound to one entity and skill pair, re-rendering on LeveledUp. Bind and Unbind are idempotent, and Unbind drops the subscription so a destroyed HUD stops receiving events.

  • void Bind(IProgressionService service, string entityId, SkillId skillId)
  • void Unbind()

LevelXpHUDHost

component

Drops the XP bar into a UIDocument and binds it to the registered service, retrying each Update until one exists so it works whichever wakes first. Defaults to the entity id player and the skill id skill.combat.

Surface

Authoring assets

ProgressionNodeAsset

asset

One authoring asset covering all five node types. Identity and description, the node type, a maximum rank, the effect list, skill and perk prerequisites, and an XP curve used only by Skill and Proficiency nodes. Created via Assets, Create, Tools, ZOA, Progression, Progression Node.

  • string NodeId; string Description; ProgressionNodeType NodeType; int MaxRank
  • IReadOnlyList<SerializedPerkEffect> Effects
  • IReadOnlyList<SerializedSkillPrerequisite> SkillPrerequisites
  • IReadOnlyList<string> RequiredPerks
  • IReadOnlyList<LevelRequirement> LevelCurve

Usage

Examples

Registering a skill and a gated perkcsharp
using ZOA.Progression.Core.Definitions;
using ZOA.Progression.Core.Models;
using ZOA.Progression.Core.Runtime;

var progression = new ProgressionTracker();

// Thresholds are TOTAL xp from zero, not per-level increments, and the
// curve must ascend: level resolution stops at the first unmet entry.
var gunsmithing = new SkillDefinitionBuilder("gunsmithing", "Gunsmithing")
    .WithMaxLevel(10)
    .WithLinearCurve(baseXp: 250)
    .AddTag("crafting")
    .Build();

progression.RegisterSkill(gunsmithing);

var fastReload = new PerkDefinitionBuilder("fast_reload", "Fast Reload")
    .WithNodeType(ProgressionNodeType.Perk)
    .WithMaxRank(3)
    // Additive scales by rank; a multiplier scales linearly around 1.
    .AddStatMod("reload_speed", additive: 0f, mult: 1.1f)
    .RequiresSkill("gunsmithing", minLevel: 3)
    .Build();

progression.RegisterPerk(fastReload);

// AddXp on an unregistered skill is a silent no-op, which is the usual
// reason XP appears to vanish.
progression.AddXp("player", DefaultSkills.Gunsmithing, 800);
Unlocking and reacting to progression eventscsharp
using ZOA.Progression.Core.Contracts;
using ZOA.Progression.Core.Models;

progression.LeveledUp += (entityId, skillId, oldLevel, newLevel) =>
    _hud.ShowLevelUp(skillId.Value, oldLevel, newLevel);

progression.PerkUnlocked += (entityId, perkId) =>
    _hud.ShowPerkToast(perkId.Value);

// Unlocked fires as a side effect of unlocking a perk that carries an
// Unlock effect. There is no separate grant path for unlock flags.
progression.Unlocked += (entityId, unlockId) =>
    _crafting.RevealRecipe(unlockId);

// One rank per call. Returns false when the perk is unregistered, is
// already at MaxRank, has an unmet skill level, or is missing a
// required perk.
if (!progression.TryUnlockPerk("player", DefaultPerks.FastReload))
    _hud.ShowLocked("Requirements not met.");
Rendering a skill treecsharp
using ZOA.Progression.Core.Contracts;
using ZOA.Progression.Core.Models;

// GetAvailablePerks applies exactly the gate TryUnlockPerk applies,
// across the whole registry: below max rank, every skill prerequisite
// met, every required perk held.
var available = progression.GetAvailablePerks("player");
var owned = progression.GetUnlockedPerks("player");

foreach (var perkId in owned)
{
    int rank = progression.GetPerkRank("player", perkId);
    _tree.SetOwned(perkId.Value, rank);
}

foreach (var perkId in available)
    _tree.SetPurchasable(perkId.Value);

// GetSkills only returns skills the entity has actually accrued XP in,
// because it enumerates the ledger's touched ids.
foreach (var skillId in progression.GetSkills("player"))
    _tree.SetSkill(skillId.Value, progression.GetLevel("player", skillId));
Combining both modifier pipelinescsharp
using System.Collections.Generic;
using ZOA.Conditions.Core.Contracts;
using ZOA.Progression.Core.Contracts;
using ZOA.Stats;

public sealed class CombinedStatResolver
{
    private readonly StatBlock _stats;
    private readonly IProgressionModifierPipeline _perks;
    private readonly IConditionModifierPipeline _conditions;

    public CombinedStatResolver(
        StatBlock stats,
        IProgressionModifierPipeline perks,
        IConditionModifierPipeline conditions)
    {
        _stats = stats;
        _perks = perks;
        _conditions = conditions;
    }

    public float Resolve(string entityId, StatId statId)
    {
        var combined = new List<StatModifier>();

        // Progression modifiers all carry order 0, so a condition
        // modifier that must apply after them needs a positive order.
        combined.AddRange(_perks.CollectModifiers(entityId, statId));
        combined.AddRange(_conditions.CollectModifiers(entityId, statId));

        return _stats.Evaluate(statId, combined);
    }
}
Applying a perk's condition grantcsharp
using ZOA.Conditions.Core.Contracts;
using ZOA.Progression.Core.Contracts;
using ZOA.Progression.Core.Models;

// The pipeline converts StatModifier effects only. A ConditionGrant
// effect is authored data the integration layer has to act on.
progression.PerkUnlocked += (entityId, perkId) =>
{
    if (!_perkDefinitions.TryGetValue(perkId, out var def)) return;

    foreach (var effect in def.Effects)
    {
        if (effect.Type != PerkEffectType.ConditionGrant) continue;
        if (!_conditionDefinitions.TryGetValue(effect.ConditionId, out var condition)) continue;

        // Sourcing the application by perk id means it can be removed
        // cleanly if the perk is ever revoked.
        _conditions.Apply(entityId, condition, source: "perk:" + perkId.Value);
    }
};

Tooling

Editor tools

Progression Wizard

Tools / ZOA / Advanced / Define / Characters / Progression / Progression Wizard

Authors a ProgressionNodeAsset for any of the five node types, covering effects, skill and perk prerequisites, rank and the XP curve, with a headless emitter behind it that writes into the generated progression folder.

Progression Workbench module

ZOA Platform Workbench, Progression module

Three capabilities: a Progression browser that edits, validates, duplicates and deletes skills, perks, traits and unlocks; a Defaults Browser over the shipped skill, perk and trait ids; and an Overview of the stat pipeline integration.

Read this

Notes and caveats

See also