Prefabs

A Prefab in Altruist is a composite entity that bridges persistence and gameplay. It aggregates multiple vault models (database records) into a single in-memory object that can also live in the game world as a physics-enabled entity.

Two Kinds of World Objects

Altruist provides two base classes for 3D world entities:

Base ClassPersistenceUse Case
WorldObject3DNone (runtime only)NPCs, monsters, projectiles, environment objects
WorldObjectPrefab3DBuilt-in via PrefabModelPlayers, characters, anything saved to database

Both implement IWorldObject3D and support physics, transforms, and the Step() tick method.

WorldObject3D — Runtime-Only Entities

For entities that don't need database persistence:

[WorldObject("monster")]
public class MonsterEntity : WorldObject3D
{
    public uint Vnum { get; set; }
    public string Name { get; set; } = "";
    public int Hp { get; set; }
    public int MaxHp { get; set; }

    public MonsterEntity(Transform3D transform)
        : base(transform) { }

    public override void Step(float dt, IGameWorldManager3D world)
    {
        // AI logic, movement, etc.
    }
}

The [WorldObject] Attribute

[WorldObject("name")] does two things:

  1. Links to world JSON — When the "archetype" field in your world JSON matches the name (e.g., "monster"), the framework creates your class with the JSON's transform and colliders. See Worlds.
  2. Sets ObjectArchetype — Lets you filter objects by category (e.g., find all "monster" objects nearby).

Note:

Classes loaded from world JSON must have a parameterless constructor. If you spawn entities manually at runtime, this restriction doesn't apply.

WorldObjectPrefab3D — Persistent Entities

For entities that need to save/load from the database, use WorldObjectPrefab3D. This extends PrefabModel, giving you the full prefab persistence system.

Defining a Prefab

A prefab has:

  • A root component ([PrefabComponentRoot]) — the main vault model
  • Component references ([PrefabComponentRef]) — related vault models loaded via foreign keys
[Prefab("character")]
[WorldObject("character")]
public class CharacterPrefab : WorldObjectPrefab3D
{
    // Root component — the main database record
    [PrefabComponentRoot]
    public CharacterVault Character { get; set; } = default!;

    // Collection ref — items belonging to this character
    [PrefabComponentRef(
        principal: nameof(Character),
        foreignKey: nameof(ItemVault.CharacterId))]
    public List<ItemVault> Items { get; set; } = new();

    // Collection ref — equipped gear
    [PrefabComponentRef(
        principal: nameof(Character),
        foreignKey: nameof(EquipmentVault.CharacterId))]
    public List<EquipmentVault> Equipment { get; set; } = new();

    // Single ref — guild membership (FK on root pointing to dependent PK)
    [PrefabComponentRef(
        principal: nameof(Character),
        foreignKey: nameof(CharacterVault.GuildId),
        PrincipalKey = nameof(GuildVault.StorageId))]
    public GuildVault? Guild { get; set; }
}

Vault Models (Components)

Each component is a standard vault model:

[Vault("characters")]
public class CharacterVault : VaultModel
{
    [VaultColumn] public override string StorageId { get; set; } = "";
    [VaultColumn] public string Name { get; set; } = "";
    [VaultColumn] public int Level { get; set; } = 1;
    [VaultColumn] public float X { get; set; }
    [VaultColumn] public float Y { get; set; }
    [VaultColumn] public float Z { get; set; }
    [VaultColumn] public string? GuildId { get; set; }
}

[Vault("items")]
public class ItemVault : VaultModel
{
    [VaultColumn] public override string StorageId { get; set; } = "";
    [VaultColumn] public string CharacterId { get; set; } = "";
    [VaultColumn] public int ItemId { get; set; }
    [VaultColumn] public int Count { get; set; }
}

Component Kinds

KindAttributeProperty TypeFK Direction
Root[PrefabComponentRoot]Single IVaultModel
Collection[PrefabComponentRef]List<T>FK on dependent → root's StorageId
Single[PrefabComponentRef]Single IVaultModelFK on root → dependent's PK

Understanding PrefabComponentRef

The [PrefabComponentRef] attribute defines how a component relates to the root. It has three properties:

PropertyRequiredDescription
principalYesThe name of the root component property. Must always point to the [PrefabComponentRoot] property (e.g., nameof(Character)).
foreignKeyYesFor collections: The property on the dependent model that references the root's StorageId (e.g., nameof(ItemVault.CharacterId) — each item stores its owner's ID). For singles: The property on the root model that references the dependent's PK (e.g., nameof(CharacterVault.GuildId) — the character stores its guild's ID).
PrincipalKeyNoOnly used for single refs. The property on the dependent model to match against the FK. Defaults to StorageId.

