The strategy pattern is what I reach for when a game system has one job, but several valid ways to do it.

A weapon can calculate damage in different ways. An enemy can choose movement differently depending on its type. A racing game can score drift, time trial, and checkpoint modes with different rules. A character controller can use grounded, swimming, climbing, and flying movement. The surrounding system wants to say “do the thing,” but it should not need a switch statement that knows every possible version of the thing.

That is the small promise of the strategy pattern: put interchangeable behavior behind a shared contract, then let the caller use the contract instead of hard-coding every branch.

It is not a license to turn every if into an interface. Games already have enough indirection from scenes, prefabs, assets, and engine callbacks. Strategy is useful when the variation is real, repeated, and likely to grow. If the code has two simple branches and nobody expects a third, keep the branch. Boring code wins until boring code starts fighting back.

Strategy pattern flow

The shape of the pattern

A strategy has three parts.

The context is the object that needs behavior. A weapon, enemy brain, movement controller, damage resolver, quest reward system, or scoring manager can all be contexts.

The strategy interface describes the behavior the context needs. It should be small. One method is common. Two or three can be fine if they belong together. If the interface starts describing half the game, it is no longer a strategy; it is a subsystem wearing a pattern hat.

The concrete strategies are the different implementations. Each one handles one version of the behavior.

public interface IDamageStrategy
{
    int CalculateDamage(DamageRequest request);
}

public sealed class FlatDamageStrategy : IDamageStrategy
{
    public int CalculateDamage(DamageRequest request)
    {
        return request.BaseDamage;
    }
}

public sealed class CriticalHitStrategy : IDamageStrategy
{
    private readonly float critChance;
    private readonly float critMultiplier;
    private readonly IRandom random;

    public CriticalHitStrategy(float critChance, float critMultiplier, IRandom random)
    {
        this.critChance = critChance;
        this.critMultiplier = critMultiplier;
        this.random = random;
    }

    public int CalculateDamage(DamageRequest request)
    {
        var damage = request.BaseDamage;

        if (random.Value01() < critChance)
            damage = (int)(damage * critMultiplier);

        return damage;
    }
}

public readonly record struct DamageRequest(
    int BaseDamage,
    int AttackerLevel,
    int DefenderArmor);

The caller does not need to know whether the damage was flat, critical, elemental, armor-piercing, or difficulty-scaled. It only needs CalculateDamage.

public sealed class Weapon
{
    private readonly IDamageStrategy damageStrategy;

    public Weapon(IDamageStrategy damageStrategy)
    {
        this.damageStrategy = damageStrategy;
    }

    public void Hit(Target target, DamageRequest request)
    {
        target.ApplyDamage(damageStrategy.CalculateDamage(request));
    }
}

That is the pattern. Put the changing algorithm behind a stable call site.

The trick, as usual, is deciding where the stable call site actually is.

Start with the branch you are trying to remove

Strategy usually begins life as a branch.

public int CalculateDamage(WeaponType type, DamageRequest request)
{
    switch (type)
    {
        case WeaponType.Sword:
            return request.BaseDamage;

        case WeaponType.Dagger:
            return RollCrit(request.BaseDamage);

        case WeaponType.Hammer:
            return ApplyArmorBreak(request.BaseDamage, request.DefenderArmor);

        default:
            throw new ArgumentOutOfRangeException(nameof(type));
    }
}

This is not automatically bad. A switch can be the clearest code in the room. It is local, visible, easy to debug, and cheap to step through. I would rather maintain a five-case switch that fits on one screen than chase five classes that exist only because someone wanted to use a pattern.

The smell appears when the same decision leaks into several places.

Maybe damage calculation switches on WeaponType. Then VFX selection switches on WeaponType. Then tooltip text switches on WeaponType. Then AI weapon preference switches on WeaponType. Now adding a hammer means editing four unrelated files and hoping every switch was updated.

That is when strategy starts to earn its keep. It moves the variation into one replaceable object, so the rest of the system can depend on behavior instead of category checks.

A useful rule: do not extract a strategy because a branch exists. Extract a strategy because the branch represents a policy that changes independently from the caller.

