Game features rarely stay singular. A weapon deals damage, then gains critical hits, elemental damage, life steal, analytics, tutorial restrictions, or a temporary event rule. The first addition is easy. The fifth exposes whether the original design can carry optional behavior without becoming a matrix of special cases.

Inheritance handles one fixed variation reasonably well. It handles combinations badly. FireSword, CriticalFireSword, and LifeStealCriticalFireSword are not a healthy class hierarchy; they are a list of feature permutations disguised as types. A boolean-heavy base class is not much better. It moves the combinations into branches and leaves one object responsible for every possible rule.

The decorator pattern offers another shape. A decorator implements the same interface as the object it wraps. It forwards the operation to that wrapped object while adding work before or after the call. Because a decorator can wrap another decorator, small behaviors can be assembled into a stack.

That is the attractive part. The corrective part is that decorators are not a universal status-effect system. Wrapper order matters, runtime inspection can be awkward, and careless assembly can allocate far too often. The pattern works best when the operation has a stable interface and the optional layers have clear ownership.

The useful definition

A decorator has three defining properties:

  1. It implements the same contract as the wrapped component.
  2. It stores another implementation of that contract.
  3. It delegates to the wrapped implementation while adding behavior.

The shared contract is what makes recursive composition possible. Code expecting an IAttack can receive a plain attack, one wrapped in a critical-hit rule, or a stack containing critical damage, fire damage, and life steal. The caller still invokes one method.

caller
  -> life steal
      -> critical hit
          -> basic weapon attack
      <- damage result
  <- heal from actual damage dealt

Work can happen while the request travels inward, while the result travels outward, or both. A critical decorator may modify the attack request before forwarding it. A life-steal decorator may wait for the inner attack to finish, then heal from the damage actually dealt.

That two-way flow is more important than the usual “wrapper” metaphor. It lets a layer transform input, observe output, enforce a condition, or add a side effect without changing the caller or the base operation.

Start with one narrow gameplay operation

A decorator interface should describe one cohesive operation. Avoid starting with a broad IGameObject interface containing movement, rendering, damage, saving, audio, and interaction. Any wrapper implementing that contract would either forward dozens of unrelated members or quietly become another god object.

An attack-resolution seam is narrow enough for a useful example:

public interface IAttack
{
    DamageResult Execute(AttackContext context);
}

public readonly struct AttackContext
{
    public AttackContext(Actor source, Actor target)
    {
        Source = source;
        Target = target;
    }

    public Actor Source { get; }
    public Actor Target { get; }
}

public readonly struct DamageResult
{
    public DamageResult(int attempted, int dealt)
    {
        Attempted = attempted;
        Dealt = dealt;
    }

    public int Attempted { get; }
    public int Dealt { get; }
}

DamageResult deserves to exist even in a small example. Downstream behavior usually needs the result of resolution, not merely the requested amount. Armor, immunity, shields, and clamping can all make actual damage lower than attempted damage. Life steal based on the original number is a common bug.

The concrete component performs the ordinary operation:

public sealed class WeaponAttack : IAttack
{
    private readonly int damage;

    public WeaponAttack(int damage)
    {
        if (damage < 0)
            throw new ArgumentOutOfRangeException(nameof(damage));

        this.damage = damage;
    }

    public DamageResult Execute(AttackContext context)
    {
        int dealt = context.Target.TakeDamage(damage);
        return new DamageResult(damage, dealt);
    }
}

This class should remain boring. It is the center of the composition, not a registry of every optional rule the game may add later.

A base decorator should only remove repetition

The formal version of the pattern often includes an abstract base decorator. In C#, that class is useful when it removes forwarding boilerplate. It should not become a second inheritance framework.

public abstract class AttackDecorator : IAttack
{
    protected AttackDecorator(IAttack inner)
    {
        Inner = inner ?? throw new ArgumentNullException(nameof(inner));
    }

    protected IAttack Inner { get; }

    public abstract DamageResult Execute(AttackContext context);
}

