Skip to content

Moon ECMAScript Guide

The definitive ECMAScript reference for writing Moon expression: scripts.

How to read this guide. Signatures use ? for optional parameters (slice(start?, end?)), for the return value, and fn for a callback whose parameters are spelled out next to it. Methods that modify their receiver are marked (mutates) — everything else returns a new value. Every example line annotated // → result shows the value the Moon runtime actually produces; the results are real, not aspirational. The examples in §15 (and any example using params.… / assets.…) assume the standard fixtures defined there.

1. Execution model

Expressions are sand-boxed, purely functional, synchronous ECMAScript (ES2023 baseline). There is no access to the network, filesystem, system time, or mutable global state; identical inputs always produce identical output.

  • A single-expression script returns its value implicitly: expression: params.diameter / 2
  • A multi-statement script (anything containing a declaration or statement) must end with an explicit return. Omitting it is a validation error.
  • Inputs arrive via with: bindings, the input: sugar, and params.X / assets.Y auto-binding (dot access only — see §4).
  • The result becomes an asset; declare produces: for any non-DATA result (see §16).
yaml
moon: "1.0"
doc: |
    Multi-statement script with explicit return; params via auto-binding
params:
    width: 2
    height: 1
render:
    expression: |
        const area = params.width * params.height
        return { area, ratio: params.width / params.height }

