MathPets language

Essentials

The shared vocabulary every MathPets model uses, before you pick patches, pets, or graph as the agent surface.

Chapter

Essentials

See it live in the guide

File structure

A model file is a sequence of top-level sections. Declaration sections come first in any order, then the lifecycle sections, then an optional stop condition. The compiler does not care about blank lines; the Studio cares about indentation, which marks block bodies.

The declaration sections are defs:, world:, params:, memory:, monitors:, patches:, zones:, pets:, and links:. The lifecycle sections are setup:, one or more step: blocks (optionally step staged:), and an optional stop when (...).

Metadata and display live outside the source. The Studio reads definition.petmeta, styles.petstyle, and presets.petpreset companion files for catalog copy, color, transitions, inspector tooltips, and named parameter sets. All three are standard YAML. The separation lets a model be explained or restyled without touching its rules.

Comments can sit beside declarations with # or wrap longer explanations between triple quotes.

"""Minimal forest-fire model. A longer note can explain the intentwithout becoming part of the runtime model.""" world:  x: -25..25  y: -18..18  topology: box params:  density: number = slider(62, 0..100) # percent of patches that start as trees patches:  state: empty | tree | burning = empty setup:  patches:    state := if (random-float(100) < density) then tree else empty step staged:  patches:    where burning:      state := tree stop when (count patches where burning = 0)

Types and values

Five declared types cover everything you can name in a model: number, boolean, string, enum (written as a pipe-separated list of symbols), and set (an unordered collection of symbols or numbers).

Enums are the bridge between logic and display. A field declared as state: empty | tree | burning holds one of those three symbols at a time, and when the field is named state the Studio registers each symbol as a named visual state for styling.

Bare enum symbols are usable wherever an expression is — comparisons, set membership, and predicates. The symbol tree is the same value whether it appears in a declaration default, an assignment, or a where condition.

A set is initialized with a square-bracket literal. The element type is inferred from the literal: bare symbols become symbolic-set elements, numbers become numeric-set elements. Membership is checked with in.

patches:  state: empty | tree | burning | burned = empty  heat: number = 0  protected: boolean = false  label: string = "forest"  flagged: set = [] # enum symbols are first-class:patches where (state in [tree, burning]):  heat := heat + 1

Params and controls

params: declares the editable inputs the Studio renders as controls. Each declaration has a type, an initial value, and a control constructor that tells the Studio which widget to show: slider for numbers, toggle for booleans, select for enums.

The control constructor is purely declarative — the model code reads the param exactly like any other declared value. There is no event wiring, no setter, no observer to register.

For an interactive demonstration of how these declarations become real widgets, see Controls and monitors on the documentation Intro.

params:  density: number = slider(62, 0..100)  diffusion-rate: number = slider(50, 0..99, step 0.5)  show-grid: boolean = toggle(true)  seed-mode: single | random-seed = select(single)

Memory and monitors

memory:is for observer-level state that the model writes but the user doesn't edit directly — bookkeeping that doesn't belong on any one agent. A staged sum, a flag for whether the outbreak has started, a list of seeded coordinates.

monitors: is the opposite: readonly values derived from current model state on every snapshot, shown in the Studio inspector. A monitor is just an expression with a name and a type.

Memory values are mutable from setup and step. Monitors are not assignable — they recompute. If you find yourself writing to a monitor, you probably want memory instead.

memory:  outbreak-started: boolean = false monitors:  living-trees: number = count patches where tree  burning-trees: number = count patches where burning  fire-active: boolean = burning-trees > 0

Expressions

Expressions are arithmetic, comparison, boolean composition, and membership tests, plus the conditional if (...) then ... else .... Boolean operators are spelled and, or, and not.

Comparison uses single = (not ==). The full set is = != < <= > >=. Set membership uses in: value in [a, b, c].

Math helpers are the usual suspects: floor, ceil, round, abs, sqrt, pow, min, max, clamp, sin, cos, tan. They behave like the JavaScript equivalents.

# arithmetic, comparison, booleanlet live-neighbors = count neighbors8 where alivelet busy = live-neighbors > 2 and not protected # conditionalstate := if (live-neighbors in [2, 3]) then alive else dead # set membershipwhere (rule in [30, 90, 110]):  ... # math helpersenergy := clamp(energy + 0.1, 0, 2)distance := sqrt(pow(dx, 2) + pow(dy, 2))

Assignment and blocks

Assignment uses :=. The form is target := expression, where the target can be a local let, a field on the current agent, or a writable helper like patch.field or patch-at(x, y).field.

When several fields on the same target want to change together, the block form keeps the names aligned. Write target := : on its own line, then indent field: value entries beneath.

Local values use let name = expression. A let binding is immutable for the rest of the enclosing block. Inside an agent update, each agent gets its own evaluation of the let.

