Skip to content

Component Type Visitors

Artyom Romskiy edited this page Oct 13, 2026 · 5 revisions

Component Type Visitors

A visitor lets code selected by a runtime ComponentId receive the registered CLR type as a generic type argument. Use it when the component registration is chosen dynamically but the processing code should still be type-aware. A component ID is included in every visit, so two registrations of the same CLR type can still be distinguished.

Visit any registered type

Implement IUnconstrainedVisitor to receive any registered component type, regardless of the registration's constraint route:

using System;
using Delta.ECS;

var layouts = new ComponentRegistry();
ComponentId positionId = layouts.Register<Position>(new SchemaId(1));
ComponentId velocityId = layouts.Register<Velocity>(new SchemaId(2));
ComponentId[] componentIds = { positionId, velocityId };
var visitor = new TypeNameVisitor();

foreach (ComponentId componentId in componentIds)
{
    if (layouts.TryVisit(componentId, visitor))
    {
        Console.WriteLine($"Visited registration {componentId.Value}.");
    }
}

public sealed class TypeNameVisitor : IUnconstrainedVisitor
{
    public void Visit<TComponent>(ComponentId componentId)
    {
        Console.WriteLine($"{componentId.Value}: {typeof(TComponent).Name}");
    }
}

public struct Position { public float X; }
public struct Velocity { public float Value; }

TryVisit returns false for an invalid ID, a null visitor, or an unsupported visitor route. Use its result when the input list can contain IDs that do not match. Visit is the shorter form when a skipped visit is acceptable; it silently does nothing in those cases.

Restrict visits by type shape

The built-in visitor contracts express the CLR constraints the visitor accepts:

Visitor contract Constraint on the visited type
IStructVisitor struct
IUnmanagedVisitor unmanaged
IClassVisitor class
INewVisitor new()
IClassNewVisitor class, new()

For example, an IStructVisitor implements Visit<TComponent> with where TComponent : struct. Narrow visitors run only when the registration route supports their constraint. That route is selected when Register<T> is called, from the constraints visible at that call site. If a generic helper should register a struct route, declare where T : struct on the helper; a type's runtime shape does not add constraints that were absent at registration.

Every registration route also supports IUnconstrainedVisitor, so it is the right contract when the visitor must process a mixed list without filtering by registration shape.

Use an interface visitor for configurable damage targets

Suppose weapon data selects a damage component by ComponentId. Enemies may use EnemyHealth, while destructible props use BuildingDurability; both support the same gameplay operation. The game does not know the component's CLR type when it reads the weapon configuration, so it builds typed handlers once during startup:

using System;
using System.Collections.Generic;
using Delta.ECS;

using var world = new World();
ComponentRegistry layouts = world.Components;
layouts.BindInterface<EnemyHealth, IDamageable>();
layouts.BindInterface<BuildingDurability, IDamageable>();

ComponentId enemyHealthId = layouts.Register<EnemyHealth>(new SchemaId(10));
ComponentId buildingDurabilityId = layouts.Register<BuildingDurability>(new SchemaId(11));

var damageHandlers = new DamageHandlerRegistry();
var visitor = new DamageHandlerVisitor(damageHandlers);
ComponentId[] damageableIds = { enemyHealthId, buildingDurabilityId };
foreach (ComponentId id in damageableIds)
{
    if (!layouts.TryVisit(id, visitor))
    {
        throw new InvalidOperationException($"No damage handler route for component {id.Value}.");
    }
}

Entity target = world.Create(enemyHealthId);
ref EnemyHealth health = ref world.GetRef<EnemyHealth>(target, enemyHealthId);
health.Value = 100;
ComponentId configuredDamageId = enemyHealthId; // Loaded from weapon data.
damageHandlers.TryApply(world, target, configuredDamageId, 25);
if (health.Value != 75)
{
    throw new InvalidOperationException("The damage handler did not update enemy health.");
}

public interface IDamageable
{
    void ApplyDamage(int amount);
}

public struct EnemyHealth : IDamageable
{
    public int Value;
    public void ApplyDamage(int amount) => Value -= amount;
}

