Visibility & Virtual IDs

Altruist's visibility system answers one question every tick: which observers can currently see which world objects? It maintains those sets, diffs them against the previous tick, dispatches visibility events, bridges them into the collision system, and cooperates with entity hibernation.

What it does not do is invent your game's spawn/despawn packet format. Visibility is the authoritative interest-management layer; packet shape stays in your game code.

What Altruist Handles Automatically

  • Tracks explicitly registered observers
  • Computes each observer's visible entity set
  • Diffs current visibility against the previous tick
  • Fires OnEntityVisible and OnEntityInvisible
  • Dispatches EntityVisible / EntityInvisible into collision handlers
  • Hibernates zero-observer entities and wakes them when observers approach
  • Exposes reverse lookup for sync broadcasting via GetObserversOf

It also reacts to live world changes:

  • if an entity spawns in range, visible observers receive visibility immediately
  • if an entity is destroyed, visible observers receive invisibility immediately

What Altruist Does Not Send For You

Altruist does not automatically send game-specific spawn, despawn, nameplate, quest-marker, or appearance packets. The framework only tells you that visibility changed.

That split is intentional:

  • Altruist decides who can see what
  • your game decides what packet the client needs when that happens

If you never attach custom handlers, visibility tracking, hibernation, and observer bookkeeping still work. You only need handlers when clients must actually receive entity add/remove data.

The Interface

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

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

    bool Observe(ITypelessWorldObject observer);
    void RemoveObserver(ITypelessWorldObject observer);
    void RefreshObserver(string clientId);
    void RemoveObserver(string clientId);
    IReadOnlySet<string>? GetVisibleEntities(string clientId);
    IEnumerable<string> GetObserversOf(string entityInstanceId);
}
MemberMeaning
ObserveExplicitly registers a world object as an observer
RemoveObserver(worldObject)Removes a registered observer object and fires invisible events for anything it still saw
RefreshObserverClears cached visibility so the next tick re-fires current nearby entities
RemoveObserver(clientId)Removes observer state when you only have the network destination
GetVisibleEntitiesReturns the current visible instance-id set for one observer
GetObserversOfReturns all observer client IDs currently seeing one entity

GetObserversOf is especially important because Entity Synchronization uses it to decide where automatic sync packets go.

Observer Rules

Observer status is now explicit. A non-empty ClientId is still required as the network destination, but it does not make a world object an observer by itself.

The normal flow is:

  1. spawn or initialize the player world object
  2. call Observe(worldObject)
  3. let visibility maintain the observer's visible set from there

In practice, observers are usually player-controlled entities.

var player = new PlayerWorldObject(transform)
{
    ClientId = clientId,
};

await world.SpawnDynamicObject(player);
_visibilityTracker.Observe(player);

Observe(player) also clears any previous cached visibility for that observer, so newly entered players immediately refire nearby objects on the next visibility update.

NPCs, monsters, and static world objects are usually observed but not observers.

End-to-End Packet Flow

The most common pattern is:

  1. Visibility says an entity became visible.
  2. Your handler sends a full spawn/snapshot packet.
  3. Later sync packets patch that spawned entity.
  4. Visibility says the entity became invisible.
  5. Your handler sends a remove packet.

That looks like this:

[CollisionHandler]
public sealed class VisibilityPackets
{
    private readonly IAltruistRouter _router;

    public VisibilityPackets(IAltruistRouter router)
    {
        _router = router;
    }

    [CollisionEvent(typeof(EntityVisible))]
    public void OnPlayerSeesMonster(EntityVisible _, PlayerEntity observer, MonsterEntity monster)
    {
        _router.Client.SendAsync(observer.ClientId, new MonsterSpawnPacket
        {
            VirtualId = monster.VirtualId,
            Name = monster.Name,
            Position = new[] { monster.PosX, monster.PosY, monster.PosZ },
            Health = monster.Hp,
        });
    }

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

Note:

Use visibility for first appearance and disappearance. Use sync for incremental state changes after the entity already exists on the client.

When to Call RefreshObserver

Call RefreshObserver(player.ClientId) when an already registered observer should recalculate its current visibility set from scratch:

  • after teleport
  • after changing world or zone

For first registration, prefer Observe(player) instead of RefreshObserver(...).

RefreshObserver does not send packets by itself. It clears the cached visible set so the next visibility update will treat currently nearby entities as newly visible again.

When to Call RemoveObserver

Prefer RemoveObserver(player) before destroying the player's world object or clearing the session:

_visibilityTracker.RemoveObserver(player);

The RemoveObserver(clientId) overload still exists for cases where you only have the disconnected network destination and no live object reference.

This gives your handlers one last chance to send remove packets for everything the client was still tracking.

Hibernation

Visibility also controls entity hibernation:

  • when an entity has zero observers, Altruist may hibernate it
  • when an observer comes near, Altruist wakes it
  • AI State Machine stops ticking while the entity is hibernated

This is why visibility is more than a packet helper; it is part of the world simulation lifecycle.

Visibility and Sync

Automatic sync is visibility-aware:

  • visibility computes who can currently see an entity
  • sync asks visibility for that observer list
  • sync sends the delta only to those observers

So the intended client model is:

  • spawn/full snapshot from visibility
  • patch updates from sync
  • remove from visibility

Do not try to build first-time entity creation from sync alone.

Virtual IDs

Altruist tracks entities internally by InstanceId, but many games want a compact network identifier such as a uint VirtualId.

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

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

Virtual IDs are a game-level pattern, not a framework requirement. They are usually the identifier you send in:

  • visibility spawn packets
  • remove packets
  • client target-selection packets
  • any sync payload fields where the client needs to map deltas back to an existing entity