Kernelcom.zoa.stats · v0.1.0

ZOA Stats

Stat identifiers, modifiers, and the evaluation rule every gameplay package agrees on.

Stats exists so that two packages that have never heard of each other can still agree on what "recoil" means. An attachment in Weapon Modding wants to say it reduces recoil by fifteen percent. A weapon runtime in Armory wants to ask what recoil actually is right now. Neither should have to know the other's types to have that conversation, so the vocabulary lives here, in a package that depends on nothing.

Three types and no framework. StatId is a string wrapped in a struct so it compares case-insensitively and serialises cleanly. StatModifier is a flat record of what one source does to one stat. StatBlock holds base values and evaluates them against a set of modifiers you supply at call time. Nothing else ships in the package.

The omissions matter as much as the contents. There is no registry of known stats, no attribute scanning, no modifier lifetime tracking, and no change notification. Ownership of the modifier set stays with whoever produced it, so an attachment graph, a status effect system, and a difficulty curve can all feed the same evaluation without fighting over a central table.

Depends on (0)

Nothing. This is a root package.

How it works

Concepts

Identity is a trimmed, case-insensitive string

StatId wraps a single string field. The constructor trims surrounding whitespace, and equality and hashing both run through OrdinalIgnoreCase, so "Recoil", "recoil", and " recoil " are the same stat. The tolerance matters because ids cross assembly boundaries as literals, and a stat that fails to match on a capital letter is a bug that surfaces during tuning.

Implicit conversions run in both directions, so a plain string literal can be passed anywhere a StatId is expected and vice versa. In practice you declare your ids as constants somewhere central, the way Weapon Modding declares WeaponStatIds, and let the implicit conversion do the rest.

The struct is marked Serializable and carries a public field rather than a property, so it survives Unity serialisation and shows up in the inspector without a custom drawer.

A modifier is add-then-multiply, applied in order

Each StatModifier carries an additive term, a multiplier, an integer order, and a source label. Evaluation sorts the supplied modifiers by order, skips any whose statId does not match the one being evaluated, and then folds each remaining modifier into a running value: add the additive term, then multiply by the multiplier.

Per-modifier interleaving is the part worth internalising, because it does not sum all additives and then apply all multipliers. A plus-ten at order zero followed by a times-two at order one gives a different number than the reverse. The order field is how you express which one you meant, and it is the only sequencing tool the package offers.

A multiplier of exactly zero is treated as one rather than as annihilation. This makes a default-constructed StatModifier harmless, which matters because Unity serialises new list entries with every numeric field zeroed. A modifier that only wants to add can leave the multiplier alone.

The block owns bases, the caller owns modifiers

StatBlock holds a dictionary of base values and nothing else. Evaluate takes the modifier set as an argument on every call, which means the block never has to be told when a source appears or disappears. You build the modifier list from whatever is currently true, hand it over, and read the answer.

IStatModifierSource is the interface a contributor implements to hand over its current list. Armory's weapon runtime implements it directly, so an equipped weapon can be asked what its attachments are doing without the asker knowing anything about attachments. A source is free to recompute or to hand back a cached list; the contract says nothing about lifetime.

Evaluating a stat with no base value returns zero rather than throwing, so a stat that only exists as a modifier target behaves sensibly.

Setup

Workflow

  1. 01

    Declare your ids in one place

    Put the stat names your project cares about in a static class of string constants rather than scattering literals. Weapon Modding's WeaponStatIds is the shipped example: nineteen constants covering recoil, spread, muzzle velocity, heat, jam chance, fire rate, reload duration, and magazine capacity. Constants make refactors greppable and keep the implicit string-to-StatId conversion honest.

  2. 02

    Seed a StatBlock with base values

    Call SetBase once per stat with the unmodified value, typically read from whatever definition asset owns the number. The block is a plain object with no Unity lifetime, so it can live on a runtime class rather than a component.

  3. 03

    Collect modifiers and evaluate at the point of use

    Gather the current modifier list from your sources, then call Evaluate for the stat you need. Evaluate sorts and allocates a working list per call, so on a hot path collect once per frame or per state change rather than once per read.

