MathPets language
Essentials
What every MathPets model uses, whatever its agents: file layout, types, params and monitors, expressions, control flow, defs, and the setup and step lifecycle.
File structure
A model file is a sequence of top-level sections: declarations first, in any order, then the lifecycle sections, then an optional stop condition. Blank lines don't matter. Indentation does: it marks the body of each block.
The main 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 presentation live outside the source, in three YAML files next to it: definition.petmeta (title and descriptions), styles.petstyle (colors, transitions, inspector tooltips), and presets.petpreset (named parameter sets). A model can be restyled or re-described without touching its rules.
# starts a comment that runs to the end of the line; triple quotes enclose a multi-line comment.
"""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 where (tree and px = min-x): state := burning step staged: patches: where burning: state := empty where (tree and any neighbors4 where burning): state := burning stop when (count patches where burning = 0)Types and values
The basic field types are number, boolean, string, enum (written as a pipe-separated list of symbols), and set (an unordered collection of symbols or numbers). Monitors add counter. A field can also hold a reference to an agent: its type is a breed name or patch, and its default must be nobody. Pet fields can hold a genome(...) or tree(...), used with the genome and gp libraries.
A field declared as state: empty | tree | burning holds one of those three symbols. When the field is named state, the Studio also registers each symbol as a visual state that the stylesheet can color.
Enum symbols are ordinary values: they work in comparisons, set literals, and predicates. The symbol tree means the same thing in a declaration default, an assignment, or a where condition.
Strings use single or double quotes and keep their contents exactly, spaces and punctuation included. A field such as username: string = "@ada-lovelace" can be assigned, compared, and shown in a tooltip defined in the stylesheet.
A set's initial value is a square-bracket literal. If every element is a number it is a set of numbers; otherwise its elements are symbols. Test membership with in.
A declaration can leave out the type when the initial value implies it: energy = 0, tired = false, density = slider(62, 0..100). Enums, references, select, counters, sets, and genomes still need an explicit type. Most library models use this short form.
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 + 1Params and controls
params:declares the model's inputs, which the Studio shows as controls. The constructor chooses the widget, and its first argument is the initial value: slider for numbers (with a range and an optional step), toggle for booleans, select for enums. A quoted string default gives a text input.
Model code reads a param like any other value; there is no event wiring. A param read in a step section takes effect on the next tick after you change it. One read only in setup takes effect after Reset.
The documentation Overview shows the widgets these declarations produce, under Controls and monitors come from the model.
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:holds model-level values that the model writes and the user doesn't edit: bookkeeping that belongs to no single agent, such as a flag for whether an outbreak has started or a running total.
monitors: lists the values shown in the Studio inspector. A derived monitor is an expression, recomputed for every snapshot. A counter monitor (name: counter = 0) is a number that the model itself updates, so a tally can be shown without a separate memory field.
Memory values can be assigned in setup and step; derived monitors can't be assigned at all. A counter resets to its declared initial value on every setup and can be updated from setup, step, and inside agent blocks. Add to it with name :+ 1 (any number).
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 rechoose-events: counter = 0 step: people where (random-float(100) < rechoose-chance): rechoose-events :+ 1Expressions
Expressions combine arithmetic, comparisons, boolean operators, and set membership, plus the conditional if (...) then ... else .... Boolean operators are spelled and, or, and not. An expression must fit on one line; the compiler rejects a condition or call split across lines.
Comparison uses single = (not ==). The full set is = != < <= > >=. Set membership uses in: value in [a, b, c].
Math functions: floor, ceil, round, abs, sqrt, pow, min, max, clamp, sin, cos, tan, and also atan, ln, log, and exp. They follow JavaScript's Math functions: trigonometry uses radians, not headings, and log is base 10. clamp(value, low, high) limits a value to a range. % is JavaScript's remainder, so -1 % 5 is -1.
# 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 :=, in the form target := expression. The target is a field of the current agent or a writable reference such as patch.field or patch-at(x, y).field. A letbinding can't be reassigned. energy += 1 is shorthand for energy := energy + 1 (also -=, *=, and /=).
To set several fields of one target at once, write target := : on its own line and indent field: value entries beneath it.
Local values use let name = expression. A let binding is immutable and lasts until the end of the enclosing block. Inside an agent update it is evaluated separately for each agent.
# 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 (...): runs an indented block only when the predicate is true. Inside an agent update the predicate is evaluated for each agent. otherwise: is the else branch of the where block just before it at the same indentation.
match dispatches on one or more enum or boolean values. Each arm lists the values it handles, and for each agent the first matching arm runs. Use _ as a wildcard, and a | b to match either of two literals of the same type. The compiler checks that the arms cover every combination; an otherwise: arm covers the rest.
repeat n: runs an indented block n times, and repeat i in a..b: runs it once for each integer from a to b inclusive, with i bound in the body. The example below runs a link-creation pass three times; see tick and randomness for how random calls behave inside a repeat.
# 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 functions that take typed arguments and return a typed value. Use them for reusable calculations and conditions; they should not change the world.
The last expression in the body is the return value; an explicit returnalso works. The body may bind let values before it. Defs can call other defs and any built-in reporter that doesn't need a current agent, such as patch-at.
Reusable behavior, which changes the current pet or its patch, is an action rather than a def. Actions are covered in the Pets 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 = 1Lifecycle
setup: runs once when the model is initialized or reset: seed patches, create pets, and build the initial link graph there.
step: runs every tick. A model can declare several step: sections; they run in source order within one tick, and the tick counter advances once, after the last of them.
step staged:is the two-phase variant. Every update in the section reads the state as it was when the section started, including the agent's own fields, and all writes are committed together at the end. This is the synchronous update cellular automata need: every cell sees the same snapshot of its neighborhood. A later step section reads the committed result.
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. In Life, each cell counts its live neighbors as they were at the start of the section, so the order in which cells are visited can't affect the result. The reaction-diffusion update in Turing Diffusion and the spread rule in Fire need the same guarantee: a patch that ignites this tick must not ignite its neighbors until the next one.
Use a plain step:when agents should see each other's changes within the tick. The agents of a block are visited in random order, reshuffled every tick from the seeded random stream, and each write is visible at once. In Ants, chemical dropped by one ant is already there for ants visited later in the same tick; in Wolf Sheep Predation, a sheep killed by one wolf is gone before the next wolf looks. A model that needs both kinds of update declares several step sections, each staged or not; 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: chemical dropped here is visible to ants visited later this tickstep: ants: where carrying: patch.chemical := patch.chemical + 60 follow-patch-gradient(nest-scent) forward(1)tick and randomness
tick is the read-only number of completed steps since setup. It is 0 during the first step and goes up by one after each full step. Use it in monitors, stop conditions, and rules that change over time.
random(max) and random-int(max) return an integer, and random-float(max) a real number, in [0, max). random-pc is an integer from 1 to 100 and random-ratio a real number in [0, 1). Each call is keyed by the seed, the current tick, the current agent, and the call's place in the source, so the same source, params, pinned seed, and engine version reproduce a run exactly. One consequence: inside a repeat, a call returns the same value on every iteration for a given agent and tick. one-of draws from the seeded sequential stream instead, so it does vary between iterations.
The Studio shows the current seed in the inspector. By default each run picks a fresh seed; the advanced seed control pins one for exact replays.
# tick in a stop conditionstop when (tick > 1000) # tick as a clock: release one more ant each tickants: released := id < min(tick + 1, population) hidden := not released # seeded randomnesspatches: state := if (random-float(100) < density) then alive else deadModels that use this
These three library models use little beyond this chapter. Open one and read its source against the sections above.
Model
Fire
Params, monitors, a staged patch step, and a stop condition, in 27 lines.
Open model →Model
Life
An enum state field, where/otherwise branching, and a count over neighbors8, in one staged step.
Open model →Model
Wolfram Automaton
Defs as pure functions: the rule lookup is computed from typed arguments with floor and pow, and tick selects the row being drawn.
Open model →