Gameplaycom.zoa.conditions · v0.1.0

ZOA Conditions

Status effects with explicit duration and stacking policy, feeding the shared stat-modifier pipeline.

A condition is a named bundle of stat modifiers with a policy for how long it lasts and what happens when it is applied twice. Poison, haste, wet, reloading and a scope's accuracy bonus are all the same shape here: they differ in category, duration and stacking, not in kind. One service and one query surface therefore covers buffs, debuffs, environmental effects, internal states and equipment passives.

The system is a definition and an instance. IConditionDefinition is the static description: identity, category, duration policy, stacking policy, maximum stacks, the modifier entries and a set of tags. IConditionInstance is the live application of that definition to one entity, tracking remaining duration, current stacks and tick progress. ConditionTracker is the default implementation of both the service that owns instances and the pipeline that aggregates their modifiers.

Nothing here evaluates a stat. Conditions produce StatModifier values and hand them over; the arithmetic belongs to com.zoa.stats. Conditions, equipment, progression and anything else that moves a stat all feed the same evaluation without knowing about each other.

How it works

Concepts

Duration policy: three shapes

ConditionDuration carries a DurationType and two numbers, and the type decides what those numbers mean. Infinite lasts until something explicitly removes it, which is right for equipment passives and toggled states, and its Tick does nothing at all. Timed treats Duration as seconds and counts it down against delta time, expiring when it crosses zero. TickCount treats Duration as a number of ticks and TickInterval as the seconds between them.

The tick loop is where damage-over-time lives. The instance accumulates delta time and, while the accumulator exceeds the interval and ticks remain, it subtracts the interval, decrements the remaining tick count by one, and counts a fired tick. Tick returns how many fired during that update, which is normally one but is two or more after a frame hitch, and the service re-raises that count on its Ticked event. A poison that applies damage per pulse has to read that count; a handler assuming a single pulse silently loses damage on a stutter.

The three static factories are how you build them: ConditionDuration.Infinite, ConditionDuration.Timed with seconds, and ConditionDuration.Ticks with a count and an interval.

Stacking policy: what a second application does

When Apply runs and the entity already has an unexpired instance of the same condition id, the definition's StackingPolicy decides the outcome and no second instance is ever created. There are five policies and each answers the question differently.

None does nothing and returns the existing instance, which suits a binary state such as sprinting. Stack adds one stack, capped at MaxStacks, and raises StacksChanged only if a stack was actually added, so a re-application at the cap is silent. Refresh resets the remaining duration to the definition's maximum and clears the tick accumulator without touching stacks. StackAndRefresh does both, which is the classic damage-over-time behaviour where each new hit deepens the effect and resets the clock.

KeepStrongest compares definitions rather than instances. It sums the absolute additive values plus the absolute distance of each multiplier from one across both definitions' modifier lists, and if the incoming definition scores higher it removes the existing condition and applies the new one, inheriting the existing instance's source. Otherwise the existing instance stands. That is how two different grades of armour buff coexist without stacking into something unintended.

MaxStacks is only meaningful when the policy is not None, and the condition asset clamps it to at least one on validate.

How modifiers reach the stat pipeline

A ConditionModifierEntry is authored data: a StatId, an additive term, a multiplier where one means no change, an evaluation Order, and a ScalesWithStacks flag. Its ToStatModifier method converts it into the StatModifier struct that com.zoa.stats evaluates, and that conversion is where stacking becomes arithmetic.

When ScalesWithStacks is false, the entry's values pass through unchanged no matter how many stacks are present. When it is true, the additive term is multiplied by the stack count, and the multiplier is scaled around one, so a 1.2 multiplier at three stacks becomes 1.6, not 1.728. The excess scales linearly because compounding makes high stack counts explode, and linear growth is what a tooltip reading plus twenty percent per stack promises.

Every modifier a condition produces carries a source string of the form condition: followed by the condition id, so a stat that came out wrong can be traced back to the effect that moved it.

ConditionInstance implements IStatModifierSource, which is the same interface every other modifier source in the project implements, so an instance can be fed straight into stat evaluation. It caches its modifier list and only rebuilds when the stack count changes, and an expired instance returns an empty array.

The aggregation step is IConditionModifierPipeline. CollectModifiers gathers every modifier from every unexpired condition on an entity, and the overload taking a StatId filters to one stat, which is the cheaper call when you only need one number. ConditionTracker implements both the service and the pipeline, so the same object that owns the instances is the one that answers the query.

The final evaluation is StatBlock.Evaluate, which sorts the modifiers by Order, then walks them applying additive first and multiplier second for each entry in turn. Order therefore carries weight: a plus ten that runs before a times two is worth twice as much as one that runs after it, and a condition that intends to be a final scalar wants a high order.