A critical-hit layer can now make a decision and adjust the result. For clarity, this example receives randomness through an interface rather than calling a global random generator. That makes the rule deterministic in tests and lets a server-authoritative simulation provide its own random stream.

public interface IRandomSource
{
    float Next01();
}

public sealed class CriticalAttack : AttackDecorator
{
    private readonly IRandomSource random;
    private readonly float chance;
    private readonly int multiplier;

    public CriticalAttack(
        IAttack inner,
        IRandomSource random,
        float chance,
        int multiplier) : base(inner)
    {
        if (chance < 0f || chance > 1f)
            throw new ArgumentOutOfRangeException(nameof(chance));
        if (multiplier < 1)
            throw new ArgumentOutOfRangeException(nameof(multiplier));

        this.random = random ?? throw new ArgumentNullException(nameof(random));
        this.chance = chance;
        this.multiplier = multiplier;
    }

    public override DamageResult Execute(AttackContext context)
    {
        DamageResult result = Inner.Execute(context);
        if (random.Next01() >= chance)
            return result;

        int bonusRequested = result.Attempted * (multiplier - 1);
        int bonusDealt = context.Target.TakeDamage(bonusRequested);

        return new DamageResult(
            result.Attempted + bonusRequested,
            result.Dealt + bonusDealt);
    }
}

There are several valid ways to model a critical hit. It could multiply a damage request before mitigation instead of issuing bonus damage afterward. It could affect only physical damage, or it could duplicate all child effects. Those choices are game rules, not pattern details.

The example deliberately makes the choice visible. The decorator does not make combat semantics correct by itself. It only gives the rule a composable home.

Result-side behavior is where wrapping becomes valuable

Life steal demonstrates why a returned result is useful:

public sealed class LifeStealAttack : AttackDecorator
{
    private readonly float fraction;

    public LifeStealAttack(IAttack inner, float fraction) : base(inner)
    {
        if (fraction < 0f || fraction > 1f)
            throw new ArgumentOutOfRangeException(nameof(fraction));

        this.fraction = fraction;
    }

    public override DamageResult Execute(AttackContext context)
    {
        DamageResult result = Inner.Execute(context);
        int healing = (int)MathF.Floor(result.Dealt * fraction);
        context.Source.Heal(healing);
        return result;
    }
}

The layer does not need to understand armor, shields, or how the attack produced damage. It depends on one explicit fact: the amount actually dealt. This is a good decorator boundary because the added behavior is local and the inner implementation remains interchangeable.

Assembly happens at a composition root such as an equipment factory, character loadout builder, or match setup system:

IAttack attack = new WeaponAttack(damage: 20);
attack = new CriticalAttack(attack, combatRandom, chance: 0.25f, multiplier: 2);
attack = new LifeStealAttack(attack, fraction: 0.20f);

DamageResult result = attack.Execute(new AttackContext(player, enemy));

The calling code sees one IAttack. The stack can be configured once when equipment changes rather than reconstructed for every swing.

That last point matters in a game loop. The pattern creates object graphs. If code builds a fresh chain on every shot, every AI tick, or every card preview, the architectural neatness may arrive with avoidable garbage collection. Compose on loadout changes, pool where measured, or use value-oriented pipelines for truly hot paths.

Order is part of the design

Decorators look modular, but they are not automatically independent. The order of wrappers can change the result.

Consider these two stacks:

LifeSteal(Critical(BaseAttack))
Critical(LifeSteal(BaseAttack))

In the first stack, life steal sees the combined result returned by the critical layer. In the second, life steal completes before the outer critical layer applies its bonus. The player heals for different amounts.

The same issue appears with elemental conversion, clamping, shields, logging, cooldown gates, and analytics. A logger outside the stack observes the final result. A logger inside observes only what happened beneath it. A clamp before a multiplier differs from a clamp after it.

Treat ordering as a game rule rather than an incidental constructor detail. A practical project can define a small number of phases:

  • validation and permission checks
  • input conversion
  • base operation
  • result modifiers
  • side effects
  • observation and reporting

