Appearance
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, theinput:sugar, andparams.X/assets.Yauto-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
| Global | Notes |
|---|---|
Math | Full standard library (§10), except Math.random (blocked) |
JSON | parse, stringify, including reviver/replacer (§14) |
String, Number, Boolean | Converters (Number("42")) and statics |
Array, Object | All statics: Array.from, Object.entries, Object.groupBy, … |
RegExp | Plus regex literals /…/flags (§13) |
Set, Map | Full API; returned values auto-convert (§12) |
Error, TypeError, RangeError, SyntaxError, ReferenceError | For throw new Error("…") and instanceof checks |
Date | Explicit-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, isFinite | Standard |
undefined, NaN, Infinity | Standard |
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:
classdeclarations and expressions — rejected at parse. Use factory functions and plain objects (§7.6). - Modules:
import/export— dependencies come fromwith:and auto-binding. console— there is no log output; referencing it is a link error. Return intermediate values to inspect them (§17).Symbol,BigInt(and1nliterals),Proxy,Reflect, typed arrays (Float64Array,ArrayBuffer),WeakMap/WeakSet,globalThis,window,eval,structuredClone,Intl.Math.random— blocked with an error; use the seededRandomNormal({ shape, seed })operation for reproducible randomness.- Wall-clock time —
Date.now(),Date(), and zero-argumentnew Date()throw a guiding error; only explicit-argumentDateconstruction 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-argumentnew 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 onlyconstfor every binding that is never reassigned;letonly for loop counters and accumulators. Nevervar(function-scoped, hoisted — all of its uses are better served byconst/let).- Both are block-scoped: a binding inside
{ … }, a loop body, or anifbranch is invisible outside it. Inner blocks may shadow outer names — avoid shadowing; pick a new name. constfreezes the binding, not the value:const arr = []permitsarr.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 — worksjs
params["width"] // ✗ NOT bound — "Unknown variable: 'params'"
const { width } = params // ✗ NOT bound — the namespace itself is not a valueTo 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):
| Operators | Meaning | Notes |
|---|---|---|
(…), a.b, a[i], f(x), a?.b | Grouping, member access, call, optional chain | a?.b / a?.[i] / f?.() short-circuit to undefined when a is null/undefined |
!x, -x, +x, typeof x, void x | Unary | typeof never throws on unknown properties (it does on unknown variables at link time) |
** | Exponentiation | Right-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, instanceof | Relational | |
===, !==, ==, != | Equality | See 5.2 |
&, ^, | | Bitwise AND / XOR / OR (32-bit) | &, ^ are overloaded on geometry (5.4) |
&&, ||, ?? | Logical AND / OR / nullish coalescing | Short-circuit; ?? may not be mixed with &&/|| without parens (parse error) |
cond ? a : b | Ternary | |
=, +=, -=, *=, /=, %=, **=, &&=, ||=, ??= | Assignment | Statements, 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 // → trueThe 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" == 1is true) — on plain values it has no legitimate use. - On tensors the rule inverts:
==/!=are overloaded element-wise;===is plain reference identity (5.4). NaNnever equals anything, including itself — test withNumber.isNaN(x).Object.is(a, b)is===that also distinguishesNaN(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 // → false5.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"):
| Operands | Overloaded operators | Meaning |
|---|---|---|
| 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, neverif (t). - Plain arrays carry no operators.
[1, 2] + 1andarr1 + arr2are errors pointing you toData(...); 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 : valueIfFalseAn 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 // → 76.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 stringExceptions 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) // → 557.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) // → 2507.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) // → 307.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 })() // → 6Reach 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 // → 48. 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
| Form | Produces |
|---|---|
[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
| Signature | Returns |
|---|---|
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.length | Element 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) // → true8.3 Transforming (non-mutating)
| Signature | Returns |
|---|---|
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
| Signature | Returns |
|---|---|
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.
| Signature | Effect / 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
| Signature | Returns |
|---|---|
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]) // → true9. 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
| Signature | Returns |
|---|---|
s.length | Code-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") // → 19.3 Transforming
| Signature | Returns |
|---|---|
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 + t | Concatenation — 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: POLYGONS10. Numbers and Math
10.1 Numeric literals and precision
js
1_000_000 // → 1000000
1.5e3 // → 1500
0xff // → 255
0b101 // → 5
0o17 // → 15All 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 // → trueCompare 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
| Group | Functions |
|---|---|
| Sign/magnitude | abs(x), sign(x) (−1/0/1), min(…xs), max(…xs) |
| Rounding | round(x) (half away from zero toward +∞), floor(x), ceil(x), trunc(x) (toward zero) |
| Powers/roots | sqrt(x), cbrt(x), hypot(…xs) (√Σx²), pow(a, b) (same as a ** b), exp(x), expm1(x) |
| Logarithms | log(x) (natural), log2(x), log10(x), log1p(x) |
| Trigonometry | sin cos tan (radians), asin acos atan, atan2(y, x) (full-quadrant angle), sinh cosh tanh asinh acosh atanh |
| Constants | PI, E, SQRT2, LN2, LN10 |
| Blocked | random() — 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]) // → 9min/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.510.3 Number and parsing
| Signature | Returns |
|---|---|
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, EPSILON | Limits |
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 // → 3Dot 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
| Signature | Returns |
|---|---|
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.7The 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
| Signature | Returns / 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.size | Count |
[...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.
| Signature | Returns / 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.size | Count |
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)
| Pattern | Matches |
|---|---|
. \d \w \s | Any 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: .*?) |
^ $ \b | Start; end; word boundary |
(…) (?:…) | Capturing group; non-capturing group |
(?<name>…) | Named capturing group |
a|b | Alternation |
(?=…) (?!…) | Lookahead (positive / negative) |
(?<=…) (?<!…) | Lookbehind (positive / negative) |
13.3 Methods
| Signature | Returns |
|---|---|
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) / replaceAll | repl 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
| Signature | Returns |
|---|---|
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") // → trueNotes:
JSON.parse(JSON.stringify(x))is the deep-clone idiom for plain data.NaN/Infinitystringify tonull; functions andundefinedproperties 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:
| Direction | How |
|---|---|
| Inline array → tensor | Data([1, 2, 3]); nested rectangular literals become multi-dimensional tensors: Data([[1, 2], [3, 4]]) is a 2×2 matrix |
| Flat data + shape → tensor | Reshape({ 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 array | j.toArray() |
| Table → array of row objects | table.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 chainm[i][j]— the two-index formm[i, j]is rejected at validation. - There is no
.at()on host values (usev[-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)wheresourceis the host value itself. map/flatMapalways return a plain array (JS semantics) — re-wrap withData(...)or an operation if you need a tensor back.JSON.stringifyemits the data form (nested arrays / row objects); butArray.isArrayisfalseandObject.keyslists 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) // → false15.3 Tensors
Members (callbacks iterate axis-0 slices: numbers for a vector, row tensors for a matrix):
| Signature | Returns |
|---|---|
t.length | Axis-0 count |
t[i] | Element (vector) or row tensor (matrix); negative ok |
t.map(fn) | Plain array of results |
t.flatMap(fn) | Plain array — map(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:
| Signature | Returns |
|---|---|
t.length | Row count |
t[i] | Row object (integer index only — columns go through col) |
t.columns | Column-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 array — map(fn) flattened one level (JS flatMap) |
t.sort(cmp) | New table ordered by cmp(rowA, rowB) |
t.find/findIndex/every/some/forEach/reduce | As 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")) // → 33400000Aggregation 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: MESHESOperators 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: MESHESBounds 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: MESHESConstructed 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, neverpixels.valuesis in the same0–1convention you author colors in, soimg.values * 0.6darkens correctly.img.pixels * 0.6is a0–255field that clamps to white when packed —pixelsis for inspection / raw byte access only. (Hoist a repeatedimg.valuesinto aconst; 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 return | Becomes |
|---|---|
number, string, boolean, null | Scalar |
| rectangular numeric array (any nesting) | Tensor |
| array of uniform objects | Table |
| object | Record (getters evaluated, methods dropped) |
| numeric array with ragged rows | Jagged array |
| flat string array | String list |
| mixed array | List |
| mesh/polygon/material/image/font/graph value | That asset — declare produces: |
mesh record or { meshes: [...] } with produces: MESHES | MESHES asset (validated like Mesh) |
[H, W, C] / [H, W] numeric field with produces: IMAGE | IMAGE asset (packed like Image) |
Pitfalls:
undefined→null;NaN/Infinitysurvive in-script but serialize tonullin JSON.- A returned
Setbecomes an array, aMapa record (§12); aMapwith non-primitive keys is an error. - Returning a function or a
BigIntis 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' parameter | Bare-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 Y | Fix or add the produces: declaration |
18. Style
The Moon expression style (the spec's examples are the reference):
- 4-space indentation; omit semicolons;
constoverlet; nevervar. - Arrow functions;
params.X/assets.Yauto-binding over explicitwith:. - 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; explicitforloops are fine when accumulating geometry or multiple outputs. - Explicit over clever:
Number(s)over+s,?? defaultover|| default, a named intermediateconstover 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