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.
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);
}
| Method | Description |
|---|---|
OnEntityVisible | Fired when an entity enters a player's view range |
OnEntityInvisible | Fired when an entity leaves a player's view range |
RefreshObserver | Force a full visibility refresh (e.g., after teleport or spawn) |
RemoveObserver | Remove all tracking for a player (e.g., on disconnect) |
GetVisibleEntities | Get 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?
| Concern | InstanceId (GUID) | Virtual ID (uint) |
|---|---|---|
| Uniqueness | Globally unique | Unique per server session |
| Size | 32+ bytes | 4 bytes |
| Network cost | Expensive to send frequently | Cheap |
| Use case | Persistence, internal tracking | Network 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.