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 ClientId is 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 own ClientId

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
ParameterMeaning
bitIndexDeclared property index within the class
SyncAlwaysAlways included when this entity is processed
oneTimeIncluded on first send, then stops
syncFrequencyPer-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:

  1. visibility spawn/full snapshot creates the entity locally
  2. sync patches update the already-known entity
  3. 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.

  • Put stable identity in your visibility spawn packet.
  • Keep auto-sync focused on fields that change often.
  • Use SyncAlways sparingly.
  • Give auto-synced AI entities a synthetic non-empty ClientId.
  • Let Visibility & Virtual IDs control first appearance and disappearance.