FERRETGame engine over Babylon.js
Ferret

The motion language

@ferret/motion (stage A4) is a small Lisp for things that happen over time: a stroke of an oar, a gust, a door that opens and closes. It has no Babylon and no physics; hosts add the words that touch the world (force, a sensor) with define and action. Programs run across fixed steps: wait suspends, nothing blocks.

Text and JSON are one tree#

(defmotion stroke (side :power 600)       ; positional, then keyword pairs with defaults
  (par (for 0.4s (force side forward power))
       (seq (wait 0.4s) (emit "stroke" side))))

The JSON form is the same tree: a list is an array, a symbol is a plain string, a string literal is { "s": "text" }: ["emit", {"s": "stroke"}, "side"]. Condition objects of the animation graph ({ "param": "row", "op": ">", "value": 0 }, { "trigger": "abort" }, { "stateTime": 0.4 }, { "finished": true }) are allowed wherever a condition is.

Units are folded in when reading: 30rpm, 90deg, 0.4s, 250ms, 600N, 2m, 5kg, 3W become numbers in SI (radians, seconds). Names are kebab-case. ; starts a comment.

Forms#

GroupForms
Time(wait t), (wait-until cond), (wait-event name [filter]) (the payload is event)
Flowseq, if, when, unless, cond, let, set!, loop, (repeat n …), (for t …), (while cond …), (guard cond …)
Together(par a b …) waits for all, (race a b …) takes the first and kills the rest
Events(on name …) handles every event while the enclosing form lives; (emit "name" payload)
States(states :initial a (a body… [:until cond] -> b) (* :when cond -> c)); state-time counts from entering the state
Functions(defmotion name (params…) …) lasts (may wait); (defn name (params…) …) and (lambda …) are instant calculations
Maths+ - * / mod min max abs sin cos sqrt floor clamp lerp, comparisons, and or not, vec +v -v *v dot cross len norm, random, curve
Data(param name), (bb name), (bb-set! name v), time

An action (force and the like, registered by the host) is a hold: the thread stays on it, its arguments are evaluated again every step, and it stops when the enclosing for, while, guard, race ends. (ramp t (action …)) fades a hold in over t. half-sine, periodic and curve read the age of the action they are in.

A symbol with no binding is a name: forward, oarlock-left.

Running#

const rt = new MotionRuntime({ seed: world.seed, budget: 10000 });
rt.define('log', (ctx, ...a) => …);                       // an instant word
rt.action('force', { start: (ctx, args) => ({ step: (a, c) => …, stop: () => … }) });
const mod = rt.load(text, 'rower.motion');                // checked now: names, arity, keywords, states
const p = rt.run(mod, '(stroke left)', { power: 400 });    // or mod.main[0]; JSON works too
rt.tick(1 / 60);                                           // every fixed step
p.set('row', 1); p.trigger('abort'); rt.deliver('ping', 3);
p.pause(); p.resume(); p.stop(); await p.done;

load rejects unknown functions, wrong argument counts, unknown keywords and missing states with file:line:col:. Run-time errors (a number expected, a loop that never waits) stop that program, emit motion:error on the runtime, reject p.done and name the form.

Budget, determinism, snapshots#

  • Budget: each thread may do budget reductions (default 10 000) per step; a loop with no wait stops with an error naming the form.
  • Determinism: no wall clock; random is seeded from the world seed and the program id, and its state is part of the program.
  • Snapshot: p.snapshot() is plain JSON (stack, waits, environments, random state). rt.restore(json) continues exactly as an uninterrupted run would; holds are started again in the restoring runtime.

Physics words (stage A5)#

motionPhysics(actuators) from @ferret/physics-actuators is the capability motion (phase field, before the actuators, requires actuators). It owns a MotionRuntime, ticks it once per fixed step and adds the words that touch the world.

