Shapes: archetypes and mixins
Everything the SDK keeps track of is an entity: each player, each character, each NPC, each item on the ground. A
shape is how you say what an entity holds. There are two kinds. An archetype is a kind of entity, such as
MainCharacter, Npc or DroppedItem. A mixin is a reusable set of fields: archetypes include it, or you add it
to an archetype someone else declared. You declare a shape as a small struct, and the SDK writes the rest.
using ReadyM.SDK.Archetypes.Core;
using ReadyM.SDK.Attributes;
namespace MyMod.Common;
// A mixin: a set of fields. [Extends] gives every player one.
[ArchetypeMixin]
[Extends(typeof(Player))]
[Replicated]
[Propagates(Propagation.ServerAuthoritative)]
public readonly partial struct Wallet
{
public partial int Balance { get; set; }
}
Both halves now read player.Balance on any Player, and the server writes it:
// On the server, in a service that asked for IEntities in its constructor.
foreach (var player in entities.Query<Player>())
player.SetBalance(player.Balance + 10);
[Replicated] and [Propagates] decide who sees the balance and who may change it. They have a page of their own:
Replication and authority.
How it works
Declaring a shape
A shape is a public readonly partial struct with one of two attributes:
| Attribute | Declares | Example |
|---|---|---|
[Archetype(replicated: ...)] | A kind of entity. replicated: true gives each entity a network identity and an owner, and sends it to the players; false keeps it on the half that created it. | MainCharacter, Npc, Player |
[ArchetypeMixin] | A set of fields that archetypes include, or that [Extends] adds to one. | Transform, Vitals, Wallet |
Declare each field as a public partial property with get and set. The SDK writes the other half of each
property: the value lives on the entity, not in the struct. A shape is a handle to one entity, so copying it is cheap,
and every copy reads and writes the same entity. A list or dictionary is the one exception to public; see
Replication and authority.
Shapes go directly in a namespace, not nested inside a class. Put the shapes both halves use in MyMod.Common
(see Project layout).
A shape that only one project uses can be internal. Everything the SDK writes for it stays internal too, and a
public archetype may include an internal mixin.
Building an archetype from mixins
[Include(typeof(Mixin))] puts a mixin's fields on an archetype. An archetype can include as many mixins as it
likes, and can declare fields of its own next to them:
using ReadyM.SDK.Attributes;
namespace MyMod.Common;
[ArchetypeMixin]
public readonly partial struct Health
{
public partial float Current { get; set; }
public partial float Max { get; set; }
}
[Archetype(replicated: false)]
[Include(typeof(Health))]
public readonly partial struct Target
{
public partial int Points { get; set; }
}
A Target has Current, Max and Points. The included fields read as if Target declared them:
target.Current, not target.Health.Current.
[Include] takes mixins only. To build on an archetype, use [IncludeArchetype] (below).
Prefixes
A shape exposes each name once. If two includes bring a member of the same name, or an include brings one that the
archetype declares itself, the build stops with READYM041. Give one of the includes a prefix, the optional second
argument, and every member it brings gets that word in front:
[Archetype(replicated: false)]
[Include(typeof(Health))]
[Include(typeof(Shield), "Shield")] // Shield also has Current and Max
public readonly partial struct Target;
Now target.Current is the health and target.ShieldCurrent is the shield.
Prefixes are also there for names that would read badly once flattened. The SDK's MainCharacter includes
ParentCell with the prefix ParentCell, so its X and Y read ParentCellX and ParentCellY rather than a bare
X next to the character's position. It includes three attribute mixins with the prefixes Effect, Skill and
Social, which is why it has GetSkillAttribute and GetEffectAttribute instead of three methods called
GetAttribute.
Two things are not clashes: the same mixin reached twice (for example once directly and once through an included archetype) is one member, and methods of the same name that take different parameters are overloads.
Building on another archetype
[IncludeArchetype(typeof(X))] builds an archetype on another one and takes all of its fields, including everything
that archetype includes. This is inheritance, so an archetype has at most one; combine further data with [Include].
The SDK's ServerSummon is built this way:
[Archetype(replicated: true)]
[IncludeArchetype(typeof(Npc))]
[Include(typeof(SummonedBy))]
public readonly partial struct ServerSummon;
A ServerSummon converts to an Npc on its own, and an Npc converts back with a cast, which throws
InvalidCastException when the entity is not a ServerSummon:
Npc npc = summon; // always works
var again = (ServerSummon)npc; // throws if npc is not a ServerSummon
When you are not sure, use TryAs (see What every shape can do).
Adding fields to a shape you do not own
[Extends(typeof(X))] on a mixin adds it to every entity created as the archetype X. This is how a mod puts its own
data on the SDK's shapes: the core scopes (Player, Area, Cell, World, see Scopes) and the
Oblivion shapes (MainCharacter, Npc, DroppedItem and the rest, see
Players and characters). Wallet above extends Player. A mixin that only one
half uses can extend a shape too; this one keeps a map marker per character on the client:
using OblivionMp.Sdk.Common.Archetypes;
using OblivionMp.Sdk.Values;
using ReadyM.SDK.Attributes;
namespace MyMod.Client;
[ArchetypeMixin]
[Extends(typeof(MainCharacter))]
public readonly partial struct Marker
{
public partial MarkerHandle MarkerHandle { get; set; }
}
The mixin's fields appear on the extended shape: character.MarkerHandle. You can also query the mixin itself,
alone or next to the shape it extends: Query<Marker>(), Query<MainCharacter, Marker>().
An extended field is a property you can assign outside a loop. Inside a foreach over a query, C# does not allow
assigning it, so the SDK also generates a SetX method: player.SetBalance(...) in the first example.
[Extends] takes an optional prefix as well, for the same reasons as [Include]. If two mods both add a Level to
MainCharacter, C# cannot tell which one character.Level means and refuses to compile. The SDK's own Dialogue
mixin extends Player with the prefix Dialogue for this reason. A mixin can extend more than one archetype; repeat
the attribute.
Tags
[Tag(typeof(T))] on an archetype puts a tag on every entity created as it. A tag carries no values; it changes how
the runtime treats the entity. T has to be a struct implementing Friflo's ITag interface (from the
Friflo.Engine.ECS package the SDK builds on), which is why it is named with typeof. Repeat the attribute to add
more than one.
These are the tags that change how the server treats your entities. All of them are about who owns an entity, so they matter only for a replicated archetype in an area or cell.
| Tag | What it does | The SDK uses it on |
|---|---|---|
AllowOwnershipTransferOnScopeEnterTag | Hands the entity to the player whose game runs its area or cell. The first player into an empty one takes it, and it follows when that player changes or when the entity moves to another cell. Without it, an entity keeps the owner it was created with, which for one the server creates is the server. | NPCs, containers, flora, furniture, dropped items |
AllowOwnershipTransferOnScopeLeaveTag | When the entity's owner leaves its area or cell, hands it to another player there, or to the server if nobody is left. | NPCs, containers, flora, furniture, dropped items |
KeepOwnerOnScopeLeaveTag | The owner keeps the entity after leaving its area or cell, and no player arriving takes it while the owner is connected. It wins over the two tags above. Use it for something that belongs to one player, such as a companion. | Summons. NPCs get it while they are in combat, so the player fighting them keeps them. |
An entity a player owns is deleted when that player leaves its area or cell, unless it carries
AllowOwnershipTransferOnScopeLeaveTag or KeepOwnerOnScopeLeaveTag. Entities the server owns, and entities in no
area or cell, are not affected.
A campfire the server places, run by whichever player is nearby, and still there when they leave:
[Archetype(replicated: true)]
[Tag(typeof(AllowOwnershipTransferOnScopeEnterTag))]
[Tag(typeof(AllowOwnershipTransferOnScopeLeaveTag))]
[Include(typeof(Transform))]
public readonly partial struct Campfire;
The other tags in the SDK are internal to the runtime and cannot be put on an archetype.
Not in 1.0 yet: checking or querying an entity by a tag. Tags are useful today for the SDK's own tags, which the runtime reads.
Finding an entity by a value
[Index] on one property lets you find the entity holding a given value, and keeps that lookup current when the value
is written. One property per shape may carry it.
[ArchetypeMixin]
public readonly partial struct Ticket
{
[Index]
public partial int Number { get; set; }
}
// Remove the entity holding ticket 42, if there is one.
if (entities.TryLookup(42, out Ticket ticket))
entities.Delete(ticket);
The core shapes are indexed already: Player by its PlayerId, Area by its AreaId, Cell by its FullCellId,
and MainCharacter by its PlayerId. That is how a server RPC handler finds the sender's player:
entities.TryLookup(context.Sender, out Player player). See Identity values.
Scope shapes
An archetype that declares IScope (from ReadyM.SDK.Archetypes) can hold other entities: those created in it are
removed with it, and a query can be narrowed to it. Such a shape converts to a scope on its own, so you pass the shape
itself wherever a scope is asked for, as in entities.Create<Tent>(camp). Scopes covers them.
[Archetype(replicated: true)]
public readonly partial struct Camp : IScope
{
public partial int Size { get; set; }
}
What every shape can do
On top of its fields, the SDK gives every shape a few members:
| Member | Archetype | Mixin | What it does |
|---|---|---|---|
IsValid | yes | IsValid() | Whether the entity still exists. On a mixin, and in generic code, it is the extension method IsValid(). |
Is<T>() | yes | Whether the entity also carries the shape T. | |
As<T>() | yes | The same entity as the shape T. Throws InvalidEntityException when it does not carry T. | |
TryAs<T>(out T) | yes | The same, returning false instead of throwing. | |
Keep() | yes | yes | The entity as a shape you can store. See below. |
Field | replicated only | replicated only | A nested class naming each field, such as Wallet.Field.Balance. |
if (npc.TryAs(out ServerSummon summon))
logger.LogInformation("Summoned by {Player}", summon.Requester);
Keep(). A foreach over a query may hand you a lightweight view of the entity that only works inside the
loop, depending on how the shape is stored. Keep() turns it into the shape, which you can put in a list, keep in a
field, or call TryAs on. On a shape you already hold, Keep() returns it unchanged, so writing Keep() is always
safe. Entities and queries has the details.
var summons = new List<ServerSummon>();
foreach (var npc in entities.Query<Npc>())
if (npc.Keep().TryAs(out ServerSummon summon))
summons.Add(summon);
Field. A replicated shape gets a nested Field class with one token per field (Wallet.Field.Balance) and
Field.All for every field at once. A token names a value without reading it. You pass one to
Override (see Replication and authority) and to the client's game-state mapping (see
Game state mapping). A shape that does not replicate has no Field class.
Related diagnostics
| Id | What it means | How to fix it |
|---|---|---|
READYM041 | The shape would expose one name twice: two includes, or an include and the shape itself, have a member of the same name. | Give one [Include] a prefix, as in [Include(typeof(Shield), "Shield")], or rename one of the members. |
READYM042 | [Include] names an archetype. [Include] is for mixins. | Build on it with [IncludeArchetype(typeof(X))]. |
READYM043 | [IncludeArchetype] names a mixin. | Include it with [Include(typeof(X))]. |
CS0579 | Two [IncludeArchetype] on one archetype. An archetype builds on one other at most. | Keep one, and include the other's data as mixins. |
CS9248 | A shape's partial property has no implementation. The SDK did not generate the shape: it is nested inside a class, or it has neither [Archetype] nor [ArchetypeMixin]. | Move the struct directly into a namespace and give it one of the two attributes. |
The errors about replicating a shape (READYM009, READYM014 and others) are listed on
Replication and authority.
Reference
- ArchetypeAttribute
- ArchetypeMixinAttribute
- IncludeAttribute
- IncludeArchetypeAttribute
- ExtendsAttribute
- TagAttribute
- IndexAttribute
- IScope
- ArchetypeExtensions
- IArchetype, IArchetypeMixin, IArchetypeQueryable, IIndexed<TKey>
- Value<TShape, TValue>, Collection<TShape, TAccess>, Shape<TShape>
- Player, Area, Cell, World
- MainCharacter, Npc, ServerSummon
- AllowOwnershipTransferOnScopeEnterTag, AllowOwnershipTransferOnScopeLeaveTag, KeepOwnerOnScopeLeaveTag