Skip to content

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.

typescript
import { buildStandardMeshSource, defineEntity } from '@skewedaspect/sage';
import type { GameEntityDefinition, MeshConfig } from '@skewedaspect/sage';

Entity definitions

typescript
interface GameEntityDefinition<Behaviors extends readonly BehaviorClass[]>
{
    type : string;
    tags ?: string[];
    defaultState ?: Partial<StateFor<Behaviors>>;
    behaviors : Behaviors;
    mesh ?: MeshConfig;
    actions ?: readonly Action[];
}
FieldMeaning
typeThe class-table key the definition registers under
tagsThe tags every instance starts with
defaultStateStarting state beyond what the behaviors already default, layered under the state a spawn supplies
behaviorsThe behavior classes in attachment order, exactly what a hand-written class's tuple names
meshThe mesh configuration for the standard mesh source
actionsThe 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:

typescript
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

typescript
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;
}
FieldMeaning
sourceThe 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
paramsPassed through unchanged as the primitive's options. Meaningless for a glb source.
materialBuilt 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.
visibleThe 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:

typescript
export class TorchEntity extends NodeEntity
{
    static override readonly behaviors = behaviors;
    static override readonly meshConfig : MeshConfig = { source: 'models/props.glb#torch' };
}

The standard mesh source

typescript
function buildStandardMeshSource(
    entityClasses : Readonly<Record<string, GameEntityType>>,
    assets : MeshAssetSource
) : EntityMeshLoader

Builds 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:

typescript
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

typescript
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.

MarkerValueMeaning
spawnA spawn point nameThe node is a place. It never becomes part of any entity and is disposed once whatever spawns there has its own node.
entityAn entity marker nameThe 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.

typescript
interface SpawnDefinition
{
    entity : string;
    name ?: string;
    tags ?: string[];
    config ?: Record<string, unknown>;
}

interface EntityDefinition
{
    entity : string;
    name ?: string;
    tags ?: string[];
    config ?: Record<string, unknown>;
}
FieldMeaning
entityThe class-table name to spawn
nameA per-instance label, written into displayName
tagsThe tags the spawned entity starts with
configPer-spawn state, layered over the class's defaults
yaml
name: arena
scene: /assets/arena.glb

spawns:
    player_start:
        entity: PlayerEntity
        name: Hero
        config:
            health: 100

entities:
    door:
        entity: DoorEntity
        tags: [ interactive ]
        config:
            locked: true

A 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:

typescript
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.

Released under the MIT License.