Motion
Get Motion

DocsStudio

Logic as code

A scene can have a script: TypeScript that runs beside the wallpaper and changes it. A click fades a layer, the sky turns warmer at sunset, a text layer shows the weather. This page is about writing one in Studio.

18 min readMotion

Try it: Rain Harbour (a script that plays a sound), Constellations (a TypeScript script).

Screenshot: the code editor with a scene's script
A scene's script in Studio's code editor.

Where the script lives

Each scene has one script. Studio keeps it inside the scene, so it travels with the .motion file and plays wherever the scene does.

There are two ways in:

Ctrl+Shift+E opens and closes the script editor from anywhere. In the command palette (Ctrl+K) it's Logic as code.

The editor fills the window under the tabs; Scene takes you back to the preview, and the tab's × closes the editor. F11 has it fill the whole screen.

Starting from something

You don't have to start from an empty page.

Running it

Run (Ctrl+Enter) runs the script in the preview from the top. The arrow beside it runs it again from the start, and the square stops it. The dot by the title says whether it's Running, Stopped, Running an older version (you've changed it since) or has problems.

The menu beside the stop button picks when it runs by itself: Run when asked, Run on save or Run as you type (a moment after you stop typing, as long as nothing is wrong).

On the desktop, the script starts when the wallpaper starts. Its top level runs once; after that it keeps going through what it handed to timers and events.

The editor

It's a proper code editor:

F12, or Ctrl and a click on a name, goes to where it's declared. The full reference is on this site: the scripting API.

In an editor outside Studio

The button along the top named after your editor (VS Code, or Open outside) opens the script in an editor outside Studio: VS Code when it's installed, with the scripting API's types beside the script so it completes and checks as Studio does, or else your system's editor for the file. Changes saved there come back into Studio, and changes made in Studio go to it. If the file changed in both places, Studio asks which to keep: Keep Studio's or Use the other editor's.

Which editor, and whether scripts and shaders always open there, are in Settings.

TypeScript

A script is written in TypeScript. It's checked as you type, with the types of everything a scene has (they're in motion.d.ts, which Studio reads beside your code), so a misspelt layer call or a number where words should go is underlined before you run it. Studio compiles it when it runs and when it saves. A script with problems is saved as you wrote it, and the wallpaper plays without it until they're fixed.

A scene whose script is JavaScript plays as it is. Studio makes its script TypeScript when you save it (your JavaScript kept as written, with a first line that turns the checking off), or straight away with Make it TypeScript above the code.

A script can have more than one file. The + beside script.ts adds another, which the script imports by name (import { greet } from "./greet"). Each file gets a tab; you can rename it or take it out.

The scene object

Everything a script does goes through scene. It's already there; you can also write import { scene } from "motion".

Layers are named by their name or their id. Settings are named by their key: hover over a setting in Studio to see it.

// change a setting at once, or move it there over some seconds
scene.set("Sun", "opacity", 0.5);
scene.animate("Sky", "colour1", "#123a24", 2);

// or hold on to one layer
const orbs = scene.layer("Orbs");
orbs.animate("x", 0.2, 3);

// fill {temp} in every text layer
scene.setVar("temp", "21°");