# inline assignmentstate := burning # multi-field assignment blockpatch := :  state: empty  has-chip: false # local bindinglet flockmates = flockers in-radius visionlet count = count flockmateslet nearest-distance = distance-to(nearest flockmates)

Control flow

where (...): branches an indented block on a predicate. Inside an agent update, the predicate is evaluated per agent — the block runs only on the matching subset.otherwise: attaches to the previous sibling where as the else branch.

match dispatches on one or more enum or boolean discriminators. Each arm names the value(s) it handles; the first matching arm fires per agent. Use _ as a wildcard, and a | b to alternate over literals from the same type.

repeat n: runs an indented block a fixed number of times. It is most useful for stochastic construction passes — for example, three rounds of link creation in a graph setup.

# where / otherwisepatches:  where alive:    where (live-neighbors in [2, 3]):      state := alive    otherwise:      state := dead  otherwise:    where (live-neighbors = 3):      state := alive # match on enummatch state:  searching:    follow-patch-gradient(chemical)    forward(1)  carrying | dropping:    forward(1)  leaving:    jump-away # repeatsetup:  repeat 3:    people:      where (random-float(100) < link-chance):        let partner = one-of other people        create-link-with(contacts, partner)

Defs

defs:declares global value functions — pure calculations that take typed arguments and return a typed value. Use defs for reusable arithmetic or condition logic that shouldn't mutate the world.

The final expression in a function body is the return value. Explicit return works too. Defs can call other defs and any built-in reporter that makes sense without an agent context.

If you want reusable behavior instead of a reusable value — something that mutates the current pet or its patch — that is an action, not a def. Actions are covered in the MathPets chapter.

defs:  bit(value: boolean): number:    if (value) then 1 else 0   wolfram-cell(rule: number, left: boolean, center: boolean, right: boolean): boolean:    let pattern: number = bit(left) * 4 + bit(center) * 2 + bit(right)    floor(rule / pow(2, pattern)) % 2 = 1

Lifecycle

setup: runs once when the model is initialized or reset. Use it to seed patches, create pets, build the initial link graph.

step: runs every tick. A model can declare several step: sections; they fire in source order inside one tick, and the UI tick counter only advances after all of them complete.

step staged: is the two-phase variant. All matching updates inside a staged block read the previous-phase state of their fields before any of them commit. That is what makes cellular-automaton rules behave the way the textbook describes them: every cell sees the same neighborhood snapshot.

stop when (...) is evaluated at the start of each tick. When the predicate is true, the world finishes and no further steps run.

setup:  patches:    state := if (random-float(100) < density) then tree else empty     where (tree and px = min-x):      state := burning step staged:  patches:    where burning:      state := burned     where (tree and any neighbors4 where burning):      state := burning stop when (count patches where burning = 0)

Staged vs serial steps

Use step staged: when every agent must read the same pre-tick state. Life is the canonical case: each cell counts its live neighbors as they were at the start of the phase, so the order in which cells update cannot leak into the result. Diffusion-style rules (Turing Diffusion, fire spread) want the same guarantee — a patch that already ignited this tick must not ignite its neighbor within the same tick.

Use a plain step:when agents should react to each other's updates within the tick. In Ants, an ant that drops chemical strengthens the trail for the ants processed after it in the same tick — that immediate feedback is the mechanism, not a bug. The same goes for predation: once a wolf eats a sheep, the sheep must be gone before the next wolf looks. If a model mixes both needs, declare several step sections — each one chooses staged or serial independently, and they run in source order.

# staged: every cell sees the same neighborhood snapshotstep staged:  patches:    where burning:      state := burned     where (tree and any neighbors4 where burning):      state := burning # serial: each ant senses trails earlier ants already strengthenedstep:  ants:    where carrying:      patch.chemical := patch.chemical + 60      follow-patch-gradient(nest-scent)      forward(1)

tick and randomness

tick is a read-only builtin expression that holds the number of completed steps since setup. It is 0 during step 1, then increments after each step body. Useful in monitors, in stop conditions, and for time-varying parameters.

Random helpers are seeded by the model run. The triple random(max), random-int(max), random-float(max) all return values in [0, max). Each call is keyed by the world seed, the current tick, the current agent index, and the source call site, so the same source plus the same pinned seed always produces the same trajectory.

The Studio shows the current seed in the inspector. By default each run picks a fresh seed; the advanced control lets you pin one for exact replays.

# tick in a stop conditionstop when (tick > 1000) # tick as a clock for staged behaviorants:  released := id < min(tick + 1, population)  hidden := not released # seeded randomnesspatches:  state := if (random-float(100) < density) then alive else dead

Models that use this

Three catalog models stay close to the material in this chapter — open them in the Studio and read the source against the sections above.