Skip to content

MoonLive

The MoonLive language: what a script may declare, what the engine hands it, and what it can call. A script compiles to native code on the device, so it runs at the speed of a compiled module. The library that ships with MoonLight is moonlive/, and the shortest way in is Write your first script. Scripts other people shared are in MoonLight-Gallery, and the Gallery under the Control card's pads installs one into /moonlive with a click.

A script is a class

Each script declares a class, and the host calls its functions: tick() for an effect, placeLights() for a layout, modifyLogical() for a modifier. A function is called when it is present and its moment arrives, so which entry points a class defines is what decides what it does. The class name is independent of the file name, the way a C file and the functions in it are.

A class may also define functions of its own and call them, including calling itself:

class CrosshairEffect {
  byte bpm = 30;

  void defineControls() { addControl("bpm", bpm, 1, 240); }

  void column(int cx) { for (int y = 0; y < height; y = y + 1) { setRGB(y * width + cx, 255, 40, 0); } }
  void tick()         { fill(0, 0, 0); column(scale(beat(bpm, t), width)); }
}

These are real calls, not pasted-in text: the callee gets its own frame when it runs, which is what lets one helper call another and lets a function recurse. Arguments are passed BY VALUE, so a function that writes a parameter changes its own copy and the caller's variable is untouched. A function takes up to eight words of parameters, an RGB or Coord3D taking one per field. A function's name may not be a builtin's, since the builtin is found first and the function would never run. effects/basics/crosshair.mle is the worked example; layouts/sixteen-rings.mll is the one where the arguments earn their place, calling one helper sixteen times with different coordinates.

A script is C++

Every shipped script compiles under c++ -std=c++20 -fsyntax-only, pinned by test/python/test_scripts_are_cpp.py. The language therefore stays a subset rather than drifting into a dialect one feature at a time. An editor highlights a script correctly, and a reader brings their C++ intuition to it.

One shape difference is deliberate, and the test bridges exactly that one: a class here needs no public: and no trailing semicolon.

Anything else a compiler rejects is a divergence, and the test is where it surfaces.

Every function declares what it returns

A script does this the way the compiled module it stands in for does. void acts and answers nothing, int hands back a number, string hands back one of the script's own literals, and RGB or Coord3D hands back a whole struct.

return leaves a function, with a value or without one. Inside tick() a bare return; is an early exit, which is what a guard wants:

void tick() {
  if (width < 2) { return; }        // nothing to draw on a single column
  fill(0, 0, 0);
}

string names what comes back rather than introducing a string type: a script returns a literal, and building, joining or comparing text is out of scope. Text a script assembles, such as a temperature with its unit, lives in a JSON document, which the host holds.

Members, and which become controls

byte bpm = 30; is state the script owns: visible in every function, surviving every tick. Naming it in defineControls() with addControl("bpm", bpm, 1, 240) also puts it on the UI as a slider, which is the same call a compiled module makes. A member no addControl names stays private to the script, which is how a stateful effect holds a value the user should not see.

The default comes from the declaration, the range from the call, and the quoted name is the UI label, free to differ from the member's name. addSelect("shape", shape, "disc|ball|egg") makes a dropdown instead: shape holds 0, 1 or 2, the index of the name picked. The engine keeps every dropdown's names together in 96 bytes, each name with one byte for its end, up to 16 names in all.

A member can be written

That is what makes it state. level = level + 10; assigns, and the value is still there on the next tick, because a member lives in storage that outlives the call. A loop variable can be assigned too. A system variable (width, t, xPos) cannot: the engine rewrites it before every call, so the store would vanish.

A control CAN be assigned, and the effect is visible rather than surprising: the value moves under the slider until the user drags it again. Whether a member is a control is decided by defineControls() at run time, so the language does not distinguish the two here.

Branching

if and else take <, <=, >, >=, == and !=, and both sides are ordinary expressions:

