MoonLive scripts¶
Scripts for the MoonLive engine, one file per script,
grouped by the module that runs it. Name one in a module's script control on a running device and
it compiles to native code on the next tick.
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() { for (int y = 0; y < height; y = y + 1) { setRGB(y * width + scale(beat(bpm, t), width), 255, 40, 0); } }
void tick() { fill(0, 0, 0); column(); }
}
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. A function takes no arguments yet, so a
helper is parameterized through the class's members. effects/crosshair.mle is the worked example.
A script is C++, and a compiler checks that. Every shipped script compiles under
c++ -std=c++20 -fsyntax-only (test/python/test_scripts_are_cpp.py), so the language cannot drift
into a dialect one feature at a time: an editor highlights a script correctly, a reader brings their
C++ intuition, and a script stands a good chance in any engine that speaks the same subset.
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, the way the compiled module a script stands in for
does: void tick() beside void tick() override. Three types, which is all the language has values
for:
| Type | Means | Example |
|---|---|---|
void |
it acts, it answers nothing | void tick() { … } |
int |
a number: any whole value, and a fixed one |
int dimensions() { return 2; } |
string |
a literal | string tags() { return "🌀"; } |
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:
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.
A declaration is a MEMBER; defineControls() decides what the UI shows. 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.
A member can be WRITTEN, which 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.
if and else, with <, <=, >, >=, == and !=. Both sides are ordinary expressions:
Members can be wider than a byte, and can be arrays. byte spans 0..255; 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, including 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 (heat[i * 2 + 1]), and an index outside the array is
clamped to the last element rather than refused or allowed through: a script computes indices
from live control values, so out of range is a normal run-time state, and the fixture shows a
repeated last light instead of crashing.
All of a class's members share a small fixed budget (kCtrlBytes), so a class that declares more
than fits is a compile error naming the arena, not a failed allocation while a fixture runs.
effects/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.
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(). 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, which is what every script rendered as before
this existed.
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: 📊 audio-reactive, ✨ 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.
A script's ROLE is its file extension: .mle an effect, .mll a layout, .mlm a modifier. One
language, three names, the way GLSL uses .vert/.frag for one shading language. It is what a card
filters its picker on, so an effect card offers effects.
Stated in the name rather than worked out from the file's contents, and deliberately: the entry
point a class defines (tick, placeLights, modifyLogical) already tells the ENGINE which moment
to call, but reusing that as the role would tie a UI filter to a language feature. The day a modifier
wants a per-frame tick(), every modifier would start appearing in effect pickers with nothing
changed. The engine stays role-blind either way: it runs whichever moment the binding asks for, so a
class defining several is still legal.
| folder | run by | a script writes |
|---|---|---|
layouts/ |
MoonLiveLayout | where the lights physically are — addLight(x, y, z) |
effects/ |
MoonLiveEffect | a color per light: setRGB(index, r, g, b), or a whole shape at once with line(x1, y1, x2, y2, r, g, b) |
modifiers/ |
MoonLiveModifier | where one light lands: setXYZ(xPos, yPos, zPos) |
Each module ships one of these as its default, so the folder doubles as the reference for what a working script looks like.
unit_MoonLiveScripts compiles every file here, so a script that stops parsing when the language
changes fails the build rather than waiting to be pasted into a device.
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 tells you 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.