Categories and tags are two different filters

ConditionCategory is a single required enum value describing behavioural intent: Buff for positive effects, Debuff for negative ones, Environmental for neutral world state such as wet or irradiated, State for internal mechanics such as reloading or aiming, and Passive for equipment-granted bonuses. It drives UI grouping, icon styling and dispel interactions, and GetByCategory queries it directly.

Tags are a free-form list on the definition and a condition can carry any number. They exist for the cross-cutting queries a single category cannot express: fire, dot, movement. GetByTag matches them. The rule of thumb is that category answers what kind of thing this is, and tags answer what rules it participates in.

DefaultConditions names the shipped ids across all five categories, from poisoned, bleeding, burning, slowed, stunned, weakened, suppressed and irradiated through regenerating, haste, damage_boost, armor_boost, shielded and adrenaline, the environmental set, the state set, and equipment passives such as scope_accuracy and heavy_armor_slowdown. Custom conditions should pick ids that do not collide with these.

Ticking, expiry and the event surface

Conditions do not tick themselves. The owner calls Tick with an entity id and a delta, or TickAll to advance every tracked entity, and TickAll snapshots the key list first so that an expiry firing a handler that touches another entity cannot invalidate the iteration.

Within one entity's tick, each unexpired instance is advanced, a non-zero tick count raises Ticked, and anything that has become expired is collected into a reusable buffer. Removal happens after the loop, and each removal raises Removed. Instances that were already expired when the tick started are collected and cleaned up in the same pass, which is how an instance force-expired by gameplay code gets its Removed event on the next tick.

Four events cover the lifecycle: Applied when a new instance is created, Removed on expiry or explicit removal, Ticked with the number of ticks that fired, and StacksChanged when a stack is actually added. A re-application under a Refresh policy raises none of them, because nothing observable changed except the clock.

In the editor

Screens

Screenshot pending

/screenshots/conditions-wizard.png

The wizard showing the duration type dropdown set to TickCount with the interval field visible, the stacking policy dropdown and max stacks beside it, and the step rail on the left.

The Condition Wizard on its duration and stacking step.

Screenshot pending

/screenshots/conditions-category-guide.png

The Conditions module with the Category Guide selected, showing the five categories and the five stacking policies with their explanations laid out for reference.

The Category Guide capability in the Workbench.

Setup

Workflow

  1. 01

    Pick the duration shape first

    The duration type decides the rest of the authoring. Infinite for anything that ends because something removed it, which covers equipment passives and toggled states. Timed for a buff or debuff that simply runs out. TickCount for anything that should pulse, because that is the only shape that fires the Ticked event, and a damage-over-time effect authored as Timed will never pulse.

  2. 02

    Choose the stacking policy with care

    None and Refresh are the safe defaults for states and simple buffs. Reach for Stack or StackAndRefresh only when the effect is meant to intensify, and set MaxStacks to a number you have reasoned about, because with ScalesWithStacks on it caps how far the modifier can move. KeepStrongest is for families of the same effect at different grades, where the better one should win instead of both applying.

  3. 03

    Author the condition

    Run the Condition Wizard from Tools, ZOA, Advanced, Define, Characters, Conditions, Condition Wizard, or create a ConditionDefinitionAsset directly. The wizard walks identity, modifiers, duration and stacking, then review, and emits an asset into the generated conditions folder with a ZOA_Condition prefix.

  4. 04

    Set modifier order where it matters

    StatBlock.Evaluate sorts by Order and then applies each modifier's additive term before its multiplier. A flat bonus that should be multiplied by a later percentage buff needs a lower order than that buff; a final scalar that should apply to everything needs a higher order than all of them. Leaving every order at zero is fine only when no condition on that stat has a multiplier.

  5. 05

    Tick the service

    Nothing advances on its own. Call TickAll once per frame from whatever owns the simulation, or Tick per entity if you want to advance only some of them, such as freezing conditions on a paused actor. Subscribe to Ticked and scale per-pulse effects by the tick count it hands you; assuming a single pulse loses damage on a hitch.

  6. 06

    Feed the stat evaluation

    Ask the pipeline for the modifiers, combine them with whatever other sources apply such as equipment or progression, and pass the whole set to StatBlock.Evaluate. Use the single-stat overload on hot paths: it filters inside the pipeline instead of handing back everything for the caller to discard.

Surface

Key types

IConditionService

service

