Skip to content

Actors and level files ​

Copy templates/blank, put placements in src/level.json, and register classes in src/game.ts. Use a premade actor before writing a new class. See place actors from a level file for a complete game definition and premade actors for exact defaults. Game code imports from gameable only.

ConceptGameable equivalentWhat to edit
ActordefineActor class; spawnActor returns an ECS-backed handlesrc/game.ts
Actor/Scene/Primitive Componentscene, box, sphere, capsule, mesh, characterBodyClass components
Place Actors / levelPremade classes and a format-1 level filesrc/level.json
PlayerStart / TargetPointPlayerStart / TargetPoint markersLevel entries
Construction / BeginPlay / Tick / EndPlayconstruct / beginPlay / tick / endPlayClass hooks

Place data, write only new behaviour ​

An actor is one root ECS entity plus component entities. Declare exactly one component named root; non-root components attach to it unless attach names another component. Use manifest ids for meshes, sounds and characters. A class name is unique in the guest registry. Level class names are case-sensitive; the TypeScript export PickupActor registers the level name Pickup.

props are JSON-safe defaults, overridden per instance. Store evolving custom state in self.state. Query with actors.named('gate'), actors.ofClass(Door), actors.withTag('enemy'), or actors.of(entity) (also resolves component ids). Use self.component('look') for a component's entity. Do not allocate objects or arrays in fixed-step hooks; initialise state and scratch in construction.

defineGame({ level }) validates and loads the level before init on solo and authority. Import JSON through validateLevel(data) to narrow format to 1. For runtime loading, loadLevel(ctx, level) returns { actors, named }. Several levels are defineGame({ levels, startLevel }) and ctx.levels.load, which unloads the loaded one (levelUnload) first; a level's place is the world it plays in. Every level actor has a name, the entry's or an automatic one (Door1, Door2, ...), and a source naming its level and entry: that is what an editor lists and writes back (applyLevelEdits, levelDocumentOf), so an actor spawned from code cannot be edited that way. Unknown classes, props, duplicate names and attachment cycles produce errors with JSON paths. The level schema checks the structural format; runtime validation also uses registered class defaults. Neither validates every class-specific nested reward or wave option.

Lifecycle and authority ​

On spawn, component entities and instance props are created, component create hooks run, then construct runs synchronously. On the next actor step:

  1. Pending actors receive beginPlay in spawn order.
  2. Each actor's component step hooks run before its tick.
  3. Attachments are composed, overlaps and reporting contacts are dispatched, and life spans expire.
  4. Queued destruction runs endPlay once, releases components and despawns their entities. Game systems then run; attachments are composed again before packing.

An actor spawned during a hook exists immediately but begins and ticks on the next actor step. Destruction during that step is deferred to its end; outside it, destruction completes immediately. endPlay reasons are destroyed, lifeSpan, the legacy kill-volume reason, and levelUnload. lifeSpan: 0 means forever.

Gameplay hooks and component hooks run in solo or on authority. clientTick is the client cosmetic hook. Transform rows replicate; arbitrary actor props, state and lifecycle do not become replicated gameplay automatically. Do not spawn a second level or award inventory on clients.

Attach things ​

Use component attach plus offset, rotation and scale for a class graph. Use attach(ctx, child, parent, { offset: [0, 1, 0] }) for separate live actors, and detach(ctx, child) to preserve the child's world transform. A level's attachTo references a named actor; its position and rotation become local to that parent. Quaternion [x,y,z,w] takes precedence over yaw. Level/spawn yaw is degrees about +Y, with zero facing +Z; SDK setYaw takes radians.

Attachments are guest-side world transforms. Attached shapes may be static, kinematic or sensors; a dynamic body cannot be attached. Destroying a parent removes its component entities but detaches surviving child actors. Move roots with self.setPosition(ctx, position) and SDK facing helpers such as setYaw and faceToward; do not write a body's Transform as a substitute for teleporting.

Overlaps, hits and damage ​

Overlap shapes test entity root points, not full collider intersections. Players are watched automatically; other actors must declare overlaps: true. Receive onBeginOverlap and onEndOverlap, or subscribe to a named TriggerBox, TriggerSphere or TriggerCapsule with onTrigger(ctx, name, { enter, exit }). The other participant is an actor handle or a numeric player/entity id.

For blocking contacts, set reportHits: true on the shape and implement onHit(self, other, ctx, contact). Overlaps need no Jolt contact report. applyDamage(ctx, target, amount, info) calls an actor's onDamage before reducing its optional Health. Reaching zero does not automatically destroy an actor: its game decides when to call destroyActor. Numeric targets use the damage service without an actor hook. info may supply instigator, causer and a game-defined type string.

The game framework ​