if (heat[i] > 40) { setRGB(i, 255, 90, 0); }
else { setRGB(i, 0, 0, 0); }

Conditions join with && and || and negate with !, as in C++: && binds tighter than ||, and parentheses group. Evaluation stops at the first side that decides the answer, so if (i > 0 && heat[i - 1] > 40) never reads before the array. A bare value is true when it is not zero, so if (audioBeat()) reads as it says. A condition decides a branch rather than being a value, so int both = a && b; is refused.

Wider members, and arrays

byte spans 0..255, and int spans -2,147,483,648..2,147,483,647, which is what a position on a wall wider than 255 needs. An array is declared with a literal length and starts at zero:

int phase = 900;      // a value a byte cannot hold
byte  heat[16];         // sixteen elements, all zero to begin with

Every variable is declared

That includes a loop's counter. A member states its type, an assignment to a name that was never declared is refused, and a for writes for (int i = 0; ...). One rule with no exception, and the same line C++ would take.

An index is an arbitrary expression such as heat[i * 2 + 1], and one outside the array is clamped to the last element. A script computes indices from live control values, so out of range is an ordinary run-time state. The fixture shows a repeated last light rather than crashing.

All of a class's members share a small fixed budget (kCtrlBytes). A class that declares more than fits is a compile error naming the arena, not a failed allocation while a fixture runs.

effects/particles/ember.mle is the worked example: a heat array that decays and re-ignites, so what it draws this frame depends on the last one. That is the line between an effect that evaluates a formula and one that runs a simulation, and it is the reason arrays exist. plasma.mle would look identical if every frame started from scratch; ember.mle would go dark.

Helpers and recursion

Declare a helper above the function that calls it. Only functions already parsed are visible, so a call to one declared further down reports unknown function. A function can always call itself.

Recursion is bounded. About 30 calls deep, a further call does nothing and returns. A render task has a fixed stack, so the alternative to a limit is a device that resets mid-frame. What you see if you hit it is the picture being wrong where the recursion stopped, on a device that keeps running. Nothing is reported; the exact depth is kMaxCallDepth.

A script says what it is

dimensions() and tags() are both optional, both named after the member functions a compiled module declares (Dim dimensions() const override, const char* tags() const override), and both read once when the script compiles.

class RainEffect {
  int dimensions() { return 2; }        // an x/y picture
  string tags() { return "✨"; }         // shown on the card and in the picker

  void tick() { fill(0, 0, 40); }
}

dimensions() returns 1, 2 or 3, and it decides how the layer EXTRUDES the script. A script that returns 1 paints the x=0 column, and the framework fans it across the width. One that returns 2 paints the z=0 slice, and the framework copies it through the depth. So a script fills a rig it never indexed, and a wrong answer is visible: declare 1 and paint a picture, and only the first column survives. A script that stays silent is treated as 2.

tags() returns the emoji shown beside the script, so a row in the picker reads like a compiled effect's. The vocabulary is shared with the compiled modules: 🎶 reacts to frequency, ✨ particles, 🎯 aims moving heads. A script that declares none shows 📝, the mark of a scripted effect.

Both reach the picker before a factory script is downloaded, because the build extracts them from the source into the catalog. That copy is for display only: once a script is on the device, the compiled script is what decides.

The six types

A type says what a value means, and the storage is the compiler's business. Every scalar occupies the same 4-byte slot whatever its type, and only arrays pack by element. That is where width still earns its keep: a byte[] heat map costs a quarter of an int[] one.

Type Range For
int -2,147,483,648 to 2,147,483,647 counts, indices, milliseconds, anything whole
byte 0 to 255 a channel, a palette index, a heat cell: the LED's own range
bool true or false a flag
fixed -32,768.0 to 32,767.99998, in steps of 1/65,536 coordinates and anything fractional
string one of the script's own literals a name passed to a builtin
json a document the host holds, or none data a service reads from the internet or sends to a device