The central surface for applying, removing, querying and ticking conditions. Apply respects the stacking policy and returns the resulting instance, which may be the one that already existed.

  • IConditionInstance Apply(string entityId, IConditionDefinition definition, string source)
  • bool Remove(string entityId, ConditionId conditionId)
  • bool Remove(string entityId, ConditionId conditionId, string source)
  • void RemoveAll(string entityId)
  • bool Has(string entityId, ConditionId conditionId)
  • IReadOnlyList<IConditionInstance> GetConditions(string entityId)
  • IReadOnlyList<IConditionInstance> GetByCategory(string entityId, ConditionCategory category)
  • IReadOnlyList<IConditionInstance> GetByTag(string entityId, string tag)
  • void Tick(string entityId, float deltaTime) / void TickAll(float deltaTime)
  • event Action<string, IConditionInstance> Applied, Removed, StacksChanged
  • event Action<string, IConditionInstance, int> Ticked

IConditionDefinition

interface

The static half: identity, category, duration policy, stacking policy, stack cap, the per-stack modifier entries, and tags. Implemented by ScriptableObject assets and by builder-created instances alike.

  • ConditionId Id { get; }
  • string DisplayName { get; } / string Description { get; }
  • ConditionCategory Category { get; }
  • ConditionDuration Duration { get; }
  • StackingPolicy Stacking { get; } / int MaxStacks { get; }
  • IReadOnlyList<ConditionModifierEntry> Modifiers { get; }
  • IReadOnlyList<string> Tags { get; }

IConditionInstance

interface

The live half: which definition it came from, who applied it, current stacks, remaining duration and expiry. Extends IStatModifierSource, so an instance can be fed straight into stat evaluation.

  • IConditionDefinition Definition { get; } / string Source { get; }
  • int Stacks { get; } / float RemainingDuration { get; } / bool IsExpired { get; }
  • int Tick(float deltaTime)
  • int AddStacks(int count) / int RemoveStacks(int count)
  • void RefreshDuration() / void ForceExpire()

IConditionModifierPipeline

interface

The bridge into stat evaluation. Aggregates modifiers from every active condition on an entity, with a single-stat overload for the common case where only one number is needed.

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

ConditionTracker

class

The default implementation of both the service and the pipeline. Holds instances per entity id, enforces stacking on apply, ticks and cleans up expiries, and answers modifier queries.

ConditionInstance

class

The default instance. Caches its converted modifier list and rebuilds it only when the stack count changes; an expired instance returns an empty modifier array.

  • IReadOnlyList<StatModifier> GetStatModifiers()

StackingPolicy

enum

None ignores re-application, Stack adds a stack up to the cap, Refresh resets the clock, StackAndRefresh does both, and KeepStrongest replaces the existing instance only when the incoming definition has greater total modifier magnitude.

ConditionDuration

struct

The duration policy: a DurationType of Infinite, Timed or TickCount, a Duration meaning seconds or tick count depending on the type, and a TickInterval used only by TickCount.

  • static ConditionDuration Infinite()
  • static ConditionDuration Timed(float seconds)
  • static ConditionDuration Ticks(int tickCount, float intervalSeconds)

ConditionModifierEntry

struct

One authored stat change: which stat, an additive term, a multiplier, an evaluation order, and whether it scales with stack count. ToStatModifier performs the conversion and applies stack scaling.

  • StatId StatId; float Additive; float Multiplier; int Order; bool ScalesWithStacks
  • StatModifier ToStatModifier(string source, int stacks = 1)

ConditionCategory

enum

Buff, Debuff, Environmental, State, Passive. Behavioural intent rather than mechanics, driving UI grouping, icon styling and dispel rules.

ConditionId

struct

A trimmed, case-insensitive string id with implicit conversion in both directions, so a literal can be passed anywhere a ConditionId is expected.

  • string Value
  • bool IsEmpty { get; }

ConditionDefinitionBuilder

class

Fluent construction of an IConditionDefinition in code, for tests and for conditions computed rather than authored. Produces an immutable definition.

  • WithDescription(string) / WithCategory(ConditionCategory)
  • WithDuration(ConditionDuration) / WithStacking(StackingPolicy policy, int maxStacks = 1)
  • AddModifier(StatId statId, float additive, float multiplier = 1f, ...) / AddModifier(ConditionModifierEntry)
  • AddTag(string tag)
  • IConditionDefinition Build()

DefaultConditions

class

The shipped condition id vocabulary across all five categories, from Poisoned, Bleeding and Burning through Haste and Adrenaline to Wet, Reloading and ScopeAccuracy. Custom ids should avoid colliding with these.

Surface

Authoring assets

ConditionDefinitionAsset

asset

The authoring asset. Identity and description, a category, the duration block, the stacking block, and a list of serialised modifiers naming the stat by string id. Created via Assets, Create, Tools, ZOA, Conditions, Condition Definition.

  • string ConditionId; string Description; ConditionCategory Category
  • DurationType DurationType; float DurationValue; float TickInterval
  • StackingPolicy StackingPolicy; int MaxStacks
  • IReadOnlyList<SerializedConditionModifier> Modifiers