Not every game needs a general pipeline framework. Often one clearly named factory method and a test for the expected order are enough:

public static IAttack BuildVampireBlade(
    int damage,
    IRandomSource random)
{
    IAttack attack = new WeaponAttack(damage);
    attack = new CriticalAttack(attack, random, 0.25f, 2);
    return new LifeStealAttack(attack, 0.20f);
}

If designers must reorder dozens of effects at runtime, a list of explicit modifiers with phases and priorities may be more transparent than nested wrappers. Decorator is a composition technique, not an excuse to hide ordering policy.

Do not confuse decorators with stat modifiers

A stat system often needs to answer questions that a wrapper chain answers poorly:

  • Which effects currently contribute to this value?
  • Remove the modifier from item instance 417.
  • Stack poison up to five times, then refresh its duration.
  • Serialize all active effects.
  • Show each contribution in a tooltip.
  • Recalculate only when a dependency changes.

Those requirements point toward data records in a collection, not anonymous layers in a recursive object graph. A list of modifiers can be filtered, sorted, grouped, saved, and displayed directly.

Decorators are a better fit when behavior must surround an operation. Examples include retrying a request, measuring an operation, gating an ability, adding a post-hit effect, caching a query, or translating the input and output of a service.

The distinction is not “numbers versus behavior.” It is about the operations the rest of the game needs. If removal by identity, UI explanation, duration, and persistence are central, model those concepts explicitly. Do not force them through wrappers because the initial class diagram looks tidy.

A hybrid is common. ScriptableObject assets or plain data records describe effects. A factory reads those definitions and creates a runtime attack stack for a loadout. The persistent model remains inspectable, while the runtime operation remains small.

Decorator, strategy, command, and chain are not synonyms

Several patterns use composition and can look similar in C#.

Strategy swaps the implementation of an operation. A flying movement strategy replaces a walking movement strategy. Decorator keeps an implementation and surrounds it with additional behavior. Strategy changes the core choice; decorator layers optional responsibilities around a compatible core.

Command represents a request as data or an object that can be queued, recorded, or undone. A command may call a decorated service, but the command itself does not need to wrap another command with the same interface.

Chain of responsibility passes a request among handlers, often allowing one handler to consume it or stop traversal. A decorator normally preserves one logical component and delegates through every configured layer. A permission decorator may reject an operation, but if early termination is the system’s main idea, a handler chain may describe the code more honestly.

Adapter changes one interface into another. Decorator preserves the component interface so wrapped and unwrapped objects remain interchangeable.

Pattern names matter less than intent. Use the name that explains why the indirection exists. Future maintainers need to know whether an object replaces behavior, records intent, translates an API, or adds a layer around an existing operation.

Unity-specific assembly choices

Plain C# objects are usually the easiest decorators to test. They do not need to be MonoBehaviour components merely because the game runs in Unity. A MonoBehaviour can own the lifetime of the assembled stack and pass Unity services into it.

Avoid relying on constructors for MonoBehaviour or ScriptableObject instances created by Unity. Use constructors for ordinary C# runtime objects. Use serialized fields, factory methods, OnEnable, or an explicit initialization path for Unity-managed objects, depending on the lifetime involved.

ScriptableObjects are useful as effect definitions shared by many instances. They are assets, however, so mutable per-character state should not quietly live on a shared definition. A poison asset can hold base duration and tick damage. The remaining duration for one enemy belongs in a runtime object.

A small definition might expose a factory method:

public abstract class AttackEffectDefinition : ScriptableObject
{
    public abstract IAttack Wrap(IAttack inner, AttackServices services);
}

An equipment asset can hold an ordered list of definitions. At runtime, the loadout system starts with the base attack and applies each definition in order. This gives designers an editable composition while keeping mutable combat state outside the assets.

There is still a tradeoff. An inspector list makes order editable but does not prove that every order is meaningful. Add validation for known incompatible phases, display the effective order in a debug view, and keep the supported combinations narrower than the theoretical ones.

Debugging a stack should not require archaeology