An initializer is range-checked against its type, so byte n = 300; is a compile error naming the member rather than a silent 44. A string array is refused, since there is no runtime string to fill one with.

A script's literals live in a pool sized to them, so a long one costs only the script that holds it, up to 16 KB of text a script. R"( ... )" is C++'s raw literal: it spans lines and keeps every quote, so a JSON document pastes in as it is, and R"x( ... )x" takes a delimiter for text holding )".

RGB and Coord3D

A color and a position travel as one value, under the names the compiled modules use for them. RGB is three byte fields, r, g and b, and Coord3D is three int fields, x, y and z.

class Warm {
  RGB last;                                       // a member starts at zero

  RGB toward(RGB c, int k) { return RGB(c.r, c.g / k, 0); }

  void tick() {
    RGB c = paletteRGB(mod(t / 16, 256), 255);    // one call for the whole color
    last = toward(c, 2);
    for (int i = 0; i < width; i = i + 1) { setRGB(i, last); }
    setRGB(0, 255, getRGB(1).g, 0);               // one field of a struct a call answers
  }
}

A struct is a member, a local, a parameter and a return value, read and written by field or as a whole. RGB(r, g, b) builds one, and a local declared without a value starts at zero. Passed to a call, a struct fills one position per field, so setRGB(i, c) is setRGB(i, c.r, c.g, c.b) and every builtin taking three channels takes an RGB. The compiler expands every use into the operations a script would write field by field, so a struct costs nothing a script written without one does not. A member RGB takes 3 bytes of the class's member budget and a Coord3D 12; a local takes one frame slot per field.

Fractional arithmetic with fixed

fixed is Q16.16: the number is stored scaled by 65,536, which is how every coordinate in the engine works. There is no float anywhere, so a result is bit-identical on all four backends and an effect is reproducible.

fixed ux = 0.0;
ux = uvX(x, width, height);     // uvX and uvY hand back a fixed coordinate
ux = ux * 2 + 0.5;              // ordinary arithmetic, decimals written as decimals
setRGB(0, toInt(ux * 100), 0, 255);

Mixing a whole number and a fixed value is a compile error naming the conversion to write. At run time the two are the same 32 bits, so a silent mix is a number 65,536 times off with nothing reporting it. toFixed(v) and toInt(v) convert explicitly, each one instruction.

An integer literal is the exception: it adopts the fixed side and converts at compile time, so ux * 2 and ux = 5; read naturally and cost nothing. A variable keeps the explicit rule, because its scaling is invisible where it is used.

System variables

Some names are reserved: the engine defines them, a script reads them, and a declaration reusing one is a compile error. One vocabulary serves every role, so a name means the same thing in a layout, an effect and a modifier.

name what it is
t elapsed milliseconds, the clock an animation is written against
width, height, depth the logical grid
xPos, yPos, zPos the light being transformed, which a modifier is handed

Each is a full 32-bit value, so a 768-wide wall reports 768 rather than the 255 a byte-wide slot once reported. A modifier handed a coordinate it cannot place passes it through untransformed. So a script always sees a value that means what it says.

The coordinate is xPos/yPos/zPos rather than x/y/z, which keeps x and y free as loop counters in every script. Reserving those globally would break the most ordinary code there is.

An effect is told its canvas rather than declaring it, since a size restated as a control is a second answer that can disagree with the first. A layout is upstream of that grid, so it names its own controls such as cols and rows.

The vocabulary

Registered by the light domain rather than built into the compiler, so the list is one edit in MoonLiveBuiltins_light.h. The core owns the grammar and a generic call mechanism, and nothing else.

