Files
dusk/docs/SCRIPTING.md
T
2026-07-12 10:10:05 -05:00

15 KiB
Raw Blame History

Scripting

Dusk embeds JerryScript to drive gameplay logic from JavaScript. The engine itself (rendering, physics, asset loading, entity storage) is all C; scripts sit on top and manipulate that state through a small set of bound objects.

This document covers the JS-facing scripting API. UI (buttons, sliders, menus, etc.) is not exposed to scripts — it's a separate, C-only API. See UI.md if you're building screens/menus from engine/game C code.

Lifecycle

On startup, engineInit() loads and evaluates assets/engine.js as the main script, then calls the global init() function if one is defined. From then on, every engine tick calls (in order):

  1. fixedUpdate() — once per fixed timestep. Use this for gameplay logic that must be deterministic and independent of display refresh rate (movement, physics-adjacent input handling, etc). Skipped on interpolation/dynamic frames when the build has variable-timestep rendering enabled.
  2. update() — once per rendered frame, including interpolation frames. Use this for smooth, purely presentational animation (nothing that needs to be deterministic).

On shutdown, deinit() is called once.

All four hooks (init, update, fixedUpdate, deinit) are optional — if a script doesn't define one, the engine simply skips it, no error.

function update() {
  cubePosition.rotation.y += TIME.delta * 1.5;
}

async init() and include()

init (or any of the other hooks) can be declared async and use await freely, including awaiting include() (see below), even though the engine calls these functions synchronously from C with no external JS event loop. When a hook returns a pending Promise, the engine keeps driving the asset system and JerryScript's job queue until that promise settles before continuing — so by the time e.g. init() "returns" from the engine's point of view, everything it awaited has actually finished.

async function init() {
  Actions = await include("input.js");
  // ...
}

If the awaited work throws/rejects, it surfaces as a C-level error.

Loading other scripts: include(path)

var Actions = await include("input.js");

include(path) always returns a Promise. The named file is loaded and evaluated once no matter how many times (or from how many different scripts) you include() it — later calls for the same path are handed the same in-flight/resolved promise rather than re-running the file.

The included script communicates its result back by assigning to the bare global module:

// input.js
Input.bind("w", INPUT_ACTION_UP);
// ...

module = {
  UP: INPUT_ACTION_UP,
  DOWN: INPUT_ACTION_DOWN,
  // ...
};

Whatever input.js assigns to module becomes the resolved value of the promise include("input.js") returned — that's what Actions ends up being in the example above. If the included script throws, the promise rejects instead.

Full example

This is the actual shipped example content (assets/engine.js + assets/input.js):

// input.js — binds physical buttons to abstract actions, then exports the
// action constants so other scripts don't need to know raw INPUT_ACTION_* names.
Input.bind("w", INPUT_ACTION_UP);
Input.bind("s", INPUT_ACTION_DOWN);
Input.bind("a", INPUT_ACTION_LEFT);
Input.bind("d", INPUT_ACTION_RIGHT);
Input.bind("space", INPUT_ACTION_ACCEPT);
Input.bind("escape", INPUT_ACTION_RAGEQUIT);

if(typeof INPUT_GAMEPAD !== "undefined") {
  Input.bind("gamepad_up", INPUT_ACTION_UP);
  Input.bind("gamepad_down", INPUT_ACTION_DOWN);
  Input.bind("gamepad_left", INPUT_ACTION_LEFT);
  Input.bind("gamepad_right", INPUT_ACTION_RIGHT);
  Input.bind("gamepad_a", INPUT_ACTION_ACCEPT);
  Input.bind("gamepad_start", INPUT_ACTION_RAGEQUIT);
}