Usage

Examples

Building a stacking damage-over-time conditioncsharp
using ZOA.Conditions.Core.Definitions;
using ZOA.Conditions.Core.Models;
using ZOA.Stats;

var poison = new ConditionDefinitionBuilder("poisoned", "Poisoned")
    .WithDescription("Takes damage every second and moves more slowly.")
    .WithCategory(ConditionCategory.Debuff)
    // Ten pulses one second apart. Only TickCount fires the Ticked event.
    .WithDuration(ConditionDuration.Ticks(tickCount: 10, intervalSeconds: 1f))
    // Each new application deepens the stack and resets the clock.
    .WithStacking(StackingPolicy.StackAndRefresh, maxStacks: 5)
    // ScalesWithStacks: at three stacks this multiplier is 0.85 -> 0.55,
    // scaled linearly around 1 rather than compounded.
    .AddModifier(new StatId("move_speed"), additive: 0f, multiplier: 0.85f,
        order: 10, scalesWithStacks: true)
    .AddTag("dot")
    .AddTag("poison")
    .Build();
Applying, ticking, and reacting to pulsescsharp
using ZOA.Conditions.Core.Contracts;
using ZOA.Conditions.Core.Runtime;

var conditions = new ConditionTracker();

conditions.Ticked += (entityId, instance, ticksFired) =>
{
    // ticksFired is usually 1, but a frame hitch can fire several at
    // once. Multiplying by the count is what stops a stutter from
    // silently eating damage.
    if (instance.Definition.Id == "poisoned")
        ApplyDamage(entityId, 4f * instance.Stacks * ticksFired);
};

conditions.Removed += (entityId, instance) =>
    Log(instance.Definition.DisplayName + " ended on " + entityId);

// The source string is free-form: it identifies who applied this so a
// specific application can be removed later without touching others.
conditions.Apply("player.1", poison, source: "weapon.venom_blade");

// Nothing advances on its own.
void Update(float deltaTime) => conditions.TickAll(deltaTime);
Feeding the stat pipelinecsharp
using ZOA.Conditions.Core.Contracts;
using ZOA.Stats;

public sealed class MoveSpeedResolver
{
    private static readonly StatId MoveSpeed = new StatId("move_speed");

    private readonly StatBlock _stats;
    private readonly IConditionModifierPipeline _pipeline;

    public MoveSpeedResolver(StatBlock stats, IConditionModifierPipeline pipeline)
    {
        _stats = stats;
        _pipeline = pipeline;
    }

    public float Resolve(string entityId)
    {
        // The single-stat overload filters inside the pipeline instead of
        // collecting everything and discarding most of it.
        var modifiers = _pipeline.CollectModifiers(entityId, MoveSpeed);

        // Evaluate sorts by Order, then applies additive before multiplier
        // for each modifier in turn: order is load-bearing, not decoration.
        return _stats.Evaluate(MoveSpeed, modifiers);
    }
}
Querying by category and tagcsharp
using ZOA.Conditions.Core.Contracts;
using ZOA.Conditions.Core.Models;

// Category is the single behavioural bucket: what kind of thing is this.
var debuffs = conditions.GetByCategory("player.1", ConditionCategory.Debuff);

// Tags are the cross-cutting filter: which rules does it participate in.
var overTime = conditions.GetByTag("player.1", "dot");

// Cleansing a family of effects, honouring a maximum count.
int cleansed = 0;
foreach (var instance in overTime)
{
    if (cleansed++ >= 3) break;
    conditions.Remove("player.1", instance.Definition.Id, instance.Source);
}

if (conditions.Has("player.1", DefaultConditions.Stunned))
    SuppressInput();
The two-argument Remove targets a single application by source, which matters when several systems have applied the same condition id.

Tooling

Editor tools

Condition Wizard

Tools / ZOA / Advanced / Define / Characters / Conditions / Condition Wizard

Four-step authoring: identity, modifiers, duration and stacking, review. Built on the Foundry wizard shell with a step rail, issue tray and preview dock, and it exposes a snapshot the headless emitter reads.

Conditions Workbench module

ZOA Platform Workbench, Conditions module

Four capabilities: a Conditions browser that edits, validates, duplicates and deletes definitions with their modifiers; a Defaults Browser listing the built-in conditions by category; a Category Guide documenting categories and stacking policies; and an Overview of the stat-modifier pipeline integration.

ConditionDefinitionAssetEmitter

The headless save path behind the wizard. Emits a ConditionDefinitionAsset from a wizard snapshot into the generated conditions folder, generating a unique asset path so repeated emits do not overwrite each other.

Read this

Notes and caveats

See also