Skip to content

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:

  1. 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.
  2. Components: reusable parts an actor is built from: a scene node, a collision shape, a mesh, a character body, movement, sound, light, text.
  3. 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 an Actor marker. 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 Attach relation (parent entity + local position/rotation/scale). An attach system runs after user systems and before packing and writes each attached entity's world Transform from its parent. We do NOT use the host's set-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 authority side, sdk/src/net/sidedSystems.ts). clientTick is 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 its beginPlay runs 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) and defineGame composes it into the game's snapshot automatically when actors are used. The Actor marker 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 sets overlaps: true, plus every player entity. Hits come from ctx.contacts for actors whose shapes block and report contacts.
  • Damage goes through applyDamage(target, amount, { instigator, causer, type }), which calls the target class's onDamage and, if the actor has health, the gameplay damage part.

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:

  • defineActor names 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; attach names 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.
  • destroyActor destroys every component entity and body, calls endPlay once, and the handle's alive turns 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.

GroupActorComponentsBehaviour
Markers (2)PlayerStartscene, arrow-only debugProps 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)TargetPointsceneMarker only. Found by name/tag.
Markers (2)LevelSettingssceneOne per level; nullable killHeight height in metres, null disables the global plane.
Volumes (2)TriggerBox / TriggerSphere / TriggerCapsuleshape, overlapNo behaviour; a game reacts with onBeginOverlap or triggers.on(name, ...).
Volumes (2)BlockingVolumestatic box bodyInvisible wall.
Volumes (2)KillVolumeoverlap boxEntering destroys the actor (endPlay 'killHeight') or respawns a player at a PlayerStart/checkpoint. Plus LevelSettings.killHeight height on a level actor.
Volumes (2)DamageVolumeoverlap boxdamagePerSecond, interval, type; damages actors inside on the interval.
Volumes (2)PhysicsVolumeoverlap boxgravityScale, terminalVelocity, water (character moves slower, buoyant), priority.
Gameplay (3)Pickupsphere overlap + meshProps give (`
Gameplay (3)Interactablesphere range + meshProps 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)Doorbox block (kinematic) + meshInteractable-driven; openOffset, seconds, lockedBy (inventory item), autoClose.
Gameplay (3)SpawnersceneUses gameplay waves; actorClass, points (TargetPoint tag) or its own transform, maxAlive.
Gameplay (3)CheckpointVolumeoverlap boxSets the player's respawn point; order for races.
Gameplay (3)JumpPadoverlap boxlaunch velocity applied to the character entering.
Gameplay (3)MovingPlatformkinematic box + meshInterpTo movement through points, pingPong/loop, speed.
Gameplay (3)Projectilesphere + meshProjectile movement (speed, gravityScale, bounces), damage on hit, life span.
Components (3)rotatingMovement / waypointMovement / projectileMovement–Movement components any actor can add.
Presentation (4)MeshActormesh (+ optional static body from the asset's manifest collider)Prop.
Presentation (4)PointLight / SpotLight / DirectionalLightlight componentOnly if the host has a light command the guest can send; otherwise report what is missing.
Presentation (4)AmbientSoundaudio componentLooping positional sound through ctx.audio.
Presentation (4)CameraActorcamera componentactivate(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 ​

  1. Core actor runtime and base components (scene, box, sphere, capsule, mesh, characterBody), attach, lifecycle, overlaps/hits, damage, snapshot. Everything else depends on it.
  2. Markers and volumes, PlayerStart wired into defineGame's player.spawn, LevelSettings.killHeight.
  3. Gameplay actors and movement components.
  4. Presentation actors.
  5. 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.