Entity Synchronization
Altruist provides delta-based synchronization for world entities. The runtime inspects [Synced] properties, compares them with the last sent state, and emits a SyncPacket containing only the current patch.
This is a patch stream, not a spawn system. The normal production flow is:
- Visibility & Virtual IDs spawns the entity on the client
- sync sends incremental updates after that
- visibility removes the entity when it leaves range
Automatic Sync Prerequisites
For automatic per-tick sync, all of these must be true:
- the object is in the game world's snapshots
- it implements
ISynchronizedEntity - its type is marked with
[Synchronized] - its
ClientIdis not empty
[Synchronized]
[WorldObject("player")]
public sealed class PlayerEntity : WorldObject3D, ISynchronizedEntity
{
public string ClientId { get; set; } = "";
[Synced(0)]
public uint VirtualId { get; set; }
[Synced(1, SyncAlways: true)]
public string Name { get; set; } = "";
[Synced(2)]
public int PosX => (int)Transform.Position.X;
[Synced(3)]
public int PosY => (int)Transform.Position.Y;
[Synced(4)]
public int PosZ => (int)Transform.Position.Z;
[Synced(5)]
public int Health { get; set; } = 100;
public PlayerEntity(Transform3D transform) : base(transform) { }
}
Note:
The current implementation skips automatic sync entirely when ClientId is empty. AI or NPC entities that should auto-sync need a stable synthetic ClientId such as mob_42 or drone_1001.
Who Receives Automatic Sync
Automatic sync is visibility-aware.
For each eligible entity:
- Altruist computes the changed
[Synced]fields - builds one
SyncPacket - sends it to every observer returned by
IVisibilityTracker.GetObserversOf(entity.InstanceId) - if the entity is not an
IAIBehaviorEntity, it also self-sends to its ownClientId
That means the current runtime model is:
- player entities: visible observers plus self-sync
- AI entities: visible observers only
Sync Frequency
Use [Synchronized] to control how often the entity is considered for automatic sync:
[Synchronized] // every eligible engine tick
[Synchronized(10)] // every 10 ticks
[Synchronized(10, SyncUnit.Hz)] // 10 times per second
[Synchronized(1, SyncUnit.Seconds)] // once per second
The 2D/3D world organizers pass the sync service the rate their world step is assumed to run at, which turns Hz / Seconds frequencies into ticks. It is 25 Hz unless you set altruist:game:worlds:entity-sync-hz (for example to your engine framerateHz, since the organizers step once per engine frame):
altruist:
game:
worlds:
entity-sync-hz: 25 # default
The [Synced] Attribute
[Synced] controls which properties participate in the delta:
[Synced(0)] // normal change-based sync
[Synced(1, SyncAlways: true)] // always included
[Synced(2, oneTime: true)] // include on first send only
[Synced(3, syncFrequency: 5)] // at most every 5 ticks
| Parameter | Meaning |
|---|---|
bitIndex | Declared property index within the class |
SyncAlways | Always included when this entity is processed |
oneTime | Included on first send, then stops |
syncFrequency | Per-property tick throttle |
Important SyncAlways Behavior
In the current implementation, SyncAlways is stronger than "include this when something else changed". A SyncAlways field can make the entity produce a packet on every eligible auto-sync tick by itself.
That is useful for fields that must always be present in patch traffic, but it is also a bandwidth footgun if you mark too many properties that way.
Important oneTime Behavior
oneTime is not a substitute for first-time spawn data. New observers may appear after the one-time value has already been sent earlier.
Use oneTime for optional convenience fields only. For identity and first render, send a full spawn packet from visibility handlers.
What the Packet Contains
The built-in sync payload is intentionally small:
[MessagePackObject]
public struct SyncPacket : IPacketBase
{
[Key(0)] public uint MessageCode { get; set; } // 3
[Key(1)] public string EntityType { get; set; }
[Key(2)] public Dictionary<string, object?> Data { get; set; }
}
There is no built-in InstanceId or VirtualId field in the packet. If your client needs a stable identifier in delta traffic, you must provide that yourself as part of the synced surface or carry it in your separate spawn protocol.
Example payload:
{
"messageCode": 3,
"header": { "sender": "server", "receiver": "player-123" },
"message": {
"entityType": "PlayerEntity",
"data": {
"PosX": 1092,
"PosY": 440,
"Health": 95
}
}
}
Client Expectations
Clients should treat SyncPacket as a partial patch:
- merge fields into an existing entity
- do not replace the entire local entity model
- do not assume every property is present every time
- do not rely on sync to create the entity for the first time
The safe client-side lifecycle is:
- visibility spawn/full snapshot creates the entity locally
- sync patches update the already-known entity
- visibility remove deletes the entity locally
If you need identity, appearance, or static metadata at first sight, put that in the spawn packet.
Manual Sync
You can also push sync manually:
await _router.Synchronize.SendAsync(entity);
await _router.Synchronize.SendAsync(entity, forceAllAsChanged: true);
The current manual path uses the gaming IClientSynchronizator, which broadcasts the packet rather than filtering it through visibility.
Use that carefully. It is good for admin tools, debugging, or small explicit events, but it is not the visibility-aware path used by automatic entity sync.
Inheritance
Base-class synced properties are included automatically, and derived-class bitIndex values are offset after the base surface:
public class BaseEntity : ISynchronizedEntity
{
public string ClientId { get; set; } = "";
[Synced(0)]
public uint VirtualId { get; set; }
[Synced(1)]
public int PosX { get; set; }
}
[Synchronized]
public class PlayerEntity : BaseEntity
{
[Synced(0)]
public int Health { get; set; }
}
Health becomes the next global bit after the base-class fields.
Recommended Production Pattern
- Put stable identity in your visibility spawn packet.
- Keep auto-sync focused on fields that change often.
- Use
SyncAlwayssparingly. - Give auto-synced AI entities a synthetic non-empty
ClientId. - Let Visibility & Virtual IDs control first appearance and disappearance.