Welcome

Stigmery is a browser-based tool for agent-based modeling - building worlds out of many small, rule-following entities and watching what patterns emerge.

It is inspired by NetLogo but built around two ideas:

  • A declarative model that you can read, edit by hand, save, and share.
  • An AI copilot that understands the same model and can build or tweak it in plain language.

You don't have to choose between them. The same actions an expert user takes through the UI are the same actions the AI takes through its tools. Watching the AI work is one of the better ways to learn the system.

This guide focuses on the parts that matter when you're building or editing a model on the Code tab. If you've never used agent-based modeling before, the Concepts chapter is the right place to start.

Core concepts

A Stigmery model has four moving parts.

World

A grid of square cells called patches. You choose the width, height, and how big each patch renders. Edges can either wrap (a torus) or act as hard walls.

Patches

The cells underneath everything. They can hold properties - for example grass (a number) or burning (a boolean) - and can run their own rules each tick. Most simple models barely touch patches; ecological and spatial models lean on them heavily.

Agents

The mobile entities walking around on top of the patches. Every agent belongs to an agent type (e.g. sheep, wolf, boid) and carries:

  • a position (x, y) on the world,
  • a heading (degrees, 0 = east),
  • a unique id,
  • whatever properties you declare on its type - energy, age, whatever you need.

Appearance

Every agent type has an appearance:

shape:  'circle' | 'triangle' | 'square' | 'arrow'
color:  a CSS color or an expression that returns one
size:   a number or an expression

color and size are evaluated per agent, per frame, so you can make appearance react to state. Wolves-and-Sheep uses literal strings (in quotes) so the DSL doesn't treat them as identifiers:

color: "'red'"
color: "energy > 5 ? 'green' : 'pink'"
size:  "1 + energy / 50"

Patches have an appearance.color expression too - the wolves-and- sheep model paints empty patches brown and grass-covered patches green with a single rule:

color: "grass > 0 ? 'green' : 'brown'"

Rules

The verbs of the model. A rule has a name, a when condition, and a do action. Rules live in one of four scopes:

  • setup_rules - run once when the user clicks Setup. This is where you create initial agents.
  • go_rules - run once per tick, with no specific agent context. Useful for global bookkeeping.
  • agent_type.rules - run for every agent of that type, every tick. This is where most behaviour lives.
  • patches.rules - run for every patch, every tick.

Each tick is just: evaluate go_rules, then every patches.rules for every patch, then every agent_type.rules for every agent. Order within a scope is the order you wrote them in.

Building your first model

The fastest way to learn is to make a small one yourself. Open the + New model button in the library; you'll get an empty world.

1. Add an agent type

Open the Code tab. Under Agent types click + Agent type. Rename it to creature in the inspector.

2. Give it a property

Under creature, click + Property. Call it energy and set default to 50. This is the expression that runs once per agent at setup; you can put anything DSL-shaped there.

3. Add a setup rule

Under Setup rules click + Rule. The default rule body is empty - replace it with:

create_agents('creature', 30)

Now click Setup. Thirty agents appear, scattered randomly.

4. Add a behaviour rule

Under creatureRules click + Rule. Body:

turn(random(-20, 20))
move_forward(1)
energy = energy - 1

This is a classic random walk with energy decay. Click Setup, then hold Go - you should see your creatures meander around.

5. Add a die-off rule

Under creatureRules, add another rule:

when:  energy <= 0
do:    die()

That's the whole loop: agents wander, lose energy, eventually die. From here you can plot population over time, add food patches, give creatures the ability to reproduce - whichever direction you want to take it.

Rules

A rule has three fields:

  • name - for display in the navigator and the change log.
  • when - a DSL expression that returns true/false. When omitted or blank, the rule always runs.
  • do - what to do when when is true. Either a DSL expression or a code block (see below).

A simple rule

name:  eat_grass
when:  patch().grass > 0
do:    energy = energy + 4; patch().grass = 0

Toggling a rule

Every rule has an enabled flag. Disabling a rule keeps it visible in the navigator but skips it during ticks - useful for A/B tests.

Scopes recap

  • setup_rules - initialization. Run once at Setup. No self.
  • go_rules - per-tick global. No self.
  • agent_type.rules - per-agent. self is the current agent.
  • patches.rules - per-patch. self is the current patch.