Good game-code candidates

Damage is the easy example, but strategy shows up in many ordinary game systems.

Movement is a strong candidate. A character might use different movement strategies for walking, swimming, climbing, knockback, ladders, rails, or vehicles. The controller still owns input, collision integration, animation parameters, and lifecycle. The movement strategy owns how desired movement becomes velocity for the current mode.

AI decisions are another common fit. A shooter enemy might choose between aggressive, defensive, patrol, and flee strategies. A tactics unit might choose a target using nearest enemy, lowest health, highest threat, or objective pressure. The blackboard and perception system can stay shared while the selection policy changes.

Spawning rules often benefit from the same idea. A wave director can ask a spawn strategy for the next group: fixed sequence, budget-based random, difficulty curve, biome-filtered, or director-driven catch-up. The director should not become a 900-line random table parser just because level design needs several modes.

Scoring and rewards are also good candidates. Arcade score, combo score, stealth score, time-trial medals, daily challenge modifiers, and assist-mode adjustments are all policies. Keeping them behind a small strategy makes it easier to test and easier to explain to designers.

Serialization and save migration can use strategy too, though I would be careful there. Version-specific migration strategies can keep old save handling isolated. The danger is hiding data-loss behavior behind too much indirection. If a strategy touches player saves, the contract needs validation, logging, and tests. Lazy code does not mean casual with player data.

Keep the strategy interface narrow

Most strategy implementations rot because the interface is too wide.

The context starts with one need:

Vector3 GetDesiredVelocity(MoveInput input, MoveState state);

Then someone adds animation. Then footstep audio. Then stamina. Then camera bob. Soon the interface has ten methods and every implementation contains empty stubs.

That is a warning. A strategy should represent one axis of variation. Walking, swimming, and flying movement can reasonably share velocity calculation. They probably should not also own UI prompts, inventory restrictions, and camera logic unless those things genuinely vary as part of the same policy.

When an interface grows, split the reason for change before adding more methods. Movement calculation can be one strategy. Animation presentation can be another system that reads movement state. Stamina cost can be data on the movement mode or a separate cost policy. The exact split depends on the project, but the question is consistent: who needs to change together?

A narrow interface also makes testing less painful. A one-method strategy can be tested with a few input cases. A strategy that requires a scene, animator, camera, audio mixer, inventory, and network connection is not a strategy anymore. It is a feature module, and it should be treated like one.

Unity gives you two common implementation styles

In plain C#, a strategy is often just an interface and a class. In Unity, there are two common ways to wire that into real projects.

The first is runtime composition with normal C# objects. This works well for systems created by code: combat resolvers, simulation models, server-authoritative gameplay, testable domain logic, or tools that do not need every policy exposed in the Inspector.

var weapon = new Weapon(
    new CriticalHitStrategy(0.15f, 2.0f, random));

This style is explicit and easy to test. It also avoids pretending that every gameplay decision needs to be a Unity asset. For pure logic, plain C# is often the cleanest option.

The second style is ScriptableObject strategies. This is useful when designers need to select or tune behavior in the editor.

public abstract class DamageStrategyAsset : ScriptableObject
{
    public abstract int CalculateDamage(DamageRequest request);
}

[CreateAssetMenu(menuName = "Combat/Damage/Flat Damage")]
public sealed class FlatDamageStrategyAsset : DamageStrategyAsset
{
    public override int CalculateDamage(DamageRequest request)
    {
        return request.BaseDamage;
    }
}

[CreateAssetMenu(menuName = "Combat/Damage/Armor Piercing")]
public sealed class ArmorPiercingDamageAsset : DamageStrategyAsset
{
    [SerializeField] private float armorIgnored = 0.5f;

    public override int CalculateDamage(DamageRequest request)
    {
        var effectiveArmor = request.DefenderArmor * (1f - armorIgnored);
        return Math.Max(1, request.BaseDamage - (int)effectiveArmor);
    }
}

Then a weapon definition can reference the asset.

public sealed class WeaponDefinition : ScriptableObject
{
    [SerializeField] private DamageStrategyAsset damageStrategy;

