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.

Chapter

Patches

See it live in the guide

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 := burning

Topology

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 + 1

Patch 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 := food

Neighbors

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 = live

Coordinate 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) / 100

Walls

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 wall

Aggregates

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).state

Models that use this

These catalog models are patch-driven end to end — each one leans on a different part of this chapter.