The DSL

The when and do fields accept a tiny expression language. It's deliberately small; the goal is one-line readability. Drop into a CodeBlock (next chapter) when you need anything more.

The full alphabetical reference is one click away inside the rule editor

  • the ? DSL button opens a categorised popup with every function and

identifier. This chapter explains the shape of the language.

Operators

+ - * / %            arithmetic
== != < <= > >=      comparison (=== and !== work too, same meaning)
&& || !              boolean
? :                  ternary
=  +=  -=  *=  /=    assignment (only inside do)
;                    statement separator

Numeric helpers

random(min, max)        float in [min, max), seeded RNG
random_int(min, max)    integer in [min, max] inclusive
random_normal(mean, sd) Gaussian sample
chance(p)               true with probability p
sqrt(x)  abs(x)  floor(x)  round(x)
min(...)  max(...)      smallest / largest argument
Math.PI  Math.atan2(y, x)  ...

Use the seeded helpers (random, chance) where you can - they make the simulation reproducible across runs with the same seed. Math.random() works but breaks reproducibility.

Movement (agent rules)

move_forward(d)         walk d patches along the current heading
turn(deg)               rotate heading by deg degrees (negative = left)
turn_random()           pick a uniform random heading
face(target)            turn to face an agent or patch
set_position(x, y)      teleport; wrapping rules apply
distance_to(target)     distance to an agent or patch

Patches and agents nearby

patch()                 the patch under the current agent
patch_ahead(d)          the patch d steps ahead in the current heading
patches_in(r)           patches within radius r
neighbors()             8 surrounding patches (patch rules) /
                        nearby agents (agent rules)
neighbors4()            4 cardinal neighbour patches (Von Neumann)
agents_here()           every agent currently on this patch (patch rules)
all_patches()           every patch in the world (observer)
all_agents()            every alive agent (observer)
agents_of_type('foo')   every alive agent of that type
nearest('foo')          the closest agent of type 'foo', or null

Agent lifecycle

create_agents('foo', n)        spawn n agents at random positions (observer)
create_agents_at('foo', n,x,y) spawn n agents at exact (x, y) (observer)
spawn('foo')                   one new agent at the current spot (agent rule)
spawn_at('foo', x, y)          one new agent at (x, y) (agent rule)
sprout('foo')                  one new agent at this patch (patch rule)
hatch(count?, init?)           clone the current agent; run init(child) on each
die()                          remove self at end of tick

You don't have to write create_agents in a setup rule: every agent type has an initial_count field, and at setup we auto-spawn that many agents (at random positions) if no setup rule has already created any.

Diffusion (patch fields)

Two helpers spread a numeric patch property across its neighbours each tick - useful for chemical gradients, pheromone trails, heat, anything field-like. They live in observer scope, so they go on a go_rule:

diffuse('pheromone', 0.5)   // 8-neighbour Moore stencil
diffuse4('heat', 0.2)       // 4-neighbour Von Neumann stencil

The second argument is the fraction of each patch's value that gets shared out per tick.

Links (networks)

Agents can be connected by undirected links - a small network sits on top of the population. Create and inspect them with:

link(a, b)              // observer or agent rules
unlink(a, b)
linked(a, b)            // observer: bool
my_links()              // agent: links touching this agent
link_neighbors()        // agent: every agent I'm linked to
all_links()             // observer: every link in the world

Aggregations

count, sum, mean, min, max operate on arrays. count accepts any array (agents, patches, anything); the others want numbers, so .map to project a property first:

count(agents_of_type('sheep'))                            // 47
count(agents_of_type('sheep').filter(a => a.energy > 5))  // 33
mean(agents_of_type('sheep').map(a => a.energy))          // 12.4
sum(all_patches().map(p => p.grass))                      // 1280

A common slip is mean(agents_of_type('sheep').energy) — that reads .energy on the array and gets undefined. Always .map first.

Lambdas

Anonymous functions are allowed wherever an array method (.filter, .map, .sort, .reduce) or the ask helper needs a callback. Three shapes work:

x => x.energy > 0                       single arg
(a, b) => a.energy - b.energy           multi arg, e.g. sort comparator
() => spawn('child')                    zero arg, for ask(arr, () => ...)

ask(collection, fn) calls fn for each item; errors in one item don't stop the sweep.

me and globals

Inside agent or patch rules, me is the current entity. Bare names work when there's no name collision: energy = energy - 1 is the same as me.properties.energy = me.properties.energy - 1. Use me. when something else (a global, a function) shadows the name you wanted.

Sliders, switches, and choosers you've added on the Interface tab are available as bare identifiers - so a slider named sheep_repro shows up as just sheep_repro inside expressions.

When a rule fails

If a when or do body throws (compile error, undefined name, runtime exception), the rule is silently skipped for that agent/patch on that tick - one bad rule never crashes the simulation. The failure message is captured on RuntimeState.rule_errors and shown as a red ! error badge next to the rule in the Code tab. The badge disappears the moment the rule runs cleanly again.

CodeBlock escape

The DSL covers most simple rules. For anything that needs loops, multi-step state changes, or genuine helper code, set the do field to a CodeBlock.

In the editor, toggle the CodeBlock (TS escape) checkbox above the Monaco editor. The body accepts JavaScript-shaped code with the same scope as a DSL expression: bare names resolve to the current agent/patch/globals, all DSL functions are available, and me refers to the current entity.

Example: flocking-style alignment

const others = neighbors(3);            // agents within 3 patches
if (others.length === 0) return;

// average heading of nearby boids
let sx = 0, sy = 0;
for (const b of others) {
  sx += Math.cos(b.heading * Math.PI / 180);
  sy += Math.sin(b.heading * Math.PI / 180);
}
const target = Math.atan2(sy, sx) * 180 / Math.PI;

// rotate 10% of the way toward the group's average heading
turn(0.1 * (target - heading));
move_forward(1);

What's allowed

  • Assignments, conditionals, loops (for, while), local let/const.
  • The full DSL function set (move_forward, neighbors, chance, ...).
  • Standard JS globals: Math, JSON, Array, Object, Number, String, Boolean, parseInt, parseFloat, isNaN, isFinite. These are also available in regular one-line expressions.

What's banned

  • await / async - rules must finish in one tick.
  • eval, new Function(...), import(...) - no dynamic code loading.

Violations throw at parse time; the rule then surfaces as a red ! error badge next to its name in the Code tab.

Properties & globals

There are two kinds of named state in a model.

Properties

Each agent type and the patches collection can declare properties. They are per-instance: every agent of that type gets its own copy.

sheep: { properties: [
  { name: 'energy', type: 'number', default: 'random(10, 50)' }
]}

The default is a DSL expression that runs once for each instance at setup. Inside rules you can read and write the property as a bare name (energy = energy + 1) or as me.properties.energy if you need to disambiguate.

Globals

Globals are model-wide values bound to a widget on the Interface tab:

  • Slider - a number with min / max / step.
  • Switch - a boolean.
  • Chooser - one value from a list of choices.
  • Input - a free-form string or number.

Globals are read in rules as bare identifiers - a slider grass_regrow_rate appears as grass_regrow_rate inside an expression. They can be tweaked live while the simulation is running.

Naming