    public int CalculateDamage(DamageRequest request)
    {
        return damageStrategy.CalculateDamage(request);
    }
}

This can be a good Unity workflow. Designers can create assets, tune values, and reuse behavior across many definitions. The strategy asset becomes a small data-backed policy.

The catch is state. ScriptableObjects are assets, not per-enemy scratchpads. A ScriptableObject strategy should generally be stateless or treat serialized fields as shared configuration. If a strategy needs per-instance runtime state, keep that state in the context or create a runtime strategy instance from the asset.

Shared mutable state in an asset is one of those bugs that looks fine with one enemy and turns weird with fifty.

Do not use strategy to hide bad ownership

Strategy can clean up variation. It cannot fix unclear ownership.

If a weapon strategy needs to change the inventory, camera, quest log, audio mixer, player stats, and UI, the problem is not that the strategy needs more dependencies. The problem is that the behavior is crossing too many boundaries.

Sometimes the right answer is an event. The weapon applies damage, publishes a hit event, and lets audio or UI respond. Sometimes the right answer is a command object. The strategy decides what command should happen, and a separate executor applies it. Sometimes the right answer is to keep the behavior in the owning system and pass in data, not services.

A clean strategy usually answers a focused question:

  • How much damage should this hit do?
  • Which target should this unit prefer?
  • Which spawn group should come next?
  • What velocity should this movement mode request?
  • How should this score be converted into a medal?

A messy strategy tries to run the game from the side.

That distinction matters because indirection has a cost. Not usually a frame-time cost, though virtual calls in hot loops can matter in data-oriented code. The bigger cost is comprehension. A programmer debugging a weapon hit now has to find which strategy is assigned, which asset configured it, which prefab references it, and whether play mode state modified it. That is fine when the behavior is truly variable. It is annoying when there was only one behavior and a pattern got there first.

A practical refactor path

The safest way to introduce strategy is not to redesign the whole system. Extract one policy.

Start by finding the repeated branch or bloated method. Pick the smallest behavior that changes independently. Define a contract for that behavior only. Move one existing case behind the contract, then move the other cases. Keep the caller almost unchanged.

Before:

public SpawnGroup ChooseNextGroup(Biome biome, int danger, bool bossAlive)
{
    if (bossAlive)
        return bossAddsTable.Roll(danger);

    if (biome == Biome.Swamp)
        return swampTable.Roll(danger);

    if (danger > 80)
        return eliteTable.Roll(danger);

    return defaultTable.Roll(danger);
}

After:

public interface ISpawnStrategy
{
    SpawnGroup ChooseNextGroup(SpawnContext context);
}

public sealed class BiomeSpawnStrategy : ISpawnStrategy
{
    public SpawnGroup ChooseNextGroup(SpawnContext context)
    {
        if (context.BossAlive)
            return context.Tables.BossAdds.Roll(context.Danger);

        if (context.Biome == Biome.Swamp)
            return context.Tables.Swamp.Roll(context.Danger);

        if (context.Danger > 80)
            return context.Tables.Elite.Roll(context.Danger);

        return context.Tables.Default.Roll(context.Danger);
    }
}

That first extraction may still contain branches, and that is fine. The goal is not branch elimination. The goal is placing the policy behind a seam so a different mode can replace it later.

For example, a daily challenge can use a different strategy without editing the normal campaign logic.

public sealed class DailyChallengeSpawnStrategy : ISpawnStrategy
{
    public SpawnGroup ChooseNextGroup(SpawnContext context)
    {
        return context.Tables.DailyChallenge.RollSeeded(
            context.RunSeed,
            context.WaveIndex);
    }
}

Now the wave director depends on ISpawnStrategy, not on every possible spawn rule.

That is enough. Stop there unless the next problem demands more.

Testing strategies without dragging Unity into it

One of the best reasons to use strategy is that it gives you small pieces of behavior you can test without booting a scene.

For deterministic strategies, use plain inputs and expected outputs.

