Visibility & Virtual IDs

Altruist provides an automatic visibility tracking system that detects when world objects enter or leave a player's view range. This is essential for large worlds where sending every entity to every client would be wasteful.

What Altruist Handles Automatically

The visibility system is fully automatic — no game code required for basic functionality:

  • Player moves near an entity → Altruist detects it entered view range → dispatches EntityVisible
  • Player moves away → Altruist detects it left view range → dispatches EntityInvisible
  • Entity hibernation activates when zero observers see it (saves resources)
  • Entity wakes up when an observer approaches

Each tick, the VisibilityTracker calculates which entities are within each player's ViewRange, diffs against the previous tick, and dispatches events.

Loading diagram...

When You Need Custom Code

You only write visibility handlers if you need extra logic beyond spawn/despawn:

  • Play a sound effect when a boss appears
  • Show a quest marker when an NPC becomes visible
  • Log analytics when a player first sees a rare entity
  • Trigger a cutscene or warning

If you don't register any EntityVisible/EntityInvisible collision handlers, the visibility system still works — entities are tracked, hibernated, and woken automatically.

Configuration

Set the view range in config.yml:

altruist:
  game:
    visibility:
      range: 5000    # Units (game-specific)

Or it defaults to 5000 if not specified.

Note:

The view range unit depends on your game's coordinate system. If your world uses centimeters, 5000 means 50 meters. If it uses meters, 5000 means 5 kilometers. Match this to your game's scale.

The IVisibilityTracker Interface

public interface IVisibilityTracker
{
    float ViewRange { get; set; }

    event Action<VisibilityChange> OnEntityVisible;
    event Action<VisibilityChange> OnEntityInvisible;

    void RefreshObserver(string clientId);
    void RemoveObserver(string clientId);
    IReadOnlySet<string>? GetVisibleEntities(string clientId);
}
MethodDescription
OnEntityVisibleFired when an entity enters a player's view range
OnEntityInvisibleFired when an entity leaves a player's view range
RefreshObserverForce a full visibility refresh (e.g., after teleport or spawn)
RemoveObserverRemove all tracking for a player (e.g., on disconnect)
GetVisibleEntitiesGet the set of instance IDs currently visible to a player

VisibilityChange Event

public readonly struct VisibilityChange
{
    public string ObserverClientId { get; init; }     // The player who sees/unsees
    public ITypelessWorldObject Target { get; init; } // The entity that appeared/disappeared
    public int WorldIndex { get; init; }              // Which world this happened in
}

Custom Visibility Handler (Optional)

If you need custom logic when entities become visible/invisible, use [CollisionHandler] with EntityVisible / EntityInvisible event types:

[CollisionHandler]
public class VisibilityHandler
{
    private readonly IAltruistRouter _router;
    public VisibilityHandler(IAltruistRouter router) => _router = router;

    [CollisionEvent(typeof(EntityVisible))]
    public void OnPlayerSeesMonster(PlayerEntity player, MonsterEntity monster)
    {
        _router.Client.SendAsync(player.ClientId, monster.ToAddPacket());
    }

    [CollisionEvent(typeof(EntityInvisible))]
    public void OnPlayerLosesMonster(PlayerEntity player, MonsterEntity monster)
    {
        _router.Client.SendAsync(player.ClientId, new EntityRemovePacket
        {
            VID = monster.VirtualId
        });
    }

    [CollisionEvent(typeof(EntityVisible))]
    public void OnPlayerSeesPlayer(PlayerEntity observer, PlayerEntity other)
    {
        _router.Client.SendAsync(observer.ClientId, other.ToAddPacket());
    }

    [CollisionEvent(typeof(EntityInvisible))]
    public void OnPlayerLosesPlayer(PlayerEntity observer, PlayerEntity other)
    {
        _router.Client.SendAsync(observer.ClientId, new EntityRemovePacket
        {
            VID = other.VirtualId
        });
    }
}

Note:

Visibility events use the same [CollisionHandler] pattern as proximity, combat, and zone events. Define one handler per entity type pair — no manual event subscription needed. See Collision Handling for all event types.

Observer Detection

An observer is any IWorldObject3D with a non-empty ClientId. When you spawn a player entity into the world and set its ClientId to the connection ID, the visibility tracker automatically starts tracking what that player can see.

var player = new PlayerWorldObject(transform);
player.ClientId = clientId;  // This makes the player an observer
await world.SpawnDynamicObject(player);

// Trigger initial visibility refresh so the player sees everything nearby
_visibilityTracker.RefreshObserver(clientId);

Note:

Only objects with a non-empty ClientId become observers. NPCs, monsters, and static objects are observed but never observe — they don't trigger visibility calculations, keeping the system efficient.

Key Moments to Call RefreshObserver

  • When a player first enters the world (after spawn)
  • When a player teleports to a new location
  • When a player changes world/zone

Disconnection Cleanup

When a player disconnects, remove their observer state:

_visibilityTracker.RemoveObserver(clientId);

This fires OnEntityInvisible for all previously visible entities, letting you clean up on the client side.

Note:

Always call RemoveObserver before destroying the player's world object. This ensures all OnEntityInvisible events fire correctly and nearby players remove the disconnected entity from their view.


Virtual IDs

In many game architectures, entities need a compact numeric identifier in addition to their string-based InstanceId. This is commonly called a Virtual ID (VID).

Altruist world objects use InstanceId (a GUID string) for internal tracking. For network-efficient entity references, you can add a numeric VID as a property on your world objects:

[WorldObject("player")]
public class PlayerWorldObject : WorldObject3D
{
    public uint VirtualId { get; set; }
    public string Name { get; set; } = "";

    public PlayerWorldObject(uint vid, string name, Transform3D transform)
        : base(transform)
    {
        VirtualId = vid;
        Name = name;
    }
}

Generating Virtual IDs

Use an atomic counter in your session or entity service:

[Service]
public sealed class EntityIdService
{
    private uint _nextVID = 1;

    public uint NextVirtualId() => Interlocked.Increment(ref _nextVID);
}

Then assign VIDs when creating entities:

uint vid = _entityIdService.NextVirtualId();
var player = new PlayerWorldObject(vid, "Hero", transform);
player.ClientId = clientId;
await world.SpawnDynamicObject(player);

Why Virtual IDs?

ConcernInstanceId (GUID)Virtual ID (uint)
UniquenessGlobally uniqueUnique per server session
Size32+ bytes4 bytes
Network costExpensive to send frequentlyCheap
Use casePersistence, internal trackingNetwork packets, client references

Clients reference entities by VID in packets (attack target, movement sync, etc.), while the server uses InstanceId internally for world object lookups.

Note:

Virtual IDs are a user-level pattern, not a framework requirement. Altruist tracks entities via InstanceId internally. Add VIDs only if your network protocol benefits from compact numeric references.