Property and global names must be valid identifiers (letters, digits, underscore, can't start with a digit) and unique inside their scope. The form will reject reserved words and duplicates inline.

Observation - plots & monitors

A model isn't useful unless you can see what's happening. Two ways:

Metrics

A metric is a named expression evaluated each tick over the whole model. They live in model.metrics and act as the data source for both plots and monitors.

sheep_count:   count(agents_of_type('sheep'))
grass_total:   sum(all_patches().map(p => p.grass))

Plots

A plot charts one or more metrics over time. Each line is called a pen; you choose its colour. Plots live in the Observation panel on the Interface tab.

Monitors

A monitor shows the current value of one metric, big and live. Use them for single-number dashboards (population, total energy, ...).

Exporting

Open the model in the library and use the ⬇ download icon on the file row to grab the full JSON. Run-time metric history can later be exported as CSV (planned).

Working with the AI

The AI chat on the right is not a generic chatbot - it has access to the same toolset you do (add agent type, add rule, set parameter, run setup, tick, etc.) and applies changes through the same change-log. Watching it work is a good way to learn the system.

Good prompts

  • Be specific about behaviour, not syntax. "Make the sheep wander randomly and lose energy each tick" works better than "write me a random-walk function".
  • Ask for one thing at a time. You'll get a faster, more reviewable result.
  • Iterate. "Now add wolves that chase the nearest sheep." "Now plot the populations." You can build a model piece by piece.

Things to ask the AI to do

  • "Build me a flocking model with 30 boids."
  • "Set sheep_repro to 0.10 and run 50 ticks. What's the sheep_count?"
  • "Switch to Game of Life."
  • "Why does the wolf population crash so fast?"

Things to do yourself instead

  • Fine-tuning of slider defaults - quicker by hand.
  • Choosing which plot to show - the AI can't see your plot.
  • Renaming things for clarity - go straight to the navigator.

Library & sharing

Everything model-management happens in the library. Open it from the top bar.

My models vs Examples

  • Examples are read-only built-in models. Double-click to open one, or hover the row and use the ⬇ icon to download its JSON.
  • My models are your saved Firestore-backed models. Folders are implicit: any path you save into becomes a folder until the last model in it is moved away. You can also pre-create empty folders with + New folder.

Actions

  • Save - opens the library with a save banner; pick a folder by navigating the tree, set the name and visibility, click Save here.
  • Auto-save - every edit to a saved model is silently persisted after a 2-second pause. The TopBar Save button flips to Saving… briefly.
  • Move - drag a file row onto a folder in the tree, a breadcrumb segment, or a subfolder tile.
  • Rename / edit description - click the title or description in the details panel on the right.
  • Delete - hover a row, click the 🗑 icon, confirm.
  • Download - hover, click ⬇.

Sharing

Select your model in the library. In the details panel on the right, flip the Sharing toggle to Public - a share URL appears. Anyone with that link can view (not edit) the model. Flip back to Private to revoke; the URL stops working immediately.

Simulation controls

The bar above the workspace controls the running simulation.

  • Setup - re-initialise the world. Runs every setup_rules, sets the tick counter to 0, and re-seeds the RNG from Seed.
  • ← Step - replay one tick earlier. Only works while time-travel history is valid (i.e. you haven't made a structural edit since the last Setup).
  • Step → - run one tick forward.
  • Go / Pause - run forward continuously until paused.
  • Tick / Agents / Seed - live counters. Click the seed number to edit it or hit ↻ to randomise. Either action re-runs Setup.
  • Undo - pop the last structural edit (add/update/delete on an agent type, rule, property, etc.). Doesn't affect ticks or slider changes.

Time travel

Every Setup starts a fresh history. Stepping forward records checkpoints; ← Step replays from the nearest checkpoint forward to tick - 1. Any structural edit (changing a rule, adding an agent type) invalidates the history - the back button greys out until your next Setup.

Tips & gotchas

Iterate small

Add one rule, click Setup + a few Steps, see what changes. Stigmery's change-log + Undo are aggressive enough that you can experiment without fear.

Use Setup early and often

It's the only deterministic anchor in the simulation. If something looks weird, Setup brings you back to a known state for the current model.

Use the seed when reproducing bugs

If a behaviour looks suspicious, note the seed shown in the top bar before reporting it (or before asking the AI to dig in). With the same seed the run is identical.

Patches before agents - sometimes

For models with environment dynamics (grass regrowth, fires, pollution), declare the patch property first, then add the agent rules that read it. The setup is easier to reason about that way.

When something doesn't behave

  • Did you click Setup after editing a rule? Auto-save persists, but structural changes don't reset the runtime - Setup does.
  • Is the rule enabled? The toggle is right there in the rule editor.
  • Is the agent type initial_count non-zero (or do you create them yourself in a setup rule)?

Feedback & contact

Found a bug, have an idea, or just want to share what you built? Email marvin.kleijweg@aivoorimpact.nl.

Citing Stigmery

If you use Stigmery in academic or published work, please cite it as:

> Kleijweg, M. (2026). Stigmery: Browser-based agent-based modeling with an AI copilot [Computer software]. https://stigmery.com