KindWords
Actions (last while their form does)(force point dir magnitude), (torque axis magnitude), (motor axis speed :max-torque t :gain g), (flow-ramp source seconds value); :on Node picks another body
Instants(impulse point dir magnitude), (command actuator v), actuator-on, actuator-off, (fire actuator), flow-on, flow-off, (flow-source name :key v …), (flow-move name (list (vec …) …) speed), list, (world x y z)
Sensorsspeed, speed-along, angular-speed, position, height, tilt, rpm, angle, in-angle, in-zone, in-medium, flow-at, apparent-wind, wind-speed, wind-dir, power, work, stalled, phase
Macro(over-angle shaft (from a b [axis]) body…): the body runs while the shaft is between the angles, at any speed and either way

A point is a vector in the body's frame or a node name (oarlock-left finds Oarlock_L, ignoring case, - and _; addPoint(name, node) maps other names). A direction is forward (+z), back, up, down, right, left, a vector in the body's frame, or (world x y z).

A program has a target (the node it pushes): motion.run { target } or (bind "Boat" program) in a file. A level-wide program (weather) has none and may use only the flow words; a body word without a target stops that program with "no target body".

Events: actuator:*, machine:stall, zone:enter, zone:exit, interact:use reach wait-event (the payload is the actuator or zone name); emit, motion:done, motion:stop, motion:error come out as world events motion:<name>.

Commands: motion.run, motion.stop, motion.pause, motion.resume, motion.set, motion.trigger, motion.state, motion.eval (run to the first wait and return the value); edit.motion.set { name, source } (text or JSON; a file with an error is refused whole and the old one stays), edit.motion.remove, edit.motion.list. A level's physics.motions is a list of { name?, source } (the loader that reads the file hands the text over). The programs of a file that are not definitions start when it is set and stop when it is replaced or removed.

Not yet#

Machine and vessel words (throttle, cutoff, ignition, pitch, set-surface, pressure) register from their packages as those get their controls (engines: A10); touching and contact-force need the contacts in the field phase; depth; the RunMotion leaf of the behaviour tree. Code both ways is A6, strokes A7, networking A8.

Code and the language, both ways#

MotionBridge (in @ferret/motion) joins a runtime to code.

const br = new MotionBridge(rt, { commands: world.commands });
br.define('stamina', { args: [], returns: 'number' }, ctx => stats.stamina);                    // instant: usable in conditions
br.define('pressed', { args: ['symbol'], returns: 'boolean', where: 'owner' }, (c, a) => input.pressed(a));   // owner only
br.lasting('walk-to', { args: ['vec3'] }, (ctx, p) => agent.moveTo(p));                         // a {done(), result} or a promise
br.action('vibrate', { args: ['number'] }, { start: (c, [s]) => ({ step() { pad.rumble(s); }, stop() { pad.rumble(0); } }) });
br.load('(defmotion stroke (side :power 600) ...)');
const h = br.run(br.tag`(par (stroke left) (stroke right))`, { power: 600 });   // a handle: stop, pause, resume, set, trigger, on, state, done
br.get('stroke')(sym('left'), { power: 700 });                                  // a defmotion as a TypeScript function
  • A signature (args, min, rest, returns, where) is checked when the program loads (argument count, literal types) and when the function is called (values). An argument typed function arrives as a plain JS function: a (lambda ...) run to the end (it must not wait).
  • A lasting call is a statement (like wait): the program sleeps until the waitable is done; the value is the statement's. A snapshot taken while it runs resumes after it.
  • where: 'owner' marks a function that reads something local; br.run(..., {}, { owner: false }) refuses a program that uses one.
  • (cmd "name" :key value ...) calls a registered command. A program may call only the allow list (actuator., flow., rotor., vessel., motion., gravity., wind., body. by default; the game can replace it) and never edit.*, physics.configure / enable / disable, pkg.* or catalog.*.
  • br.parse, br.print, br.toJson, br.fromJson give programs as data for tools.

Handing a program to another machine (A8)#

netPrograms(rt, net, onAdopt).handOver(name, program, peer) stops the program here and sends its snapshot; the peer restores it and continues exactly where it stopped (the same actions must be defined there).