public static class ArmorPiercingDamageTests
{
    public static void Run()
    {
        var strategy = new ArmorPiercingDamage(armorIgnored: 0.5f);

        var damage = strategy.CalculateDamage(
            new DamageRequest(BaseDamage: 30, AttackerLevel: 1, DefenderArmor: 10));

        Debug.Assert(damage == 25);
    }
}

For random strategies, inject randomness. Do not call UnityEngine.Random.value directly from the strategy if you want repeatable tests. Pass an IRandom, a seeded random source, or a precomputed roll value in the request. This is not ceremony. It is the difference between a test that catches a balance bug and a test that occasionally fails because the moon looked at it wrong.

For ScriptableObject strategies, keep the calculation method as pure as practical. If the asset only reads serialized configuration and a request struct, it is still easy to test. If the asset searches the scene, pulls singletons, and mutates global state, the strategy is now coupled to the runtime and the tests will pay for it.

A small amount of discipline here saves a lot of editor-time debugging later.

Performance notes

Most strategy use in game architecture is not performance-sensitive. A virtual call when a weapon hits or a wave spawns is noise. The design clarity matters more.

Hot loops are different. If you are running behavior selection for tens of thousands of entities every frame, classic object-oriented strategy may fight the CPU. Interface dispatch, scattered objects, and asset lookups can hurt cache behavior. In those cases, a data-oriented representation is often better: enums plus Burst-friendly function selection, lookup tables, or separate jobs per behavior type.

That does not make strategy wrong. It means the implementation should match the layer.

At authoring and gameplay-system scale, strategy objects are often fine. At simulation-kernel scale, prefer data layouts that the runtime can chew through efficiently. Same concept, different mechanics.

The lazy version is not “always use interfaces.” It is “use the smallest representation that keeps the code honest at this scale.”

Common mistakes

The first mistake is making a strategy for every tiny variation. If each strategy is a two-line class and the caller was simpler before, you probably extracted too early. Wait until there is a real seam.

The second mistake is letting strategies know too much. A damage strategy should not need the entire player object if a DamageRequest with level, armor, tags, and buffs would do. Passing smaller data makes the contract clearer and reduces accidental dependencies.

The third mistake is using inheritance when composition would be simpler. A FireSword : Sword : Weapon : MonoBehaviour hierarchy grows brittle quickly. A weapon with a damage strategy, targeting strategy, and presentation data can often handle variation without forcing every combination into a subclass.

The fourth mistake is hiding assignment. If a prefab gets the wrong strategy asset, debugging should be straightforward. Use clear asset names, validate required references, and fail loudly when a strategy is missing. A null strategy should not silently become “do no damage” unless that is an intentional, named behavior.

The fifth mistake is pretending strategy replaces design. You still have to decide which behaviors are allowed, how they combine, and what owns the result. Patterns organize decisions. They do not make the decisions for you.

When a switch is still better

There are plenty of times when I would keep the switch.

If the behavior is tiny and closed, a switch is clearer. If the cases are tightly coupled and always change together, a switch keeps that relationship visible. If the code is in a Burst job or another hot path where object dispatch is the wrong shape, data-oriented branching may be the correct tool. If the variation is only in values, use data. Do not create FastEnemyMovementStrategy, MediumEnemyMovementStrategy, and SlowEnemyMovementStrategy when a speed field would do.

This is the part that gets missed in pattern discussions. The strategy pattern is not about avoiding if. It is about separating policies that need to vary independently.

A branch answers “which case is this?” A strategy answers “which behavior did we plug in?”

Both are useful. Pick the one that makes the next change cheaper, not the one that looks more architectural.

Practical takeaway

Use the strategy pattern when a game system has a stable job but multiple real policies for doing that job.

Keep the interface narrow. Pass data instead of whole objects. Use plain C# strategies for runtime logic and ScriptableObject strategies when editor-driven authoring matters. Avoid shared mutable state in assets. Do not extract a strategy just to remove a branch. Extract it when the branch is a policy that needs to change, be tested, or be selected independently.

The best strategy implementation is usually small enough to look boring. That is the point. The caller says what it needs. The strategy decides how to do it. The rest of the game stays out of the argument.

References