call does
setRGB(index, r, g, b) write one light
getRGB(index) read a light's color as an RGB, what earlier effects in the layer drew
mulRGB(index, k) scale one light by k out of 255, the dimming a mask applies
addRGB(index, r, g, b) add a color to one light, saturating at 255
blendRGB(index, r, g, b, amount) mix one light toward a color by amount out of 255
setXYZ(x, y, z) write one position, for a modifier
fill(r, g, b) write every light
addLight(x, y, z) place the next light, for a layout
line(x1, y1, x2, y2, r, g, b) a straight segment, via the shared draw::line
circle(cx, cy, radius, thickness, r, g, b) a circle outline of a given stroke width, via the shared draw::ring
random16(n) a value in [0, n)
mod(a, b) the wrap a cyclic animation needs
abs(v), min(a, b), max(a, b), clamp(v, lo, hi) magnitude, the smaller, the larger, and a value held between bounds; on whole or fixed values, the result following them
beat(bpm, t) a 0..65535 sawtooth at bpm
beatsin(bpm, t, high) a sine 0..high at bpm
noise(x, y, z) 0..255 gradient noise, the field behind fire, clouds and plasma
scale(value, n) a 0..65535 value onto 0..n-1, which lands a wave on an axis
sin(angle), cos(angle) the circle: one turn is 0..65535, centered at 32768
turn(n) one revolution split n ways, the angle step for n points on a circle
print(v) log a value and return it, which belongs in a cold path
a / b, a % b divide and remainder, each a host call. Dividing by zero saturates, and a remainder by zero is 0
toFixed(v), toInt(v) convert between a whole number and a fixed one
smoothstep(e0, e1, v) a soft 0..65535 ramp between two edges, the anti-aliasing primitive
uvX(x, w, h), uvY(y, w, h) shader space as a fixed value, centered on 0.0 and normalized on the short side
smin(a, b, k) the smooth minimum of two distances, so shapes melt into one surface
fade(amt) dim every light toward black, the trail primitive
polarA(dx, dy), polarR(dx, dy) angle and distance from a center, for a radial effect
fbm(x, y, octaves) octaves of noise summed at doubling frequency, 0..255: the cloud and terrain field
warp(x, y, strength) the field sampled where the field displaced it, 0..255: the marbled look
fbm3(x, y, z, octaves), warp3(x, y, z, strength) the same two fields with a third axis
osc(rate, ms, shape) an oscillator, 0..65535, at rate cycles per minute. Shapes: 0 sine, 1 triangle, 2 sawtooth, 3 square
escape(cx, cy, jx, jy, iters) the Mandelbrot escape count, 0..255, 0 inside the set
setPaletteColor(x, y, index, bri) one light from the active palette, in one call
setPaletteColorZ(x, y, z, index, bri) the same, addressing a light in a volume
paletteRGB(i, bri) a palette color at a brightness, as an RGB, for a value rather than a pixel
trail(1) ask for a trail plane, from defineControls(). Returns whether it got one
flowNoise(zoom, strength), flowCurl(zoom, strength) carry the trail plane one frame along a flow
trailDecay(halfLifeMs) fade the trail by a half-life, so a tail holds its length at any framerate
emitTrail(x, y, z, index, bri, radius) throw light into the trail as a disc
fieldRate(n) true once every n frames, the lever that makes a per-pixel loop affordable
pool(n) size this script's particle pool, from defineControls(). Returns what it got
emit(x, y, angle, speed, n, life, hue) throw n particles from a point
gravity(g), drag(k) the two forces
step() move every particle, and drop what left the grid
age(rate) count down life; a dead particle frees its slot
bounce(e) reflect off the grid walls, keeping e/256 of the speed
collide(radius) particles notice each other and pile up, at a cost above linear
render(maxLife) draw the pool from the active palette
audioLevel(), audioSmooth() how loud the room is: the raw level, and the one that swells
audioBand(i) one of sixteen third-octave band magnitudes, bass at 0 and treble at 15
audioPeakHz(), audioBeat() the dominant frequency, and true on the frame a drum is heard
audioChroma(i), audioChordChange() one of the twelve pitch classes, C at 0 to B at 11, and true on the frame the chord changed
audioKey() the key over the last 15 s: 0 none yet, 1 to 12 C to B major, 13 to 24 C to B minor
audioBass(), audioMid(), audioTreble() the bass to 125 Hz, the mids to 2 kHz and the treble above, each 0..255 on the bands' scale
audioBassNote() the bass line's note as a MIDI number, A4 being 69, or 0 while none stands out
audioOnset(r) a hit's strength on the frame range r hit, 0 otherwise: 0 the bass, 1 the mids, 2 the treble
audioBandOnset(i) 1 on the frame third-octave band i had a hit, 0 otherwise: a tom in its own band
audioNoteOnset(i) 1 on the frame piano key i, 0 for A0, started a note the core keeps, 0 otherwise: an attack's flash, an overtone or a neighbor's leakage starts none; while the notes run
setPan(i, v), setTilt(i, v) aim one moving head, which a strip ignores
setZoom(i, v), setRotate(i, v), setGobo(i, v) shape that head's beam, wheel and pattern
setPalEntry(i, r, g, b) write one of the sixteen active palette entries
setPalEntryHSV(i, h, s, v) the same, in the space a palette is usually reasoned in
addControl(name, member, min, max) surface a member in the UI, from defineControls()
addSelect(name, member, "a\|b\|c") surface a byte member as a dropdown of those names, its value the index picked
gpioRead(pin), gpioWrite(pin, v) read and drive a pin, from a service script
adcRead(pin), adcMv(pin), adcMax() an analog reading: raw, in millivolts, and its full scale
setControl("fader2", v) move a slot of the Control surface, which drives whatever the slot targets: what a service does