Collection example — "load all items where ItemVault.CharacterId == Character.StorageId":

[PrefabComponentRef(
    principal: nameof(Character),                    // Root property
    foreignKey: nameof(ItemVault.CharacterId))]      // FK on dependent → root StorageId
public List<ItemVault> Items { get; set; } = new();

Single example — "load the guild where GuildVault.StorageId == Character.GuildId":

[PrefabComponentRef(
    principal: nameof(Character),                     // Root property
    foreignKey: nameof(CharacterVault.GuildId),       // FK on root → dependent PK
    PrincipalKey = nameof(GuildVault.StorageId))]     // Which dependent PK to match
public GuildVault? Guild { get; set; }

Note:

The principal must always be the root property name — nesting (refs that reference other refs) is not supported. All relationships are flat, from root to component.

Querying Prefabs

Use IPrefabs to query prefabs with eager loading:

[Service]
public class CharacterService
{
    private readonly IPrefabs _prefabs;

    public CharacterService(IPrefabs prefabs)
    {
        _prefabs = prefabs;
    }

    // Load a character with all components
    public async Task<CharacterPrefab?> LoadCharacter(string characterId)
    {
        return await _prefabs.Query<CharacterPrefab>()
            .Where(p => p.Character.StorageId == characterId)
            .IncludeAll()
            .FirstOrDefaultAsync();
    }

    // Load with specific components only
    public async Task<CharacterPrefab?> LoadCharacterLight(string characterId)
    {
        return await _prefabs.Query<CharacterPrefab>()
            .Where(p => p.Character.StorageId == characterId)
            .Include(p => p.Equipment)      // Only load equipment
            .FirstOrDefaultAsync();
    }

    // Find all characters matching a condition
    public async Task<List<CharacterPrefab>> FindByLevel(int minLevel)
    {
        return await _prefabs.Query<CharacterPrefab>()
            .Where(p => p.Character.Level >= minLevel)
            .IncludeAll()
            .ToListAsync();
    }
}

Note:

Use Include() to selectively load only the components you need. IncludeAll() loads everything — convenient but heavier. For performance-critical paths (e.g., leaderboards), load only the root.

Saving Prefabs

Prefabs have built-in save methods:

// Save all dirty components
await characterPrefab.SaveAsync();

// Save only a specific component (e.g., just the items)
await characterPrefab.SaveComponentAsync(nameof(CharacterPrefab.Items));

The system uses dirty tracking — only modified vault models are written to the database.

Note:

SaveComponentAsync is useful for high-frequency saves (e.g., saving inventory after every trade) without writing the entire prefab to the database.

Physics & the Step Loop

Every world object (prefab or not) participates in the game loop via the Step() method, called each engine tick by the GameWorldOrganizer3D.

The Tick Cycle

Loading diagram...

Note:

The organizer automatically removes objects with Expired = true before stepping them. Set this flag to have the framework clean up an entity on the next tick.

Writing Custom Step Logic

Override Step() on your world object to add per-tick behavior:

[WorldObject("projectile")]
public class Projectile : WorldObject3D
{
    public float Speed { get; set; } = 50f;
    public float Lifetime { get; set; } = 3f;
    private float _elapsed;

    public Projectile(Transform3D transform) : base(transform) { }

    public override void Step(float dt, IGameWorldManager3D world)
    {
        _elapsed += dt;
        if (_elapsed >= Lifetime)
        {
            Expired = true;  // Framework removes this next tick
            return;
        }

        // Move forward
        if (Body is IPhysxBody3D body)
        {
            var forward = Vector3.Transform(Vector3.UnitZ, body.Rotation);
            body.LinearVelocity = forward * Speed;
        }
    }
}

Note:

Keep Step() lightweight — it runs for every object every tick. Offload heavy work (pathfinding, database queries) to async services outside the game loop.

Prefab with Custom Physics Update

For persistent entities with physics, override Step() on WorldObjectPrefab3D and sync state back to vault for persistence:

[Prefab("character")]
[WorldObject("character")]
public class CharacterPrefab : WorldObjectPrefab3D
{
    [PrefabComponentRoot]
    public CharacterVault Character { get; set; } = default!;