Besides the layers, scene reads the world: scene.clock (the hour, the day, how light it is outside), scene.weather (the conditions, temperature, wind, rain, cloud and daylight where the desktop is, the weather a Weather layer shows; scene.on("weather") hears it change, and scene.weatherAt("Tokyo") reads it at a place a Weather layer names), scene.audio and scene.beat (what's playing, when the computer listens), scene.pointer, scene.width and scene.height, and the settings the wallpaper offers with scene.prop. There are helpers for numbers and colours too: scene.random (repeatable with scene.seed), scene.lerp, scene.clamp, scene.mix and scene.hsv, and more in scene.maths and scene.colour.

Everywhere, time is in seconds, places on the scene go 0 to 1 from the top left (a mote's are the scene's pixels), turns are in degrees, and amounts go 0 to 1.

Names and ids

A layer is named by its id or its name. An id is checked first, then a name. Names can be shared; ids can't. When several layers share a name, that name means the lowest of them (the first in the Outliner), and the console says so once: name the one you mean by its id (hover over a layer in Studio to see it), or use scene.layersNamed to get them all.

A folder's name or id names every layer in it: scene.hide("Clouds") hides each of them. Hidden layers and locked layers are layers like any other to a script (locking is for the editor). scene.layers lists every layer with its id, name, kind and folder, and scene.folders the folders.

When something isn't there

A call about something that isn't there never stops the script: a layer no layer is called, one that was taken out, an effect a layer hasn't got, a mote without a body, an emitter a particle layer hasn't got. It does nothing, and the console says so once, however often it's asked. A move asked of a layer that isn't there gives false.

Mistakes in the call itself are errors, as they always were: an event there's no such thing as, a function where a number goes, an easing there's none of.

Reading what's drawn

scene.get gives what the script last set, or else what the scene started with. It doesn't know about keyframes, settings that follow the sound, or a move half way there. scene.read does: it gives the setting as it was last drawn. It's live from the turn after a setting is first read; until then it gives what get gives.

const cloud = scene.layer("Cloud");
scene.on("tick", () => {
    // the cloud's x as its keyframes have it now, to rain beneath it
    scene.set("Rain", "x", cloud.read("x"));
});

A layer knows where it is on the scene, too: layer.bounds is the box round it as last drawn (0 to 1, with its turn), and layer.contains(x, y) says whether a point is on it. scene.layerAt(x, y) gives the top layer drawn at a point. All of them are null (or false) until the scene has been drawn once, the turn after they're first asked.

Effects, shaders and copies

layer.effect(which) is one of a layer's effects, by its number in the stack, its name or its kind: set, animate, enable and disable it. Reordering the stack doesn't break a script that names its effects. layer.uniform("speed", 2) sets a Shader layer's own value, and layer.material("glow", 1) a material's.

scene.clone(layer) makes a copy just above it while the scene plays (1024 at most), scene.remove(layer) takes one out, and scene.bringToFront and scene.sendToBack move one above or below the others beside it. None of them changes the scene's file.

Events

scene.on runs a function each time something happens.

EventWhenIt hands you
clicka click or tap on the wallpaperx, y from 0 to 1, and the button (0 the main one)
dblclicktwo clicks close togetherx, y
pressa button pressed or let gox, y, the button, and whether it's down
dragthe pointer moves with a button heldx, y, how far since (dx, dy), the button
wheelthe wheel turnsnotches across and down
pointerthe pointer moves over itx, y from 0 to 1
hoverthe pointer comes over the wallpaper, or leavestrue or false
layerClicka click lands on a layerthe layer's id, x, y
beata beat of what's playingthe beat's count
loud, quietthe sound gets louder than a level, or stays quieter than onehow loud (loud)
tickeach frame, or as often as the tick rate saysthe time, and the seconds since the last tick
minute, hourChanged, dayChangedthe clock's minute, hour or day changesthe hour and minute; the hour; the clock
propsthe wallpaper's settings were changedall of them, by id
pause, resumeit stops playing, or plays againnothing
resizeits size changeswidth, height
qualityhow finely it's drawn changes0 to 1
sensora new reading of a sensor the scene readsid, value, when, source
mediawhat's playing on the desktop changesas scene.media has it
weatherthe desktop's weather changesas scene.weather has it
calendar, systemthe calendar's events change, or the load is readas scene.calendar and scene.system have them
moteClicka mote is clickedits id, x, y, and the mote
moteEnter, moteLeavethe pointer comes over a mote, or leaves itits id, and the mote
hit, hitEndtwo motes' bodies start or stop touchingtheir ids, and the motes
videoEnda Video layer's film goes roundthe layer's id
markerthe timeline passes a markerits name and time
key, pada key, or the gamepad, while the scene has the inputthe key's code, whether down, whether it repeats, what it types; the buttons and axes
capturedthe scene is given the input, or it's taken backtrue or false
pagea web project's page sends somethingthe layer's id, and what

There's no start event: the top of the script is the start. scene.on gives back a function that stops listening, and scene.off(event, fn) does the same. loud and quiet take their level (and quiet its seconds) as a third argument: scene.on("quiet", fn, { level: 0.05, seconds: 2 }). None of these events costs anything while nothing listens, and those that watch for a change (hover, loud, quiet, the clock's) look once a tick for everything listening.