Semicolons and ASI. Omit semicolons (Moon style). Automatic semicolon insertion is safe under two habits: never put return and its value on separate lines, and never start a line with ( or [ (it would be parsed as a call/index of the previous line). The mandatory return rule already eliminates the worst ASI trap.

2. The global environment

Available built-ins

GlobalNotes
MathFull standard library (§10), except Math.random (blocked)
JSONparse, stringify, including reviver/replacer (§14)
String, Number, BooleanConverters (Number("42")) and statics
Array, ObjectAll statics: Array.from, Object.entries, Object.groupBy, …
RegExpPlus regex literals /…/flags (§13)
Set, MapFull API; returned values auto-convert (§12)
Error, TypeError, RangeError, SyntaxError, ReferenceErrorFor throw new Error("…") and instanceof checks
DateExplicit-argument construction only — new Date(0), Date.parse, Date.UTC. The wall-clock forms Date.now() and zero-argument new Date() throw (determinism)
parseInt, parseFloat, isNaN, isFiniteStandard
undefined, NaN, InfinityStandard

Not available

Referencing any of these is an error (most are rejected at link time as Unknown variable):

  • Async machinery: Promise, async/await — scripts are synchronous; rejected at parse.
  • Classes: class declarations and expressions — rejected at parse. Use factory functions and plain objects (§7.6).
  • Modules: import/export — dependencies come from with: and auto-binding.
  • console — there is no log output; referencing it is a link error. Return intermediate values to inspect them (§17).
  • Symbol, BigInt (and 1n literals), Proxy, Reflect, typed arrays (Float64Array, ArrayBuffer), WeakMap/WeakSet, globalThis, window, eval, structuredClone, Intl.
  • Math.random — blocked with an error; use the seeded RandomNormal({ shape, seed }) operation for reproducible randomness.
  • Wall-clock timeDate.now(), Date(), and zero-argument new Date() throw a guiding error; only explicit-argument Date construction is allowed (see the table above).

Operations and compositions

Every Moon API operation is a global function taking one named-argument object; an operation with an input parameter can also be called with a single bare value:

js
Box({ size: [1, 1, 1] })          // constructor-style: named arguments only
Sum({ input: data })              // named form
Sum(data)                         // bare-value shorthand for the input parameter
Data([1, 2, 3])                   // upgrade an inline literal to a tensor (rectangular arrays of any rank, §15.1)

Compositions bound via assets.X / params.X are callable the same way: assets.beam({ len: 3 }) (§15.9).

3. Determinism rules

  • No Math.random, no wall-clock time (Date.now(), zero-argument new Date()) — both throw guiding errors. No external state.
  • Randomness must be seeded through inputs: RandomNormal({ shape: [n], seed: params.seed }) is deterministic — the same seed always produces the same tensor.
  • Supplied values are read-only or defensively copied: writing a tensor element (params.v[0] = 9) is an error. Build new values instead of mutating inputs.

4. Declarations and bindings

4.1 const and let

js
const r = 0.5            // immutable binding — the default choice
let total = 0            // reassignable — counters and accumulators only
  • const for every binding that is never reassigned; let only for loop counters and accumulators. Never var (function-scoped, hoisted — all of its uses are better served by const/let).
  • Both are block-scoped: a binding inside { … }, a loop body, or an if branch is invisible outside it. Inner blocks may shadow outer names — avoid shadowing; pick a new name.
  • const freezes the binding, not the value: const arr = [] permits arr.push(1).
  • Scripts run in strict mode: assigning to an undeclared name is an error, not an implicit global.

4.2 Auto-binding

params.X and assets.Y are detected statically and injected as dependencies — but only literal dot access:

js
params.width             // bound — works
js
params["width"]          // ✗ NOT bound — "Unknown variable: 'params'"
const { width } = params // ✗ NOT bound — the namespace itself is not a value

To access a param whose name is computed, bind the whole record explicitly via with: and a value:/asset: node, then index that.

4.3 Binding forms

All standard ECMAScript binding forms work — on values (the params/assets namespaces themselves are the one exception, §4.2):

js
const pair = [3, 4]
const point = { x: 1, y: 2 }
const [a, b] = pair                      // array destructuring
const { x, y: yRenamed, z = 9 } = point  // object destructuring: rename, default
const head = ([first]) => first          // destructured parameter
const dist = ({ x: px, y: py }) => Math.hypot(px, py)
const sum = (...nums) => nums.reduce((s, n) => s + n, 0)   // rest parameter
const scale = (v, factor = 2) => v * factor                // default parameter
const results = [a, b, head(pair), dist(point), sum(1, 2, 3), scale(5)]
results                              // → [3,4,3,2.23606797749979,6,10]

for (const [k, v] of pairs) destructures loop variables the same way, and catch (e) binds the thrown value (§6.9).

yaml
moon: "1.0"
doc: |
    Destructuring bindings on values, recursion via a named declaration
params:
    pair: [3, 4]
render:
    expression: |
        const [x, y] = params.pair
        function fact(n) { return n <= 1 ? 1 : n * fact(n - 1) }
        return { hypot: Math.hypot(x, y), factOfFive: fact(5) }

5. Operators

5.1 Reference

From highest to lowest precedence (parenthesize whenever in doubt):

OperatorsMeaningNotes
(…), a.b, a[i], f(x), a?.bGrouping, member access, call, optional chaina?.b / a?.[i] / f?.() short-circuit to undefined when a is null/undefined
!x, -x, +x, typeof x, void xUnarytypeof never throws on unknown properties (it does on unknown variables at link time)
**ExponentiationRight-associative; unary minus needs parens: (-2) ** 2
*, /, %Multiplicative% is remainder with the dividend's sign, not modulo
+, -Additive; + also concatenates strings
<<, >>, >>>Bit shifts (32-bit integers)
<, <=, >, >=, in, instanceofRelational
===, !==, ==, !=EqualitySee 5.2
&, ^, |Bitwise AND / XOR / OR (32-bit)&, ^ are overloaded on geometry (5.4)
&&, ||, ??Logical AND / OR / nullish coalescingShort-circuit; ?? may not be mixed with &&/|| without parens (parse error)
cond ? a : bTernary
=, +=, -=, *=, /=, %=, **=, &&=, ||=, ??=AssignmentStatements, not for use inside expressions
js
2 ** 3 ** 2                          // → 512
7 % 3                                // → 1
-7 % 3                               // → -1
((-7 % 3) + 3) % 3                   // → 2
1 << 3                               // → 8
5 & 3                                // → 1
5 | 2                                // → 7
~5                                   // → -6
"px-" + 12                           // → "px-12"
"5" * 2                              // → 10
"5" + 2                              // → "52"
typeof NaN                           // → "number"
typeof null                          // → "object"
"area" in { area: 2 }                // → true
[1] instanceof Array                 // → true

The last four lines show why never rely on implicit coercion: convert explicitly with Number(…) / String(…) and the intent is unambiguous.

5.2 Equality and truthiness

  • Use === / !== everywhere on plain values. == performs type coercion ("1" == 1 is true) — on plain values it has no legitimate use.
  • On tensors the rule inverts: == / != are overloaded element-wise; === is plain reference identity (5.4).
  • NaN never equals anything, including itself — test with Number.isNaN(x).
  • Object.is(a, b) is === that also distinguishes NaN (equal to itself) and ±0.

Exactly these values are falsy: false, 0, -0, "", null, undefined, NaN. Everything else is truthy — including "0", [], {}, and every Moon host value (even an empty tensor):

js
Boolean("0")                         // → true
Boolean([])                          // → true
NaN === NaN                          // → false
Number.isNaN(NaN)                    // → true
Object.is(NaN, NaN)                  // → true
"1" == 1                             // → true
"1" === 1                            // → false

5.3 Short-circuit selection

|| falls through on any falsy value; ?? only on null/undefined — use ?? for defaults so that legitimate 0 and "" survive:

js
0 || 10                              // → 10
0 ?? 10                              // → 0
null ?? 10                           // → 10
({ a: { b: 1 } })?.a?.b              // → 1
({ a: { b: 1 } })?.missing?.b        // → undefined
({ a: 1 }).missing ?? "default"      // → "default"

The assignment forms mirror the operators: x ??= v assigns only when x is null/undefined, x ||= v when falsy, x &&= v when truthy.

5.4 Moon operator overloads

Moon extends a fixed operator set to Moon values (full rules in the spec's "Values in Expressions"):

OperandsOverloaded operatorsMeaning
Tensors (and scalars)+ - * / % **, unary -, == != > >= < <=Element-wise with broadcasting; comparisons yield a boolean tensor
Meshes+ - &Union, difference, intersection
Polygons+ - & ^Union, difference, intersection, XOR

Practical rules that follow:

  • === is identity, not element-wise. On tensors use == / !=.
  • A comparison on a tensor returns a boolean tensor, not a boolean. Reduce it before branching: (t > 2).some(x => x), (t > 0).every(x => x). On a matrix the axis-0 slices are rows, so reductions nest: (m > 3).some(row => row.some(x => x)).
  • Host values are always truthy — test t.length === 0, never if (t).
  • Plain arrays carry no operators. [1, 2] + 1 and arr1 + arr2 are errors pointing you to Data(...); only string concatenation keeps native behavior ("p: " + arr).
  • & and ^ are geometry-only; on numbers they stay native JS bitwise. Neither bitwise nor logical operators are element-wise on tensors.
  • Tables and jagged arrays have no operators — use .map, or pull a column/row out as a tensor.
yaml
moon: "1.0"
doc: |
    Element-wise tensor math with broadcasting, boolean-tensor reduction
params:
    points: [[0, 0, 0], [1, 0, 0], [2, 0, 0]]
    offset: [0, 0.5, 0]
    series: [12, 7, 23]
render:
    expression: |
        const moved = (params.points + params.offset) * 2
        const anyBig = (params.series > 20).some(x => x)
        return { moved, anyBig }

6. Control flow

Each statement below lists its syntax, what it does, and when to reach for it.

6.1 if / else

js
if (cond) {
    // …
} else if (otherCond) {
    // …
} else {
    // …
}

The default branching tool. cond is evaluated for truthiness (§5.2) — write explicit comparisons (if (n > 0), if (s !== "")) rather than relying on coercion. Braces may be omitted for a single statement (if (rows.length === 0) return null), but use braces the moment a branch has two statements. When to use: any branch that performs statements (declarations, pushes, early return). For pure value selection prefer the ternary.

6.2 Conditional (ternary) expression

js
cond ? valueIfTrue : valueIfFalse

An expression, not a statement — it yields a value, so it can sit inside a return, an argument, or an object literal. Chains read top-to-bottom like an if/else ladder and are the idiomatic way to classify a value:

js
const pick = n => n < 0 ? "neg" : n === 0 ? "zero" : "pos"
pick(-1)                             // → "neg"
pick(0)                              // → "zero"
pick(2)                              // → "pos"

When to use: selecting between values. The moment a branch needs side effects or multiple statements, switch to if.

6.3 switch

js
switch (value) {
    case "a":
        // … runs when value === "a"
        break
    case "b":
    case "c":            // fall-through: b and c share a branch
        // …
        break
    default:
        // … no case matched
}

Compares value against each case label with strict equality (===). Without break (or return), execution falls through into the next case — useful for sharing a branch (stack the labels), a bug otherwise. default is optional and may appear anywhere (put it last). When to use: three or more discrete branches on one scalar — an enum-style style param, a mode string. For two branches use if; for value selection from a fixed map, an object lookup is often shorter: ({ matte: 0.9, glossy: 0.1 })[params.style] ?? 0.5.

A switch (true) ladder is the statement-level analogue of a chained ternary — each case is a condition; the first truthy one runs (return ends it, so no break needed):

js
const grade = n => {
    switch (true) {
        case n >= 0.9: return "A"
        case n >= 0.8: return "B"
        default: return "C"
    }
}
grade(0.95)                          // → "A"
grade(0.5)                           // → "C"

Note: at the Moon node level the same job is done declaratively by match: — prefer it when the branches are nodes rather than script values.

6.4 for (classic counted loop)

js
for (let i = 0; i < n; i++) {
    // … i is 0, 1, …, n-1
}

The three heads are init (runs once), condition (checked before each pass), update (after each pass). Multiple variables go in with commas: for (let i = 0, j = n - 1; i < j; i++, j--). When to use: you need the index itself (positions along an axis, angle steps), a non-unit stride, or parallel indices. For "one pass per element" prefer for…of or an array method; for "build an array of n things" prefer Array.from({ length: n }, (_, i) => …) (§8.1).

js
const out = []
for (let i = 0; i < 4; i++) out.push(i * 10)
out                                  // → [0,10,20,30]

6.5 for…of (iterate values)

js
for (const item of iterable) {
    // … item is each element in order
}

Iterates the values of any iterable: plain arrays, strings (characters), Set, Map (yields [key, value] pairs, destructurable as for (const [k, v] of map)), generator results — and Moon host values: a tensor iterates its axis-0 slices, a table its rows, a jagged array its row tensors (§15.2). When to use: a pass over elements that needs statements — early break/continue, accumulating several outputs at once. For a pure element-wise transformation, map/filter/reduce say it shorter.

js
let sum = 0
for (const x of [1, 2, 3]) sum += x
const pairs = []
for (const [k, v] of new Map([["a", 1], ["b", 2]])) pairs.push(k + v)
sum                                  // → 6
pairs                                // → ["a1","b2"]

6.6 for…in (iterate keys)

js
for (const key in obj) {
    // … key is each enumerable property name (a string)
}

Iterates property names of an object. When to use: rarely — prefer Object.keys(obj) / Object.entries(obj) with array methods; they give you a real array (chainable, index access) and behave identically on supplied records. Never use for…in on arrays (it yields string indices, plus any inherited keys).

6.7 while and do…while

js
while (cond) {
    // … runs zero or more times
}

do {
    // … runs at least once
} while (cond)

When to use: the iteration count is unknown in advance — converging a numeric approximation, walking a structure until a condition is met, consuming a work queue. do…while only when the body must run before the first test. Always make sure something in the body moves cond toward false — the sandbox has no execution timeout to save you from an infinite loop.

js
let x = 100
let steps = 0
while (x > 1) { x = x / 2; steps++ }
steps                                // → 7

6.8 break and continue

js
for (const item of items) {
    if (skip(item)) continue   // next iteration
    if (done(item)) break      // leave the loop entirely
}

continue skips the rest of the current pass; break exits the innermost loop (also exits a switch). The labeled forms escape nested loops:

js
const out = []
outer: for (let i = 0; i < 3; i++) {
    for (let j = 0; j < 3; j++) {
        if (j > i) continue outer    // next i
        if (i === 2) break outer     // leave both loops
        out.push(i * 10 + j)
    }
}
out                                  // → [0,10,11]

When to use: early exit is often clearer than threading a condition through the loop head; but if the body reduces to "find the first match", use find/findIndex instead. Reach for a label only when a helper function with return would be more ceremony than the label.

6.9 try / catch / finally and throw

js
try {
    // … code that may throw
} catch (e) {                  // binding optional: catch { … } also works
    // … runs if the try block threw; e is the thrown value
} finally {
    // … always runs (optional)
}

throw new Error("message")     // or throw a plain string

Exceptions abort the current call stack until caught; an uncaught throw fails the expression with the thrown message. Inside catch (e), an Error's text is e.message, and e instanceof TypeError distinguishes error kinds. When to use: sparingly. Validating inputs with an explicit if (bad) throw new Error("expected N points") is excellent — it turns a confusing downstream error into a precise one. Wrapping logic in try/catch to silence errors is not — prefer checking the condition (?., ??, .length, Number.isFinite) over catching the failure.

js
const safeRatio = (a, b) => {
    if (b === 0) throw new Error("ratio: divisor is zero")
    return a / b
}
const caught = (() => { try { return safeRatio(1, 0) } catch (e) { return e.message } })()
safeRatio(6, 3)                      // → 2
caught                               // → "ratio: divisor is zero"
yaml
moon: "1.0"
doc: |
    Loops over a supplied tensor, switch, and try/catch building a classification table
params:
    values: [-2, 0, 3, 7]
render:
    expression: |
        const rows = []
        for (const v of params.values) {
            let kind = ""
            switch (Math.sign(v)) {
                case -1: kind = "negative"; break
                case 0: kind = "zero"; break
                default: kind = "positive"
            }
            rows.push({ value: v, kind })
        }
        let note = "ok"
        try {
            if (rows.length === 0) throw "empty input"
        } catch {
            note = "fallback"
        }
        return { rows, note }

7. Functions

7.1 Arrow functions — the default form

js
x => x * 2                     // one parameter: parens optional
(a, b) => a + b                // several parameters: parens required
() => 42                       // no parameters
x => ({ value: x })            // returning an object literal: wrap in parens
x => {                         // block body: braces require an explicit return
    const y = x * 2
    return y + 1
}

The concise body (x => expr) returns the expression implicitly; the block body (x => { … }) returns undefined unless it returns. Forgetting return in a block body — or forgetting the parens around a returned object literal — are the two classic silent bugs.

Parameters support destructuring, rest, and defaults (§4.3). For an options object, destructured parameters with defaults read best:

js
const cyl = ({ r = 0.1, h = 1 }) => Cylinder({ radiusLow: r, height: h })

7.2 Function declarations and recursion

Both function declarations and const arrows bind a name the body can call — pick by taste (Moon style leans to arrows; a declaration reads better for a chunky helper). Deep recursion is fine (thousands of frames); still prefer iteration for simple accumulation.

js
function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2) }
const fact = n => n <= 1 ? 1 : n * fact(n - 1)
fact(5)                              // → 120
fib(10)                              // → 55

7.3 Closures

A function captures the variables of its defining scope — the basis for factories and configurable helpers:

js
const makeScaler = factor => x => x * factor
const toMm = makeScaler(1000)
toMm(0.25)                           // → 250

7.4 Higher-order functions

Functions are values: pass them, return them, store them in objects. This replaces everything classes would otherwise do:

js
const twice = f => x => f(f(x))
const inc = x => x + 1
twice(inc)(0)                        // → 2
const applyAll = (fns, x) => fns.reduce((acc, f) => f(acc), x)
applyAll([inc, inc, x => x * 10], 1) // → 30

7.5 IIFE and generators

An immediately-invoked arrow turns a statement sequence into an expression — occasionally useful inside an object literal. Generators (declaration or expression form) combine with spread and for…of:

js
function* gen() { yield 1; yield 2; yield 3 }
[...gen()]                           // → [1,2,3]
(() => { const a = 2; return a * 3 })()   // → 6

Reach for generators only when lazily produced sequences genuinely simplify the code — for fixed-size sequences Array.from({ length: n }, …) is more direct.

7.6 Objects as lightweight structures

Methods and getters in object literals cover the structured-value use cases that classes would; getters are evaluated when the value crosses the asset boundary:

js
const obj = { a: 2, double(x) { return x * 2 }, get twice() { return this.a * 2 } }
obj.double(5)                        // → 10
obj.twice                            // → 4

8. Arrays (plain JS)

An array written in the script — or produced by map() on anything — is a plain JS array with the full ES2023 Array.prototype and no element-wise math (wrap with Data(...) — see §15.1).

Callback convention: every method taking fn calls it as fn(element, index, array); trailing parameters may be omitted.

8.1 Construction

FormProduces
[1, 2, 3]Literal
Array.from(src, fn?)Array from any iterable or array-like (incl. Moon host values, strings, { length: n })
Array.of(a, b, …)Array of its arguments
new Array(n).fill(v)n copies of v
[...a, ...b, x]Spread-concatenation
js
Array.from({ length: 4 }, (_, i) => i * 0.5)   // → [0,0.5,1,1.5]
Array.from("abc")                    // → ["a","b","c"]
Array.of(1, 2)                       // → [1,2]
new Array(3).fill(0)                 // → [0,0,0]
[...[1, 2], ...[3], 4]               // → [1,2,3,4]

Array.from({ length: n }, (_, i) => …) is the Moon idiom for "n things by index" — angles, grid positions, repeated parts.

8.2 Reading and searching

SignatureReturns
arr[i]Element at i (0-based); undefined out of range. No negative indices on plain arrays
arr.at(i)Element at i; negative counts from the end
arr.lengthElement count
arr.includes(v)true if v is present (finds NaN too)
arr.indexOf(v) / lastIndexOf(v)First / last index of v by ===, or -1 (never finds NaN)
arr.find(fn) / findLast(fn)First / last element where fn is truthy, or undefined
arr.findIndex(fn) / findLastIndex(fn)Its index, or -1
arr.every(fn)true if fn truthy for all (true on empty)
arr.some(fn)true if fn truthy for any (false on empty)
js
const a = [5, 12, 8, 12]
a.at(-1)                             // → 12
a.includes(8)                        // → true
a.indexOf(12)                        // → 1
a.lastIndexOf(12)                    // → 3
a.find(x => x > 6)                   // → 12
a.findLast(x => x > 6)               // → 12
a.findIndex(x => x > 6)              // → 1
a.every(x => x > 0)                  // → true
a.some(x => x > 10)                  // → true
[].every(x => false)                 // → true

8.3 Transforming (non-mutating)

SignatureReturns
arr.map(fn)New array of fn(element, index, array) results, same length
arr.filter(fn)Elements where fn is truthy
arr.slice(start?, end?)Copy from start up to (not including) end; negatives from the end; no args = shallow copy
arr.concat(b, c, …)This array followed by the arguments (arrays are flattened one level)
arr.flat(depth?)Sub-arrays flattened depth levels (default 1; Infinity for full)
arr.flatMap(fn)map(fn) then flat(1) — one-to-many mapping
arr.join(sep?)String of elements joined by sep (default ",")
arr.toSorted(cmp?)Sorted copy — see sorting note below
arr.toReversed()Reversed copy
arr.toSpliced(i, n, …items)Copy with n elements at i replaced by items
arr.with(i, v)Copy with element i replaced by v
js
const a = [3, 1, 2]
a.map(x => x * 10)                   // → [30,10,20]
a.filter(x => x > 1)                 // → [3,2]
a.slice(1)                           // → [1,2]
a.slice(-2)                         // → [1,2]
a.concat([4, 5], 6)                  // → [3,1,2,4,5,6]
[1, [2, [3]]].flat()                 // → [1,2,[3]]
[1, [2, [3, [4]]]].flat(Infinity)    // → [1,2,3,4]
[1, 2].flatMap(x => [x, x * 10])     // → [1,10,2,20]
a.join("-")                          // → "3-1-2"
a.toSorted((x, y) => x - y)          // → [1,2,3]
a.toReversed()                       // → [2,1,3]
a.toSpliced(1, 1, 99)                // → [3,99,2]
a.with(0, 7)                         // → [7,1,2]

Sorting. Without a comparator, sort/toSorted compare as strings[10, 2, 1] sorts to [1, 10, 2]. Always pass a comparator for numbers: (a, b) => a - b ascending, (a, b) => b - a descending. The comparator returns negative/zero/positive to order a before/equal/after b; for strings use (a, b) => a.localeCompare(b).

8.4 Reducing

SignatureReturns
arr.reduce(fn, init?)Single value; fn(acc, element, index, array) runs left-to-right. Without init, the first element seeds acc (throws on empty array — always pass init)
arr.reduceRight(fn, init?)Same, right-to-left
js
[1, 2, 3].reduce((acc, x) => acc + x, 0)         // → 6
[[1, 2], [3]].reduce((acc, r) => acc.concat(r), [])  // → [1,2,3]
[1, 2, 3].reduce((acc, x) => Math.max(acc, x), -Infinity)  // → 3
["a", "b"].reduceRight((acc, s) => acc + s, "")  // → "ba"

Reduce to an object to build keyed aggregates (or use Object.groupBy, §11.2):

js
const byKind = [1, 2, 3, 4].reduce((acc, x) => {
    const k = x % 2 === 0 ? "even" : "odd"
    acc[k] = (acc[k] ?? 0) + x
    return acc
}, {})
byKind                               // → {"odd":4,"even":6}

8.5 Mutating methods

These modify the array in place — fine on arrays you created locally, an error on supplied tensors (read-only) and a style violation on other supplied values. Prefer the non-mutating counterparts (toSorted, toReversed, toSpliced, with, spread) except for push in a build loop.

SignatureEffect / returns
arr.push(v, …) (mutates)Append; returns new length
arr.pop() (mutates)Remove and return last element
arr.unshift(v, …) / arr.shift() (mutates)Same at the front (O(n) — avoid in hot loops)
arr.splice(i, n, …items) (mutates)Remove n at i, insert items; returns the removed elements
arr.sort(cmp?) / arr.reverse() (mutates)In-place sort / reverse; return the array
arr.fill(v, start?, end?) (mutates)Overwrite a range with v; returns the array
js
const a = [1, 2, 3, 4]
a.splice(1, 2)                       // → [2,3]
a                                    // → [1,4]
[0, 0, 0, 0].fill(9, 1, 3)           // → [0,9,9,0]

8.6 Iterating

SignatureReturns
arr.forEach(fn)undefined — side-effect loop over elements
arr.entries()Iterator of [index, element] pairs (spread to materialize)
arr.keys() / arr.values()Iterator of indices / elements
Array.isArray(x)true for plain JS arrays (false for tensors and other host values)
js
[...[7, 8].entries()]                // → [[0,7],[1,8]]
[...[7, 8].keys()]                   // → [0,1]
Array.isArray([1])                   // → true

9. Strings

9.1 Literals, escapes, templates

"double" and 'single' quotes are equivalent (Moon style: double). Backtick template literals interpolate ${expression} and may span multiple lines:

js
const n = 3
`${n} parts, ${n * 2} screws`        // → "3 parts, 6 screws"
`outer ${`inner ${1 + 1}`}`          // → "outer inner 2"

Escape sequences: \\ backslash, \" / \' / \` quotes, \n newline, \t tab, \r carriage return, \uXXXX / \u{XXXXX} Unicode. String.raw disables escapes (String.raw + a template = literal backslashes).

Strings are immutable — every method returns a new string. Characters are read with s[i] or s.at(i) (negative ok); strings are iterable ([..."abc"] → characters).

9.2 Inspecting

SignatureReturns
s.lengthCode-unit count
s.at(i) / s[i] / s.charAt(i)Character; at accepts negatives
s.includes(sub) / startsWith(sub) / endsWith(sub)Substring tests
s.indexOf(sub) / lastIndexOf(sub)Position or -1
s.charCodeAt(i) / codePointAt(i)Numeric character code
s.localeCompare(t)Negative / 0 / positive ordering — the string comparator
js
const s = "moonomat"
s.length                             // → 8
s.at(-1)                             // → "t"
s.includes("ono")                    // → true
s.startsWith("moon")                 // → true
s.indexOf("o")                       // → 1
s.lastIndexOf("o")                   // → 4
s.charCodeAt(0)                      // → 109
"b".localeCompare("a")               // → 1

9.3 Transforming

SignatureReturns
s.slice(start?, end?)Substring; negatives count from the end
s.substring(a, b)Like slice but swaps swapped arguments, no negatives — prefer slice
s.split(sep, limit?)Array of parts; sep may be a string or regex; "" splits into characters
s.trim() / trimStart() / trimEnd()Whitespace removed
s.padStart(len, pad?) / padEnd(len, pad?)Padded to len with pad (default space)
s.repeat(n)n concatenated copies
s.replace(pat, repl)First match of pat (string or regex) replaced
s.replaceAll(pat, repl)All matches replaced (string pat, or regex with g)
s.toUpperCase() / toLowerCase()Case-converted
s.concat(t, …) / s + tConcatenation — prefer template literals
s.normalize(form?)Unicode normalization ("NFC" default)
js
const s = "moonomat"
s.slice(0, 4)                        // → "moon"
s.slice(-3)                          // → "mat"
"a,b,,c".split(",")                  // → ["a","b","","c"]
"a,b,c".split(",", 2)                // → ["a","b"]
"  x  ".trim()                       // → "x"
"5".padStart(3, "0")                 // → "005"
"ab".repeat(3)                       // → "ababab"
"a-b-a".replace("a", "X")            // → "X-b-a"
"a-b-a".replaceAll("a", "X")         // → "X-b-X"
s.toUpperCase()                      // → "MOONOMAT"
String.fromCharCode(72, 105)         // → "Hi"

Regex-powered match / matchAll / search / replace(/…/g, fn) are covered in §13.

The classic Moon string job — assembling SVG path data:

yaml
moon: "1.0"
doc: |
    Template literals composing an SVG path for the Path operation
params:
    size: 0.1
    corner: 0.03
render:
    expression: |
        const s = params.size
        const r = params.corner
        const d = [
            `M ${r} 0`,
            `L ${s - r} 0`,
            `A ${r} ${r} 0 0 1 ${s} ${r}`,
            `L ${s} ${s}`,
            `L 0 ${s}`,
            `Z`,
        ].join(" ")
        return Path({ d })
    produces: POLYGONS

10. Numbers and Math

10.1 Numeric literals and precision

js
1_000_000                            // → 1000000
1.5e3                                // → 1500
0xff                                 // → 255
0b101                                // → 5
0o17                                 // → 15

All numbers are 64-bit floats — there is no integer type (and no BigInt). Integers are exact up to Number.MAX_SAFE_INTEGER (2^53 − 1); decimal fractions are not exact:

js
0.1 + 0.2                            // → 0.30000000000000004
Math.abs((0.1 + 0.2) - 0.3) < 1e-9   // → true

Compare floats with a tolerance, never ===. Division by zero yields Infinity, 0/0 yields NaN — both serialize to null in JSON output, so guard divisors (if (b === 0) throw "…").

10.2 Math

GroupFunctions
Sign/magnitudeabs(x), sign(x) (−1/0/1), min(…xs), max(…xs)
Roundinground(x) (half away from zero toward +∞), floor(x), ceil(x), trunc(x) (toward zero)
Powers/rootssqrt(x), cbrt(x), hypot(…xs) (√Σx²), pow(a, b) (same as a ** b), exp(x), expm1(x)
Logarithmslog(x) (natural), log2(x), log10(x), log1p(x)
Trigonometrysin cos tan (radians), asin acos atan, atan2(y, x) (full-quadrant angle), sinh cosh tanh asinh acosh atanh
ConstantsPI, E, SQRT2, LN2, LN10
Blockedrandom() — throws; use the RandomNormal({ shape, seed }) operation
js
Math.round(2.5)                      // → 3
Math.round(-2.5)                     // → -2
Math.floor(-1.5)                     // → -2
Math.trunc(-1.5)                     // → -1
Math.hypot(3, 4)                     // → 5
Math.atan2(1, 0)                     // → 1.5707963267948966
Math.max(...[1, 9, 3])               // → 9

min/max take argument lists — spread an array into them. Degrees ↔ radians helpers are a two-liner you will write often:

js
const rad = deg => deg * Math.PI / 180
const clamp = (x, lo, hi) => Math.min(Math.max(x, lo), hi)
const lerp = (a, b, t) => a + (b - a) * t
rad(180)                             // → 3.141592653589793
clamp(12, 0, 10)                     // → 10
lerp(0, 10, 0.25)                    // → 2.5

10.3 Number and parsing

SignatureReturns
Number(x)Strict conversion — whole string must be numeric, else NaN
parseInt(s, radix?)Leading integer; parses as far as it can; always pass the radix for non-decimal
parseFloat(s)Leading float
Number.isInteger(x) / isFinite(x) / isNaN(x) / isSafeInteger(x)Type-safe predicates (no string coercion, unlike global isNaN)
Number.MAX_SAFE_INTEGER, EPSILONLimits
n.toFixed(d)String with d decimals
n.toPrecision(d)String with d significant digits
n.toString(radix?)String in base radix
js
Number("42")                         // → 42
Number.isNaN(Number("12px"))         // → true
parseInt("12px")                     // → 12
parseInt("ff", 16)                   // → 255
parseFloat("1.5e2")                  // → 150
Number.isInteger(5)                  // → true
(1234.5678).toFixed(2)               // → "1234.57"
(12.345).toPrecision(3)              // → "12.3"
(255).toString(16)                   // → "ff"

toFixed/toPrecision return strings — format at the very end, never mid-calculation.

11. Objects (records)

11.1 Literals and access

js
const key = "depth"
const height = 3
const cfg = {
    width: 2,                  // plain property
    height,                    // shorthand for height: height
    [key]: 0.5,                // computed key
    area() { return this.width * this.height },   // method
    get volume() { return this.width * this.height * this.depth },  // getter
}
cfg.width                            // → 2
cfg[key]                             // → 0.5
cfg.area()                           // → 6
cfg.volume                           // → 3

Dot access for known names, bracket access for computed names. Missing properties read as undefined — chain ?. for nested paths and ?? for defaults (§5.3). Property order is insertion order (for string keys), and Object.keys/entries/for…in follow it.

11.2 Object statics

SignatureReturns
Object.keys(o)Array of property names
Object.values(o)Array of property values
Object.entries(o)Array of [key, value] pairs
Object.fromEntries(pairs)Object from [key, value] pairs — inverse of entries
Object.assign(target, …src) (mutates target)Shallow-merge into target — prefer spread
Object.hasOwn(o, key)true if o has own property key (like the in operator without inherited keys)
Object.groupBy(items, fn)Object of arrays, keyed by fn(item)
Object.is(a, b)Strict identity incl. NaN (§5.2)
Object.freeze(o) / isFrozen(o)Make immutable / test — writes to a frozen object throw in strict mode
js
Object.keys({ a: 1, b: 2 })          // → ["a","b"]
Object.values({ a: 1, b: 2 })        // → [1,2]
Object.entries({ a: 1 })             // → [["a",1]]
Object.fromEntries([["a", 1], ["b", 2]])   // → {"a":1,"b":2}
Object.hasOwn({ a: 1 }, "a")         // → true
Object.groupBy([1, 2, 3, 4], x => x % 2 === 0 ? "even" : "odd")  // → {"odd":[1,3],"even":[2,4]}

11.3 Copying, merging, transforming

Spread copies are shallow — nested objects are shared, so rebuild the nested level you change:

js
const defaults = { width: 1, height: 2, frame: { depth: 0.5 } }
({ ...defaults, height: 3 }).height                  // → 3
({ ...defaults, frame: { ...defaults.frame, depth: 0.7 } }).frame.depth   // → 0.7

The entries→map→fromEntries round-trip transforms keys or values wholesale:

js
const cfg = { width: 1, height: 2 }
Object.fromEntries(Object.entries(cfg).map(e => [e[0], e[1] * 2]))  // → {"width":2,"height":4}
yaml
moon: "1.0"
doc: |
    Record transformation with Object.entries / fromEntries and spread-merge
assets:
    defaults:
        value:
            width: 1
            height: 2
            depth: 0.5
render:
    expression: |
        const cfg = { ...assets.defaults, height: 3 }
        const doubled = Object.fromEntries(
            Object.entries(cfg).map(e => [e[0], e[1] * 2])
        )
        return { keys: Object.keys(cfg), doubled, hasDepth: "depth" in cfg }

12. Set and Map

Fully usable inside a script — dedup, lookup tables, grouping — and safe to return: a returned Set converts to an array, a returned Map to a record, at any nesting depth inside plain containers. Convert explicitly only when you want a different shape ([...map.entries()] for a pair list); a Map with non-primitive keys cannot become a record and fails with that hint.

Set — unique values

SignatureReturns / effect
new Set(iterable?)Set of distinct values (SameValueZero: NaN equals itself)
s.add(v) (mutates)Adds; returns the set (chainable)
s.has(v)Membership test — O(1), the reason to use a Set
s.delete(v) (mutates)Removes; returns whether it was present
s.sizeCount
[...s], s.forEach(fn)Iteration in insertion order
js
const seen = new Set([3, 1, 2, 1])
seen.size                            // → 3
seen.has(2)                          // → true
[...seen]                            // → [3,1,2]
[...new Set([1, 1, 2])].map(x => x * 10)   // → [10,20]

Map — keyed lookup

Use a Map when keys are not strings or arrive dynamically; otherwise a plain object is simpler.

SignatureReturns / effect
new Map(pairs?)From [[key, value], …]
m.get(k) / m.set(k, v) (mutates)Read (undefined if absent) / write (chainable)
m.has(k) / m.delete(k) (mutates)Test / remove
m.sizeCount
m.keys() / values() / entries(), m.forEach((v, k) => …)Iteration in insertion order
yaml
moon: "1.0"
doc: |
    Set for dedup, Map for counting — returned directly (Set becomes an array, Map a record)
params:
    tags: [a, b, a, c, b, a]
render:
    expression: |
        const counts = new Map()
        for (const t of params.tags) counts.set(t, (counts.get(t) ?? 0) + 1)
        return { unique: new Set(params.tags), counts }

13. RegExp

13.1 Literals and flags

/pattern/flags — e.g. /\d+/g. Flags: g all matches, i case-insensitive, m ^/$ match per line, s . also matches newlines, u Unicode escapes \u{…}, y sticky. re.source and re.flags read them back.

13.2 Pattern syntax (the working subset)

PatternMatches
. \d \w \sAny char; digit; word char; whitespace (capitals negate: \D …)
[abc] [^abc] [a-z]Character class; negated; range
x* x+ x? x{2,5}Quantifiers (greedy; append ? for lazy: .*?)
^ $ \bStart; end; word boundary
(…) (?:…)Capturing group; non-capturing group
(?<name>…)Named capturing group
a|bAlternation
(?=…) (?!…)Lookahead (positive / negative)
(?<=…) (?<!…)Lookbehind (positive / negative)

13.3 Methods

SignatureReturns
re.test(s)Boolean — the cheapest "does it match"
re.exec(s)First-match array ([full, group1, …], .groups for named) or null
s.match(re)Without g: like exec. With g: array of full matches, or null
s.matchAll(re) (needs g)Iterator of full match arrays — spread it
s.search(re)Index of first match or -1
s.replace(re, repl) / replaceAllrepl is a string (with $1, $<name> references) or a function (match, …groups) => string
s.split(re)Split on every match
js
/^m/.test("moon")                    // → true
"10-20".match(/(\d+)-(\d+)/)[1]      // → "10"
"a1b22c".match(/\d+/g)               // → ["1","22"]
[..."a1b22c".matchAll(/\d+/g)].map(m => m[0])   // → ["1","22"]
"a1b".search(/\d/)                   // → 1
"a1b2".replace(/\d/g, "#")           // → "a#b#"
"12-34".replace(/(\d+)-(\d+)/, "$2-$1")          // → "34-12"
"2026-06-12".match(/(?<y>\d{4})-(?<m>\d{2})/).groups.y   // → "2026"
"12-34".replace(/(?<a>\d+)-(?<b>\d+)/, "$<b>:$<a>")      // → "34:12"
"a1b2".replace(/\d/g, m => m * 2)    // → "a2b4"
"/12 m/".match(/\d+(?= m)/)[0]       // → "12"
"$42".match(/(?<=\$)\d+/)[0]         // → "42"
"x.y.z".split(/\./)                  // → ["x","y","z"]

match returns null when nothing matches — guard with ?. or test first: (s.match(/\d+/) ?? ["0"])[0].

14. JSON

SignatureReturns
JSON.parse(text, reviver?)Value; reviver(key, value) transforms every node bottom-up
JSON.stringify(value, replacer?, space?)String; replacer is a key whitelist array or (key, value) function (return undefined to drop); space indents
js
JSON.parse('{"a": [1, 2], "b": true}').a[1]      // → 2
JSON.parse('{"a": 1, "b": 2}', (k, v) => typeof v === "number" ? v * 10 : v)  // → {"a":10,"b":20}
JSON.parse(JSON.stringify({ a: 1, b: 2, c: 3 }, ["a", "c"]))   // → {"a":1,"c":3}
JSON.stringify({ a: 1 }, null, 2).includes("\n")  // → true

Notes:

  • JSON.parse(JSON.stringify(x)) is the deep-clone idiom for plain data.
  • NaN/Infinity stringify to null; functions and undefined properties are dropped.
  • Tensors, tables, and jagged arrays stringify to their data form (nested arrays / row objects). Meshes and other specialized assets have no meaningful JSON form — return them as assets instead of stringifying.
  • Parsing inline JSON is rarely needed — prefer a value: node or an imported .json, which arrive already structured.

15. Working with Moon values

This is the heart of Moon scripting. The full value model lives in the spec; this section is the practical playbook.

Fixtures. The annotated examples in this section are evaluated with these bindings:

yaml
params:
    v: [10, 20, 30]                      # 1-D tensor, shape [3]
    m: [[1, 2, 3], [4, 5, 6]]            # 2-D tensor, shape [2,3]
    names: [alpha, beta, gamma]          # string list (native JS array)
    jag: [[1, 2], [3, 4, 5], [6]]        # jagged array
    edges: [[0, 1], [0, 2], [1, 2]]      # edge list for Graph
assets:
    cities:                              # table: 4 rows × (city, population, area)
        value:
          - { city: NewYork, population: 8400000, area: 783 }
          - { city: London, population: 8900000, area: 1572 }
          - { city: Tokyo, population: 14000000, area: 2194 }
          - { city: Paris, population: 2100000, area: 105 }
    g:                                   # graph: 0→{1,2}, 1→{2}, 2→{}
        value:
            graph: [[1, 2], [2], []]

15.1 Supplied vs inline — the one rule to internalize

A numeric array supplied to the script (via params, assets, with:, input:, or an operation result) is a tensor with element-wise operators. An array written inline is a plain JS array with Array.prototype methods and no operators. Bridges between the two worlds:

DirectionHow
Inline array → tensorData([1, 2, 3]); nested rectangular literals become multi-dimensional tensors: Data([[1, 2], [3, 4]]) is a 2×2 matrix
Flat data + shape → tensorReshape({ input: Data(flat), newShape: [2, 3] })
Tensor → plain array[...t] / Array.from(t) (rows of a matrix stay tensors), or t.map(x => x)
Jagged → nested plain arrayj.toArray()
Table → array of row objectstable.map(r => r) or [...table]
js
Data([1, 2, 3]) + 1                  // → [2,3,4]
Data([[1, 2], [3, 4]]).shape         // → [2,2]
Data([[1, 2], [3, 4]]) + Data([10, 20])   // → [[11,22],[13,24]]
Array.from(params.v)                 // → [10,20,30]
Reshape({ input: Data([1, 2, 3, 4]), newShape: [2, 2] }) + 10   // → [[11,12],[13,14]]

The same inference applies to whole structures you build in script: an array of uniform records becomes a table, a numeric array with ragged rows a jagged array — exactly as if the value had been written in YAML or imported from JSON.

15.2 Host-value ergonomics

Shared accessors on every supplied value: shape (size per axis), rank (axis count), size (total element count), isNumeric; collections add length (axis-0 / row count).

  • Indexing: v[i] with negative indices (v[-1] is the last element); matrices chain m[i][j] — the two-index form m[i, j] is rejected at validation.
  • There is no .at() on host values (use v[-1]); conversely plain arrays have .at(-1) but no [-1].
  • Iterable: for…of, spread, and argument spread walk the axis-0 slices / rows — [...t] materializes a host value into a plain array.
  • Read-only: element assignment is an error; build new values.
  • Callbacks receive (element, index, source) where source is the host value itself.
  • map / flatMap always return a plain array (JS semantics) — re-wrap with Data(...) or an operation if you need a tensor back.
  • JSON.stringify emits the data form (nested arrays / row objects); but Array.isArray is false and Object.keys lists the member names, not elements — spread first if you need a real array.
js
params.v[-1]                         // → 30
params.m[1][2]                       // → 6
params.m[-1]                         // → [4,5,6]
params.v.shape                       // → [3]
params.m.shape                       // → [2,3]
params.m.rank                        // → 2
params.m.size                        // → 6
[...params.v]                        // → [10,20,30]
Math.max(...params.v)                // → 30
JSON.stringify(params.m)             // → "[[1,2,3],[4,5,6]]"
Array.isArray(params.v)              // → false

15.3 Tensors

Members (callbacks iterate axis-0 slices: numbers for a vector, row tensors for a matrix):

SignatureReturns
t.lengthAxis-0 count
t[i]Element (vector) or row tensor (matrix); negative ok
t.map(fn)Plain array of results
t.flatMap(fn)Plain arraymap(fn) flattened one level; JS-array results are spliced in, anything else (incl. host values) stays whole
t.filter(fn)Tensor of matching slices
t.find(fn) / t.findIndex(fn)First matching slice or null / its index or -1
t.every(fn) / t.some(fn)Boolean
t.forEach(fn)undefined
t.reduce(fn, init?)Accumulated value; fn(acc, element, index, source)
t.slice(start?, end?)Tensor sub-range along axis 0; negatives ok
js
params.v.map(x => x * 2)             // → [20,40,60]
params.v.map((x, i, src) => x / src.length)   // → [3.3333333333333335,6.666666666666667,10]
params.v.flatMap(x => [x, x / 10])   // → [10,1,20,2,30,3]
params.v.filter(x => x > 10).shape   // → [2]
params.v.find(x => x > 10)           // → 20
params.v.findIndex(x => x > 10)      // → 1
params.v.reduce((acc, x) => acc + x, 0)   // → 60
params.v.slice(1)                    // → [20,30]
params.v.slice(-2)                   // → [20,30]
params.m.map(row => row[0])          // → [1,4]
params.m.flatMap(row => [...row])    // → [1,2,3,4,5,6]

Everything that reshapes or aggregates is an operation, not a member:

Operation (see API reference)Purpose
Sum/Mean/Min/Max({ input, axis? })Reductions (whole tensor or per-axis)
Range({ count, start?, step? })Arithmetic sequence tensor
Zeros({ shape }), Fill({ value, shape })Construction
Reshape({ input, newShape }), Transpose({ input, permutation })Shape surgery
Slice({ input, start?, count? }), Gather({ input, indices, axis? })Extraction
Repeat({ input, repeats })Tiling
Norm({ input, axis? }), Dot({ input, other })Linear algebra
js
Sum(params.v)                        // → 60
Mean(params.v)                       // → 20
Max(params.m)                        // → 6
Range({ count: 4 })                  // → [0,1,2,3]
Range({ count: 3, start: 1, step: 0.5 })   // → [1,1.5,2]
Reshape({ input: params.v, newShape: [3, 1] })   // → [[10],[20],[30]]
Gather({ input: params.v, indices: Data([2, 0]) })   // → [30,10]

End-to-end example — normalize a series with operations + element-wise math:

yaml
moon: "1.0"
doc: |
    Normalize a supplied series to 0..1 with operations + element-wise math
params:
    series: [12, 7, 23, 18, 9]
render:
    expression: |
        const lo = Min(params.series)
        const hi = Max(params.series)
        return (params.series - lo) / (hi - lo)

Building point arrays — three equivalent styles:

yaml
moon: "1.0"
doc: |
    Building [N,3] point arrays: loop+push, Array.from, and a circle via trig
params:
    n: 8
    radius: 0.5
render:
    expression: |
        const ring = Array.from({ length: params.n }, (_, i) => {
            const a = 2 * Math.PI * i / params.n
            return [params.radius * Math.cos(a), 0, params.radius * Math.sin(a)]
        })
        const line = []
        for (let i = 0; i < params.n; i++) {
            line.push([i * 0.1, 0, 0])
        }
        return { ring, line }

15.4 Tables

Rows are plain objects (r.city); columns are read by name with col. Members:

SignatureReturns
t.lengthRow count
t[i]Row object (integer index only — columns go through col)
t.columnsColumn-name array
t.col(name)Numeric column → tensor; text column → native string array
t.schema()Record of column name → "number" / "text"
t.filter(fn) / t.slice(start?, end?)Sub-table
t.map(fn)Plain array (of whatever fn returns)
t.flatMap(fn)Plain arraymap(fn) flattened one level (JS flatMap)
t.sort(cmp)New table ordered by cmp(rowA, rowB)
t.find/findIndex/every/some/forEach/reduceAs on tensors, over row objects
js
assets.cities.length                 // → 4
assets.cities.columns                // → ["city","population","area"]
assets.cities[0].city                // → "NewYork"
assets.cities[-1].city               // → "Paris"
assets.cities.col("area").slice(0, 2)            // → [783,1572]
assets.cities.col("city").join(", ").length      // → 29
assets.cities.filter(r => r.population > 5000000).length   // → 3
assets.cities.map(r => r.population / r.area)[3] // → 20000
assets.cities.flatMap(r => [r.city, r.area]).slice(0, 4)   // → ["NewYork",783,"London",1572]
assets.cities.sort((a, b) => a.area - b.area)[0].city      // → "Paris"
assets.cities.find(r => r.area < 200).city       // → "Paris"
assets.cities.schema()               // → {"city":"text","population":"number","area":"number"}
Sum(assets.cities.col("population")) // → 33400000

Aggregation and joins are operations: GroupBy({ input, keyColumn, valueColumn, op }), Join({ input, other, onColumn, kind? }), SortBy({ input, column, descending? }), FilterRows({ input, column, op, value }), SelectColumns. Use members for ad-hoc row logic, operations for declarative table algebra.

yaml
moon: "1.0"
doc: |
    Filter rows, read a column as a tensor, compute a derived metric per row
assets:
    cities:
        value:
          - { city: NewYork, population: 8400000, area: 783 }
          - { city: London, population: 8900000, area: 1572 }
          - { city: Tokyo, population: 14000000, area: 2194 }
          - { city: Paris, population: 2100000, area: 105 }
render:
    expression: |
        const big = assets.cities.filter(r => r.population > 5000000)
        const density = big.map(r => ({ city: r.city, density: r.population / r.area }))
        const totalPop = Sum(big.col("population"))
        return { names: big.col("city").join(", "), density, totalPop }

15.5 Jagged arrays

A numeric array with rows of differing lengths. j[i] yields a row tensor (with operators); toArray() materializes the whole structure:

js
params.jag.length                    // → 3
params.jag[1] + 1                    // → [4,5,6]
params.jag[-1][0]                    // → 6
params.jag.map(row => row.length)    // → [2,3,1]
params.jag.map(row => Sum(row))      // → [3,12,6]
params.jag.flatMap(row => [...row])  // → [1,2,3,4,5,6]
params.jag.toArray()                 // → [[1,2],[3,4,5],[6]]

15.6 String lists and lists

Supplied flat string arrays and mixed arrays are native JS arrays — all of §8 applies directly. Use .at(-1), not [-1]:

js
params.names.join("-")               // → "alpha-beta-gamma"
params.names.includes("beta")        // → true
params.names.at(-1)                  // → "gamma"
params.names.map(s => s.toUpperCase())   // → ["ALPHA","BETA","GAMMA"]
[...params.names].reverse()          // → ["gamma","beta","alpha"]

15.7 Records

Supplied records behave like plain objects (§11) — including field names that collide with member names elsewhere (length, map, shape are fine as record keys). To define a record param or asset inline, use a value: node — a bare YAML mapping would be parsed as a node.

15.8 Graphs

A graph value exposes length (node count) and graph (adjacency as a jagged array). All algorithms are operations: Degree, Neighbors, EdgeCount, ConnectedComponent, Subgraph, GraphEdges, Jaccard, DegreeOfSeparation, ToUndirected, and the constructors Graph({ edges }) / Graph({ indices, edgesFlat }) / Graph({ adjacency }) — for a table with from/to columns, build the edge list inline: Graph({ edges: rows.map(r => [r.from, r.to]) }); for per-node neighbor rows (e.g. an imported graph .json), pass them as adjacency.

js
assets.g.length                      // → 3
assets.g.graph[0].length             // → 2
assets.g.graph.toArray()             // → [[1,2],[2],[]]
Degree(assets.g).slice(0)            // → [2,1,0]
yaml
moon: "1.0"
doc: |
    Build a graph from an inline edge list, read adjacency members and degrees
render:
    expression: |
        const g = Graph({ edges: [[0, 1], [0, 2], [1, 2]] })
        return { nodes: g.length, fanoutOf0: g.graph[0].length, degrees: Degree(g) }

15.9 Meshes and polygons — building geometry

The classic pattern: compute placements as data, map them to transformed parts, fuse with an operation. Any expression returning geometry must declare produces:.

yaml
moon: "1.0"
doc: |
    Fan out boxes along a circle, fuse with Union, cut a core with operators
params:
    count: 6
    radius: 0.8
render:
    expression: |
        const parts = Array.from({ length: params.count }, (_, i) => {
            const a = 2 * Math.PI * i / params.count
            const pos = [params.radius * Math.cos(a), 0, params.radius * Math.sin(a)]
            return Transform({ input: Box({ size: [0.3, 0.3, 0.3] }), translate: pos })
        })
        return Union(parts) - Cylinder({ radiusLow: 0.75, height: 1 })
    produces: MESHES

Operators on geometry: a + b union, a - b difference, a & b intersection, and on polygons additionally a ^ b (XOR). Compositions are callable too:

yaml
moon: "1.0"
doc: |
    Calling a local composition from an expression, sizing parts from Bounds
assets:
    beam:
        doc: |
            A beam of parameterized length
        params:
            len: 1
        render:
            op: Box
            with:
                size:
                    expression: |
                        [params.len, 0.05, 0.05]
render:
    expression: |
        const long = assets.beam({ len: 1.2 })
        const b = Bounds(long)
        const width = b[1][0] - b[0][0]
        const cross = Transform({ input: assets.beam({ len: width / 2 }), rotate: [0, 90, 0] })
        return long + cross
    produces: MESHES

Bounds returns a [2,3] tensor (b[0] min corner, b[1] max corner) — tensor indexing and math apply directly. Other measurement operations that feed back into scripting: Centroid, Volume, SurfaceArea, Area, Perimeter, Dimensions.

Mesh records — vertex-level access. A MESHES value exposes length and meshes[i], a mesh record: vertices ([N,3] tensor), triangles ([T,3] integer tensor), normals ([N,3] tensor), optional materialUVs ([N,2]) and material (opaque), plus vertexCount / triangleCount. The fields are tensors, so per-vertex analysis is element-wise math, not a loop. The same record constructs geometry: Mesh({ vertices, triangles }) (normals computed when omitted, winding counter-clockwise = outward), and an expression with produces: MESHES may return a bare mesh record or { meshes: [...] } directly. The round-trip idiom — read, transform with tensor math, spread the rest back — deforms imported geometry in one expression:

yaml
moon: "1.0"
doc: |
    Displace a mesh's vertices along their normals with seeded noise: read the
    mesh record, replace vertices, spread the remaining fields through Mesh
render:
    expression: |
        const m = input.meshes[0]
        const noise = RandomNormal({ shape: [m.vertexCount, 1], seed: 7 })
        return Mesh({ ...m, vertices: m.vertices + m.normals * (noise * 0.01) })
    input:
        op: Box
        with:
            size: [1, 1, 1]
    produces: MESHES

Constructed meshes render and feed Group / Transform / ApplyMaterial directly, but an open surface is rejected by boolean operations and volume measurements (watertight geometry required) — the error names the offending mesh.

15.10 Images, materials, fonts

Images expose their pixels — read and construct. An IMAGE value gives you width, height, channels, the raw packed bytes pixels (UInt8 [H, W, C], 0–255), and the normalized math surface values (Float64 [H, W, C] in [0, 1], lazily materialized).

Compute on values, never pixels. values is in the same 0–1 convention you author colors in, so img.values * 0.6 darkens correctly. img.pixels * 0.6 is a 0–255 field that clamps to white when packed — pixels is for inspection / raw byte access only. (Hoist a repeated img.values into a const; it allocates an 8×-larger buffer.)

Tensor reductions and math apply to values directly — Max({ input: img.values }), per-channel statistics, luminance, histograms.

Construct a new IMAGE with Image(field): it packs a numeric [0, 1] pixel field ([H, W, C] with C ∈ {1, 3, 4}, or [H, W] grayscale) into an image — clamp → ×255 → round. Like Data(…) it is callable bare. An expression declaring produces: IMAGE may instead return the bare field directly (the boundary packs it, like produces: MESHES). So the full read → modify → write round-trip is in script:

js
return Image(1 - img.values)        // invert; or `produces: IMAGE` + `return 1 - img.values`

Because an image is an [H, W, C] tensor, tone/channel/blend/composite operations come straight from the operators — generation is the coordinate-grid idiom (Coords + whole-tensor math, not a per-pixel loop). Dedicated kernels cover what algebra can't: Blur / Sharpen / Sobel / Dilate / Erode / Convolve, Resize / Crop / Mirror / RotateImage / Pad / TileImage, Noise (returns a tensor — pack it when done), and Rasterize (POLYGONS → IMAGE). See the spec's "Images from data".

Materials and fonts are opaque — no script members; bind them and pass them to operations. Note Text itself cannot be called from an expression (use an op: node, or wrap it in a composition and call that).

16. Returning values

The script's result crosses the asset boundary and its kind is inferred from its structure (spec: Data Asset):

You returnBecomes
number, string, boolean, nullScalar
rectangular numeric array (any nesting)Tensor
array of uniform objectsTable
objectRecord (getters evaluated, methods dropped)
numeric array with ragged rowsJagged array
flat string arrayString list
mixed arrayList
mesh/polygon/material/image/font/graph valueThat asset — declare produces:
mesh record or { meshes: [...] } with produces: MESHESMESHES asset (validated like Mesh)
[H, W, C] / [H, W] numeric field with produces: IMAGEIMAGE asset (packed like Image)

Pitfalls:

  • undefinednull; NaN / Infinity survive in-script but serialize to null in JSON.
  • A returned Set becomes an array, a Map a record (§12); a Map with non-primitive keys is an error.
  • Returning a function or a BigInt is an error (Cannot convert … to DataAsset).
  • A produces: mismatch (declared DATA, returned MESHES) is a runtime error naming both types.

17. Errors and debugging

Errors report the failing step, the document position (file.moon:line:col — line numbers map into your expression), and for validation errors a source snippet with a caret.

There is no console — to inspect intermediate values, return them:

js
// temporarily widen the return to inspect, then narrow back:
return { debugLo: lo, debugHi: hi, result: (params.series - lo) / (hi - lo) }

Common errors and their fixes:

Error (abbreviated)Cause → fix
Unknown variable: 'x'Typo, or a missing with: binding / params.assets. prefix
Unknown variable: 'console'No log output in the sandbox → return intermediate values (§17)
Multi-statement expression scripts must use an explicit 'return'Add return to the last statement
Operator '+' is not defined for a plain JavaScript array …Inline literal / map() result in math → wrap with Data(...)
Two-index subscript 'a[i, j]' is not supported …Write a[i][j]
Math.random is not available …Use RandomNormal({ shape, seed })
Date.now is not available … / new Date() with no arguments …Wall-clock breaks determinism → pass timestamps in as params
'Box' does not have an 'input' parameterBare-value shorthand on a pure constructor → use named args: Box({ size: […] })
Unknown parameter 'x' for operation 'Y'. Known parameters are: …Check the operation signature in the API reference
Property 'at' of object is not a function.at() on a host value → use v[-1]
Cannot assign to read only property …Mutating a supplied tensor → build a new value
async functions are not allowed … / classes are not allowed …Restructure synchronously / use factory functions
Expression produced asset type X but declared YFix or add the produces: declaration

18. Style

The Moon expression style (the spec's examples are the reference):

  • 4-space indentation; omit semicolons; const over let; never var.
  • Arrow functions; params.X / assets.Y auto-binding over explicit with:.
  • Operations with named arguments: Box({ size: [1, 1, 1] }); bare-value shorthand for single-input transforms: Sum(t).
  • Prefer non-mutating array methods (toSorted, map, spread) over mutation.
  • Prefer expression pipelines (filter/map/reduce) for data; explicit for loops are fine when accumulating geometry or multiple outputs.
  • Explicit over clever: Number(s) over +s, ?? default over || default, a named intermediate const over a nested one-liner.
  • Keep expressions small: hoist reusable logic into composition assets and call them as functions.
yaml
moon: "1.0"
doc: |
    Moon style: data pipeline feeding a geometry fan-out
params:
    heights: [0.2, 0.5, 0.35, 0.8]
    gap: 0.3
render:
    expression: |
        const bars = params.heights.map((h, i) =>
            Transform({
                input: Box({ size: [0.2, h, 0.2] }),
                translate: [i * params.gap, h / 2, 0],
            })
        )
        return Union(bars)
    produces: MESHES