Scripts
Back to the reference

Scripts

Attaching authored behaviour to an object, its declared settings, and what one script can expose to another.

10 symbols

providefunctionnacatamalon

provide(key: string, value: T) => void

Publishes something for the other behaviours on the same object to use, under key.

For behaviours put together through a saved scene, where the two files do not know about each other and cannot import anything. Scene code written by hand should just import.

The convention that makes this readable: the key is the name of whoever publishes it, so provide('spin', ...) is reached as useApi('spin') and named as requires: ['spin']. One word, three places.

Parameters

keystring

What to call it. The publisher's own name, by convention.

valueT

Anything: usually a small set of functions the others may call.

Example

registerScript('spin', (self) => {
    let running = true;
    provide('spin', {
        stop: () => { running = false; },
        start: () => { running = true; },
        isRunning: () => running,
    });
    useUpdate((delta) => { if (running) self.drawables[0].transform.rotation += delta; });
});
View source · hooks/script/use_api.ts:34

registerScriptfunctionnacatamalon

registerScript(name: string, fn: (self: TBox, props: TProps) => void, fields: TScriptField[] | TScriptOptions) => void

Registers a behaviour under name, so an object can attach it by that name: from scene code with useScript('chase'), or from a saved scene that names it.

Registering the same name again replaces it. Not a hook: call it at the top level of the file, next to where the behaviour is written.

The third argument declares the settings, which is what makes the behaviour configurable rather than fixed: an editor builds a form from that list and hands the values back as the second argument. Leave it out for a behaviour with nothing to tune. Declaring a setting is what makes it editable; a script may of course read keys it never declared, but nothing will ever offer to fill them.

It takes either the list of settings directly or an options object, for a script that also has something to say about when it runs. Both shapes exist because settings are what almost every script has and the rest is rare: making the ordinary case reach one level deeper to say the ordinary thing would be the worse door.

Parameters

namestring

What the behaviour is called. A saved scene stores this word.

fn(self: TBox, props: TProps) => void

The behaviour itself.

fieldsTScriptField[] | TScriptOptions

Its settings, or the full options object.

Example

registerScript('patrol', (self, props) => {
    const [sprite] = self.drawables;
    if (sprite === undefined) return;

    const start = sprite.transform.x;
    let dir = 1;
    useUpdate((delta) => {
        sprite.transform.x += Number(props.speed) * dir * delta;
        if (Math.abs(sprite.transform.x - start) > Number(props.range)) dir = -dir;
    });
}, [
    { key: 'speed', label: 'Speed', type: 'number', default: 40, min: 0 },
    { key: 'range', label: 'How far', type: 'number', default: 60, min: 0 },
]);
View source · scripts/script_registry.ts:74

useApifunctionhooknacatamalon

useApi(key: string, self?: TBox) => () => T | null

Reaches what another behaviour on the same object published under key.

It gives back a way to ask, not the answer, and that is the whole design. An object's behaviours all run in one pass, in the order they are listed, so a behaviour listed above the one it needs would find nothing and keep that nothing for ever: a bug that depends on the order of two lines in a file nobody has open. Asking at the moment of use removes the question entirely, because by the time anything is running, everything on the object has published.

It answers null when nobody published that key, and callers are meant to say ?. rather than assume: an object may legitimately carry a consumer without its provider while it is being built. To have that reported instead of merely survived, say so when registering: { requires: ['spin'] } is the half of this relationship somebody else can read.

Reaching across objects is not what this is for. That is what signals are.

Parameters

keystring

What the publisher called it.

selfoptionalTBox

Another object to ask instead of the one being built. Rarely needed.

Example

registerScript('toggle', () => {
    const spin = useApi<{ stop(): void; start(): void; isRunning(): boolean }>('spin');
    const keys = useKeyboard();

    useUpdate(() => {
        if (!keys.justPressed('Space')) return;
        const api = spin();
        if (api === null) return;
        if (api.isRunning()) api.stop(); else api.start();
    });
}, { requires: ['spin'] });
View source · hooks/script/use_api.ts:83

useScriptfunctionhooknacatamalon

useScript(ref: string, id?: string, props?: TScriptProps) => void

Attaches a behaviour by name to the object being built, and runs it.

Two things happen. The object records that it carries this behaviour, so the attachment is saved with the scene and comes back when the scene is opened. And the behaviour runs right here, inside this object's turn, receiving the object as its first argument, so whatever it registers (per-frame work, a watch, a listener) belongs to this object and goes away with it.

Call it after the object has what it is made of: a behaviour reaches for the object's sprite or its placement, which is the natural order in a scene body anyway. A scene opened from a file gets this for free, because behaviours are always built last there.

The attachment is kept whether or not the behaviour runs, and the two reasons it might not are the same reason: a saved scene outlives the code it names. A name nothing registered (its file was renamed, or this project simply has not loaded it) says so and is kept, so opening a scene shows you the problem instead of failing on it, and saving again does not delete somebody else's work. A host showing a scene rather than playing it skips the call on purpose, so the object carries its behaviour without performing it.

