Skip to content

Defining Entity Kinds

An entity kind is a named collection with a fixed schema. Before writing entities of a given kind, you must register the kind on the server.

Use the [ConvergenceEntity] attribute on a partial struct. The source generator produces serialization code that matches the server’s byte layout exactly.

[ConvergenceEntity("Player")]
public partial struct Player
{
[Field(0)] public ulong Id { get; set; }
[Field(1)] public uint Health { get; set; }
[Field(2)] public float PosX { get; set; }
[Field(3)] public float PosY { get; set; }
[Field(4, MaxLength = 32)] public string Name { get; set; }
public ReadOnlyMemory<byte> EntityId { get; init; }
}
AttributePurpose
[ConvergenceEntity("Name")]Marks the struct as an entity kind and assigns the kind name used on the server.
[Field(N)]Assigns a stable ordinal to a field. Ordinals determine the wire layout and must never change.
[Field(N, MaxLength = X)]For string and byte[] fields, sets the maximum byte length. Strings default to variable-length storage (VarString); bytes default to VarBytes.
[Field(N, MaxLength = X, FixedLength = true)]Opts into fixed-length inline storage (String/Bytes) instead of the default variable-length storage. Best for short, predictable-length values.
[Field(N, MaxCount = X)]For array fields, sets the maximum element count (1 to 1024).
[ConvergenceStruct]Marks a struct as an embeddable sub-struct for use as a field type or array element.

To enable per-assert event notifications for a kind, set EventStream = true on the attribute:

[ConvergenceEntity("AuditLog", EventStream = true)]
public partial struct AuditLog : IConvergenceEntity<AuditLog>
{
// ...
}

When enabled, the server records an event for every individual ASSERT, RETRACT, and PATCH operation on this kind. Subscribers can receive these events via SubscribeEventsAsync(). The converged state model is unaffected. Kinds without this flag pay zero overhead. See Event Streams for details.

For entity kinds with large populations but infrequent reads, you can opt into disk-backed storage. The server stores entities in a file on disk and keeps a bounded LRU cache for the hot working set, significantly reducing memory usage.

[ConvergenceEntity("TerrainChunk", DiskBacked = true, CacheCapacity = 50_000)]
public partial struct TerrainChunk : IConvergenceEntity<TerrainChunk>
{
[Field(0)] public uint ChunkX { get; set; }
[Field(1)] public uint ChunkY { get; set; }
[Field(2)] public byte BiomeType { get; set; }
public ReadOnlyMemory<byte> EntityId { get; init; }
}
PropertyDefaultDescription
DiskBackedfalseWhen true, the server stores this kind on disk with an LRU cache instead of holding all entities in memory.
CacheCapacity10,000Initial number of entities to keep in the LRU cache. The cache grows automatically if the hit rate drops below 80%, so this is a starting point rather than a hard limit.

When to use disk-backed storage:

  • The kind has millions of entities but only a fraction are read or written frequently.
  • You want to reduce steady-state memory usage at the cost of higher latency on cache misses.
  • The kind is primarily written to (assertions flow in) with only occasional point reads.

Trade-offs:

  • Cache hits have near-identical latency to in-memory storage.
  • Cache misses incur a disk read (typically 50-200us).
  • The cache grows adaptively, so sustained access to a large working set will eventually reach in-memory performance at the cost of more memory.
  • The storage mode of a kind cannot be changed after creation. To switch modes, the kind must be re-created.

See Design Patterns for guidance on choosing between in-memory and disk-backed storage.

Every entity struct must include an EntityId property of type ReadOnlyMemory<byte>. This is the entity’s unique 32-byte identifier within the kind.

Use the EntityId helper class to construct IDs from common .NET types:

ReadOnlyMemory<byte> id = EntityId.FromGuid(Guid.NewGuid()); // from a GUID
ReadOnlyMemory<byte> id = EntityId.FromLong(42L); // from a long
ReadOnlyMemory<byte> id = EntityId.FromString("order-1234"); // SHA-256 derivation

Call RegisterKindAsync<T>() to register the kind on the server and obtain a KindHandle<T>:

KindHandle<Player> players = await client.RegisterKindAsync<Player>();

This method uses register-if-not-exists semantics:

  • If the kind does not exist, it is created and a new KindId is assigned.
  • If the kind already exists with the same schema, the existing KindId is returned.
  • If the kind exists but new fields have been appended, the schema is automatically migrated. See Schema Evolution.
  • If the schema is incompatible (field type changed, field removed, field reordered), an error is thrown.

RegisterKindAsync is safe to call on every application startup.

Read-only services that only subscribe to a kind (and never write to it) should use ResolveKindAsync<T>():

KindHandle<Player> players = await client.ResolveKindAsync<Player>();

This is a read-only operation. It checks that the reader’s compiled schema fields are a valid prefix of the server’s current schema without mutating the registry. The returned KindHandle<T> works identically for subscriptions and queries.

Use ResolveKindAsync when:

  • Your service only reads and never writes to the kind.
  • The kind’s schema is owned by another service that may add fields.
  • You want an immediate error if the schema is incompatible, without any risk of mutation.

See Schema Evolution for details on how readers handle schema drift.

For each [ConvergenceEntity] struct, the generator creates:

  • Serialization code (WriteTo / ReadFrom) that reads and writes field data as raw bytes matching the server’s memory layout.
  • A {Name}Patch type for partial field updates. See Assert, Patch, and Retract.
  • A {Name}.Fields class with const int values for each field ordinal, used with HasChanged() for type-safe change detection. See Change Tracking.

The [Field(N)] ordinal is the canonical identity of a field. It determines:

  • The field’s position in the wire layout (byte offset within the entity row).
  • The field’s bit position in the PATCH presence mask and the ChangedFields bitmask.

Ordinals must be stable. Once assigned, never change a field’s ordinal. You can add new fields with new ordinals, but you cannot reuse or reorder existing ones.

The maximum number of top-level fields per kind is 64 (constrained by the u64 presence mask). See Limits.