A wrapper chain hides concrete types behind one interface. That is helpful to callers and occasionally miserable during debugging. A breakpoint on Execute may jump through six wrappers before reaching the base operation. Logs may report the outer type while omitting the rules inside it.

Solve this at the assembly boundary rather than putting reflection into combat code. The factory can record a readable description of the stack for development builds. A debug inspector can show the ordered effect definitions that produced it. Tests can construct known stacks directly.

Do not expose the mutable Inner reference publicly just so arbitrary systems can peel wrappers apart. That breaks encapsulation and creates removal logic that depends on graph surgery. If effects must be added and removed frequently by identity, that is more evidence that the game needs a modifier collection or explicit effect container.

Observability decorators are useful too. A timing or logging layer can surround an operation without polluting the operation itself. Keep such layers out of release builds when their cost is not justified, and avoid logging every call in a hot loop.

Common mistakes

Creating a subclass for each combination anyway

A decorator should reduce combinatorial types. If the project still defines a dedicated assembled subclass for every weapon and effect combination, little has been gained. Put configuration in the composition root or data definitions.

Rebuilding the chain on every operation

Wrappers are normally assembled when configuration changes. Rebuilding them per frame or per attack creates avoidable allocations and repeats work that does not change.

Hiding mandatory rules in optional wrappers

Core invariants should not depend on somebody remembering to attach the correct decorator. If every damage operation must clamp health and respect invulnerability, that belongs in the authoritative damage receiver or another unavoidable boundary.

Letting wrappers own unrelated systems

An attack decorator that directly updates quests, audio, camera shake, achievements, UI, and persistence has become a coordination hub. Return a useful result or publish a narrow event after resolution so those systems can react through their proper boundaries.

Assuming all wrappers commute

Most do not. Multiplication, addition, clamping, conversion, and side effects can produce different outcomes in different orders. Tests should lock down the intended order with concrete values.

Using decorators where a function is enough

A one-off calculation does not need a class hierarchy. A delegate, local function, or direct call may express the same rule with less machinery. Decorator earns its place when the shared interface, recursive composition, and runtime assembly are all useful.

A small test that catches the real risks

The important tests are not “is this class a decorator?” They are behavioral checks for result propagation and order.

For the vampire-blade example, use a fixed random source and actors with known armor. Then verify:

  1. a non-critical attack reports and heals from actual damage dealt;
  2. a forced critical attack includes the bonus in the outer life-steal calculation;
  3. moving life steal inside the critical layer produces the intentionally different result;
  4. zero damage produces zero healing;
  5. the stack is created once and reused across several attacks.

A compact assertion can document the ordering rule:

IAttack attack = new WeaponAttack(20);
attack = new CriticalAttack(attack, new AlwaysCritical(), 1f, 2);
attack = new LifeStealAttack(attack, 0.25f);

DamageResult result = attack.Execute(new AttackContext(hero, unarmoredTarget));

Debug.Assert(result.Dealt == 40);
Debug.Assert(hero.HealthGained == 10);

That test is more valuable than checking the number of wrapper objects or their inheritance relationships. It fails when a refactor changes combat behavior, which is the risk worth guarding.

When the pattern is the right size

Decorator is a good fit when all of the following are true:

  • callers should depend on one stable interface;
  • optional behavior belongs before or after one operation;
  • layers need to be combined at runtime;
  • inheritance would create feature combinations;
  • the stack is assembled infrequently compared with how often it runs;
  • order can be defined and tested clearly.

Prefer a simpler direct call when there is only one optional rule. Prefer strategy when one implementation replaces another. Prefer a data-oriented modifier collection when effects need identity, duration, sorting, removal, serialization, or rich UI inspection. Prefer a purpose-built pipeline when many ordered stages and early exits are the central design.

The practical takeaway is narrow: decorate operations, not entire game objects. Keep the interface small, compose the stack at a visible boundary, make ordering explicit, and test the result that travels back out. Used that way, decorators remove subclass combinations without replacing them with an invisible maze of wrappers.

References