module = {
  UP: INPUT_ACTION_UP,
  DOWN: INPUT_ACTION_DOWN,
  LEFT: INPUT_ACTION_LEFT,
  RIGHT: INPUT_ACTION_RIGHT,
  ACCEPT: INPUT_ACTION_ACCEPT,
  CANCEL: INPUT_ACTION_CANCEL,
  RAGEQUIT: INPUT_ACTION_RAGEQUIT
};
// engine.js
var Actions;
var camera, cameraPosition;
var cube, cubePosition, cubeRenderable, cubeMesh;

async function init() {
  Actions = await include("input.js");

  camera = new Entity();
  cameraPosition = camera.add(POSITION);
  camera.add(CAMERA);
  cameraPosition.position = new Vec3(3, 3, -6);
  cameraPosition.lookAt(new Vec3(0, 0, 0));

  cube = new Entity();
  cubePosition = cube.add(POSITION);
  cubeRenderable = cube.add(RENDERABLE);
  cubeMesh = Mesh.createCube();
  cubeRenderable.mesh = cubeMesh;
  cubeRenderable.color = Color.red();
}

function update() {
  cubePosition.rotation.y += TIME.delta * 1.5;
  cubePosition.rotation.x += TIME.delta * 0.7;
}

function fixedUpdate() {
  var move = 3.0 * TIME.delta;
  if(Input.isDown(Actions.LEFT))  cubePosition.position.x -= move;
  if(Input.isDown(Actions.RIGHT)) cubePosition.position.x += move;
  if(Input.isDown(Actions.UP))    cubePosition.position.z += move;
  if(Input.isDown(Actions.DOWN))  cubePosition.position.z -= move;
  if(Input.pressed(Actions.ACCEPT)) cubePosition.position = new Vec3(0, 0, 0);
}

function deinit() {
  cube.dispose();
  camera.dispose();
}

API reference

TIME

Plain global object, live getters (read fresh engine state every access, not snapshotted):

Property Type Description
TIME.delta number Seconds since the last frame.
TIME.time number Total elapsed engine time, in seconds.

PLATFORM

A single global string constant — the compile-time target name, e.g. "linux", "psp", "vita", "dolphin". Individual platform builds may inject additional platform-specific globals via their own modulePlatformPlatform() hook; those aren't documented here since they vary per target.

Input

