Formulas API
Write an expression. It becomes live code.
Try it
title: "Spinning shape follows cursor"
description: "Two formulas bind x to the mouse and rotation to time"
nodes:
- type: STAR, name: "Star", x: 250, y: 250, w: 80, h: 80, fill: "#F39C12", points: 5
formulas:
- node: "Star", prop: "x", value: "mouse.x"
- node: "Star", prop: "y", value: "mouse.y"
- node: "Star", prop: "rotation", value: "time * 120"
tools_visible: [select, play]
ui_visible: [properties, formulas]What is a formula
A formula is a JavaScript expression bound to a node property. Type = in any number field, write an expression, press Enter. The property now evaluates that expression every frame. Change a dependency and the formula re-evaluates automatically.
Ball.x = mouse.x // follows cursorthis.rotation = time * 90 // spins 90 degrees per secondthis.opacity = keys.Space ? 1 : 0.3 // dim unless Space is held
Formulas are standard JavaScript. The compiler adds automatic node resolution and dependency tracking on top. You do not import anything, register listeners, or manage state. Write the relationship and the system maintains it.
Formula syntax
Node references
Reference any node by its scene name. Access properties with dot notation.
Ball.x // read Ball's x positionBall.width // read Ball's widthparent.rotation // read parent's rotationthis.width // read this node's opacityTag.brick.length // count of nodes tagged "brick"Tag.coin[0].x // x position of the first "coin" node
| Reference | Resolves to |
|---|---|
NodeName.prop | Any node by its scene name |
this.prop | The node that owns this formula |
parent.prop | The parent node in the scene tree |
Tag.name | Array of all nodes with that tag |
Tag.name.length | Count of tagged nodes (reactive) |
Tag.name[i].prop | Property of a specific tagged node |
Operators
| Category | Operators | Example | ||
|---|---|---|---|---|
| Arithmetic | + - * / % | this.x + 100 | ||
| Comparison | == != < > <= >= | Ball.x > 400 | ||
| Logical | && `\ | \ | !` | keys.Space && mouse.down |
| Ternary | ? : | Ball.y < 0 ? 0 : Ball.y | ||
| Assignment (events only) | = += -= *= /= %= | this.x += 10 | ||
| Update (events only) | ++ -- | this.score++ |
Formulas (property bindings) are pure expressions that return a value. They cannot contain assignments. Events and methods can use assignments, compound assignments, and update operators.
Built-in globals
Every formula, event, and method has access to these without import or setup.
Runtime state
| Global | Type | Description |
|---|---|---|
mouse.x | number | Cursor x in canvas coordinates |
mouse.y | number | Cursor y in canvas coordinates |
mouse.down | boolean | Whether any pointer button is pressed |
mouse.button | number | Which button (0 = left, 1 = middle, 2 = right) |
keys.ArrowLeft | boolean | True while ArrowLeft is held (any key by name) |
keys.Space | boolean | True while Space is held |
keys.* | boolean | Any key name: keys.a, keys.Shift, keys.Enter, ... |
time | number | Seconds since play started |
dt | number | Frame delta in seconds (~0.016 at 60fps) |
frame | number | Frame counter (integer, increments each frame) |
Tag | lookup | Access tagged node groups: Tag.brick, Tag.coin |
Math functions (available directly)
| Function | Signature | Description |
|---|---|---|
sin(x) | number -> number | Sine (radians) |
cos(x) | number -> number | Cosine (radians) |
abs(x) | number -> number | Absolute value |
min(a, b, ...) | ...number -> number | Minimum |
max(a, b, ...) | ...number -> number | Maximum |
round(x) | number -> number | Round to nearest integer |
floor(x) | number -> number | Round down |
ceil(x) | number -> number | Round up |
clamp(val, lo, hi) | (number, number, number) -> number | Clamp to range |
lerp(a, b, t) | (number, number, number) -> number | Linear interpolation |
Also available: PI, E, Math (the full Math object), Infinity, NaN.
JavaScript pass-through globals
These standard JavaScript globals work unchanged inside formulas:
| Category | Globals |
|---|---|
| Data | JSON, Number, String, Array, Object, Boolean, Date, RegExp, Map, Set |
| Parsing | parseInt, parseFloat, isNaN, isFinite |
| Timers | setTimeout, setInterval, clearTimeout, clearInterval |
| Debug | console |
Compile modes
The compiler has three modes. Each handles a different context.
| Mode | Input | Output | Used for |
|---|---|---|---|
formula | Ball.x + 100 | Pure expression returning a value | Property bindings |
event | this.x += 10; event.target.destroy() | Statement block with read/write access | Event handlers (click, collide, ...) |
method | this.x = 0; this.y = 0 | Callable function on a node | User-defined reusable methods |
Formula mode parses a single expression. You cannot assign, declare variables, or use statements. The result is the property's value.
Event mode parses a statement block. You can assign properties, call methods, use if/else, loops, and local variables. Event handlers receive an event object with event-specific data.
Method mode also parses a statement block. Methods are defined on a node and called from events or formulas: this.reset() or Ball.explode().
How the compiler works
The pipeline: Source text -> Acorn parse -> AST transform -> Astring codegen -> Compiled function
The AST transform turns node references into tracked lookups:
Ball.x -> ctx.resolve('Ball', 'x')this.width -> this.getW()Ball.x = 200 -> ctx.set('Ball', 'x', 200)Ball.reset() -> ctx.callMethod('Ball', 'reset')
Formula context
The compiled function receives a context object with these methods:
| Method | Signature | Description |
|---|---|---|
resolve(nodeId, prop) | (string, string) -> any | Read a node property (registers dependency) |
set(nodeId, prop, value) | (string, string, any) -> void | Write a node property |
callMethod(nodeId, methodName) | (string, string) -> any | Call a user-defined method on a node |
getNode(nodeId) | (string) -> VNode | Get the raw node object (for builtins like .is(), .destroy()) |
Node builtins is(typeOrTag) and destroy() call directly on the node object, not through callMethod.
Dependency tracking
Dependencies are tracked automatically. When a formula evaluates, the system records every signal it reads. No manual subscription is needed.
- Compile-time extraction -- the compiler walks the AST and collects all
resolve()calls as static dependencies - Runtime tracking --
Signal.startTracking()records every.get()call during evaluation - Dirty propagation -- when a signal changes, all dependents are marked dirty
Evaluation order
Formulas form a directed acyclic graph (DAG). The runtime sorts them with Kahn's algorithm (topological sort):
- Count incoming edges (dependencies) for each formula
- Start with formulas that have zero dependencies
- Evaluate, then decrement dependents' counts
- Repeat until all formulas are evaluated
If A depends on B, B always evaluates first. Every formula evaluates exactly once per frame.
Cycle detection
Before wiring a new formula, the graph runs DFS cycle detection. If Ball.x depends on Wall.x and Wall.x depends on Ball.x, the formula is rejected with an error. Cycles are caught at compile time, never at runtime.
Batch evaluation
Multiple signals can change in one frame. Mouse x and y move together. A physics step updates many bodies at once. The system batches all changes and evaluates the entire dirty set once per microtask.
batchDepth++ signal1.set(...) // marks dependents dirty, defers flush signal2.set(...) // marks more dependents dirty, still deferredbatchDepth--flush() // evaluates all dirty formulas in topo order, once
No double evaluations. A formula reading 3 dependencies that all change in the same frame evaluates exactly once.
title: "Chained dependencies"
description: "Three shapes form a reactive chain -- move the mouse, all update in one pass"
nodes:
- type: RECT, name: "Source", x: 100, y: 200, w: 80, h: 80, fill: "#4285F4"
- type: RECT, name: "Middle", x: 250, y: 200, w: 80, h: 80, fill: "#34A853"
- type: RECT, name: "Output", x: 400, y: 200, w: 80, h: 80, fill: "#EA4335"
formulas:
- node: "Source", prop: "y", value: "mouse.y"
- node: "Middle", prop: "y", value: "Source.y + 30"
- node: "Output", prop: "y", value: "Middle.y + 30"
tools_visible: [select, play]
ui_visible: [properties, formulas]Syntax highlighting
The formula editor tokenizes your code in real time. 8 token types, 8 colors:
| Token type | What it highlights |
|---|---|
keyword | if, else, return, let, const, true, false, ... |
number | 42, 3.14, 0xFF |
string | "hello", 'world' |
nodeRef | Node names: Ball, Platform, Score |
property | Property after dot: .x, .width, .rotation |
builtin | sin, cos, clamp, lerp, Math, PI |
operator | +, -, *, ==, &&, ?, : |
comment | // note, /* block */ |
Node references get per-ID colors from an 8-color palette. Ball is always blue, Platform is always red -- same node, same color, everywhere in your formula. The palette cycles through: blue, red, yellow, green, orange, teal, light blue, salmon.
Highlighting uses the CSS Custom Highlight API -- no DOM overlays, no performance cost.
Source maps
Compiled formulas include VLQ-encoded inline source maps. The compiler generates a Base64-encoded JSON source map appended to the compiled output:
//# sourceURL=foximation:///Ball/x//# sourceMappingURL=data:application/json;base64,...
Open browser DevTools, navigate to the foximation:/// sources, set a breakpoint in your formula. The debugger shows your original expression, not the compiled ctx.resolve() calls.
Property locking
When a property has a formula bound to it, the _isLocked callback returns true. This prevents manual edits and gizmo drags from overwriting formula-driven values.
Exception: combined geometric transforms (scale-from-anchor, rotate-from-origin) bypass _isLocked because they must write x, y, and rotation as a single atomic operation. After bypassing, the handler calls _notify() manually so the formula system sees the change.
MathEval: inline arithmetic
Number fields accept arithmetic without the = prefix. Type 200 + 50% in a width field and it evaluates immediately:
| Input | Current value | Result |
|---|---|---|
200 + 50% | 200 | 300 (50% of 200, added) |
100% - 20px | 200 | 180 (200 minus 20) |
(100 + 50) * 2 | any | 300 |
.5 * 400 | any | 200 |
Supported: +, -, *, /, parentheses, % (relative to current value), px (explicit absolute). This is not a formula -- it evaluates once and sets the field. No reactive binding.
Performance
The formula hot path is designed for zero allocation per frame:
| Technique | What it avoids |
|---|---|
| Preallocated scratch buffers | No new Array() or new Object() per evaluation |
| Prototype-inherited context | Formula context uses JS prototype chain instead of object spread -- one object creation, not one per formula |
| Version counting | Dependencies track which version they last read. If the value hasn't changed, dependents skip re-evaluation |
| CSS paint caching | rgbaToCSS() results cached to avoid string allocation on every frame |
| Dirty propagation | Only formulas whose dependencies actually changed re-evaluate |
title: "Paint sub-property animation"
description: "Formula drives a color channel directly through compound property access"
nodes:
- type: ELLIPSE, name: "Orb", x: 250, y: 250, w: 100, h: 100, fill: "#3498DB"
formulas:
- node: "Orb", prop: "fills[0].color.h", value: "time * 60"
- node: "Orb", prop: "width", value: "80 + sin(time * 2) * 20"
- node: "Orb", prop: "height", value: "80 + sin(time * 2) * 20"
tools_visible: [select, play]
ui_visible: [properties, formulas]Events API
Nodes
Properties