Skip to content

Scripting

defineBehavior is the authoring sugar for the gameplay loop. One call gives you two things at once:

  1. A component that holds this behavior’s per-entity state — attachable to any entity and tunable in the editor’s Details panel.
  2. An auto-registered system that drives a start / update / destroy lifecycle for every entity carrying that component.

There is no second scripting runtime: a behavior desugars entirely onto Estella’s existing ECS — the same components, systems, and scheduling you already use — so it stays fast and fully inspectable.

defineBehavior(name, def) returns the backing component, so you can spawn or insert it like any other. The state object becomes the component’s per-entity data:

import { defineBehavior, Transform } from 'esengine';
export const Patrol = defineBehavior('Patrol', {
state: { speed: 60 }, // per-entity data, editable in the Details panel
update(ctx, dt) {
ctx.get(Transform).position.x += ctx.self.speed * dt;
},
});

That single call registers the update system. To put the behavior on an entity, insert its component — optionally overriding the starting state:

import { defineSystem, Commands, Transform } from 'esengine';
const spawn = defineSystem([Commands()], (cmds) => {
cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(Patrol, { speed: 120 }); // override the default per instance
});

Each hook is optional; define only the ones you need.

Hook When it runs
start(ctx) Once, the frame the behavior’s component first appears on an entity.
update(ctx, dt) Every frame, for each entity carrying the behavior. dt is the frame delta in seconds (same as ctx.time.delta).
destroy(ctx) Once when the component is removed or the entity is despawned.
export const Enemy = defineBehavior('Enemy', {
state: { hp: 100 },
start(ctx) {
// one-time setup: seed state, cache lookups, spawn a health bar…
},
update(ctx, dt) {
if (ctx.self.hp <= 0) ctx.commands.despawn(ctx.entity);
},
destroy(ctx) {
// teardown: runs whether the entity was killed or the component was detached
},
});

Every hook receives a BehaviorContext — the handle to the entity it runs for and to the world around it:

Member Type Description
ctx.self S This behavior’s own state. Mutate it freely — changes persist.
ctx.entity Entity The entity this instance is attached to.
ctx.time TimeData Frame timing: delta, elapsed, frameCount, fixedDelta.
ctx.input InputState Keyboard / mouse / touch / gamepad (see Input).
ctx.commands Commands Deferred spawn / despawn / insert — safe to call mid-update.
ctx.world World Full world access for cross-entity reads and writes.
ctx.get(Comp) ComponentData Read another component on this entity.
ctx.set(Comp, data) void Write a component on this entity.
ctx.has(Comp) boolean Whether this entity has Comp.
import { defineBehavior, Transform } from 'esengine';
export const PlayerController = defineBehavior('PlayerController', {
state: { speed: 200, facing: 1 },
update(ctx, dt) {
const move = (ctx.input.isKeyDown('KeyD') ? 1 : 0) - (ctx.input.isKeyDown('KeyA') ? 1 : 0);
if (move !== 0) ctx.self.facing = move; // mutating self persists
ctx.get(Transform).position.x += move * ctx.self.speed * dt;
},
});

The state object defines the component’s fields and their defaults. Pass metadata to control how those fields present in the editor — ranges, steps, units, enums — the same field metadata any component uses:

export const Turret = defineBehavior('Turret', {
state: { range: 300, fireRate: 2, target: 0 },
metadata: {
fields: {
range: { min: 0, unit: 'px', category: 'Targeting' },
fireRate: { min: 0, unit: '/s', category: 'Targeting', tooltip: 'Shots per second.' },
},
},
update(ctx, dt) { /* … */ },
});

Because the state is a real serialized component, per-entity overrides are saved with the scene and edited live in the Details panel.

By default the lifecycle system runs in Schedule.Update, once per frame. Pass a different schedule to run elsewhere in the frame — for example Schedule.FixedUpdate to advance in lockstep with physics:

import { defineBehavior, Schedule } from 'esengine';
export const Thruster = defineBehavior('Thruster', {
schedule: Schedule.FixedUpdate,
state: { force: 500 },
update(ctx) {
// fixed cadence — read ctx.time.fixedDelta for the fixed step
},
});

Available phases include Startup, PreUpdate, Update, PostUpdate, and the fixed trio FixedPreUpdate / FixedUpdate / FixedPostUpdate.

For “in 2 seconds…” / “every 0.5 s…” logic, the built-in timer resource beats hand-rolled elapsed-time bookkeeping. It ticks with the engine loop (so it pauses with the game, unlike setTimeout):

import { defineSystem, Res, TimerRes } from 'esengine';
const arm = defineSystem([Res(TimerRes)], (timers) => {
timers.delay(2, () => explode()); // once, in 2 s
const h = timers.interval(0.5, (t) => spawnWave()); // repeating
timers.interval(1, (t) => tick(), 3); // exactly 3 times
h.pause(); h.resume(); h.cancel(); h.reset(); // handle controls
});
Member Description
delay(seconds, cb) Run once after seconds; returns a TimerHandle.
interval(seconds, cb, maxRepeat?) Run every seconds; maxRepeat 0 = forever.
handle pause() / resume() / cancel() / reset() Control one timer (chainable).
handle isActive / elapsed / repeatCount Inspect it.
cancelAll() · activeCount Manage the whole set.
timeScale Slow / speed every timer at once (0 freezes).

Timers advance in play mode only — in the editor’s edit mode they hold, like the rest of game time.

For “just keep moving” motion — projectiles, drifting debris, spinners — attach the builtin Velocity component instead of writing a movement system. The engine integrates it into Transform every update in play mode:

import { Velocity } from 'esengine';
world.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(Sprite, { texture: bulletTex })
.insert(Velocity, {
linear: { x: 240, y: 0, z: 0 }, // units per second
angular: { x: 0, y: 0, z: Math.PI }, // radians per second (z = 2D spin)
});

Entities with a RigidBody are skipped — the physics solver owns their transform, so the two never fight over one entity. Velocity is also replication-aware: its fields are replicated, so networked clients can dead-reckon between snapshots.

A behavior is not a special case — it compiles to exactly one defineComponent plus one defineSystem. Reach for defineBehavior when your logic is naturally per-entity (a patrol, a projectile, a pickup); reach for a plain system when you’re processing a whole query at once or coordinating across entities.

The hook signature — entity, world, commands, plus self — is also the same programming model used by the Gameplay AI layer’s actions and behavior-tree leaves, so moving logic between a behavior and an AI action is straightforward.

While the editor plays, saving your code triggers a state-preserving hot swap when it can: if your components’ shapes are unchanged and no system was added, removed, or renamed, the live world is kept and only your function bodies are replaced. Otherwise the editor falls back to a full reload. What that asks of your code:

  • Component and behavior names are identity. Renaming one is a structural change — expect a full reload and fresh state for that data.
  • On any reload, per-entity start runs again for live entities — matching Estella’s fast-restart semantics, so treat start as idempotent setup.

The mechanism (probe context, schema fingerprint, App.hotSwapSystems) is documented in App Setup & Lifecycle.

  • Systems — the full query vocabulary, filters, and schedules.
  • ECS Architecture — entities, components, and how systems iterate them.
  • Input — read keyboard, pointer, and gestures from a system.