Parameters

refstring

The behaviour's name, as registerScript was given it.

idoptionalstring

Pass one only to keep an attachment's identity across saving and opening. Left out, it gets a fresh one.

propsoptionalTScriptProps

The authored settings. What the behaviour actually receives is its declared defaults with these laid over them, key by key.

Example

const Guard = () => {
    const texture = useLoadTexture({ src: '/assets/guard.png' });
    createSprite({ texture, transform: { x: 40, y: 112 } });
    useScript('patrol', undefined, { speed: 80 });
};
View source · hooks/script/use_script.ts:44

TScriptAttachmenttypenacatamalon

One script attached to one object: which behaviour, and what it was given.

props is already resolved, the script's own defaults with the saved values laid over them, so this says what the behaviour actually ran with, which is the question anything reading it has.

Properties

idstring
refstring
View source · scripts/types/t_script.ts:144

TScriptFieldtypenacatamalon

One setting a script declares about itself.

A behaviour's settings cannot be guessed from outside: they are variables inside a function and nothing but the code knows they exist. So the code says so here, and an editor builds a form from it without knowing anything about the behaviour.

default is not optional. It is what a freshly attached script starts with, and it is what fills the gap when a script gains a setting that a scene saved earlier knows nothing about. An absent value has no other sensible answer.

Properties

keystring
labeloptionalstring

What the form calls it. The key itself when left out.

type'number' | 'boolean' | 'string' | 'select' | 'key'

How the value is written.

'key' is a 'string' that happens to be a keyboard key, and it exists because typing one into a text box does not work: what the input system matches is the browser's own spelling, where space is a single blank character. That renders as an empty box and reads as a setting nobody filled in. A form shows 'key' as "press the key you want" instead, which is both easier and the only way to write down the keys with nothing to print. Saved exactly like a string; nothing further down needs to know the difference.

defaultstring | number | boolean
minoptionalnumber
maxoptionalnumber
stepoptionalnumber
optionsoptional{ label, value }[]

'select' only: what may be chosen.

View source · scripts/types/t_script.ts:51

TScriptFntypenacatamalon

TScriptFn: (self: TGameObject, props: TScriptProps) => void

A behaviour, written as a function and registered under a name.

It is handed the object it was attached to, so it can reach what that object carries (self.drawables[0], its placement) and register per-frame work over it.

A script adds behaviour, not things. It should not create sprites or meshes and should not call useData in its body: what a scene is made of is the scene's business, and a script is run again every time the scene is opened, so anything it built would be built twice. That line is the same one the whole saved format rests on.

View source · scripts/types/t_script.ts:34

TScriptOptionstypenacatamalon

What a script says about itself beyond its settings.

Properties

fieldsoptionalTScriptField[]
tooloptionalboolean

Whether this behaviour also runs while a scene is being edited rather than played.

Off by default, which is what keeps an editor usable: a behaviour that walks an object around would walk it away from where you just put it, and its idea of the arrow keys would start competing with the editor's. Turn it on for a behaviour whose purpose is to be seen while building, such as one that lays out its own children or draws a guide.

requiresoptionalTScriptRequirement[]

What this script needs its object to already carry, so that an object missing it is reported instead of quietly doing nothing.

Almost every script opens by giving up in silence: take the first drawable, and return if there is none. That guard is right, because one badly set up object must not take a whole scene down. But the guard is also the entire diagnosis, and nobody can see it. Saying what is needed turns the same situation into a line somebody reads, and leaves the guard exactly where it is.

It is also what lets behaviours be written apart and put together later. One that expects a sibling behaviour names it, the two files never import each other, and the contract between them is a word in a document, which is the only kind two separately written scripts can share.

None of this decides whether a script runs. It is a diagnosis and never a gate: refusing to run would make a half built object behave differently from a broken one, and building is mostly half built objects.

View source · scripts/types/t_script.ts:100

TScriptPropstypenacatamalon

TScriptProps: Record<string, string | number | boolean>

The authored settings of one attached script, and what makes a behaviour configurable instead of fixed: two boxes can carry the same patrol at different speeds.

Only numbers, text and true or false, and that ceiling is on purpose rather than an oversight. These are typed into a form by hand, so anything with structure (a point, a colour, a list) would need an editor of its own and a story for changing it later. A behaviour that wants a place already has one, its box's; one that wants a target names it and looks it up. If a nested shape ever becomes unavoidable it should arrive as a new kind of field, not by opening this up.

View source · scripts/types/t_script.ts:17

TScriptRequirementtypenacatamalon

TScriptRequirement: string

One thing a script needs its object to already have: the type of a component ('mesh'), the stand-in word 'drawable' for "anything this object draws", or the name of another script.

One list and not three, because from inside a script the question is always the same one: is this on my object? Splitting it would make an author pick a category before saying what they need, and a name that matches nothing is reported exactly like a name that is genuinely missing, so a typo is loud instead of a check that quietly never fires.

View source · scripts/types/t_script.ts:91