Every audio call answers 0 without audio, so a script written for a rig with a microphone still runs on one without. A motion call reaches nothing on a light that carries no such channel, so one script serves a moving head and a strip. The last three belong to a service script, which runs on the poll rather than the render tick, and so do the JSON calls below.

Each particle call is one pass over the whole pool per frame rather than one per light. A 300-spark script therefore costs far less than a shader touching every pixel. Size the pool from defineControls(), which keeps an allocation off the render path. The vocabulary follows the WLED Particle System by Damian Schneider (@DedeHai); the fixed-point kernel and this binding are ours.

sin and cos return an unsigned wave centered on 32768, so a coordinate comes from scaling by the full span. uvX and uvY return a signed coordinate with the grid's center at 0, because a coordinate has an origin where a wave does not.

noise takes 16.8 fixed-point coordinates: the high byte selects the noise cell and the low byte interpolates within it. Its time axis must be monotonic, so feeding it a beat() sawtooth reads as a hiccup once per beat.

turn(n) exists because a full revolution is 65536, one past the largest number a script can write.

JSON documents in a service

A service script reads JSON from the internet, builds documents of its own, and sends them to this device or another one. A json value is a handle to a document the host holds, in an int's slot, so text never passes through the script: it moves from document to document, and a number comes out as an int or a fixed. A path into a document is a JSON Pointer (RFC 6901): /current/temperature_2m, /results/0/title.

class Weather {
  json w;
  json show;

  void tick20ms() {
    w = getJson("https://api.open-meteo.com/v1/forecast?latitude=52.37&longitude=4.90&current=temperature_2m", 600);
    if (jsonChanged(w)) {
      show = R"({"Effects": {"Layer": {"Text": {"text": ""}}}})";
      jsonSetText(show, "/Effects/Layer/Text/text", "Amsterdam ");
      jsonAppend(show, "/Effects/Layer/Text/text", w, "/current/temperature_2m");
      pushJson(show);
    }
  }
}
call does
json d = R"({...})"; a document from the script's own text, parsed once: a string literal where a json value belongs is parsed into one
getJson(url, seconds) a document fetched from url, fetched again once seconds passed and it is read; it answers at once, with the last document that arrived
jsonChanged(d) true when a new document arrived since the script last asked
jsonStatus(d) the HTTP status of the last answer, 0 while none came
jsonInt(d, "/p"), jsonFixed(d, "/p") the number at a pointer, a quoted one such as "252.11" included, 0 when absent
jsonSize(d, "/p") how many elements or members the array or object at a pointer holds
jsonSetInt(d, "/p", v), jsonSetFixed(d, "/p", v), jsonSetText(d, "/p", "text") write a value, creating the members on the way
jsonAppendText(d, "/p", "text"), jsonAppend(d, "/p", src, "/q") join text onto a string: the script's own, or another document's value as it reads
jsonCopy(d, "/p", src, "/q") copy a value from one document to another, a whole object included
pushJson(d) apply a state document to this device, and answer the reply
pushJsonTo("MM-eye1.local", d) send it to another MoonLight, or POST it to a URL, and answer the reply