public struct BuildingDurability : IDamageable
{
    public int Value;
    public void ApplyDamage(int amount) => Value -= amount;
}

public sealed class DamageHandlerVisitor : IComponentVisitor<IDamageable>
{
    private readonly DamageHandlerRegistry _handlers;

    public RuntimeTypeHandle ConstraintType => typeof(IDamageable).TypeHandle;

    public DamageHandlerVisitor(DamageHandlerRegistry handlers) => _handlers = handlers;

    public void Visit<TComponent>(ComponentId id) where TComponent : IDamageable =>
        _handlers.Register<TComponent>(id);
}

public sealed class DamageHandlerRegistry
{
    private readonly Dictionary<ComponentId, IDamageHandler> _handlers = new();

    public void Register<TComponent>(ComponentId id) where TComponent : IDamageable =>
        _handlers.Add(id, new DamageHandler<TComponent>(id));

    public bool TryApply(World world, Entity target, ComponentId id, int amount)
    {
        return _handlers.TryGetValue(id, out IDamageHandler? handler)
            && handler is not null
            && handler.TryApply(world, target, amount);
    }

    private interface IDamageHandler
    {
        bool TryApply(World world, Entity target, int amount);
    }

    private sealed class DamageHandler<TComponent> : IDamageHandler where TComponent : IDamageable
    {
        private readonly ComponentId _id;

        public DamageHandler(ComponentId id) => _id = id;

        public bool TryApply(World world, Entity target, int amount)
        {
            if (!world.Has(target, _id))
            {
                return false;
            }

            ref TComponent component = ref world.GetRef<TComponent>(target, _id);
            component.ApplyDamage(amount);
            return true;
        }
    }
}

At hit time, gameplay calls damageHandlers.TryApply(world, target, weapon.DamageComponentId, weapon.Damage). The handler first checks that the target owns the selected component, then updates it through a typed reference. Thus a weapon configuration can select between health, shield, or durability without a switch over component types in the hit system. DamageHandlerRegistry is application code; the ECS visitor API is used to create its per-component handlers.

IComponentVisitor<TConstraint> accepts registered types that implement the selected constraint. Its ConstraintType must match TConstraint; bind each concrete component type with BindInterface<TComponent, TConstraint>. One binding covers every schema registration of that CLR type. TryVisit may also succeed through another compatible visitor contract implemented by the same object.

Use a shape-specific interface visitor when both the interface and CLR constraint matter. The available contracts are IStructVisitor<TConstraint>, IUnmanagedVisitor<TConstraint>, IClassVisitor<TConstraint>, INewVisitor<TConstraint>, and IClassNewVisitor<TConstraint>. For example, IStructVisitor<IDamageable> receives only struct components implementing IDamageable.

Interface routes are explicit and use closed generic calls. They do not require the consumer source generator; the generator is needed for generated iteration and other generated API forms, not for BindInterface or TryVisit.

Handle several interface route shapes

After binding the component CLR types as above, derive from GeneralComponentTypeVisitor<TConstraint> when one visitor should handle multiple registration shapes through virtual hooks. Override only the hooks the application needs; an unoverridden hook is a no-op:

public sealed class DamageableShapeVisitor : GeneralComponentTypeVisitor<IDamageable>
{
    protected override void VisitUnmanaged<TComponent>(ComponentId componentId)
    {
        // TComponent is unmanaged and implements IDamageable.
    }

    protected override void VisitClass<TComponent>(ComponentId componentId)
    {
        // TComponent is a class and implements IDamageable.
    }
}

The hooks correspond to the route selected during registration: Visit<T> for an unconstrained route, VisitUnmanaged<T>, VisitStruct<T>, VisitClass<T>, VisitConstructible<T>, and VisitClassConstructible<T>. For example, override VisitClassConstructible<T> to handle a class, new() route. The base class exposes one hook per route, so the constraints remain visible in the base API without adding marker arguments to overrides.

The visitor APIs are runtime APIs and do not depend on generated iteration overloads. For iteration forms that visit every matching entity, see Entity Component Iteration; for the complete call grammar, see the API grammar.

Previous: Caching Operations · Next: Home