Original actor design
This is specs/2026-10-23-actors.md, the original design. For the implemented API and limitations, use Actors and Premade actors.
Actors, components and premade actors
Date: 2026-10-23. Base: claude/roadmap-gameplay-lib-72dce0 (main 6f5c9352 + the gameplay library).
Why
Games on this engine are written by an agent. Today every template re-writes the same things: spawn-point tables (templates/fps/src/arena.ts:21, templates/third-person/src/arena.ts:22, with opposite yaw conventions), pickups (fps/systems/pickups.ts), interact and doors (third-person/src/systems/interact.ts, door.ts), kill planes and respawn (race/respawn.ts, action/systems/hero.ts). The engine will provide three layers:
- Actors: a thing placed or spawned in the world. One root transform, attached components, tags, props, owner/instigator, life span, and lifecycle hooks for startup, updates, shutdown, overlaps, hits and damage.
- Components: reusable parts an actor is built from: a scene node, a collision shape, a mesh, a character body, movement, sound, light, text.
- Premade actors: ready classes a level drops in: PlayerStart, TargetPoint, Trigger box/sphere/capsule, BlockingVolume, KillVolume, DamageVolume, PhysicsVolume, MeshActor, lights, AmbientSound, CameraActor, and from the templates Pickup, Interactable, Door, Spawner, CheckpointVolume, JumpPad, MovingPlatform, Projectile.
An agent then writes a level as data (which actors, where, with which props) and only writes code for what is new in its game.
Decisions
- An actor is an ECS entity. It has
Transform(world space, as today) plus anActormarker. Everything about the actor (class, props, tags, components, owner) lives in the actor runtime's typed tables, indexed by entity. No second object model; queries still work. - Components are entities too, attached to the actor root through a guest-side
Attachrelation (parent entity + local position/rotation/scale). An attach system runs after user systems and before packing and writes each attached entity's worldTransformfrom its parent. We do NOT use the host'sset-parent(commands.ts:752): the host would then read our world-space rows as local (wasm-host/src/engineAdapter.ts:1219). Children of an actor whose root is a dynamic body follow the body's rows. An attached component may carry a fixed, kinematic or sensor body (moved by teleport), never a dynamic one (v1). - Authoring shape is declarative: a class declares its default components, which one is root, what attaches to what, default props, tags, and the lifecycle hooks it overrides.
- Authority. Hooks run on the authority and in solo (the existing
authorityside,sdk/src/net/sidedSystems.ts).clientTickis the one client-side hook, for cosmetics. Transforms already replicate through the frame-output rows; nothing new on the wire in v1. - Determinism. Everything steps in the fixed step (
adr/0016), in actor spawn order. Spawns requested during a step are deferred: the actor exists at once but itsbeginPlayruns at the start of the next actor step. Destroys are deferred to the end of the actor step. - Saving. The actor runtime is a
GameplayPart(gameplay/steps.ts: reset/snapshot/restore) anddefineGamecomposes it into the game's snapshot automatically when actors are used. TheActormarker takes one of the 6 free built-in slots in the snapshot mask (save.ts:19). - Overlaps use the gameplay library's
createTriggers(guest-side point tests,gameplay/triggers.ts), extended if needed to boxes/spheres/capsules that follow their actor. What generates overlaps: every actor whose class setsoverlaps: true, plus every player entity. Hits come fromctx.contactsfor actors whose shapes block and report contacts. - Damage goes through
applyDamage(target, amount, { instigator, causer, type }), which calls the target class'sonDamageand, if the actor has health, the gameplaydamagepart.
Public API (task 1), exported from @gameable/sdk
Module: packages/sdk/src/actors/ (one file per concern, ~150 lines each, 300 max).
ts
import { defineActor, spawnActor, destroyActor, actors, attach, detach, applyDamage,
scene, box, sphere, capsule, mesh, characterBody } from '@gameable/sdk';
const Crate = defineActor('Crate', {
components: {
root: box([0.5, 0.5, 0.5], { collision: 'block', body: 'dynamic', mass: 2 }),
look: mesh('crate', { attach: 'root', offset: [0, 0, 0] }),
},
props: { hp: 3, loot: 'coin' }, // defaults; a spawn or level overrides any of them
tags: ['breakable'],
health: 3, // optional: gives the root a Health component
overlaps: false, // whether it generates overlap events on others
lifeSpan: 0, // seconds; 0 = forever
construct(self, ctx) {}, // after props are set, before beginPlay
beginPlay(self, ctx) {},
tick(self, ctx) {}, // every fixed step (uses ctx.dt)
clientTick(self, ctx) {}, // client only, cosmetic
endPlay(self, ctx, reason) {}, // 'destroyed' | 'lifeSpan' | 'killHeight' | 'levelUnload'
onBeginOverlap(self, other, ctx) {}, // other: ActorHandle | player entity handle
onEndOverlap(self, other, ctx) {},
onHit(self, other, ctx, contact) {},
onDamage(self, amount, info, ctx) {}, // info: { instigator, causer, type }
});
const crate = spawnActor(ctx, Crate, {
position: { x, y, z }, rotation?: Quat, yaw?: number /* degrees, 0 faces +Z */,
props?: Partial<Props>, tags?: string[], owner?: ActorHandle, instigator?: ActorHandle, name?: string,
});
crate.entity; crate.class; crate.name; crate.props; crate.alive;
crate.component('look'); // the component's entity
crate.hasTag('breakable'); crate.addTag(t); crate.removeTag(t);
crate.position; crate.setPosition(ctx, v); crate.rotation; crate.setRotation(ctx, q);
destroyActor(ctx, crate, reason?);
actors.all(); actors.ofClass(Crate); actors.withTag('breakable'); actors.named('gate1');
actors.of(entity); // the ActorHandle owning an entity (root or component), or null
attach(ctx, child, parent, { offset?, rotation?, scale? }); detach(ctx, child);
applyDamage(ctx, target, amount, { instigator?, causer?, type? });Rules every test checks:
defineActornames are unique (throws on a duplicate) and the class is findable by name (actorClass('Crate')), which the level loader (task 5) needs.- Exactly one root;
attachnames must exist; a cycle throws. - Hook order for one actor: construct → beginPlay (next actor step) → tick… → endPlay → gone. An actor spawned inside a hook does not tick in the same step.
destroyActordestroys every component entity and body, callsendPlayonce, and the handle'saliveturns false;actors.*no longer returns it.- Attached children's world transforms equal parent world × local after the attach system.
- Snapshot → restore reproduces the actor tables exactly (replay hash equal, test-harness).
Premade actors (tasks 2-4), packages/sdk/src/actors/premade/
Each is a defineActor call built only from the public API, so an agent can read it as an example. Props are plain data so a level file can set them.
| Group | Actor | Components | Behaviour |
|---|---|---|---|
| Markers (2) | PlayerStart | scene, arrow-only debug | Props tag, seat?. player.spawn: 'playerStart' in defineGame picks one (by tag, else round-robin by seat, skipping a start a player stands on). Also used for respawn. |
| Markers (2) | TargetPoint | scene | Marker only. Found by name/tag. |
| Markers (2) | LevelSettings | scene | One per level; nullable killHeight height in metres, null disables the global plane. |
| Volumes (2) | TriggerBox / TriggerSphere / TriggerCapsule | shape, overlap | No behaviour; a game reacts with onBeginOverlap or triggers.on(name, ...). |
| Volumes (2) | BlockingVolume | static box body | Invisible wall. |
| Volumes (2) | KillVolume | overlap box | Entering destroys the actor (endPlay 'killHeight') or respawns a player at a PlayerStart/checkpoint. Plus LevelSettings.killHeight height on a level actor. |
| Volumes (2) | DamageVolume | overlap box | damagePerSecond, interval, type; damages actors inside on the interval. |
| Volumes (2) | PhysicsVolume | overlap box | gravityScale, terminalVelocity, water (character moves slower, buoyant), priority. |
| Gameplay (3) | Pickup | sphere overlap + mesh | Props give (` |
| Gameplay (3) | Interactable | sphere range + mesh | Props prompt, range, facingDegrees, action (input action, default interact key), once. Shows the prompt on the HUD for the nearest one in reach; calls onInteract(self, who, ctx). |
| Gameplay (3) | Door | box block (kinematic) + mesh | Interactable-driven; openOffset, seconds, lockedBy (inventory item), autoClose. |
| Gameplay (3) | Spawner | scene | Uses gameplay waves; actorClass, points (TargetPoint tag) or its own transform, maxAlive. |
| Gameplay (3) | CheckpointVolume | overlap box | Sets the player's respawn point; order for races. |
| Gameplay (3) | JumpPad | overlap box | launch velocity applied to the character entering. |
| Gameplay (3) | MovingPlatform | kinematic box + mesh | InterpTo movement through points, pingPong/loop, speed. |
| Gameplay (3) | Projectile | sphere + mesh | Projectile movement (speed, gravityScale, bounces), damage on hit, life span. |
| Components (3) | rotatingMovement / waypointMovement / projectileMovement | – | Movement components any actor can add. |
| Presentation (4) | MeshActor | mesh (+ optional static body from the asset's manifest collider) | Prop. |
| Presentation (4) | PointLight / SpotLight / DirectionalLight | light component | Only if the host has a light command the guest can send; otherwise report what is missing. |
| Presentation (4) | AmbientSound | audio component | Looping positional sound through ctx.audio. |
| Presentation (4) | CameraActor | camera component | activate(ctx) makes it the view, like ACameraActor. |
Level files (task 5)
level.json, validated with a JSON schema in docs/schemas/ like the others:
json
{
"format": 1,
"actors": [
{ "class": "PlayerStart", "position": [0, 0, 0], "yaw": 0 },
{
"class": "Pickup",
"position": [3, 0.5, 2],
"props": { "give": { "heal": 25 }, "respawnSeconds": 10 }
},
{ "class": "Door", "name": "gate1", "position": [0, 0, 8], "props": { "lockedBy": "key" } }
]
}defineGame({ level }) or loadLevel(ctx, level) spawns them; unknown class names throw with the list of known ones. One template (third-person) is ported to actors to prove it, and its old interact/door/key code is deleted. Docs: a concept page, recipes ("place actors from a level file", "make your own actor", "add a pickup", "add a door that needs a key"), and an llms bundle section listing every premade actor with its props.
Tasks
- Core actor runtime and base components (scene, box, sphere, capsule, mesh, characterBody), attach, lifecycle, overlaps/hits, damage, snapshot. Everything else depends on it.
- Markers and volumes, PlayerStart wired into
defineGame'splayer.spawn,LevelSettings.killHeight. - Gameplay actors and movement components.
- Presentation actors.
- Level files, the third-person port, docs and the llms bundle.
2, 3 and 4 run in parallel on task 1's accepted branch; 5 runs last.
Deferred
Gameplay Ability System (separate project), replicated actor props beyond transforms, actor streaming / world partition, child actor components, splines, editor placement (the studio).
See Wheeled vehicles for vehicle placement, possession, handling and circuit respawn.