The game rules layer, on top of the bodies definition.player already spawns:

  • GameMode (one per game, authority only): logs each seat in, runs the match (waitingToStart → inProgress → waitingPostMatch), counts deaths, credits kills to the last applyDamage instigator's seat, and respawns after respawnDelay. Props: minPlayers, startDelay, matchTime, scoreToWin, killScore, respawnDelay, restartAfter.
  • PlayerState per seat: playerName, score, kills, deaths, team. GameState mirrors matchState, elapsed, remaining, winners.
  • PlayerController per seat possesses the seat's body. possess(ctx, controller, pawn) moves a player into a vehicle or a FlyingPawn (flying) / Spectator; inputFor(ctx, pawn) reads that seat's input.
  • AIController drives a pawn with moveTo, stopMovement, setFocus; it follows paths over the nav grid (see Navigation below).
  • cameraArm + cameraComponent put the player's view behind a pawn; setViewTarget(ctx, seat, actor) switches to any camera.

Rounds, phases and teams are props on the same GameMode: phases (ordered { name, duration, respawn? }, repeated each round; a null duration waits for endPhase(ctx)), rounds, roundsToWin, teams ({ name, maxSize }), autoAssign ('balanced' or 'none'), friendlyFire, allowLateJoin, lastTeamStanding and tieBreak ('draw' or 'suddenDeath'). With no phases the legacy flow above runs unchanged. GameState.rulesState is 'waiting', 'inProgress' or 'ended' for both flows. Hooks: onPhaseStarted, onPhaseEnded, onRoundStarted, onRoundEnded, onMatchEnded, each given { phase, round, winner } (winner is a team id or seat, null for a draw). Helpers: endPhase(ctx), endRound(ctx, winner?), setTeam, teamOf, sameTeam, teamScore. Team ids are one-based; 0 is unassigned.

Put the mode in the level ({ "class": "GameMode", "props": { "scoreToWin": 10 } }) and override only what changes: defineGameMode('Race', { readyToEndMatch(self, ctx) { … } }). Other hooks: onPostLogin, onLogout, onMatchStateChanged, onPlayerDied. The HUD stays the game's: ctx.hud.set({ ...mine, match: matchSummary() }).

Sound, physics, navigation, effects and world UI ​

Each wraps an engine system as a reusable component or actor:

  • Sound: audioComponent plays from its actor; AmbientSound loops; AudioVolume sets a bus effect while the player is inside.
  • Constraints: physicsJoint / PhysicsJoint hinge, slide or fix two named actors (actorA, actorB), with limits, springs and motors.
  • Navigation: a NavigationBounds builds the game's nav grid (a height-sampled grid from physics rays, not a polygon mesh), NavigationModifier blocks areas (areaClass: 'null') or makes them cost more (areaClass: 'costly' with cost), NavigationLink joins two points the grid cannot (a jump or drop; the AI fires onNavigationLinkReached), AIController.moveTo follows paths; runBehaviorTree drives an AI.
  • Perception: senses({ onSeePawn, onHearNoise }) sees and hears; reportNoise makes a sound an AI can hear.
  • Effects: particleEffect, ParticleEffect, spawnEmitterAtLocation; PostProcessVolume and HeightFog (need features: { vfx: true }).
  • World UI: worldWidget and worldText / WorldText anchor UI to an actor; the game shows them with ctx.ui.set([...myScreens, ...actorWidgets()]).

Stats, content and inventory ​

  • Ability system: abilities({ attributes: { health: { base: 100, min: 0, max: 100 } } }) gives an actor attributes, dot-separated tags, effects and abilities. Define effects and abilities once (defineEffect, defineAbility), then applyEffect(actor, id), grantAbility(actor, id), activateAbility(actor, id); read with getAttribute, hasTag, canActivate. Effects are 'instant', 'infinite' or last a number of seconds, may pulse every period, add/multiply/override attributes and grant tags.
  • Data tables: defineDataTable(name, { rows, schema? }) keeps content (items, waves, loot) out of systems. dataTable(name).get(key), .pickWeighted(rng, field), .curve(field, x); defineDataAsset holds one config; rowHandle(table, key) is JSON-safe for level files and saves.
  • Inventory: inventoryComponent({ slots, items, equipSlots }) gives an actor a bag; addItem, removeItem, moveItem, transferItem, equipItem, unequipItem, equipped.

Saving ​

Actor tables, props, JSON-safe self.state, ownership, attachments and pending lifecycle state join defineGame snapshots automatically. Classes must already be registered when restoring. Custom external bookkeeping can use class saveState and restoreState; the latter runs after all handles and ownership are restored. Do not put handles, callbacks or scratch buffers in self.state.

configureGameplayActors(ctx, { inventory }) in init shares one inventory between pickups and doors; owner indices are player seats (solo is seat 0). Its inventory, damage, scores and checkpoints are included by the actor runtime. Trigger subscription callbacks are code, not snapshot data; register them in init. Reload starts a new run; persistence across visits still needs the application's snapshot storage.

The original actor design is specs/2026-10-23-actors.md; use the current reference for implemented props and host limitations.