12 minutes
Visitor Pattern in Game Code: Add Tools Around Stable Data
A game ability often starts as a few fields: a damage amount, a cooldown, and a status effect. Then designers ask for combinations. An ability can deal damage, apply a status, branch on a target condition, or run several effects in sequence. The data becomes a small tree, and tools need to inspect it in different ways.
A validator checks that every status id exists. A balance report totals damage and duration. An editor preview turns the tree into readable text. A build step gathers references so unused assets can be found. Putting all of those jobs on the data nodes couples the model to every tool. Copying a chain of type checks into each tool spreads the same knowledge around.
The Visitor pattern is one way to keep a stable set of node types separate from a growing set of operations. In game code, that can be a useful fit for content validation and editor or build tooling. It is usually a poor fit for every object in a running scene. The choice depends on which side of the model changes more often.
The useful shape of the problem
Visitor works on a known family of element types. Think of a content tree with DamageNode, ApplyStatusNode, and SequenceNode. Those types describe the shape of an ability. They do not decide how the ability is validated, summarized, exported, or previewed.
That separation is useful when the node model is fairly stable but new tools keep appearing. Adding an operation means adding a visitor. The node classes stay focused on representing data and enforcing their own basic invariants. A designer-facing validator can report bad references without giving every node a dependency on the editor. A build report can inspect the same data without adding build-only methods to runtime types.
The trade is deliberate. When a new node type appears, every visitor must decide what that node means. A ConditionalNode added to the tree affects the validator, exporter, balance report, and any other complete visitor. That extra work is a safeguard when skipping the new case would make a report incomplete. It is friction when node types change every week.
Double dispatch, without the fog
C# normally chooses an overloaded method using the argument’s compile-time type. If a method receives an AbilityNode, an overload for DamageNode is not selected merely because the object happens to be a damage node at runtime. Virtual dispatch chooses an implementation based on the runtime type of the receiver; it does not also dispatch on the runtime type of every argument.
Visitor makes two calls. First, the node’s virtual Accept method selects the concrete node implementation. That method then calls the matching overload on the visitor. In a DamageNode.Accept method, this has the compile-time type DamageNode, so C# selects Visit(DamageNode). Those two single-dispatch steps are called double dispatch.
using System;
using System.Collections.Generic;
public interface IAbilityVisitor<TResult>
{
TResult Visit(DamageNode node);
TResult Visit(ApplyStatusNode node);
TResult Visit(SequenceNode node);
}
public abstract class AbilityNode
{
public abstract TResult Accept<TResult>(IAbilityVisitor<TResult> visitor);
}
public sealed class DamageNode : AbilityNode
{
public float Amount { get; }
public DamageNode(float amount) => Amount = amount;
public override TResult Accept<TResult>(IAbilityVisitor<TResult> visitor)
=> visitor.Visit(this);
}
public sealed class ApplyStatusNode : AbilityNode
{
public string StatusId { get; }
public float Duration { get; }
public ApplyStatusNode(string statusId, float duration)
{
StatusId = statusId;
Duration = duration;
}
public override TResult Accept<TResult>(IAbilityVisitor<TResult> visitor)
=> visitor.Visit(this);
}
public sealed class SequenceNode : AbilityNode
{
public IReadOnlyList<AbilityNode> Children { get; }
public SequenceNode(IEnumerable<AbilityNode> children)
=> Children = Array.AsReadOnly(new List<AbilityNode>(children).ToArray());
public override TResult Accept<TResult>(IAbilityVisitor<TResult> visitor)
=> visitor.Visit(this);
}
Each concrete node implements one overload from IAbilityVisitor<TResult>. The visitor’s result type is generic so one operation can return diagnostics, another can return a formatted description, and a third can return a number without stashing results in mutable fields.
For a real content model, the node types also need their data. A status node might expose a status id and duration. A sequence node holds an ordered list of child nodes. The sequence owns that list because ordering is part of the ability’s meaning; visitors choose how to process it. Keeping this model as ordinary C# data also makes it possible to test without loading a scene or constructing MonoBehaviour instances.
A visitor that validates ability content
Validation is a good first operation because its rules are easy to state and its output is useful before a match starts. Damage must be finite and positive. A status needs a non-empty id and a finite, positive duration. A sequence is valid only when its children are valid.
using System;
using System.Collections.Generic;
public sealed class AbilityValidator : IAbilityVisitor<IReadOnlyList<string>>
{
public IReadOnlyList<string> Visit(DamageNode node)
{
if (float.IsNaN(node.Amount) || float.IsInfinity(node.Amount) || node.Amount <= 0f)
return new[] { "Damage must be finite and greater than zero." };
return Array.Empty<string>();
}
public IReadOnlyList<string> Visit(ApplyStatusNode node)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(node.StatusId))
errors.Add("Status id is required.");
if (float.IsNaN(node.Duration) || float.IsInfinity(node.Duration) || node.Duration <= 0f)
errors.Add("Status duration must be finite and greater than zero.");
return errors;
}
public IReadOnlyList<string> Visit(SequenceNode node)
{
var errors = new List<string>();
foreach (var child in node.Children)
errors.AddRange(child.Accept(this));
return errors;
}
}
The sequence visitor owns the recursive step because it knows that this node contains children. The leaf visitors validate only their own fields. A caller can validate a root with root.Accept(new AbilityValidator()) and show the returned messages in an inspector, content pipeline, or command-line check. A small edit-mode test can protect the traversal contract:
var ability = new SequenceNode(new AbilityNode[]
{
new DamageNode(12f),
new ApplyStatusNode("", 3f)
});
var errors = ability.Accept(new AbilityValidator());
if (errors.Count != 1)
throw new Exception($"Expected one validation error, got {errors.Count}.");
This checks that the sequence visits both children and that invalid status data produces a diagnostic. A larger test suite can assert the exact message and include nested sequences, but this small case catches a missing recursive step.
This example returns errors rather than keeping a list on the visitor instance. That makes each call’s output explicit and avoids stale diagnostics when someone reuses the same visitor for a second asset. For a very large tree, repeated lists and AddRange may allocate more than desired. Measure the editor or build step before changing the API; if it matters, pass a caller-owned result buffer or use a visitor with carefully scoped state.
The model has a cost
The visitor interface lists every concrete element type. That list is the contract for the model. When a new HealNode is introduced, each concrete IAbilityVisitor<TResult> implementation must add Visit(HealNode). The compiler points to implementations that have not been updated.
That pressure is valuable for exhaustive operations. A validator that forgets a new node should not report success by accident. A serializer or balance exporter should make an explicit decision about how healing behaves. If an operation truly does not care about a node, a default visitor can say so, but silent no-op defaults trade away the compiler’s reminder. Use them only for intentionally partial operations, such as a visitor that searches for one optional reference type.
The inverse cost appears when adding operations. A new visitor can be added without changing every node class. This is the pattern’s main benefit. It moves the change cost toward extending operations and away from extending the data hierarchy.
That choice resembles the expression problem: a design cannot make both adding new variants and adding new operations equally cheap. Visitor favors a stable set of variants and many independent operations. If that is not how the game model evolves, the pattern is pushing against the project.
Traversal is a separate decision
Visitor decides which operation handles a node. It does not automatically define how a tree is walked. In the validator above, Visit(SequenceNode) walks the children. A text exporter may walk them in order but add indentation. A dependency collector may need to de-duplicate asset ids. A balance summary may carry totals or context from parent nodes.
Putting traversal in each visitor is simple while the operations genuinely need different traversal rules. It becomes repetitive when several operations must walk exactly the same structure. At that point, consider a small shared walker or a base visitor that traverses children and lets subclasses handle node-specific work. Keep that abstraction narrow. A traversal framework with configurable enter/exit hooks, mutation policies, cancellation, and scheduling is a new subsystem; do not build it for three foreach loops.
A conditional ability node makes the difference concrete. The runtime evaluator follows the branch selected by the current target or game state. A content validator should inspect both branches, including the one that is not active in the editor preview, so a missing status id does not wait until a rare condition occurs. The same node hierarchy can therefore need distinct traversal policies. Sharing dispatch is useful; forcing every operation through one traversal can hide content errors.
Mutation during traversal deserves a clear rule. If a visitor rewrites or removes nodes while iterating the same child collection, ordering and skipped-element behavior become accidental. Prefer visitors that return a replacement tree, or gather edits and apply them after the walk. A reporting visitor should not quietly mutate the data it is reporting on.
A switch can be the better C# answer
A local switch expression often keeps a small external operation shorter than classic Visitor. Keep leaf validation in small helper methods, then make the dispatch explicit:
using System.Collections.Generic;
using System.Linq;
static IReadOnlyList<string> Validate(AbilityNode node) => node switch
{
DamageNode damage => ValidateDamage(damage),
ApplyStatusNode status => ValidateStatus(status),
SequenceNode sequence => sequence.Children
.SelectMany(Validate)
.ToArray(),
_ => throw new NotSupportedException($"Unhandled node type: {node.GetType().Name}")
};
The fallback makes an unknown node fail instead of quietly disappearing. With an open abstract base class, the compiler cannot always prove that the switch covers every subtype. Newer C# versions and analyzers can improve diagnostics for closed type hierarchies, but check the language version and compiler settings actually used by the Unity project. A _ => Array.Empty<string>() fallback is shorter, but it can hide missing behavior.
A switch is often easier to read when there are only one or two operations. It also keeps the data classes unaware of a visitor interface. Classic Visitor becomes more attractive when several operations need their own configuration, result type, lifecycle, or polymorphic selection, or when an existing framework already exposes a visitor API.
Do not create both a visitor interface and a large set of switches for the same hierarchy without a reason. That leaves two dispatch systems to update. Pick the one that matches the model and the language support in the project.
Where it fits in Unity
A Unity project can keep the visited model separate from scene objects. A ScriptableObject may hold authoring data; an import or validation step can convert that data to a plain node graph; visitors can inspect the graph before it produces runtime objects. The exact serialization design is a separate choice. Unity’s serialization support for custom polymorphic data has constraints, so confirm how the project stores the hierarchy rather than assuming that an interface-shaped object graph will serialize automatically.
Unity also provides a Properties API with property bags and property visitors for inspecting .NET objects. That is a better starting point when the job is generic property traversal, such as building inspector-like tooling or walking reflected data. A custom ability visitor is useful when the rules are domain-specific: validate that a status id exists in this game’s catalog, or calculate a balance report using this game’s conventions. Reimplementing generic property walking to handle those rules adds work without adding meaning.
This kind of visitor is most comfortable in editor tooling, content validation, import pipelines, tests, and infrequent debug reports. It can run at runtime too, but a per-frame visitor over thousands of nodes is a design decision worth measuring. Each node visit involves virtual dispatch, and allocating a visitor or result collection on every frame can create avoidable garbage. For frequent gameplay decisions, ordinary methods, data tables, or a purpose-built evaluator may express the behavior with less machinery.
Avoid making GameObject or MonoBehaviour hierarchies accept a new visitor just to calculate ordinary gameplay behavior. Components already expose operations through methods and interfaces. A damage system that needs to apply damage can call an IDamageable contract. Keep the visitor for external passes over a stable structure; use ordinary polymorphic methods for one game object at a time.
Visitor versus Strategy
These two patterns can look similar because both move behavior out of a class. Strategy swaps one algorithm behind a stable interface. A movement controller can choose a ground or flying movement strategy. The context asks one strategy to perform one job.
Visitor applies an operation across several known element types. A balance visitor can visit damage, status, and sequence nodes and compute one report over the complete ability tree. Strategy varies the implementation of one behavior; Visitor varies the operation over a structure. If the code needs one interchangeable behavior at a time, start with Strategy or a normal interface. If it needs many distinct passes over a stable tree, Visitor is worth considering.
A practical decision check
Before adding Accept methods, ask what actually changes in this feature.
- If node types are stable and tooling operations keep growing, Visitor may centralize those operations cleanly.
- If node types change often, prefer methods on the types, a switch with explicit failure, or a model designed for open extension.
- If there is one small operation, write the method or switch first. A visitor interface is unlikely to repay its extra files.
- If the job is generic reflection or property traversal, look for a platform API before writing a custom walker.
- If the work runs in a hot gameplay loop, measure allocations and dispatch cost, then compare the visitor against a direct evaluator.
For a stable ability-data tree, keep validators and reports beside the content pipeline as separate visitors. Keep per-frame combat behavior on gameplay interfaces or a purpose-built evaluator, and add the visitor boundary only when multiple tools are starting to crowd the data model.