scene.on("click", (x, y) => {
    scene.animate("Orbs", "x", x * 2 - 1, 0.5);
});

scene.on("beat", (count) => {
    if (count % 4 === 0)
        scene.animate("Glow", "opacity", scene.random(0.4, 1), 0.3);
});

Sensors are explained in Sensors and watches, web projects in Web.

Moves

scene.animate moves a number or a colour setting smoothly. It eases in and out unless you give it another easing, can wait before it starts, and can go round and round or there and back. It gives back a promise that's true when the move gets there, and false when it's stopped first (by scene.stopAnimating, a set of the same setting, or another move of it). So steps can follow one another:

await scene.animate("Moon", "y", 0.2, 4, { easing: "sineInOut" });
scene.animate("Glow", "opacity", 1, 1);
scene.animate("Lantern", "rotation", 8, 1.5, { easing: "sineInOut", loop: "pingpong" });
scene.animate("Title", "opacity", 1, 2, { delay: 3, easing: [0.2, 0.8, 0.2, 1] });

The easings are linear, step, sineIn, sineOut, sineInOut, easeIn, easeOut, easeInOut, expoIn, expoOut, expoInOut, circIn, circOut, circInOut, backOut, bounceOut and elasticOut (the logic graph's), a cubic Bézier's [x1, y1, x2, y2], or a drawn curve, { curve: [...] }. Motion moves it each frame, so a move costs the script nothing while it runs.

Timers

scene.every runs a function over and over; scene.after runs it once. Both give back an id for scene.cancel. They run at the first tick that's due, so they're as exact as the tick rate. The seconds come first, as they do in a mote's setTimer(seconds, fn); the function first works too, for both.

const clock = scene.every(1, () => {
    const c = scene.clock;
    scene.setVar("time", `${c.hour}:${String(c.minute).padStart(2, "0")}`);
});
scene.after(30, () => scene.show("Hello", false));

Sounds

A script can play sounds of its own, without a Sound layer. Load each with scene.loadSound at the top of the script: the file (WAV, OGG, MP3, FLAC, Opus or M4A, in the scene's folder) is decoded as the scene opens, so one loaded later (in a handler, or a timer) plays nothing, and the console says so once. Each play is a voice that can be turned down, panned, stopped with a fade, or crossed over to another sound.

const chime = scene.loadSound("sounds/chime.ogg");                    // the Effects bus
const rain = scene.loadSound("sounds/rain.ogg", { bus: "ambience" });

let shower = rain.play({ loop: true, volume: 0.6, fadeIn: 2 });
scene.on("click", () => chime.play({ pan: -0.5, rate: 1.2 }));
scene.after(60, () => shower.stop({ fadeOut: 3 }));

play takes volume (0 to 2), pan (-1 left to 1 right), rate (0.25 to 4: faster is higher), loop, fadeIn and offset (where it starts, in seconds). A voice has stop({ fadeOut }), setVolume, setPan, setRate, seek(seconds), playing, position (where it is, in seconds), ended (a promise of its end, played out or stopped), and crossfadeTo(otherSound, seconds), which gives the new voice. Read a voice with its properties; change it with its methods. Playing a voice costs the frame nothing: it's a message to the mixer.

const thunder = scene.loadSound("sounds/thunder.ogg");
const v = thunder.play({ rate: scene.random(0.8, 1.1) });
await v.ended;
scene.animate("Sky", "colour1", "#20263a", 4);

The files a script loads go with the scene: Motion finds them in its loadSound calls when it saves a .motion file or puts the scene on Motion Hub. A script that picks its file at run time can list what it might play instead: export const sounds = ["sounds/a.ogg", "sounds/b.ogg"];. In Studio, the script's entry in Assets lists the sounds it uses.

A Sound layer is played from a script with scene.sound("Name").play() (and stop, pause, resume, setVolume, setPan, fadeTo(volume, seconds); playing and time say how it is). Every sound, a layer's or a script's, plays through one of the mixer's buses.

Beside scene.audio, scene.beat and scene.spectrum, a script reads scene.waveform (64 samples, -1 to 1), scene.beat.bpm (beats a minute), scene.beat.onset (true just as a beat lands) and scene.band(lowHz, highHz) (how loud a band of pitches is). All of it is the sound the computer plays, as a whole: not one bus's.

Particles, video and 3D

scene.particles(layer) is a particle layer's system (Particle system, Motes, Rain, Fireworks, Pointer light, Fire and 3D particles):

const embers = scene.particles("Embers");
scene.on("beat", (n) => { if (n % 4 === 0) embers.burst(80); });
scene.on("click", (x, y) => embers.burst(40, { x, y }));
embers.setRate(scene.weather?.rain ? 0 : 20);

burst(count, at, emitter) fires up to 2000 at once, at the emitter or at a point of the layer (0 to 1); setRate(perSecond, emitter) and resetRate() change and give back how many it makes a second; pause() and resume() hold and let go its spawning (what's alive lives on), and clear() takes every particle away. alive says how many there are. A burst asked while the scene is paused, or before the layer is first drawn, waits for it; a burst still fires at a rate of 0.

scene.video(layer) is a Video layer's film: play(), pause(), seek(seconds) and setRate(rate) (0 holds it), and time, duration and playing. A layer a script plays, holds or seeks gets a film of its own, so another layer showing the same file goes on as it was. Seeking decodes from the frame before it: a few times a second is fine, every frame isn't.

// a time-lapse sky that always shows the time of day
const sky = scene.video("Timelapse");
sky.pause();
scene.every(60, () => sky.seek(scene.clock.day * sky.duration));

scene.camera(layer) is a 3D layer's camera: setOrbit, setHeight, setDistance, setFov (each with seconds to take, or at once) and lookAt(point, seconds). scene.pickItem(layer, x, y) gives, the next turn, which of its items is under a point and its mote.

The timeline

scene.timeline is the clock the scene's keyframes go by. Until a script touches it, it's the scene's own time. play(), pause(), seek(seconds) and setSpeed(k) take it over; release() gives it back. The markers you put on the timeline (Marker in the Timeline's bar) are timeline.markers, and scene.on("marker") hears each as the timeline passes it. Waves and the rest of what moves by the scene's time go on as they were.

The pointer, the keyboard and where it runs

scene.pointer has buttons (a bit each: 1 the main one, 2 the second, 4 the middle), down and speed (the scene's width a second) beside where it is. A scene that wants the keyboard asks with scene.requestInput(), as a game layer does; the person gives it with a click on the scene's Play, and only then does the script hear key and pad. scene.captured says whether it has them, and scene.releaseInput() gives them back. A wallpaper on the plain desktop never hears what's typed.

scene.fps is the frames a second it's drawn at now, scene.isPreview whether it's in Studio's preview, scene.platform "windows", "linux" or "android", scene.locale the person's language ("en-GB"), and scene.screen which screen this is, of how many. scene.storeFor("screen") keeps values for this screen alone, beside the scene's own scene.store.

What the desktop knows

The scene reads what a Now playing, System monitor or Calendar layer would show:

Motion asks the desktop for each only when the script reads it.

Maths and colour

scene.maths has what the logic graph's maths nodes have: noise and noise2 (smooth noise), smoothstep, remap, ease, wrap, pingPong, damp (a value followed toward another, the same at any frame rate), degrees and radians. scene.colour has mix, fromHsv, toHsv, lighten, saturate, opposite and fromKelvin (the colour of light at a temperature). A module can import them as maths and colour from "motion".

scene.on("tick", (t, dt) => {
    scene.set("Firefly", "x", 0.5 + 0.3 * scene.maths.noise(t * 0.2));
    scene.set("Sky", "colour1", scene.colour.fromKelvin(scene.lerp(2000, 6500, scene.clock.daylight)));
});

The tick rate

By default the script hears every frame. scene.tickRate sets how many ticks a second it hears instead; fewer is lighter. The same setting is in the scene's settings, under Script timing, as Script ticks a second (0 is every frame).

scene.tickRate = 10;

Script timing also has Script's budget a frame (ms), 4 unless you change it. A script whose ticks take longer than that is heard every second frame, or every fourth or eighth, until its ticks are quick again. The drawing never waits for it.

The store

scene.store keeps values from one run to the next: a count of visits, the last colour someone picked. It's kept on this computer only.

const visits = scene.store.get("visits", 0) + 1;
scene.store.set("visits", visits);
scene.setVar("visits", `${visits} visits`);

It holds up to 64 values, 256 KB in all, and a key is at most 64 letters. Lists and objects are fine. remove, keys and clear do what they say.

Reading the web

scene.fetch reads from a website, but only from the sites the scene names. They're listed under Websites it may read on the Scene's Script card (api.example.com, *.example.org). When you write a scene.fetch with an address in it, Studio adds that site for you.

Naming a site isn't enough on its own: each site also has to be allowed on this computer. The card shows each one as Allowed, Refused or Not allowed yet, with an Allow button. Someone who uses your wallpaper is asked about each new site when the scene is first shown, and can change their mind in Motion Player's settings. A script reaches a site only over HTTPS, without anyone's cookies or sign-ins, and never the computer itself or its network. It can GET, POST or HEAD, up to 30 times a minute; it sends 256 KB at most, and an answer bigger than 4 MB is refused.

scene.fetch("https://api.example.com/now")
    .then((r) => r.json<{ value: string }>())
    .then((data) => scene.setVar("now", data.value))
    .catch((e: Error) => scene.warn(e.message));

The console

scene.log, scene.warn and scene.error write to the console under the code, and to the Console tab in the dock. You can also type an expression into it and the running script works it out. See Console.

The sandbox

A script can't hurt the computer or the wallpaper:

Classes, decorators and pins

A script can have classes for objects (motes, as the script calls them) that move, tick and get clicked. The "motion" module gives you Mote, Stage, Vector2, Vector3, Signal and some decorators. The decorators tell Studio how to show and run the class:

A callable's pins come from its signature. Each parameter is an input, its type gives the pin its colour (Seconds, Colour, Layer, Int), Ranged<0, 1> gives it a slider, and a doc comment is the node's help. Units and values lists them all, and what each gives the pin.

import { Mote, Seconds, Ranged, placeable, property, callable } from "motion";

/** A firefly that drifts about. */
@placeable({ category: "Garden" })
export class Firefly extends Mote {
    @property({ min: 0, max: 400, unit: "px/s" }) speed = 60;

    onTick(dt: Seconds): void {
        this.addOffset({ x: this.speed * dt, y: 0 });
    }

    /** Turns it a little, now and then. */
    @callable({ category: "Garden" })
    wobble(amount: Ranged<0, 1> = 0.5): void {
        this.addRotation(amount * 30);
    }
}

Objects, templates and their components are explained in Objects and templates.

Ask about this page on the forumAsk a question in Help

Last changed 2 November 2026. Something wrong here? Tell us.