Static namespace (not constructible — there's no new Input()).

Method Description
Input.bind(buttonName, action) Binds a physical button/key (string, e.g. "w", "space", "gamepad_up") to an abstract INPUT_ACTION_* constant. Many buttons can bind to the same action. Throws on an empty/unrecognized button name or invalid action.
Input.isDown(action) → boolean Is the action currently held.
Input.pressed(action) → boolean Action transitioned to down this frame.
Input.released(action) → boolean Action transitioned to up this frame.
Input.getValue(action) → number Current analog value for the action.
Input.axis(negAction, posAction) → number Combined axis value from two opposing actions.
Input.axis2D(negX, posX, negY, posY)Vec2 Combined 2D axis from four actions.

Global INPUT_ACTION_* constants (names are stable API; treat the numeric values as opaque/build-specific): INPUT_ACTION_UP, INPUT_ACTION_DOWN, INPUT_ACTION_LEFT, INPUT_ACTION_RIGHT, INPUT_ACTION_ACCEPT, INPUT_ACTION_CANCEL, INPUT_ACTION_PAUSE, INPUT_ACTION_RAGEQUIT, INPUT_ACTION_CONSOLE, INPUT_ACTION_POINTERX, INPUT_ACTION_POINTERY.

Conditionally-defined boolean globals reflecting build capability — only present at all if the corresponding input method is compiled in, so feature-test with typeof, don't assume they exist: INPUT_KEYBOARD, INPUT_GAMEPAD, INPUT_POINTER, INPUT_TOUCH.

Vec2 / Vec3 / Vec4

new Vec2(x?, y?), new Vec3(x?, y?, z?), new Vec4(x?, y?, z?, w?) — all components optional, default 0.

Common instance surface across all three: .dot(other), .length(), .lengthSq(), .normalize(), .negate(), .add(other), .sub(other), .scale(n), .lerp(other, t) — each of add/sub/scale/negate/ normalize/lerp returns a new vector (non-mutating). Vec3 additionally has .cross(other) and .distance(other); Vec2 has .distance(other) too; Vec4 has neither .cross() nor .distance().

Vec4 also has UV aliases over the same four floats: .u0 (= .x), .v0 (= .y), .u1 (= .z), .v1 (= .w) — handy for texture-rect style code.

All three have .x/.y(/.z/.w) get/set properties and a .toString() like "Vec3(1, 2, 3)".

"Vec3Ref" — live references. Several engine properties (entity position/rotation/scale, physics velocity, a mesh vertex's position) return a vector-like object instead of a plain Vec3. It has the identical .x/.y/.z surface, but reads/writes go straight into the underlying native buffer — writing .x on entity.position.position immediately moves the entity, no separate assignment needed. Anywhere the API expects a Vec3 argument, a Vec3Ref works too. You never construct one directly; you only ever receive them from properties like the ones above.

Mat4

new Mat4() — always constructs identity; no other constructor form.

Member Description
.mul(other)Mat4 this * other.
.transpose()Mat4
.inverse()Mat4
.determinant() → number
.mulVec3(vec3, w?)Vec3 w defaults to 1.0 (point); pass 0 for a direction.
.mulVec4(vec4)Vec4
.translate(vec3)Mat4 Non-mutating — returns a translated copy.
.scale(vec3)Mat4 Non-mutating — returns a scaled copy.
Mat4.identity()Mat4 Static.
Mat4.perspective(fov, aspect, near, far)Mat4 Static, all 4 args required.
Mat4.lookAt(eye, center, up)Mat4 Static, all 3 args required Vec3s.

Color

new Color(r?, g?, b?, a?) — each an int 0..255, default 255 (so new Color() is opaque white). Properties .r/.g/.b/.a get/set.

Named factories, each a zero-arg static returning a new opaque Color (alpha 255 unless noted): Color.black(), Color.white(), Color.red(), Color.green(), Color.blue(), Color.yellow(), Color.cyan(), Color.magenta(), Color.transparent() (alpha 0), Color.transparent_white() (alpha 0), Color.transparent_black() (alpha 0), Color.gray(), Color.light_gray(), Color.dark_gray(), Color.orange(), Color.purple(), Color.brown(), Color.pink(), Color.lime(), Color.navy(), Color.teal(), Color.cornflower_blue().

Color.rainbow(t?, speed?)Colort defaults to TIME.time * 4.0; produces a shifting rainbow color, useful for debug visuals.

Mesh

new Mesh(vertexCount) — allocates an uninitialized CPU-side vertex buffer (not yet uploaded to the GPU).

Member Description
.vertices Array of vertex wrappers, each with a .position (Vec3Ref, writes straight into that vertex).
.vertexCount Read-only.
.flush() Uploads to the GPU. First call initializes the GPU mesh; later calls re-upload the current vertex data — call this after editing .vertices[i].position.
.dispose() Frees GPU + CPU resources.

Static engine-owned singletons (read-only, not something you dispose): Mesh.DEFAULT_CUBE, Mesh.DEFAULT_QUAD, Mesh.DEFAULT_SPHERE, Mesh.DEFAULT_PLANE, Mesh.DEFAULT_CAPSULE, Mesh.DEFAULT_TRIPRISM.

Static factories (each builds and uploads a brand-new Mesh):

Factory Notes
Mesh.createCube(min?, max?) Both Vec3, default (-0.5,-0.5,-0.5)..(0.5,0.5,0.5).
Mesh.createQuad(minX?, minY?, maxX?, maxY?) Default -0.5..0.5 both axes; UV fixed 0,01,1.
Mesh.createSphere(radius?, stacks?, sectors?) radius default 0.5.
Mesh.createPlane(width?, height?) Defaults 1.0/1.0; XZ-aligned, centered at origin.
Mesh.createCapsule(radius?, halfHeight?, capRings?, sectors?) Defaults 0.5, 0.5.
Mesh.createTriPrism(x0, y0, x1, y1, x2, y2, minZ, maxZ) All 8 args required — a triangular cross-section extruded along Z.

Entity and components

var e = new Entity();
var pos = e.add(POSITION);

new Entity() allocates an entity. .id is the read-only numeric engine ID. .add(TYPE) adds a component and returns its wrapper (TYPE is one of the constants below). .dispose() removes the entity and all its components.

Component-type constants: POSITION, CAMERA, RENDERABLE, PHYSICS, TRIGGER. Each entity also exposes a lowercase getter that returns the existing wrapper if the component is present, or undefined if not (it does not add the component — use .add() for that): entity.position, entity.camera, entity.renderable, entity.physics, entity.trigger.

entity.add(POSITION) → position component

Member Description
.position Vec3Ref. Writing rebuilds the transform automatically.
.rotation Vec3Ref, Euler angles. Same rebuild-on-write behavior.
.scale Vec3Ref. Same rebuild-on-write behavior.
.parent Get/set another position-component wrapper, or null to clear parenting.
.lookAt(target, up?) target a Vec3; up defaults to (0,1,0).

entity.add(CAMERA) → camera component

Member Description
.zNear / .zFar Numbers.
.fov Only meaningful when projectionType is CAMERA_TYPE_PERSPECTIVE; otherwise get returns undefined and set is a no-op.
.projectionType CAMERA_TYPE_PERSPECTIVE or CAMERA_TYPE_ORTHOGRAPHIC.
.orthoTop / .orthoBottom / .orthoLeft / .orthoRight Only meaningful in orthographic mode, same undefined/no-op rule otherwise.

entity.add(RENDERABLE) → renderable component

Member Description
.type ENTITY_RENDERABLE_TYPE_MATERIAL, _SPRITEBATCH, or _CALLBACK.
.mesh Get/set a Mesh instance or a Mesh.DEFAULT_* singleton.
.color Get/set a Color instance (throws if given something else).
.addSprite({ min?, max?, uvMin?, uvMax? }) Adds a sprite to this renderable's sprite batch; all fields optional, default zero.
.clearSprites() Clears the sprite batch.
.setCallback(fn?) Switches to ENTITY_RENDERABLE_TYPE_CALLBACK and calls fn() on every render of this entity. Omit/pass non-function to clear. Exceptions inside fn surface as a C error.

entity.add(PHYSICS) → physics component

Member Description
.velocity Vec3Ref, plain (no rebuild-on-write).
.onGround Read-only boolean.
.bodyType PHYSICS_BODY_STATIC, PHYSICS_BODY_DYNAMIC, PHYSICS_BODY_KINEMATIC.
.applyImpulse(vec3) Adds to velocity. No-op on static bodies.
.setShapeCube(halfExtents) halfExtents a Vec3.
.setShapeSphere(radius) Number.
.setShapeCapsule(radius, halfHeight) Two numbers.
.setShapePlane(normal, distance) Vec3 + number.

Shape-type constants (for reading .type on the underlying shape, not for .bodyType): PHYSICS_SHAPE_CUBE, PHYSICS_SHAPE_SPHERE, PHYSICS_SHAPE_CAPSULE, PHYSICS_SHAPE_PLANE.

entity.add(TRIGGER) → trigger component

Member Description
.min / .max Plain Vec3 values (copies, not live refs).
.setBounds(min, max) Sets both at once.
.contains(point) → boolean point a Vec3.

Not yet available to scripts

The following C modules exist and are fully implemented, but aren't currently wired into script registration (moduleRegister() in src/dusk/script/module/module.h), so none of these globals exist in a script today: Screen, SpriteBatch, Text, Scene, Easing, Console, Engine. If you need one of these from a script, it needs to be registered in moduleRegister() first — see the existing entries there and the modules under src/dusk/script/module/ for the pattern to follow.