MathPets language
Patches
Patches are the grid. Most of what makes a MathPets model feel like a MathPets model — local rules, neighborhood feedback, diffusion — lives in patch updates.
The world grid
A MathPets world is a finite rectangle of integer-coordinate patches. world: declares the bounds, and the bounds are inclusive on both ends: x: -25..25 spans 51 patches. Widths and heights may be even or odd. The origin (0, 0) is a real patch only when the range crosses zero.
Inside a patch update, the current patch's integer coordinates are px and py. The world bounds — min-x, max-x, min-y, max-y — are available in every expression context.
MathPets that move (the moving agents in the next chapter) carry continuous coordinates x and y. These names do not overlap with the patch coordinates; reading one in the wrong context is a compile-time error.
world: x: -25..25 y: -18..18 topology: box setup: patches: where (px = min-x): state := burningTopology
Topology controls what happens at the edges of the world. Four values: box (both axes hard), torus (both wrap), wrap-x and wrap-y (one of each). The choice is global to the world.
Wrapped axes normalize coordinates modulo the axis width. Distance, heading, and radius reporters all use the shortest topological path — radius queries on a torus can span the seam, neighborhood reads roll over the edge, pet motion crosses cleanly.
Hard axes behave the way intuition suggests: patch-at(x, y) with an out-of-range coordinate returns nothing, neighborhood queries at a corner or edge return fewer entries (a corner of a box world has count neighbors8 = 3), and pet motion clamps to the edge instead of wrapping.
world: topology: torus x: -24..24 y: -16..16 # radius and nearest queries cross the seam on a torusstep: flockers: let nearby = flockers in-radius vision turn-towards(average-heading(nearby), 5) forward(1)Fields and state
patches: at the top level declares the patch-owned fields. Every patch in the world receives the declared initial value. Field names live in the patch namespace, so a patch update can read and write them without any prefix.
The field named state is special. When its type is an enum, the Studio registers each enum symbol as a named visual state for styling. A numeric state is also legal — it does not register discrete states, and the stylesheet treats it as a continuous field with a color scale.
State symbols are first-class values inside expressions, and they can also be used as bare predicates in where clauses. The short form where burning lowers to where (state = burning).
patches: state: empty | tree | burning | burned = empty protected: boolean = false heat: number = 0 # state name as bare predicatepatches where burning: state := burned # state name as expression valuepatches where (state in [tree, burning] and not protected): heat := heat + 1Patch updates
A patch update is patches: followed by an indented body that runs once per patch. Add a predicate with patches where (...): to restrict the update to matching patches. The block body is a sequence of assignments, nested where branches, local let bindings, or further structure (match).
Inside the block, bare field names refer to the current patch. To write several fields together, use the multi-assign block form target := : with an indented field map.
Spatial regions are first-class update headers. patches in-radius r around (x, y): runs the body only on the patches inside a circle of radius r around (x, y). There is also in-square for the square analogue. Topology applies to the region.
# update every patchpatches: state := empty chemical := 0 # filtered updatepatches where (tree and py > 0): heat := heat + 1 state := burning # spatial region (circle around 0.6 * max-x, 0)patches in-radius 5 around (0.6 * max-x, 0): food-source-number := 1 state := foodNeighbors
neighbors4 is the four orthogonal neighbors of the current patch; neighbors8 is all eight. They are agentsets — you can count them, filter with where, ask any for at least one match, or aggregate a numeric expression over them.
When you need a specific direction, neighbor-at(direction).field reads one neighbor, and neighbors-at(dir, dir, ...) returns a selected subset. The direction symbols are top-left, top, top-right, left, right, bottom-left, bottom, and bottom-right.
Topology applies everywhere. On a torus the corner of the world still has 8 neighbors (the diagonals roll over the seam); on a box it has 3.
step staged: patches: let live = count neighbors8 where alive state := dead where alive: where (live in [2, 3]): state := alive otherwise: where (live = 3): state := alive # directed readspatches where (py = max-y - tick - 1): let left-live = neighbor-at(top-left).state = live let center-live = neighbor-at(top).state = live let right-live = neighbor-at(top-right).state = liveCoordinate lookup
patch-at(x, y).field reads or assigns a field on the patch at an explicit coordinate. Topology is applied first, so wrapped axes resolve as expected. On a hard axis, an out-of-range coordinate yields no patch — dereferencing .field on the missing patch fails, so guard the read.
The canonical guard pattern is a small def that takes the coordinates and a fallback. Turing Diffusion uses one to make the discrete Laplacian clean to read.
defs: sample-u(col: number, row: number, fallback: number): number: if (col < min-x or col > max-x or row < min-y or row > max-y) then fallback else patch-at(col, row).u patches: let center = u u := clamp( center + diffuse-u * ( sample-u(px + 1, py, center) + sample-u(px - 1, py, center) + sample-u(px, py + 1, center) + sample-u(px, py - 1, center) - 4 * center ), 0, 1 )Diffusion
diffuse(field, rate) spreads a numeric patch field outward in a single tick. The rate is a fraction in [0, 1] — each patch retains 1 - rate of its current value and contributes the rest evenly to its eight neighbors.
Diffusion is a standalone statement at the same level as a patch update block. Use it inside a step: section as a single line; the underlying field assignment is implicit and topology-aware.
step: ants: where released: ... diffuse(chemical, diffusion-rate / 100) patches: chemical := chemical * (100 - evaporation-rate) / 100Walls
Every patch carries a built-in wall boolean (default false, no declaration needed). Wall patches isolate the two sides: forward stops pets at the wall face, follow-patch-gradient never steers onto a wall, and diffuse neither sends values into walls nor through them. Explicit teleports (set-position, move-to) are exempt.
Walls can be set in code like any field, drawn with a brush: section, or stamped from a preset: a walls: list in a .petpreset entry is ASCII art, one string per patch row top-to-bottom, where # marks a wall. The Maze model uses preset wall maps for its layouts.
setup: patches: where (px = 0 and py > min-y + 4): wall := true brush: wall := not wallAggregates
count reports a number, any reports a boolean, and sum, mean, min, and max aggregate a numeric report (...) expression. All of them accept a where filter, and the around (x, y) modifier scopes the query to the eight patches around a coordinate.
These aggregates work over any agentset: patches, a breed name, patches-in (inside a zone update), or a query local. Use them in monitors, in stop conditions, and as the workhorse of any model that needs a population summary.
monitors: living-trees: number = count patches where tree fire-active: boolean = any patches where burning average-heat: number = mean patches report (heat) hottest: number = max patches report (heat) # scoped to neighborspatches: let crowded = count neighbors8 where alive >= 6 state := if (crowded) then dead else state # scoped to a coordinate's neighborhoodlet food-nearby = count patches where food around (x, y)Zones
Zones are named square aggregations over patches. Declare them in zones: with a side length, and the world tiles itself with one zone per size n square. Each zone carries its own fields and behaves like an agentset member.
Inside a zone update, patches-in is the patches contained by the current zone. Inside a patch or pet update, zone-at(zones) finds the zone that contains the current agent, and zone-at(zones, x, y) resolves an explicit coordinate.
Zones are useful for two things: caching neighborhood summaries that would be expensive to recompute per patch, and giving regions coarse-grained behavior. Hierarchical CA uses 4×4 clusters and 12×12 districts so each patch can read aggregate state at two scales.
zones: zone clusters size 4: live-count: number = 0 state: growth | stable | cool = stable step: clusters: live-count := count patches-in where (living) state := stable where (live-count >= cluster-threshold): state := growth patches: cluster-state := zone-at(clusters).stateModels that use this
These catalog models are patch-driven end to end — each one leans on a different part of this chapter.
Model
Life
The textbook patch model: neighbors8 counting and a staged update over the whole grid, nothing else.
Open model →Model
Turing Diffusion
Numeric patch fields and guarded coordinate lookups: a discrete Laplacian built from patch-at reads behind a fallback def.
Open model →Model
Hierarchical Cellular Automaton
Zones at two scales — 4×4 clusters and 12×12 districts whose aggregate state changes the local birth and survival rules.
Open model →Model
Fire
Edge seeding with px = min-x, neighbors4 spread, and a box topology that stops the fire at the world edge.
Open model →