Scripting
defineBehavior is the authoring sugar for the gameplay loop. One call gives you two
things at once:
- A component that holds this behavior’s per-entity
state— attachable to any entity and tunable in the editor’s Details panel. - An auto-registered system that drives a
start/update/destroylifecycle 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.
Your first behavior
Section titled “Your first behavior”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) { const t = ctx.get(Transform); t.position.x += ctx.self.speed * dt; ctx.set(Transform, t); },});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});Where declarations live
Section titled “Where declarations live”The editor knows about your components without ever running your game: it evaluates
one entry module and reads back whatever declared itself. That entry is
src/components.ts by default — the manifest’s scripts.register, if you want it
somewhere else.
The rule is reachability, not location. defineBehavior and defineComponent
register when the call runs, so a module nobody imports never registers anything.
Spread declarations across as many files as you like and pull them in from the entry:
// src/components.ts — the editor's view of your projectexport * from './behaviors/patrol';export * from './components/health';A behavior the entry cannot reach still works at runtime, because the game starts
from src/main.ts — which is exactly what makes this confusing. What you lose is the
editor: no fields in the Details panel (the component round-trips but shows as
unknown), no entry under Scripts in the Create-entity popover, and no
registerAction / registerCondition names in the behavior-tree and event-binding
palettes.
Saving any file under src/ re-extracts, so new fields appear in the Details panel a
moment after you save. If a component never shows up, look at
.esengine/cache/schemas.json: absent there means it was never reached; present there
but missing in the inspector is a different problem.
The New Script dialog
Section titled “The New Script dialog”Because reachability is the rule, a hand-created .ts file is dead until an entry
imports it. Content Browser → create menu → New Script… writes both halves.
Pick what the module is, and the editor knows which entry to wire it into — the distinction is your project’s architecture, not a menu convenience:
| Kind | What it is | Where it lands |
|---|---|---|
| Component | a declaration the editor reads without running the game | re-exported from the declaration entry — export * from './Health'; |
| System | behavior the play realm bundles and runs | imported by the startup entry for its registration side effect — import './Patrol'; |
The dialog shows the module path it will write and the exact line the entry gains, before the file exists. A few things worth knowing:
- The name is asked for up front, not left to inline rename, because a script’s name is not only its file name — it is the exported identifier and the string every scene serializes. It must be a plain JS identifier.
- The entries come from the manifest (
scripts.register/scripts.main), so a project that moved them is wired at its own pair, not at the conventional one. - The file lands in the folder you’re browsing when that folder is inside the
source root, and in the source root otherwise — creating “here” under
assets/would hand back a module neither entry can reach, which is the failure this exists to prevent. - A component is in Add Component the moment the dialog closes — creating re-extracts the schemas rather than waiting on the file watcher. The new file is revealed in the Content Browser, not opened: the editor has no code editor of its own.
Using an npm package
Section titled “Using an npm package”Your scripts are bundled with esbuild, and it resolves dependencies out of the
project’s own node_modules — so a library is an ordinary install:
cd my-gamenpm install protobufjsimport { Writer, Reader } from 'protobufjs/minimal';
const packet = Writer.create().uint32(42).string('hello').finish();It ships with the game on every target — the web and desktop builds, the mini-game package (down-levelled to the syntax that host accepts), the native content payload, and the editor’s own Play realm. There is no separate step and no per-platform configuration: what you import is what gets bundled.
New projects come with a package.json. An older project made before that may
not have one — npm init -y once, and it works the same afterwards.
What cannot come along
Section titled “What cannot come along”Anything that needs Node. A game runs in a browser or a mini-game host,
neither of which has fs, crypto, path or the rest, so a package that
reaches for one fails the build rather than shipping broken:
Could not resolve "crypto" (imported by src/net/sign.ts). "crypto" is a Nodebuilt-in, and a game does not run in Node…If the import is yours, reach for a web API (crypto.subtle, fetch,
localStorage) or the engine’s own equivalent — Assets for
files, Save & Load for player data, both of which work
everywhere. If a dependency is the one reaching, the fix is on the package:
many publish a browser-safe entry point — protobufjs/minimal above is exactly
that — and some ship a browser field that esbuild picks up on its own.
And it costs package size. A dependency is bundled whole, which matters most where the limit is hardest: WeChat caps a mini-game’s main package at 4MB. After a build, the size report breaks the package down — a library that grew shows up under Scripts.
The lifecycle
Section titled “The lifecycle”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 },});The context
Section titled “The context”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 if (move !== 0) { const t = ctx.get(Transform); // a copy — Transform is C++-backed t.position.x += move * ctx.self.speed * dt; ctx.set(Transform, t); // …so hand it back } },});Editable state & metadata
Section titled “Editable state & metadata”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.
Choosing a schedule
Section titled “Choosing a schedule”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.
Timers
Section titled “Timers”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.
Constant motion: the Velocity component
Section titled “Constant motion: the Velocity component”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.
How it fits together
Section titled “How it fits together”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.
Hot reload
Section titled “Hot reload”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
startruns again for live entities — matching Estella’s fast-restart semantics, so treatstartas idempotent setup.
The mechanism (probe context, schema fingerprint, App.hotSwapSystems) is
documented in App Setup & Lifecycle.
See also
Section titled “See also”- 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.