A fetch runs on a task of its own, so a DNS lookup or a TLS handshake never stalls the poll. A refresh that fails keeps the last good document and is asked again after 5 seconds, then 10, 20 and on up to the feed's own period, so the first fetch after a boot, before WiFi is up, costs seconds rather than a period. An answer past 16 KB, 4 KB on a board without PSRAM, is refused rather than cut. An HTTPS feed needs PSRAM: a TLS session takes about 45 KB, which a board without PSRAM never has free beside its lights, so there the request waits and jsonStatus reads 0. A plain http:// feed needs no session and runs on any board, though few services still answer without TLS.

A board without PSRAM gets an HTTPS feed from one that has PSRAM: a script there fetches it and sends what the board shows with pushJsonTo, over plain HTTP on the local network. The board itself runs no script for it, only the layer the text lands in:

class SharePriceRelay {
  bool built = false;
  json price;
  json text;

  void tick20ms() {
    if (!built) {
      pushJsonTo("MM-ticker.local", R"({"Effects": {"Asml": {"type": "Layer",
        "AsmlText": {"type": "TextEffect", "text": "ASML", "scroll": true}}}})");
      text = R"({"Effects": {"Asml": {"AsmlText": {"text": ""}}}})";
      built = true;
    }
    price = getJson("https://query1.finance.yahoo.com/v8/finance/chart/ASML.AS?interval=1d&range=1d", 300);
    if (jsonChanged(price)) {
      jsonSetText(text, "/Effects/Asml/AsmlText/text", "ASML EUR ");
      jsonAppend(text, "/Effects/Asml/AsmlText/text", price, "/chart/result/0/meta/regularMarketPrice");
      pushJsonTo("MM-ticker.local", text);
    }
  }
}

A push is live, never saved: it applies the way PATCH /api/state?live=1 does, at the next frame boundary, so a script building its look pushes it again at start. A push to a target with one still on its way waits, replacing any that waited before it, so a script may push whenever something changed. A document naming a shipped script the device lacks lists that script on the System card, and an open page downloads it, as the picker does.

jsondemo.mls in moonlive/services shows four feeds, the weather, a share price, a match and a headline, as scrolling text, and builds its own four layers to do it; it needs PSRAM for their HTTPS. weather.mls beside it shows one feed over plain HTTP, the weather, so it runs on any board.

Debugging: print

print(v) writes a value to the serial log and returns it, so it wraps any part of an expression without changing the result. addLight(print(xx), yy, 0) places the same light and reports what xx was.

Take it out again when the script works. A serial write blocks, and a script runs on the render tick, so a print costs frame time every frame it survives. Each compile grants a short burst and then goes quiet, which bounds the damage and gives every edit a fresh window; it does not make a print free. No script in this folder ships with one.

Source

MoonLive is the engine, MoonLiveCompiler the front-end, and MoonLiveBuiltins_light the table registering every call above. The service and JSON calls are MoonLiveBuiltins_service, each a thin wrapper over JsonFetch and JsonUtil, which compiled code calls for the same reach. The lowering turns the intermediate form into machine bytes, written once for every backend.