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 Class | Persistence | Use Case |
|---|---|---|
WorldObject3D | None (runtime only) | NPCs, monsters, projectiles, environment objects |
WorldObjectPrefab3D | Built-in via PrefabModel | Players, 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:
- 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. - 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
| Kind | Attribute | Property Type | FK Direction |
|---|---|---|---|
| Root | [PrefabComponentRoot] | Single IVaultModel | — |
| Collection | [PrefabComponentRef] | List<T> | FK on dependent → root's StorageId |
| Single | [PrefabComponentRef] | Single IVaultModel | FK on root → dependent's PK |
Understanding PrefabComponentRef
The [PrefabComponentRef] attribute defines how a component relates to the root. It has three properties:
| Property | Required | Description |
|---|---|---|
principal | Yes | The name of the root component property. Must always point to the [PrefabComponentRoot] property (e.g., nameof(Character)). |
foreignKey | Yes | For 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). |
PrincipalKey | No | Only 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
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.