Surface

Key types

StatId

struct

Serializable, case-insensitive identifier for a single stat. Implicitly converts to and from string so call sites read as plain literals.

  • StatId(string value)
  • bool IsEmpty
  • bool Equals(StatId other)
  • static implicit operator StatId(string value)
  • static implicit operator string(StatId statId)

StatModifier

struct

One source's effect on one stat: an additive term, a multiplier, a sort order, and a source label for debugging. Serializable, so it authors directly on a ScriptableObject.

  • StatId statId
  • float additive
  • float multiplier
  • int order
  • string source
  • StatModifier(StatId statId, float additive, float multiplier = 1f, int order = 0, string source = null)

StatBlock

class

Holds base values and evaluates one stat at a time against a supplied modifier set. Stateless with respect to the modifiers themselves.

  • void SetBase(StatId statId, float value)
  • float Evaluate(StatId statId, IEnumerable<StatModifier> modifiers)

IStatModifierSource

interface

Implemented by anything that contributes modifiers. Lets a consumer collect contributions without knowing what kind of thing is contributing.

  • IReadOnlyList<StatModifier> GetStatModifiers()

Usage

Examples

Evaluating a stat with two modifierscsharp
using System.Collections.Generic;
using ZOA.Stats;

var block = new StatBlock();
block.SetBase("recoil", 4.0f);

var modifiers = new List<StatModifier>
{
    // A compensator: multiply recoil by 0.85.
    new StatModifier("recoil", additive: 0f, multiplier: 0.85f, order: 0, source: "attachment:Compensator"),

    // A heavy stock: a flat reduction applied after the multiplier.
    new StatModifier("recoil", additive: -0.5f, multiplier: 1f, order: 10, source: "attachment:HeavyStock"),
};

// (4.0 * 0.85) - 0.5 = 2.9
float recoil = block.Evaluate("recoil", modifiers);
Order controls the fold. Swapping the two orders here gives 2.975, not 2.9, because each modifier's additive is applied before its own multiplier.
Implementing a modifier sourcecsharp
using System.Collections.Generic;
using ZOA.Stats;

public sealed class ArmorPlate : IStatModifierSource
{
    private readonly List<StatModifier> _modifiers = new();

    public void Recompute(float integrity01)
    {
        _modifiers.Clear();

        // Worn plates slow the wearer less than fresh ones.
        _modifiers.Add(new StatModifier(
            statId: "handling",
            additive: 0f,
            multiplier: 0.8f + 0.2f * (1f - integrity01),
            order: 0,
            source: "armor:ChestPlate"));
    }

    public IReadOnlyList<StatModifier> GetStatModifiers() => _modifiers;
}
Sources own recomputation. The consumer only ever calls GetStatModifiers, so a source can be as lazy or as eager as its own state demands.
Folding several sources into one evaluationcsharp
using System.Collections.Generic;
using ZOA.Stats;

private readonly List<StatModifier> _scratch = new();

float EvaluateHandling(StatBlock block, IReadOnlyList<IStatModifierSource> sources)
{
    _scratch.Clear();

    for (int i = 0; i < sources.Count; i++)
    {
        var contributed = sources[i].GetStatModifiers();
        if (contributed == null) continue;

        for (int j = 0; j < contributed.Count; j++)
            _scratch.Add(contributed[j]);
    }

    // Modifiers for other stats are ignored by Evaluate, so one
    // flat list can serve every stat you go on to read.
    return block.Evaluate("handling", _scratch);
}
Reusing a scratch list keeps the collection pass allocation-free. Evaluate still allocates its own sorted copy, which is the cost of the ordering guarantee.

Read this

Notes and caveats

See also