Scripting
Reference
Every object a level script can reach, what each one is for, and how it behaves. Written against the current development build.
Scripting a level
Every level can carry its own script. A game.js sitting beside the level's models is loaded and run as the level loads, so a world's behaviour travels with the world instead of being built into the game. That is the difference between a course a creator can shape and a course a creator can make behave: a switch that lowers a ramp, a platform that breathes, a trigger that starts the music.
Scripts are written in TypeScript and compiled into one plain script file. The game runs that file through an embedded JavaScript engine. The runtime itself does not require TypeScript. It is there for the declarations on this page, which describe every object a script can reach and let an editor complete, check, and catch mistakes in a script long before a level is ever loaded.
There is exactly one way in. A script reaches the game through a single global named game, and through nothing else. That surface is deliberately small and curated: what is documented here is reachable and the rest of the engine is not. There are no imports and no exports, because game is a true global, available in every script file without a statement of any kind.
A level's script runs as the final step of loading, after the previous level has been removed, the new one attached, and its collision and fading prepared. Anything a script looks up by name already exists by the time the script runs, so there is no waiting for the world to be ready.
Work is split across two clocks. Anything with a place in the world advances on physics ticks, which is what lets a scripted platform move what the ball collides with in step with what is drawn. The camera and sounds advance on rendered frames instead, so a camera sweep is not quantised to the physics rate.
A script belongs to its level. Reloading or replacing a level replaces its script and everything that script had running; freeing a component cancels its animations at once. Mistakes are reported rather than thrown. An unknown property, a malformed animation, or a tint on something that has no tint is logged through the game's own output and does nothing, so a scripting error will not take the level down with it.
This reference describes the scripting surface of the current development build. Once builds are published, each version of this page will name the version it documents, because a script can only use what its own copy of the game actually supports.
// game.js beside the level's models, run when the level loads.
const lift = game.find("lift");
const switch01 = game.find("switch01") as Button;
switch01.autoRelease = true;
switch01.onPressed = () => {
lift.animate("local-y(2, 2, sine-inout)")
.reverse(3)
.repeat(0, -1);
};
game.events.onEnterMesh = (mesh, event) => {
game.log(`hero left ${event.data ? event.data.name : "nothing"} for ${mesh.name}`);
};
Game
The global entry point
Everything begins at game. It holds the clocks, the three objects that outlive any single moment of play, the collections describing what is in the level, the lookups that find things by name, the factories that make new things, and the handful of global settings a script is allowed to move.
Three clocks are offered because they answer different questions. totalTime is elapsed real seconds since the game started. time is frozen for the duration of a frame, so reading it twice in the same frame gives the same answer. That is what makes it safe to drive calculations that must agree with themselves. delta is the seconds elapsed since the previous frame.
ball, camera, and events are persistent. The ball object stays valid across shattering and respawning, and the camera survives a level reload, so a handler installed once keeps working rather than going stale the first time the hero dies.
lastTouched and activeMesh are not the same question. The first is the most recent level mesh the ball touched at all, including a wall it merely brushed. The second is the mesh the hero is actually standing on, settled over a short dwell so that clipping across a seam between two meshes in a single tick is not reported as having changed floors.
find looks a visual up by the name its author gave the glTF file, reduced to letters, digits and underscores. The four collections are real arrays, so for…of, map, filter, length and index access all work; meshes, doodads and enemies are subsets of visuals.
The factories build things a level did not author. createNullPoint returns an anchor with no appearance of its own, which is the usual way to get a pivot away from the centre: parent the anchor where the pivot belongs, then parent whatever should orbit to the anchor. createDoodad and createEnemy are generic over the kind, so passing the literal "button" hands back a Button rather than a bare Doodad. A doodad made this way arrives with none of the placement that one authored in the level inherits from its placeholder, so set its position, rotation and size afterward.
spawnLocation is where the ball is dropped. Assigning it, or writing to its components, moves the ball there immediately rather than waiting for some later unrelated respawn. windDirection and windStrength drive every flag in the level at once, and both can be animated.
declare const game: {
readonly totalTime: number;
readonly time: number;
readonly delta: number;
readonly ball: Ball;
readonly camera: Camera;
readonly events: Events;
readonly lastTouched: Mesh | null;
readonly activeMesh: Mesh | null;
log(message: string): void;
setTimeout(callback: () => void, milliseconds: number): number;
setInterval(callback: () => void, milliseconds: number): number;
clearTimeout(id: number): void;
clearInterval(id: number): void;
animate(spec: string): AnimationHandle;
delay(seconds: number): AnimationHandle;
get windDirection(): ScriptVector3;
set windDirection(value: Vector3);
windStrength: number;
find(name: string): Visual | null;
readonly visuals: Visual[];
readonly meshes: Mesh[];
readonly doodads: Doodad[];
readonly enemies: Enemy[];
loadSound(name: string): Sound | null;
findSound(name: string): Sound | null;
readonly sounds: Sound[];
createNullPoint(): Visual;
createDoodad<const K extends keyof DoodadKindMap>(kind: K): DoodadKindMap[K];
createEnemy<const K extends keyof EnemyKindMap>(kind: K): EnemyKindMap[K];
get spawnLocation(): ScriptVector3;
set spawnLocation(value: Vector3);
};
Vector values
Vector3 and ScriptVector3
Two vector types, and the distinction is worth learning early. Vector3 is a plain value: any object literal with x, y and z satisfies it, so obj.position = { x: 1, y: 2, z: 3 } is valid.
ScriptVector3 is what the API hands back, and it may be a live handle onto the object's own value rather than a snapshot of it. Writing to the x of a returned position moves the object. That is convenient for obj.position.y -= 0.3 and a trap if a vector is kept and mutated much later, so copy() exists to detach one into an ordinary mutable value.
add and multiply each accept either a single scalar or three separate components, and both return a new vector rather than modifying the one they were called on.
interface Vector3 {
x: number;
y: number;
z: number;
}
interface ScriptVector3 extends Vector3 {
copy(): ScriptVector3;
multiply(scalar: number): ScriptVector3;
multiply(x: number, y: number, z: number): ScriptVector3;
add(scalar: number): ScriptVector3;
add(x: number, y: number, z: number): ScriptVector3;
}
Component
The common base of everything
Every object a script can hold is a Component: meshes, doodads, enemies, the hero ball, the camera, and sounds alike. It carries only what all of them genuinely share.
kind is the discriminator, and it is the right way to tell one type from another. Prefer obj.kind === "button" to testing for a member that only buttons happen to have. The first is a statement about what the object is, the second is a guess that will quietly break when another type gains that member.
tag is a free integer the engine never reads. It is there so a script can classify its own objects, marking every crate in a room or numbering a set of platforms, without maintaining a lookup table alongside the level.
free() removes the component and every visual parented beneath it. The removal is deferred to the end of the frame because it is routinely called from inside a handler the level is itself iterating, such as a trigger freeing the doodad that fired it, but any animation running on it is cancelled at once.
interface Component {
readonly kind: ComponentKind;
readonly name: string;
tag: number;
free(): void;
}
Visual
extends Component
A Visual is anything with a place in the world. It adds hierarchy, transform, visibility, collision, and animation to the common component identity, and every mesh, doodad, enemy and the ball itself is one.
parent is real reparenting, backed by the engine's own scene graph, so a parented object follows its parent's movement the way a light sitting on a spinning disc should. Assigning preserves the object's current world transform rather than teleporting it, and an assignment that would make an object its own ancestor is refused, logged, and leaves the parent unchanged.
Three different positions are exposed because they answer three different questions. position is the offset within the parent's frame. globalPosition is the live world position after every parent transform has been composed. center is the world centre of the visual's bounding box, which is not necessarily its position: a mesh authored in CAD keeps whatever origin it was drawn around, and that is often a corner rather than the middle. Rotating around center and rotating around position are visibly different operations.
relocate is an immediate teleport, taking either three numbers or a vector. size is the logical authored dimension while scale is a plain multiplier defaulting to one on each axis; some doodads bake their size into geometry only at load, so scale is the general tool and size works only for the types that keep reading it.
visible and collisions both apply the instant they are set and both reach the whole branch beneath the visual. Clearing visible hides the object and every child, including the meshes, particles and lights a compound doodad owns internally. Clearing collisions disables every collision shape and trigger area below it, so bodies pass straight through and its triggers stop reporting. A deactivated doodad is inert rather than merely intangible.
noBreak starts from the -nobreak flag in the object's authored name and stops the hero and breakable enemies shattering against this visual's surfaces. distance measures to another visual, or to the hero ball when called with no argument.
interface Visual extends Component {
readonly kind: VisualKind;
readonly name: string;
parent: Visual | null;
readonly center: ScriptVector3;
readonly globalPosition: ScriptVector3;
get position(): ScriptVector3;
set position(value: Vector3);
relocate(x: number, y: number, z: number): void;
relocate(position: Vector3): void;
get size(): ScriptVector3;
set size(value: Vector3);
get rotation(): ScriptVector3;
set rotation(value: Vector3);
get normal(): ScriptVector3;
set normal(value: Vector3);
get scale(): ScriptVector3;
set scale(value: Vector3);
visible: boolean;
collisions: boolean;
noBreak: boolean;
animate(spec: string): AnimationHandle;
delay(seconds: number): AnimationHandle;
distance(visual?: Visual): number;
onAnimationComplete: ((component: Component, spec: string) => void) | null;
}
Mesh
extends Visual
A Mesh is level geometry: the floors, walls, ramps, slides and caves modelled in parametric CAD and exported as glTF. Most of what makes a mesh special is decided by its authored name, and the members here expose the parts a script is allowed to read or retune afterward.
cave is read only and comes from the -cave marker. While the hero stands inside a cave, ordinary occlusion testing stops entirely and visibility is decided by height instead: everything beginning above the cave's own bounding box fades away, so the roof of the world lifts off rather than being tested ray by ray.
unstable switches off the platform carry correction that otherwise tethers the ball to a moving surface. On a staircase or a shifting bridge, where the ball ought to roll naturally rather than be carried, setting this restores ordinary contact physics. It changes only that correction. Friction, damping, and the safeguards against moving walls are untouched.
caveExitHeight decides how far a mesh must rise above the cave's top before carrying the hero out counts as having left, so a lift reveals the level as the ride finishes rather than as it begins. It lives on the mesh rather than being one global number because a shallow cave and a deep shaft want the reveal at different points.
onEnterMesh and onExitMesh fire as the hero settles onto a different mesh, on the same dwell game.activeMesh uses. Exit is always raised first, and each carries the other mesh in its event data.
interface Mesh extends Visual {
readonly kind: "mesh";
readonly cave: boolean;
unstable: boolean;
caveExitHeight: number;
onEnterMesh: ((component: Component, event: GameEventArgs<Mesh | null>) => void) | null;
onExitMesh: ((component: Component, event: GameEventArgs<Mesh>) => void) | null;
}
Enemy
extends Visual
Enemy is the base for anything driven by the game's own AI. It contributes the three radii that govern how an enemy behaves, and they are the main surface a script has for tuning aggression without rewriting behaviour.
patrolRadius is a circle around the enemy's home, meaning the position it was first placed at, not wherever it has since wandered to. Patrol targets are chosen randomly inside it, so an enemy knocked out of its region is naturally guided back rather than teleported.
noticeRadius is measured from the enemy's current position and decides how close the hero has to come to be noticed at all. chaseRadius does double duty: it is a leash around home and also the greatest separation allowed between enemy and hero. A chase ends when either is exceeded, when sight of the hero is blocked, or when the hero starts falling.
interface Enemy extends Visual {
readonly kind: "enemy" | "orb" | "acid";
patrolRadius: number;
noticeRadius: number;
chaseRadius: number;
}
Orb
extends Enemy
The rolling enemy, and the hero's opposite number, with the same size, the same physics, and the same way of shattering. Orbs are why the game is called what it is: the courses are one orb against others.
An idle orb wanders its patrol area with deliberately mild input so it does not race out of its own region. A noticed hero is charged at directly, and after a hit the orb spends a moment backing toward where it came from before attacking again, which reads as bumping and retreating rather than as being stuck to the player. An orb that shatters stays dead; unlike the hero it does not respawn.
The three death callbacks let one particular orb be watched. The same events are also raised for the whole level on game.events, which is the better place to listen when the answer is the same for every orb.
interface Orb extends Enemy {
readonly kind: "orb";
onAcidDeath: ((component: Component, event: GameEventArgs<AcidPool>) => void) | null;
onShatterDeath: ((component: Component, event: GameEventArgs<null>) => void) | null;
onFallDeath: ((component: Component, event: GameEventArgs<null>) => void) | null;
}
Acid pool
extends Enemy
A creeping pool of acid that kills the hero on contact rather than knocking it about. It is a hazard with intent: it patrols, notices, and gives chase using the same three radii as any other enemy, but it moves slowly and there is no bumping to survive.
Movement happens in pulses. The pool deforms from its current outline into a randomly chosen next shape while translating along one committed heading, then holds that finished outline through a pause before the next pulse. It cannot climb or descend. Before advancing it probes the floor ahead and to both front sides and abandons any route that is sloped or stepped, so a pool stays on the flat ground it started on.
interface AcidPool extends Enemy {
readonly kind: "acid";
onDeath: ((component: Component, event: GameEventArgs<null>) => void) | null;
}
Doodad
extends Visual
A doodad is an authored prop: everything placed in a level that is not the level's own geometry. Buttons, crates, lights, decals, flags, horns, gears, catapults, spinners and triggers are all doodads, and they share exactly one member of their own.
tint is a hex colour string such as "#ff8800", without an alpha channel. Setting an unparseable string logs an error and leaves the existing tint alone. A doodad already has a tint before a script touches it: the colour is taken from its placeholder cube in CAD, so the value here is the one the level author chose.
interface Doodad extends Visual {
tint: string;
}
Image doodad
extends Doodad
The shared image and holographic foil layer used by every doodad that displays a picture: the spinner board, the flag, and the decal. Both layers are optional and independent, so a doodad can show its tint alone, an ordinary image, a foil mask by itself, or both together.
image and foilImage are base file names of PNGs sitting beside the level, without the extension; an empty string disables that layer. The foil is read as a greyscale mask where black keeps the underlying colour and brighter pixels reveal a holographic hue that depends on the viewing angle, so the effect shifts as the camera moves rather than being painted on.
foilStrength runs from 0 to 2, with values above 1 available as deliberate overdrive. transition is the number of seconds an image change takes to crossfade; zero swaps instantly, and the first image assigned never crossfades because there is nothing to fade from.
interface ImageDoodad extends Doodad {
image: string;
foilImage: string;
foilStrength: number;
transition: number;
}
Button
extends Doodad
A button the ball presses by rolling onto it. intensity is its peak glow while unpressed; the glow eases toward zero as it goes down, and the physical movement always eases rather than snapping.
autoRelease decides which kind of switch it is. Left at its default of false, the button latches: it stays pressed after the ball rolls away, until something else releases it. Set to true, it is momentary and pops back the instant the ball leaves. The latching default is what makes a button useful for opening something and leaving it open.
onPressed and onReleased fire once per actual transition, not repeatedly while the ball sits on top. Assigning replaces whatever handler was there; assigning null clears it.
interface Button extends Doodad {
readonly kind: "button";
intensity: number;
autoRelease: boolean;
onPressed: ((component: Component) => void) | null;
onReleased: ((component: Component) => void) | null;
}
Crate
extends Doodad
A collectable crate. The ball rolls into it, the crate goes away in a puff of smoke, and the hero gains what it was carrying.
powerup names what that is, and defaults to "random". What the hero then does with it is reported through ball.onPowerup, which fires with true when the powerup is gained and again with false when its active duration runs out.
interface Crate extends Doodad {
readonly kind: "crate";
powerup: string;
}
Gear
extends Doodad
A rotating gear. It adds nothing of its own beyond its kind. It is positioned, tinted, parented and animated exactly like any other doodad, and a gear that turns is a gear with a rotate animation running on it.
interface Gear extends Doodad {
readonly kind: "gear";
}
Spinner
extends ImageDoodad
A pinball spinner: a board hinged inside a frame so that it flips end over end as the ball passes through, rather than turning like a fan. It is meant to be gone through, not bounced off, so the ball's velocity, direction included, is restored across the contact and for a short window afterward, while the board keeps the angular momentum it was given and spins freely.
onSpin fires each time the board reaches a vertical orientation opposite the last one it counted, which is one half rotation, so a complete revolution raises two events. A board settling at low speed can cross and recross the same boundary while it rocks around its rest position; those crossings are deliberately not counted, so the board has to genuinely reach the other side before firing again.
flipped mirrors the board's image and foil mask vertically, for a spinner that is normally seen from the opposite side.
interface Spinner extends ImageDoodad {
readonly kind: "spinner";
flipped: boolean;
onSpin: ((component: Component) => void) | null;
}
Trigger
extends Doodad
An invisible box volume that never physically deflects the ball. It exists purely to notice: rolling into it fires onEnter once, leaving fires onExit once, and nothing about the ball's motion changes.
Its volume defaults to a half unit cube, can be written from a script through the ordinary size property, and is taken from the placeholder's own dimensions when the level author gave it one.
interface Trigger extends Doodad {
readonly kind: "trigger";
onEnter: ((component: Component) => void) | null;
onExit: ((component: Component) => void) | null;
}
Flag pole
extends ImageDoodad
A pole with a procedurally animated cloth flag. The flag casts shadows but has no collision of its own; only the base is solid, so a flag is decoration the ball can pass through.
shape chooses between a pennant and a rectangle, the rectangle being twice as tall. Both are driven by the level's global wind: game.windDirection pivots the entire flag around its pole so it genuinely faces the wind rather than merely changing the phase of a fixed wave, and each flag drifts on its own timing so a row of them never waves in lockstep.
doubleSided mirrors the image horizontally on the back face, so text and directional artwork read correctly from either side. sheen adds a low gloss specular response from 0 for matte up to 1.
type FlagPoleShape = "pennant" | "rectangle";
interface FlagPole extends ImageDoodad {
readonly kind: "flagpole";
shape: FlagPoleShape;
doubleSided: boolean;
sheen: number;
}
Catapult
extends Doodad
A catapult that launches the ball. onBallLoaded reports that the ball has arrived and settled in the bucket: the check runs every quarter second and the event fires once the ball has been found there on two consecutive checks, not on every check afterward that still finds it sitting there.
interface Catapult extends Doodad {
readonly kind: "catapult";
onBallLoaded: ((component: Component) => void) | null;
}
Inlay light
extends Doodad
A shaped, glowing icon set into a surface, with a soft bulb beneath it and a real point light that illuminates and casts shadows on nearby geometry. Inlays are the game's usual way of marking something, whether a boost, a direction, or a door that matters, and they are lights rather than painted decoration.
shape chooses the icon. intensity drives both the icon's brightness and the light it casts, so zero leaves it dark without removing it. stroke is the outline thickness in the same world units as size, and fillet rounds the corners of the shapes that have them.
angle spins the icon within its own plane, in degrees. It is deliberately separate from rotation, which tilts the whole physical plane in three dimensions: turning an arrow to point somewhere else is a different act from reseating the inlay on a different wall.
onEnter and onExit report the ball touching the inlay. The volume that detects this is deliberately taller than the inlay's authored thickness, because a marker that thin would be crossed entirely between two physics ticks by a ball at almost any speed.
type InlayShape = "circle" | "triangle" | "arrow" | "box" | "star" | "hexagon" | "plus";
interface InlayLight extends Doodad {
readonly kind: "inlaylight";
onEnter: ((component: Component) => void) | null;
onExit: ((component: Component) => void) | null;
shape: InlayShape;
intensity: number;
stroke: number;
fillet: number;
angle: number;
}
Air horn
extends Doodad
A horn on a stick that raises, lowers, and blows. raised moves it between its mounted and lowered heights, always easing rather than snapping. blowing is independent of it, so a horn can stand raised without blowing, or blow while lowered. While it blows the horn shakes, blurs slightly, and streams small tumbling leaves out of its bell.
It also pushes the ball. The push is strongest close to the horn and fades to nothing by a set distance, and it only reaches the ball inside a cone in front of the bell; anything behind or off to the side is unaffected, with the push easing out near the cone's edge rather than cutting off.
blowStrength scales the leaf stream's speed and lifetime only, and does not change the push. blowCone is the half angle of the push cone in degrees, defaulting to 30, and does not change the leaves.
interface AirHorn extends Doodad {
readonly kind: "airhorn";
raised: boolean;
blowing: boolean;
blowStrength: number;
blowCone: number;
}
Decal
extends ImageDoodad
A flat image applied to a surface. imageWidth and imageHeight report the dimensions of the image actually loaded, and opacity is overall transparency, kept separate from the tint so the two can be changed independently.
resize is the reason a decal does not simply use size: it sets the width and then picks whatever depth preserves the current image's aspect ratio, so changing a decal's size does not stretch its artwork.
interface Decal extends ImageDoodad {
readonly kind: "decal";
readonly imageWidth: number;
readonly imageHeight: number;
opacity: number;
resize(width: number): void;
}
Ball
extends Visual
The hero. One object for the life of the application: it stays valid across shattering, respawning, and the spawning of an entirely new physical body, so a callback installed when a level boots keeps working rather than going stale the first time the ball breaks.
boost adds speed along the ball's current horizontal direction of travel, eased in over a fifth of a second rather than applied as an instant jump, and spins the ball up to match the speed it adds. Without that, the surplus would be converted straight back into spin by the contact friction that makes the ball grip in the first place, and the boost would appear to vanish as it was granted. shatter breaks the ball through its ordinary respawn lifecycle.
The events cover the four ways a run ends and the two states worth reacting to. onAcidDeath carries the pool responsible; onShatterDeath and onFallDeath carry nothing because there is nothing to name. onPowerup fires with true on gaining one and false when it times out, and onPowerupCollide reports every collision while one is active. onCameraChaseStart and onCameraChaseEnd mark the automatic chase camera engaging during sustained fast travel and letting go again.
interface Ball extends Visual {
readonly kind: "ball";
boost(speed: number): void;
shatter(): void;
onAcidDeath: ((component: Component, event: GameEventArgs<AcidPool>) => void) | null;
onPowerup: ((component: Component, gained: boolean) => void) | null;
onPowerupCollide: ((component: Component, event: GameEventArgs<Visual | null>) => void) | null;
onShatterDeath: ((component: Component, event: GameEventArgs<null>) => void) | null;
onFallDeath: ((component: Component, event: GameEventArgs<null>) => void) | null;
onCameraChaseStart: ((component: Component, event: GameEventArgs<null>) => void) | null;
onCameraChaseEnd: ((component: Component, event: GameEventArgs<null>) => void) | null;
}
Camera
extends Component
Script control of the live orbit camera. The camera persists across a level reload, so this object stays valid where the underlying native camera may be rebuilt beneath it.
zoom is an absolute distance from the current orbit pivot, clamped to the configured limits. orbitAngle is an absolute angle in degrees; reading it always returns a value normalised into 0 up to but not including 360, while an animation honours direction and whole turns, so a request for 370 degrees performs one full revolution plus ten and then reports 10.
target holds any visual at the point the camera looks at and orbits around, accounting for parent transforms and live rigid body positions; assigning null hands targeting back to the hero. Together with a null point animated through a series of positions, that is how a scripted opening sequence moves a camera through a level.
suspendInput suppresses ball, mouse, keyboard, joystick and ordinary camera controls while leaving Escape and the function keys available. It is meant for scripted sequences, and a script that sets it is responsible for clearing it when the sequence ends.
interface Camera extends Component {
readonly kind: "camera";
zoom: number;
orbitAngle: number;
suspendInput: boolean;
target: Visual | null;
animate(spec: string): AnimationHandle;
delay(seconds: number): AnimationHandle;
onAnimationComplete: ((component: Component, spec: string) => void) | null;
}
Sound
extends Component
A named, nonpositional sound with one or more variants. Trailing digits are stripped from the file name when a sound is loaded, so acid01.wav, acid02.wav and acid03.wav become one sound called acid with three variants, and a variant is chosen at random on every playback. Sounds are looked for in a folder beside the level first and then among the game's own, in WAV, MP3 and OGG order.
There are two ways to play. play is the stateful one: it stops whatever this sound was already playing and starts a fresh variant, and it is what stop, pause and position act on. fire is overlapping and forgetful, giving every call its own temporary player that frees itself when it finishes, so repeated impacts do not cut one another off.
position reads the live playback point and writing it seeks: during playback the jump is immediate, and otherwise it sets where the next play begins. hint is free text describing what the sound is for, carried so a level's sounds document themselves.
interface Sound extends Component {
readonly kind: "sound";
readonly name: string;
hint: string;
volume: number;
position: number;
pause: boolean;
play(position?: number): void;
stop(): void;
fire(volume?: number, position?: number): void;
animate(spec: string): AnimationHandle;
delay(seconds: number): AnimationHandle;
onAnimationComplete: ((component: Component, spec: string) => void) | null;
}
Events
Callbacks for the whole level
game.events is the counterpart, for the whole level, of the callbacks that individual objects carry. The same occurrence is usually reachable both ways: subscribe on one orb to watch that orb, or subscribe here to watch every orb in the level with one handler. Which to use is a question of scope, not of capability.
The component passed to a handler is the object the event is about, and that is not always the one a reader first expects. onInlayEnter sends the inlay that was touched rather than the ball that touched it, and onEnterMesh sends the mesh being entered while its event data holds the one being left.
GameEventArgs.data carries the other party where there is one, such as the pool that killed or the mesh being left, and is null where there is not. handled suppresses the level's fallback for events nobody claimed; it does not cancel the event or stop anything else from receiving it.
interface GameEventArgs<out TData> {
readonly data: TData;
handled: boolean;
}
interface Events {
onAcidDeath: ((component: Component, event: GameEventArgs<AcidPool | null>) => void) | null;
onBallPowerup: ((component: Component, gained: boolean) => void) | null;
onBallPowerupCollide: ((component: Component, event: GameEventArgs<Visual | null>) => void) | null;
onShatterDeath: ((component: Component, event: GameEventArgs<null>) => void) | null;
onFallDeath: ((component: Component, event: GameEventArgs<null>) => void) | null;
onSpinnerSpin: ((component: Component, event: GameEventArgs<null>) => void) | null;
onEnterMesh: ((component: Component, event: GameEventArgs<Mesh | null>) => void) | null;
onExitMesh: ((component: Component, event: GameEventArgs<Mesh>) => void) | null;
onInlayEnter: ((component: Component, event: GameEventArgs<null>) => void) | null;
onInlayExit: ((component: Component, event: GameEventArgs<null>) => void) | null;
onCameraChaseStart: ((component: Component, event: GameEventArgs<null>) => void) | null;
onCameraChaseEnd: ((component: Component, event: GameEventArgs<null>) => void) | null;
}
Kinds
Discriminators and kind maps
kind is a closed set of string literals, which is what makes obj.kind === "button" narrow a Visual to a Button in an editor rather than merely comparing two strings.
The two kind maps do the same job in the other direction. They pair each creatable kind with the interface it produces, so game.createDoodad("inlaylight") returns an InlayLight with its shape and intensity already in reach, without a cast.
type VisualKind = "visual" | "mesh" | "enemy" | "orb" | "acid" | "doodad"
| "button" | "crate" | "gear" | "spinner" | "trigger" | "flagpole"
| "catapult" | "inlaylight" | "airhorn" | "decal";
type ComponentKind = VisualKind | "component" | "ball" | "camera" | "sound";
interface DoodadKindMap {
button: Button;
crate: Crate;
gear: Gear;
spinner: Spinner;
trigger: Trigger;
flagpole: FlagPole;
catapult: Catapult;
inlaylight: InlayLight;
airhorn: AirHorn;
decal: Decal;
}
interface EnemyKindMap {
orb: Orb;
acid: AcidPool;
}
Animation
How behaviour is expressed
Almost every object on this page can animate itself, and this is the single most important part of the scripting system. Most of what a level script does, it does by starting an animation.
animate takes one string describing what to move, where to, over how long, and along what curve, and the engine runs it from there. JavaScript is not called back frame by frame to interpolate anything; the script declares the motion once and stops being involved. That is what makes it affordable to have a hundred animated objects in a level.
It matters more here than in a page of markup because the objects being moved are physical. Spatial animations advance on physics ticks and carry their collision bodies with them, so an animated platform moves what the ball actually rides on, not merely what is drawn. Camera and sound animations advance on rendered frames instead, so a camera sweep is smooth rather than quantised to the physics rate.
animate returns an AnimationHandle, and every one of its methods returns another handle, so sequences are built by chaining. then starts the next step when this one finishes on its own, run inserts a callback, delay inserts a wait, reverse unwinds everything recorded so far, and repeat replays it. A delay is a genuine recorded step rather than metadata, which is why reversing or repeating a chain replays its waits too, and why a platform that moves, holds, returns, holds, and repeats forever is one readable expression rather than a state machine.
Easing names are a curve family and a direction joined by a dash. Eleven families are available in four directions each, plus bare linear, which takes no direction.
The complete grammar has a page of its own: every animatable property, absolute versus relative targets, parallel specs, inline repeats, the conflict rules between position forms, and the mistakes that catch people out.
interface AnimationHandle {
stop(): AnimationHandle;
play(n?: number): AnimationHandle;
then(spec: string): AnimationHandle;
run(callback: () => void, seconds?: number): AnimationHandle;
reverse(delay?: number): AnimationHandle;
repeat(delay?: number, n?: number): AnimationHandle;
delay(seconds: number): AnimationHandle;
}
type EasingName =
| "linear"
| "sine-in" | "sine-out" | "sine-inout" | "sine-outin"
| "quad-in" | "quad-out" | "quad-inout" | "quad-outin"
| "cubic-in" | "cubic-out" | "cubic-inout" | "cubic-outin"
| "quart-in" | "quart-out" | "quart-inout" | "quart-outin"
| "quint-in" | "quint-out" | "quint-inout" | "quint-outin"
| "expo-in" | "expo-out" | "expo-inout" | "expo-outin"
| "circ-in" | "circ-out" | "circ-inout" | "circ-outin"
| "elastic-in" | "elastic-out" | "elastic-inout" | "elastic-outin"
| "back-in" | "back-out" | "back-inout" | "back-outin"
| "bounce-in" | "bounce-out" | "bounce-inout" | "bounce-outin"
| "spring-in" | "spring-out" | "spring-inout" | "spring-outin";
Two things are worth knowing before reading further. Every property target is absolute, so translate-y(2) moves to a Y of 2 in parent space rather than adding 2, with the three local axis forms as the single deliberate exception. And an animation started with a name already in use replaces the one in that slot, which is how a script retargets a moving object without stopping it first.