Authoring
Two ways authored content becomes entities. An entity definition is a plain object describing an entity class without writing one, registering into the entity registry 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
import type { Action, BehaviorClass, StateFor } from '@skewedaspect/sage';
import type { MeshConfig } from '@skewedaspect/sage';
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 registry 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 an entity registry beside any hand-written one:
import { HavokPhysicsBodyBehavior, NamedBehavior, defineEntity } from '@skewedaspect/sage';
export const CrateEntity = defineEntity({
type: 'CrateEntity',
tags: [ 'prop', 'pushable' ],
defaultState: { mass: 20 },
behaviors: [ HavokPhysicsBodyBehavior, 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 entity registry's point of view it is indistinguishable from a hand-written class.
That return type has a name, GameEntityDefinitionClass<Behaviors>: NodeEntity's statics, the definition's own behaviors tuple, and instances typed by that tuple. A game that keeps its definitions behind a helper of its own writes it down.
import { NamedBehavior, defineEntity } from '@skewedaspect/sage';
import type { GameEntityDefinitionClass } from '@skewedaspect/sage';
const behaviors = [ NamedBehavior ] as const;
export function definePickup(type : string) : GameEntityDefinitionClass<typeof behaviors>
{
return defineEntity({ type, behaviors, mesh: { source: 'box', params: { size: 0.5 } } });
}Its construct signature is abstract, because the constructor is protected: a game extends the class or hands it to an entity registry, and never constructs one directly.
Meshes
MeshConfig
import type { ColorConfig } from '@skewedaspect/sage';
interface MeshConfig
{
source : string;
params ?: Record<string, unknown>;
material ?: MeshMaterialConfig;
visible ?: boolean;
}
interface StandardMaterialConfig
{
type ?: 'standard';
color ?: ColorConfig;
emissive ?: ColorConfig;
metallic ?: never;
roughness ?: never;
}
interface PBRMaterialConfig
{
type : 'pbr';
color ?: ColorConfig;
emissive ?: ColorConfig;
metallic ?: number;
roughness ?: number;
}
type MeshMaterialConfig = StandardMaterialConfig | PBRMaterialConfig;| 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. A bare glb path with no fragment is refused, and a fragment brings that one mesh without its children. See The standard mesh source. |
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, and the two branches of the union enforce that. Naming metallic or roughness on a material that says type: 'standard', or leaves type out, is a compile error at the definition, rather than a value the standard mesh source reads and drops.
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
import type { GameEntityType, MeshAssetSource, MeshLoader } from '@skewedaspect/sage';
declare function buildStandardMeshSource(
entityClasses : Readonly<Record<string, GameEntityType>>,
assets : MeshAssetSource
) : MeshLoader;Builds a mesh source from an entity registry. It reads meshConfig off whichever class the registry holds under the name it is asked about, and answers with nothing for a name that carries no configuration. AssetManager satisfies MeshAssetSource.
It reads a source two ways and no others. A string carrying a # is a glb path followed by the name of one mesh inside that file, loaded through the asset manager and cloned for this spawn. A string carrying no # is a primitive name, and it has to be one Babylon's MeshBuilder has a Create* for. Every other string is refused, naming the shape it looked for:
No Babylon mesh builder provides the primitive shape "models/crate.glb".That includes a bare glb path with no fragment. The standard source answers with one mesh for one entity, and a whole file has no single mesh to be.
A fragment's copy is built into the scene the source was handed, which is the level's, so the mesh draws where the entity stands. clone(path, into) on the asset manager is what builds it.
A fragment brings one mesh, not a hierarchy
The copy is the named mesh alone. A mesh parented under it inside the glb does not come with it, because Babylon cannot copy a hierarchy between scenes. models/room.glb#__root__ is therefore not a way to load a whole file: the node the glTF loader parents an import under has no geometry of its own, so the copy is an empty node. A model made of several meshes needs a MeshLoader of its own that imports into the scene it is handed. The copy also carries none of the source mesh's metadata: a glb's authored properties (collider, an entity marker) are read by the level walk off the level's own scene, never off a spawned mesh. A loader of your own that hands an entity a mesh still carrying them gets a warning in the log, naming the entity class, the node, and the properties, once per entity class: nothing reads them there, so a model that needs a body composes HavokPhysicsBodyBehavior instead.
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 : MeshLoader = 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 });MeshLoader
import type { StateBag } from '@skewedaspect/sage';
import type { AbstractMesh, Scene } from '@babylonjs/core';
type MeshLoader = (
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 registry 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. The two are one type -- an entity marker's definition just adopts the marked node instead of building one.
import type { ColliderDebugConfig } from '@skewedaspect/sage';
interface SpawnDefinition
{
entity : string;
displayName ?: string;
tags ?: string[];
config ?: Record<string, unknown>;
debugCollider ?: ColliderDebugConfig;
}
type EntityDefinition = SpawnDefinition;| Field | Meaning |
|---|---|
entity | The registry name to spawn |
displayName | A per-instance label, written into the entity's displayName state field and never into its own name |
tags | The tags the spawned entity starts with |
config | Per-spawn state, layered over the class's defaults |
debugCollider | Written into the entity's own debugCollider state field |
name: arena
scene: /assets/arena.glb
spawns:
player_start:
entity: PlayerEntity
displayName: 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:
import { Behavior } from '@skewedaspect/sage';
import type { NodeBehaviorHooks, NodeEntity } from '@skewedaspect/sage';
export class ShadowCatcherBehavior extends Behavior<object, object, NodeEntity> implements NodeBehaviorHooks
{
readonly defaults = {};
onMeshLoaded() : void
{
for(const child of this.entity.node.getChildMeshes())
{
child.receiveShadows = true;
}
}
}Destroying an adopted entity never disposes or hides its node. The node is the level's content.
Position, rotation, and scale
An adopted entity's position and rotation are computed from its node, which is what they are for every NodeEntity (Entities). Each is a getter and a setter over the node's own position and rotationQuaternion: reading state.position asks the node where it is, and writing it moves the node. There is nothing kept in sync, because the field and the node are the same fact read two ways.
Both answer with the node's own transform, under whatever parent authoring gave it, which is what position and rotation already mean for an entity carrying a parentID.
What adoption changes is what construction writes. NodeEntity fills in the origin and the identity quaternion for a state that supplies neither, and writes the composed state onto a node it built or claimed from the pool. Neither reaches an adopted node; either write would drag it from where authoring put it to the origin. The node's transform is the value from the moment the entity adopts it, and only a position or a rotation the spawn state supplies is written through the setters over that. A level spawning from an entity marker supplies neither, so the node stays where authoring put it; a restore supplies the pair the snapshot holds, so the node goes there.
update() writes nothing onto any node, adopted or not, because what it would write is what the node already holds. A behavior that moves the node directly, a scripted door swinging open, is read through the fields, so a snapshot records where the node is and restoring that snapshot puts the node back.
A body built by HavokPhysicsBodyBehavior on an adopted entity computes both fields over the node's computation, and the body answers for as long as it stands. Disposing it hands nothing back to the node: destroy() takes both computations off before the behavior disposes the body. See Physics.
Scale is not state. update() never writes it, adopted or not, so a node's authored scale survives untouched either way.
Destroying an adopted entity takes the computations off and leaves what the node last answered with as plain values, so the state of a destroyed entity reads as data.
parentID still applies: an adopted entity that declares one is reparented under that entity's node, and one that declares none keeps whatever parent authoring gave it.
Restore
Restore runs the identical check, on the identical nodeName, now read from a snapshot. The level loads before buildFromSnapshot 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 registry key. A game naming one instance declares NamedBehavior, from @skewedaspect/sage-core, whose one state field is displayName. A SpawnDefinition.displayName or EntityDefinition.displayName writes it; writing it costs nothing on an entity with no NamedBehavior attached, since the state bag tolerates fields no attached behavior declares.