    public IKinematicCharacterController3D Controller { get; private set; } = default!;

    public override void Step(float dt, IGameWorldManager3D world)
    {
        base.Step(dt, world);
        if (dt <= 0f) return;
        if (Body is not IPhysxBody3D body || Controller is null) return;

        // Run the character controller physics
        Controller.Step(dt, world);

        // Sync world object transform from physics body
        Transform = Transform
            .WithPosition(Position3D.From(body.Position))
            .WithRotation(Rotation3D.FromQuaternion(body.Rotation));

        // Persist position to vault for database save
        Character.X = Transform.Position.X;
        Character.Y = Transform.Position.Y;
        Character.Z = Transform.Position.Z;
    }
}

Note:

By syncing Character.X/Y/Z in Step(), the vault always has the latest position. When you call SaveAsync() (e.g., on disconnect or periodic autosave), the correct position is persisted.

Movement & Character Controller

For player-controlled movement, Altruist provides a built-in KinematicCharacterController3D with gravity, capsule sweep collision, ground detection, slope limits, and an extensible ability system.

See the full documentation: Movement & Character Controller

Full Example: Player Prefab Lifecycle

[Prefab("character")]
[WorldObject("character")]
public class PlayerPrefab : WorldObjectPrefab3D
{
    [PrefabComponentRoot]
    public CharacterVault Character { get; set; } = default!;

    [PrefabComponentRef(principal: nameof(Character), foreignKey: nameof(ItemVault.CharacterId))]
    public List<ItemVault> Items { get; set; } = new();

    public IKinematicCharacterController3D Controller { get; private set; } = default!;

    /// <summary>
    /// Called after loading from database, before entering the world.
    /// Sets up physics body and controller.
    /// </summary>
    public async Task InitializeRuntime(IGameWorldManager3D world)
    {
        // Build transform from persisted position
        Transform = Transform3D.From(
            new Vector3(Character.X, Character.Y, Character.Z),
            Quaternion.Identity,
            Vector3.One);

        // Physics body
        var profile = new HumanoidCapsuleBodyProfile(0.28f, 0.62f, 0f, isKinematic: true);
        BodyDescriptor = profile.CreateBody(Transform);
        ColliderDescriptors = profile.CreateColliders(Transform);

        // Spawn into world
        var body = await world.SpawnDynamicObject(this);

        // Controller
        Controller = new KinematicCharacterController3D
        {
            MoveSpeed = 5f,
            SprintSpeed = 8f,
        };
        Controller.AddAbility(new SimpleJumpAbility3D());
        Controller.SetBody(body);
    }

    public override void Step(float dt, IGameWorldManager3D world)
    {
        if (Body is not IPhysxBody3D body || Controller is null) return;

        Controller.Step(dt, world);

        // Sync transform
        Transform = Transform
            .WithPosition(Position3D.From(body.Position))
            .WithRotation(Rotation3D.FromQuaternion(body.Rotation));

        // Persist
        Character.X = Transform.Position.X;
        Character.Y = Transform.Position.Y;
        Character.Z = Transform.Position.Z;
    }
}

Loading and Spawning

// 1. Load from database
var prefab = await _prefabs.Query<PlayerPrefab>()
    .Where(p => p.Character.StorageId == characterId)
    .IncludeAll()
    .FirstOrDefaultAsync();

// 2. Set client ID for visibility tracking
prefab.ClientId = clientId;

// 3. Initialize physics and enter world
var world = _worldOrganizer.GetWorld(0);
await prefab.InitializeRuntime(world);

// 4. Refresh visibility so player sees nearby entities
_visibilityTracker.RefreshObserver(clientId);

Single world with multiple maps:

In a single-world setup, Character.X/Y/Z already contains the correct map-region coordinates. A player saved at (150200, 200300, 0) spawns into the forest region automatically — no map routing needed. See Worlds for more on this pattern.

Saving on Disconnect

public async Task OnPlayerDisconnect(string clientId)
{
    var prefab = GetPlayerPrefab(clientId);

    // Remove from visibility tracking
    _visibilityTracker.RemoveObserver(clientId);

    // Remove from world
    var world = _worldOrganizer.GetWorld(0);
    world.DestroyObject(prefab);

    // Persist all components to database
    await prefab.SaveAsync();
}

Note:

Always remove the observer and destroy the world object before saving. This ensures OnEntityInvisible fires for all nearby players (so their clients remove the disconnected player), and the physics body is cleaned up before the prefab is garbage collected.