Authoring
Two ways authored content becomes entities. An entity definition is a plain object describing an entity class without writing one, registering into the class table the same way a hand-written class does. Scene-node adoption is how an entity, definition-built or hand-written, takes over a TransformNode a Blender scene already authored, instead of building its own.
import { buildStandardMeshSource, defineEntity } from '@skewedaspect/sage';
import type { GameEntityDefinition, MeshConfig } from '@skewedaspect/sage';Entity definitions
interface GameEntityDefinition<Behaviors extends readonly BehaviorClass[]>
{
type : string;
tags ?: string[];
defaultState ?: Partial<StateFor<Behaviors>>;
behaviors : Behaviors;
mesh ?: MeshConfig;
actions ?: readonly Action[];
}| Field | Meaning |
|---|---|
type | The class-table key the definition registers under |
tags | The tags every instance starts with |
defaultState | Starting state beyond what the behaviors already default, layered under the state a spawn supplies |
behaviors | The behavior classes in attachment order, exactly what a hand-written class's tuple names |
mesh | The mesh configuration for the standard mesh source |
actions | The input actions this definition's entities carry |
defineEntity
defineEntity turns a definition into a NodeEntity subclass, ready to sit in a class table beside any hand-written one:
export const CrateEntity = defineEntity({
type: 'CrateEntity',
tags: [ 'prop', 'pushable' ],
defaultState: { mass: 20 },
behaviors: [ PhysicsBodyBehavior, NamedBehavior ] as const,
mesh: {
source: 'box',
params: { size: 1 },
material: { type: 'pbr', color: { r: 0.6, g: 0.4, b: 0.2 }, metallic: 0.1, roughness: 0.8 },
},
});
const entityClasses = { CrateEntity };Composition compiles exactly as a hand-written class's tuple does: an unknown operation, an unmet requirement, a duplicate operation name, or an incompatible shared field is a compile error at the definition's own call site.
defaultState and tags apply only when the class builds a fresh entity, never on restore. A supplied field replaces the definition's default whole; tags merges as a deduplicated union of the definition's tags and the spawn's own. Restore takes a snapshot's state exactly as given.
The class defineEntity returns carries the definition's behaviors, mesh, and actions as its own statics, so from the class table's point of view it is indistinguishable from a hand-written class.
Meshes
MeshConfig
interface MeshConfig
{
source : string;
params ?: Record<string, unknown>;
material ?: MeshMaterialConfig;
visible ?: boolean;
}
interface MeshMaterialConfig
{
type ?: 'standard' | 'pbr';
color ?: ColorConfig;
emissive ?: ColorConfig;
metallic ?: number;
roughness ?: number;
}| Field | Meaning |
|---|---|
source | The name of a primitive shape Babylon's mesh builder provides (box, sphere, capsule, cylinder, ground, and the rest), or an asset path naming one mesh inside a glb with fragment syntax: models/props.glb#crate |
params | Passed through unchanged as the primitive's options. Meaningless for a glb source. |
material | Built and applied to the loaded mesh regardless of where it came from. A glb's own material is replaced. Left unset, the mesh's own material stands. |
visible | The loaded mesh's initial visibility, true unless set |
type defaults to standard. color and emissive apply to either type; metallic and roughness apply to pbr alone.
A glb source loads through the asset manager's reference-counted cache, so a hundred spawns of one definition load the container once, and each spawn gets a fresh clone.
meshConfig on a class
meshConfig is a NodeEntity static, alongside poolable and poolSize. defineEntity sets it from the definition's mesh; a hand-written class sets it directly:
export class TorchEntity extends NodeEntity
{
static override readonly behaviors = behaviors;
static override readonly meshConfig : MeshConfig = { source: 'models/props.glb#torch' };
}The standard mesh source
function buildStandardMeshSource(
entityClasses : Readonly<Record<string, GameEntityType>>,
assets : MeshAssetSource
) : EntityMeshLoaderBuilds a mesh source from a class table. It reads meshConfig off whichever class the table holds under the name it is asked about, and answers with nothing for a name that carries no configuration. AssetManager satisfies MeshAssetSource.
Its signature is the mesh source signature rebuildSimulation takes, so a level hands it there directly through the level config's meshSource, or composes it with a hand-rolled loader:
const standard = buildStandardMeshSource(entityClasses, engine.managers.assetManager);
const meshSource : EntityMeshLoader = async (scene, name, state) =>
{
const mesh = await standard(scene, name, state);
if(mesh !== undefined)
{
return mesh;
}
return loadHandRolledMesh(scene, name, state);
};
engine.managers.levelManager.registerLevelConfig({ name: 'arena', scene: 'levels/arena.glb', meshSource });EntityMeshLoader
type EntityMeshLoader = (
scene : Scene,
name : string,
state : Readonly<StateBag>
) => Promise<AbstractMesh | undefined>;The mesh source signature. It receives the scene the simulation was built for, the entity's class-table name, and its state, and answers with a mesh to parent under the entity's node, or nothing. A behavior that wants the mesh once it arrives implements onMeshLoaded.
Scene-node adoption
Markers
A Blender scene carries two kinds of marker as custom properties, exported into the glb's node metadata.
| Marker | Value | Meaning |
|---|---|---|
spawn | A spawn point name | The node is a place. It never becomes part of any entity and is disposed once whatever spawns there has its own node. |
entity | An entity marker name | The node is the entity's node, adopted rather than built |
Both name an entry in the level config: a SpawnDefinition under spawns for a spawn marker, an EntityDefinition under entities for an entity marker, one entry per distinct marker value however many nodes share it.
interface SpawnDefinition
{
entity : string;
name ?: string;
tags ?: string[];
config ?: Record<string, unknown>;
}
interface EntityDefinition
{
entity : string;
name ?: string;
tags ?: string[];
config ?: Record<string, unknown>;
}| Field | Meaning |
|---|---|
entity | The class-table name to spawn |
name | A per-instance label, written into displayName |
tags | The tags the spawned entity starts with |
config | Per-spawn state, layered over the class's defaults |
name: arena
scene: /assets/arena.glb
spawns:
player_start:
entity: PlayerEntity
name: Hero
config:
health: 100
entities:
door:
entity: DoorEntity
tags: [ interactive ]
config:
locked: trueA spawn point's position and rotation are written into the entity's state. Its scale is not carried.
Adopting a node
NodeEntityState carries nodeName, the exact name of the authored node an entity adopts. The level writes it when it first spawns an entity from an entity marker, and never again. Nothing else identifies which node an instance adopts.
NodeEntity's construction checks for nodeName before it checks whether its class is poolable and before it builds anything fresh. When present, it looks the name up in the scene and takes the node as its own: no pool taken from, no TransformNode built, no mesh loaded. Construction throws when nodeName names no node the scene holds.
An adopted entity's mesh stays undefined for its whole life. onMeshLoaded still runs, once, immediately at construction. A behavior that wants what the node carries reaches through node:
onMeshLoaded() : void
{
const entity = this.entity as NodeEntity;
for(const child of entity.node.getChildMeshes())
{
child.receiveShadows = true;
}
}An adopted entity's update() does not write state.position or state.rotation onto its node. The node's transform came from the level, in the level's own space, under whatever parent the authoring gave it. A behavior that moves an adopted prop moves the node directly. parentID still applies: an adopted entity that declares one is reparented under that entity's node.
Destroying an adopted entity never disposes or hides its node. The node is the level's content.
Restore
Restore runs the identical check, on the identical nodeName, now read from a snapshot. The level loads before fromSnapshot runs, so the node named in a restored entity's state already exists in the scene. A level loaded to restore a save spawns nothing from its markers.
Per-instance names
An entity's own name is the class-table key. A game naming one instance declares NamedBehavior, from @skewedaspect/sage-core, whose one state field is displayName. A SpawnDefinition.name or EntityDefinition.name writes displayName; writing it costs nothing on an entity with no NamedBehavior attached, since the state bag tolerates fields no attached behavior declares.
