Taos Engine · Modules

Modules

Agents (GPU agent-based modeling)

A GPU agent-based-modeling layer in the spirit of NetLogo and AgentScript — "turtles + patches + links" — built on top of the particle system. An agent is a particle, so a model of hundreds of thousands of agents runs entirely on the GPU at interactive rates.

Source lives in src/agents/. One import:

TypeScript
import { agent, buildAgentConfig, type AgentModelConfig } from 'taos/agents/index.js';

buildAgentConfig(model) lowers a declarative AgentModelConfig to a ParticleGraphConfig that runs on the stock ParticlePass — no new pass, no new shader plumbing. You feed the result to a ParticleFeature like any other particle system.


Quick start#

A wandering swarm — the "hello world" of agent modeling. runAgentModel is the one-call harness: give it your model and it wires the engine, a camera, the render stack, and the frame loop. The page needs a <canvas id="canvas">.

TypeScript
import { agent } from 'taos/agents/index.js';
import { runAgentModel } from 'taos/agents/agent_app.js';

runAgentModel({
  params: {},
  model: () => ({
    world: { min: [-50, -50], size: [100, 100], topology: 'torus' },
    population: 100_000,
    spawn: { color: [0.3, 0.8, 1.0, 1.0], size: 0.4 },
    step: [
      agent.turn(agent.randomTurn(0.3)),   // jitter the heading
      agent.moveForward(8),                 // step forward at 8 units/s
    ],
    render: { shape: 'soft', blendMode: 'additive' },
  }),
  camera: 'tilted',
});

That's a complete app: 100k agents wandering on a wrapping plane, all stepping in one GPU dispatch per frame. The model callback returns an AgentModelConfig; everything else the demo needs (control panel, patch-field ground views, stats, seeding) is optional and covered in the run harness below.

Doing it by hand. runAgentModel is sugar over the engine. If you need full control (a custom render pipeline, embedding agents in a larger scene), lower the model yourself with buildAgentConfig and wire the features:

TypeScript
import { buildAgentConfig } from 'taos/agents/index.js';
import { ForwardLitFeature, ParticleFeature, TonemapFeature } from 'taos/engine/index.js';

engine.addFeature(new ForwardLitFeature());
engine.addFeature(new ParticleFeature({ config: buildAgentConfig(myModel) }));
engine.addFeature(new TonemapFeature({ aces: true }));
engine.run();

The run harness — runAgentModel#

runAgentModel(options) (in taos/agents/agent_app.js) wires the engine, a dim sun, a top-down orbit camera with touch/WASD controls, the forward-lit → particle → tonemap render stack, optional patch-field ground views, the frame loop, a per-frame stats line, and a DOM error catch — the boilerplate every agent demo otherwise repeats. It looks the page up by element id (#canvas, #stats, #controls by default) and returns the live AgentApp handle (you usually ignore the return value and work through the onReady/controls callbacks instead).

Options#

FieldTypeWhat it does
paramsP (any)Your mutable, live-tunable parameters. Mutate a field then call app.rebuild() to recompile the model.
model(params: P) => AgentModelConfigBuilds the model from the current params — your buildModel. Called on first build and on every rebuild.
camera'tilted' | 'topdown' | CameraPresetA named preset, or a partial override merged onto the tilted base: { position, pitch, yaw, fov, near, far, speed, sensitivity }.
patchViewsPatchViewOptions[]Patch fields to draw as ground planes under the agents (see below).
extraFeatures(app) => RenderFeature[]Extra HDR features spliced in after the agents but before tonemap — e.g. an AgentLinkFeature drawing graph edges. The factory gets the live app so a feature's source closure can read app.particles.pass lazily.
tuneConfig(cfg: ParticleGraphConfig) => voidEscape hatch to mutate the lowered config for a knob AgentModelConfig doesn't expose (e.g. cfg.integrateVelocity = false, or an emitter spawnRate). Runs on build + every rebuild.
controls(panel, app) => voidBuild the control panel (the #controls element is passed in). Pair with taos/html/control_panel.js (addSlider, addSelect, …).
stats(ctx, params) => stringThe text shown in #stats each frame.
onReady(app) => voidRuns once after the engine + passes exist. Seed patch fields / agents / links here.
rebuildDelaynumberDebounce (ms) for app.rebuild(). Default 150.
canvasId / statsId / controlsIdstringOverride the looked-up element ids.

The AgentApp handle#

Passed to onReady and controls:

Patch ground views — patchViews and patchView()#

A model's patches are a GPU grid the agents sense and deposit into; on their own they're invisible. A patch view draws one field as a colored ground plane under the agents. Add one per field you want to see — the harness wires its GPU buffer source and world rectangle for you (you only describe the look):

TypeScript
patchViews: [
  // field 0 drawn with the default teal ramp
  { field: 0, intensity: 0.15 },
  // field 1 drawn as a SOLID red (for a discrete state field, not a ramp)
  { field: 1, intensity: 1.8, tint: [1.0, 0.13, 0.05], flat: true },
]

PatchViewOptions: field, tint (RGB recolor), intensity (value→color scale), flat (solid tint×value instead of the ramp — for discrete state fields), opaque (draw as solid ground under the agents instead of additive), y (ground height), plus the two-field compare/field2/tint2/intensity2 mode and source markers (a nest/food square). Give two views of the same field index distinct names to disambiguate them.

Call app.patchView(field) to get the live PatchFieldFeature back and tweak it from a control — e.g. a glow slider or a show/hide toggle:

TypeScript
controls: (panel, app) => {
  addSlider(panel, 'Glow', 0.2, 2, 0.05, 0.9, (v) => {
    const view = app.patchView(0);
    if (view) {
      view.intensity = v;
    }
  });
},

These upload CPU data into the freshly created pass. Call them from onReady (and again inside a rebuild callback, since a rebuild recreates the pass and clears its state).

app.seedPatch(field, data) — the AgentScript patches.importDataSet. data is a resolution² row-major Float32Array: the cell at grid (x, z) lives at index z * resolution + x, and field indexes the model's patches.fields. Use it to set a grid's starting state — a Game-of-Life pattern, a forest, a wall map for a flood-fill distance field:

TypeScript
const RES = 256;
function randomGrid(p: number): Float32Array {
  const g = new Float32Array(RES * RES);
  for (let i = 0; i < g.length; i++) {
    g[i] = Math.random() < p ? 1 : 0;
  }
  return g;
}

runAgentModel({
  params: { density: 0.3 },
  model: () => ({
    world: { min: [-50, -50], size: [100, 100], topology: 'torus' },
    population: 1,                       // Life has no agents; one hidden particle drives the pass
    patches: { resolution: RES, worldMin: [-50, -50], worldSize: [100, 100],
               fields: [{ diffuse: 0, evaporate: 1, update: patch.gameOfLife(0) }] },
    render: { blendMode: 'alpha' },
  }),
  camera: 'topdown',
  patchViews: [{ field: 0, intensity: 0.9, tint: [0.45, 1.0, 0.65] }],
  onReady: (app) => { app.seedPatch(0, randomGrid(app.params.density)); },
  controls: (panel, app) => {
    addButton(panel, 'Randomize', () => app.seedPatch(0, randomGrid(app.params.density)));
  },
});

The other three mirror it for the non-grid state:

To measure a running model, app.readAgents(count?), app.readScratch(), and app.readPatch(field?) (the read counterpart of seedPatch) copy the buffers back (async — they synchronize the GPU, so call a few times a second, not per frame). See Measurement & plots.


Agents on the globe (geospatial)#

The agent system runs on the geospatial stack — the AgentScript Leaflet/MapLibre equivalent, but over real 3D terrain. The key: a GeoScene's world frame is a local East-North-Up tangent plane (Y-up) at its origin, which is exactly the flat XZ plane the agent sim already runs in. So anchoring the GeoFrame at a lat/lon (at the flock's altitude) places the whole agent world on the globe with no transform — agents compute in local XZ, render in world space, and terrain streams below them. Insert the ParticleFeature into the deferred pipeline right before tonemap (deferred lighting writes frame.hdr; the agents blend on top), and geo.attach() streams the tiles. Keep the camera near the flock so the floating-origin auto-reanchor doesn't fire (which would shift the local frame).

TypeScript
deferredPreset({ sky, ao })(engine);
const geo = new GeoScene(device, GeoFrame.atLonLat(lon, lat, altitude));
geo.addTerrain(...); geo.add3DTiles(...);
geo.attach(engine, { cameraGO });
const particles = new ParticleFeature({ config: buildAgentConfig(flock) });
engine.addFeatureBefore(particles, 'TonemapFeature');   // before tonemap, after lighting

For a sim that survives camera flight (the floating origin reanchors as you move), pass ParticleFeature.worldTransform — a per-frame Mat4 mapping the sim's local ENU plane to the live world frame, e.g. worldTransform: () => mat4dToEngine(geo.frame.rigidFromSnapshot(snap)) (snapshot the frame once at setup). It's applied in the particle render shader (the same mat4dToEngine(rigidFromSnapshot) the geo tiles use), so the flock tracks the globe exactly as the terrain does. The sim still computes in local space.

See samples/geo_agents.ts — boids flocking over streamed Manhattan terrain + OSM buildings, depth-occluded by the city, reanchor- robust via worldTransform.

Walking on the terrain surface#

To make agents follow the ground instead of flying at a fixed altitude, sample the terrain into a patch field and have each turtle set its Y from it. GeoScene.sampleHeightGrid(worldMin, worldSize, res) reads the terrain tileset over the local ENU grid and returns local-Y offsets (curvature-corrected ground height relative to the y = 0 plane), which you load into a static patch field:

TypeScript
// once terrain has streamed (poll geo.heightAt(anchorLon, anchorLat) !== null):
const grid = geo.sampleHeightGrid([-HALF, -HALF], [WORLD, WORLD], GRID);
particles.pass?.setPatchField(HEIGHT_FIELD, grid);   // a static field: diffuse 0, evaporate 1

Then the turtle step drops Y onto the sensed surface (after moving in XZ):

TypeScript
step: [
  agent.face(cohesionTarget), agent.turn(agent.randomTurn(wander)), agent.moveForward(speed),
  action.set('position', expr.vec3(
    expr.swizzle(agent.position(), 'x'),
    agent.sense(HEIGHT_FIELD, 'bilinear'),   // ground height at this XZ
    expr.swizzle(agent.position(), 'z'),
  )),
],

Re-sample the grid a few times as deeper terrain LODs stream in for a sharper surface. See samples/geo_agents_ground.ts — a herd flocking across the Grand Canyon, draped on the streamed terrain.

To make the flat arrows lie on the slope (instead of horizontal), compute the terrain normal in the step — central differences of the height field — and store it in the velocity field, then set render.tiltToSurface: true. Because that repurposes velocity as a render channel, also set integrateVelocity: false on the lowered config so the stored normal isn't treated as motion:

TypeScript
const normal = expr.normalize(expr.vec3(expr.neg(dhdx), 1, expr.neg(dhdz)));
// ...in step, after moving: action.set('velocity', normal)
// render: { shape: 'arrow', tiltToSurface: true }
const cfg = buildAgentConfig(model);
cfg.integrateVelocity = false;   // velocity now holds the normal, not motion

A diffusing field draped on the terrain#

A diffusing patch field (a pheromone trail) can be drawn following the ground, not on a flat plane: give PatchFieldFeature a drape block. It draws a tessellated grid mesh whose per-vertex Y comes from a height field and is placed by the same ENU worldTransform, depth-tested against the terrain:

TypeScript
new PatchFieldFeature({
  source: () => ({ dataBuffer: particles.pass?.patchBuffer ?? null,
                   uniformBuffer: particles.pass?.patchUniformBuffer ?? null }),
  worldMin: [-HALF, -HALF], worldSize: [WORLD, WORLD],
  field: TRAIL_FIELD, intensity: 0.06, tint: [0.5, 1, 0.7],
  drape: { resolution: GRID, heightField: HEIGHT_FIELD, surfaceOffset: 2, worldTransform },
});

A 2D top-down map (Leaflet / MapLibre)#

For the slippy-map view, point an orthographic camera straight down at the geo scene; terrain drapes aerial imagery by default, so it reads as a basemap with the agents as a flat overlay. See samples/geo_agents_map.ts (drag pan, wheel zoom, P toggles 2D↔3D):

TypeScript
const cam = cameraGO.addComponent(Camera.createOrthographic(WORLD, 1, 200_000, aspect));
// ...look straight down; wheel: cam.orthoSize *= factor;
// toggle: cam.projectionType = CameraProjection.Orthographic | Perspective

The big idea: agent = particle, behavior = one compute shader#

The mapping onto the particle system is direct:

Agent-modeling conceptParticle-system reality
agent / turtlea particle
positionparticle position (XZ ground plane)
heading (radians, 0 = +X)particle rotation
species / stateusually color.a (also the sprite opacity)
per-turtle scalar (e.g. energy)a free attribute (velocity models reuse rotation)
patches (a grid of values)the patch layer: a diffusing/evaporating atomic-i32 grid
neighbors (other turtles in-radius)a GPU spatial hash
linksper-agent fixed-capacity link slots
hatch / diethe GPU reproduction free-list

The important difference from CPU AgentScript is how behavior is expressed. AgentScript runs imperative JavaScript per turtle every tick:

JavaScript
// AgentScript (CPU): a JS closure runs once per turtle, serially.
turtles.ask(t => {
  t.right(util.randomCentered(0.2))
  t.forward(1)
})

Taos Engine instead builds a small expression graph with turtle-verb sugar, which is compiled once to a single WGSL compute shader and run for every agent in parallel:

TypeScript
// Taos (GPU): authored declaratively; compiles to one compute kernel.
step: [
  agent.turn(agent.randomTurn(0.2)),
  agent.moveForward(1),
]

So you don't write per-agent loops — you describe the per-agent program, and the GPU runs it across the whole population each frame. The verbs (forward, turn, face, sense, deposit, …) are pure sugar over the particle expression IR (expr/action), so a model composes freely with raw expressions when a verb doesn't exist for what you need.


Intro tutorial#

This walks the same progression as AgentScript's Snippets tutorial — create agents, move them, give them a grid to sense, then make them interact — but using the Taos Engine declarative API. Each step is a complete AgentModelConfig you can drop into the Quick-start scaffold above (return it from the model callback).

1. Create agents#

Agents are seeded by the particle emitter from a region of the world. Default is the whole world; here we start them in a small central box.

TypeScript
{
  world: { min: [-50, -50], size: [100, 100] },
  population: 5_000,
  spawn: { region: { kind: 'box', halfExtents: [5, 0, 5] }, color: [1, 1, 1, 1], size: 0.6 },
  // no `step` yet — they just sit where they spawned
}

Position-movement models get a random heading on spawn automatically, so they're ready to walk.

2. Move forward#

agent.moveForward(speed) advances each agent along its heading at speed world-units per second (it scales by delta-time for you). For a raw discrete jump use agent.forward(distance).

TypeScript
step: [ agent.moveForward(10) ]

With topology: 'torus' agents that leave one edge reappear on the opposite one; with topology: 'bounded' they clamp to the world rectangle. The wrap/clamp is appended to your step program automatically.

3. Turn — wander#

agent.turn(angle) adds to the heading; agent.randomTurn(spread) is a fresh random angle in [-spread, spread] each frame. Together they make a random walk:

TypeScript
step: [
  agent.turn(agent.randomTurn(0.3)),
  agent.moveForward(10),
]

4. Head toward a target#

agent.face(targetVec3) snaps the heading to point at a world position; agent.towards(target) returns that angle if you'd rather steer gradually.

TypeScript
import { expr } from 'taos/agents/index.js';

step: [
  agent.face(expr.constVec3([0, 0, 0])),   // turn to face the origin
  agent.moveForward(10),                    // … and march toward it
]

5. Add a patch grid (the environment)#

Patches are a grid of float cells the agents can read and write — pheromone, food, temperature, terrain. Declare one or more fields with per-field diffuse (spread to neighbors each frame, 0 = none) and evaporate (decay multiplier, 1 = none):

TypeScript
{
  world: { min: [-50, -50], size: [100, 100], topology: 'torus' },
  population: 100_000,
  patches: {
    resolution: 512,                 // 512×512 cells
    worldMin: [-50, -50], worldSize: [100, 100],
    fields: [{ diffuse: 0.2, evaporate: 0.97 }],
    depositScale: 1024, wrap: true,
  },
  step: [
    agent.turn(agent.randomTurn(0.3)),
    agent.moveForward(10),
    agent.deposit(0, 1.0),           // drop 1.0 into field 0 at my cell
  ],
}

agent.deposit(field, amount) adds to the cell under the agent (atomic). The grid diffuses + evaporates on the GPU each frame — no extra code.

6. Visualize the grid#

Render a patch field as a colored ground plane under the agents by adding a patchViews entry to the harness call — the buffer source and world rectangle are wired for you:

TypeScript
runAgentModel({
  params: {},
  model: () => model,                 // the AgentModelConfig with `patches`
  camera: 'tilted',
  patchViews: [{ field: 0, intensity: 0.12 }],
});

(Wiring it by hand instead — outside the harness — means a PatchFieldFeature whose source returns particles.pass.patchBuffer / patchUniformBuffer; see patchViews.)

7. Sense and steer (stigmergy)#

Reading the grid is what makes emergence happen. agent.sense(field) samples at the agent; agent.senseAhead(field, dist) samples ahead along the heading. The combined verb agent.steer(field, opts) does the classic three-sensor ("left / ahead / right") uphill steering and returns a turn delta:

TypeScript
step: [
  // turn toward the strongest pheromone ahead, plus a little wander
  agent.turn(expr.add(agent.steer(0, { dist: 3, angle: 0.45, turn: 0.6 }),
                      agent.randomTurn(0.08))),
  agent.moveForward(14),
  agent.deposit(0, 1.0),
]

This is the full Physarum ("slime mold") model — see samples/agent_slime.ts. Trail networks self-organize from nothing but sense → steer → deposit.

8. Per-agent state#

Give agents a discrete state by writing color.a (alias alpha) and branching on it with action.if_. Here ants flip between searching and carrying:

TypeScript
import { action } from 'taos/agents/index.js';

const carrying = expr.gt(expr.attribute('alpha'), 0.7);
step: [
  action.if_(carrying,
    [ agent.deposit(0, 5), /* … head home … */ ],   // CARRYING branch
    [ agent.deposit(1, 5), /* … seek food … */ ],   // SEARCHING branch
  ),
  agent.moveForward(11),
]

See samples/agent_ants.ts (two pheromone fields) and samples/agent_termites.ts (chip piling).

9. Neighbors — flocking#

Agent-to-agent behavior needs a spatial hash (neighbors) so each agent can query others in a radius. The built-in boids behavior reads it. Velocity models let the particle integrator do the moving, so they set movement: 'velocity' and seed a velocity on spawn:

TypeScript
{
  world: { min: [-60, -60], size: [120, 120] },
  population: 16_000,
  movement: 'velocity',
  spawn: {
    color: [0.2, 0.7, 1.2, 1.0], size: 0.5,
    actions: [
      action.set('rotation', expr.random({ mode: 'perSpawn', min: 0, max: Math.PI * 2 })),
      action.set('velocity', expr.mul(agent.headingDir(), 13)),
    ],
  },
  neighbors: { resolution: 20, worldMin: [-60, -60], worldSize: [120, 120], capacity: 96 },
  behaviors: [{
    type: 'boids', perception: 6, separation: 2.5,
    cohesionWeight: 1.0, alignWeight: 1.4, separationWeight: 1.2,
    maxSpeed: 22, minSpeed: 10, worldHalf: 60, edgeMargin: 10, edgeForce: 55,
  }],
  render: { billboard: 'velocity', shape: 'soft', blendMode: 'additive' },
}

See samples/agent_boids.ts, agent_predator_prey.ts, and agent_schelling.ts.

links gives each agent a fixed number of link slots; you upload the topology once with ParticlePass.setLinks. The link_springs behavior pulls linked agents together while the hash repels everyone, laying out a graph:

TypeScript
const particles = new ParticleFeature({ config: buildAgentConfig({
  /* …world, population, movement:'velocity', neighbors… */
  links: { linksPerAgent: 8 },
  behaviors: [{ type: 'link_springs', restLength: 3, stiffness: 5,
                repulsion: 9, repelRadius: 5, damping: 0.85,
                maxSpeed: 45, worldHalf: 60, edgeMargin: 8, edgeForce: 90 }],
}) });
engine.addFeature(particles);
particles.pass?.setLinks(ctx, linkData);   // Int32Array, population × linksPerAgent, slot = index or -1

To draw the links as edges (not just lay out the nodes), add an AgentLinkFeature. It reads the same particle + link buffers and renders one flat ground-plane ribbon per undirected edge into the HDR target, under the nodes:

TypeScript
import { AgentLinkFeature } from 'taos/renderer/features/agent_link_feature.js';

engine.addFeature(new AgentLinkFeature({
  source: () => ({
    particleBuffer: particles.pass?.particleBuffer ?? null,
    linkBuffer: particles.pass?.linkBuffer ?? null,
    linksPerAgent: particles.pass?.linksPerAgent ?? 0,
    count: particles.pass?.maxParticles ?? 0,
  }),
  color: [0.35, 0.55, 1.0], intensity: 0.5, width: 0.25,
}));

edges.width / edges.intensity are live setters, and edges.enabled toggles the layer. See samples/agent_links.ts.

11. Births and deaths#

For populations that grow and shrink, set reproduction: true. The emitter turns off; you seed the starting population directly with ParticlePass.seedAgents, and the GPU manages a free-list so behaviors can spawn (hatch) and kill (die) agents. The lotka_volterra behavior uses this for a true predator-prey cycle:

TypeScript
const particles = new ParticleFeature({ config: buildAgentConfig({
  world: { min: [-60, -60], size: [120, 120] },
  population: 50_000,            // carrying capacity = free-list size
  movement: 'velocity',
  reproduction: true,
  neighbors: { resolution: 17, worldMin: [-60, -60], worldSize: [120, 120], capacity: 96 },
  behaviors: [{ type: 'lotka_volterra', /* …vital rates… */ }],
}) });
engine.addFeature(particles);
particles.pass?.seedAgents(ctx, seedData, seedCount);   // Float32Array, 16 floats/agent

See samples/agent_lotka.ts — populations boom and bust in the classic oscillation.


Visual authoring (node editor)#

A model's per-step turtle program can also be built visually in the editor's particle graph editor instead of in code. The turtle verbs are registered as expression-graph nodes (in the shared NODE_KINDS registry), so they appear in the Add-Node menu under Expression ▸ Agent (turtles):

These compile to exactly the same WGSL as the code-side agent.* verbs (e.g. Agent Steer → the nested three-sensor select). Wire Agent Steer → Agent Turn, add Agent Move Forward and Agent Deposit, and you've drawn the Physarum step loop. The model-level configuration (world, patches, neighbors, links, reproduction, behaviors) is still authored in code via AgentModelConfig for now.

Standalone editor: the samples/agent_graph_editor.html sample mounts the same editor widget in the browser, configured for agents: an agent template set (Wander / Seek / Slime), an orbit camera that frames the turtle field, no ground plane or bloom, and an auto-provided diffusing patch grid (the agent "world") so patch models run. It's a thin host over the editor's createParticleEditor widget (via options: templateSet: 'agent', cameraMode: 'orbit', showGround/showBloom: false, agentWorld: true), so there's no duplicated editor code. The toolbar's Mouse: Edit / Navigate toggle switches the mouse between editing the node graph and orbiting the 3D view (drag = orbit, wheel = zoom; WASD always flies). The patch grid is also visualized as a colormapped ground plane, so the Slime template's pheromone trails show under the turtles.

Templates cover all 8 samples in-editor: Wander / Seek / Slime (movement + patches), Boids / Predator-Prey / Schelling (neighbor behaviors), Termites / Ants (per-agent state via when-guards), Links (force-directed graph) and Lotka-Volterra (births + deaths). The agent editor supplies whatever each needs: a spatial hash for neighbor behaviors, a link buffer + community topology for link_springs, and the reproduction free-list + a seeded two-species population for lotka_volterra. (Links draws its edges as ground-plane ribbons under the nodes. Lotka is extinction-prone — its vital rates are editable on the node.)

The agent world provides three patch fields: two diffusing (pheromone trails — teal field 0, blue field 1) and one static (termite chips — amber field 2), all shown as colormapped ground planes. Link nodes are colored by community and their edges drawn as ground ribbons.

Neighbor queries use a counting-sort spatial hash (count → prefix-sum → scatter into a compact sorted array), so there's no per-cell capacity cap — dense clusters never drop neighbors, and the memory is O(cells + agents). The capacity field on SpatialHashConfig is legacy/ignored.

Per-agent state / branching is done with the conditional when input on effect nodes: wire a bool (from Greater Than / And / Not etc.) into an effect's when port and it runs only when true — that's how the Termites template flips carrying↔searching. Unwired, an effect always runs.

The node canvas supports marquee select (left-drag empty to rubber-band; Shift-drag adds; Shift-click toggles a node), and selected nodes move and delete as a group.


API reference#

AgentModelConfig#

TypeScript
interface AgentModelConfig {
  world: { min: [number, number]; size: [number, number];
           topology?: 'torus' | 'bounded' | 'bounce' | 'die' };
  population: number;                       // max agents (sizes the buffer)
  movement?: 'position' | 'velocity';       // default 'position'
  spawn?: AgentSpawn;
  patches?: PatchConfig;                    // a diffusing grid to sense/deposit
  neighbors?: SpatialHashConfig;            // enables agent-to-agent behaviors
  links?: LinksConfig;                      // per-agent link slots
  reproduction?: boolean;                   // GPU births/deaths
  step?: Action[];                          // per-agent per-frame program
  behaviors?: ModifierNode[];               // built-in neighbor behaviors
  render?: { shape?; blendMode?; billboard?; sizeMul?; emit?; stretch?; thickness? };
}

Breeds (N named turtle types)#

Give each agent a breed — a NetLogo/AgentScript named type with its own behavior — by storing a breed id in color.a and branching per breed. Under additive arrow rendering color.a is free (the fragment outputs color.rgb * emit), so put the breed's visible color in color.rgb:

TypeScript
spawn: { actions: [
  action.set('alpha', breedId),                                  // breed id → color.a
  action.if_(agent.isBreed(0), [action.set('rgb', expr.vec3(0.3, 1, 0.45))]),
  // ...one per breed
] },
step: [
  action.if_(agent.isBreed(0), [ agent.face(center), agent.moveForward(10) ]),
  action.if_(agent.isBreed(1), [ agent.turn(agent.randomTurn(0.35)), agent.moveForward(9) ]),
],

agent.breed() reads the id, agent.isBreed(k) tests it, agent.setBreed(k) writes it. See samples/agent_breeds.ts — three breeds bouncing in a box. Distance sugar (agent.distance(target) / agent.distanceXY(x, z)) gates behavior by proximity.

AgentSpawn#

TypeScript
interface AgentSpawn {
  region?: SpawnShape;                      // default: a box over the whole world
  color?: [r, g, b, a];                     // initial color (a often = state/species)
  size?: number;                            // sprite size
  actions?: Action[];                       // custom on-spawn program
}

If actions is omitted, position models get a random heading; supply actions to assign species, seed velocity, etc. (Ignored when reproduction is set — seed those with seedAgents.)

Turtle verbs — agent#

VerbMeaning
agent.position()this agent's position (vec3)
agent.heading()heading angle (radians)
agent.headingDir()unit forward vector (cos h, 0, sin h)
agent.forward(dist)move dist units along the heading
agent.moveForward(speed)move at speed units/second (×dt)
agent.turn(angle)add angle to the heading
agent.setHeading(angle)set the heading absolutely
agent.towards(target)angle pointing toward target (vec3)
agent.face(target)turn to face target immediately
agent.sense(field, filter?)sample a patch field at this agent
agent.senseAhead(field, dist, filter?)sample dist ahead along the heading
agent.senseAt(field, dist, angleOffset, filter?)sample at an offset angle (an antenna)
agent.deposit(field, amount)atomic-add into a patch field at this cell
agent.randomTurn(spread, seed?)random angle in [-spread, spread] per frame
agent.steer(field, opts)three-sensor uphill steering → turn delta
agent.neighborCount(r)number of other agents within r (f32)
agent.neighborCenter(r)center of mass of neighbors within r (vec3) — cohesion
agent.neighborVelocity(r)average velocity of neighbors within r (vec3) — alignment
agent.neighborSeparation(r)sum of normalized away-vectors (vec3) — separation
agent.nearest(r)offset (vec3) to the nearest neighbor within r (zero if none)
agent.neighborsAhead(r, halfAngle)count of neighbors within r and the forward vision cone (in-cone)

The neighbor queries are the NetLogo/AgentScript in-radius family — generic reductions over the agents within a radius, evaluated against the spatial hash, so you can hand-author flocking/segregation/etc. in the step program instead of using a built-in behaviors modifier. They require neighbors on the model and run in the update step only. See samples/agent_flock.ts — a full flock authored from neighborCenter + neighborSeparation, no boids behavior.

Every verb returns plain Expr/Action nodes, so you mix them with the raw expression IR (expr.add, action.if_, action.set, expr.select, …) freely.

Turtle looks (sprite shapes)#

render.shape picks how each agent draws. For the NetLogo/AgentScript "turtle" look use 'arrow' — a filled chevron that lies flat on the ground and points along the agent's heading (its rotation, or its velocity for velocity-driven models). It's self-orienting, so it ignores render.billboard; it reads best in the top-down agent views. Arrows are small by default — bump render.sizeMul (≈3×) so the heading is legible:

TypeScript
render: { shape: 'arrow', sizeMul: 3.0, blendMode: 'additive' }

Other useful shapes: 'soft' (radial puff, the default — good for dense trail fields like slime/ants), 'pixel' (solid square), 'line' (a velocity-stretched streak; pair with billboard: 'velocity'), and the VFX shapes 'ember' / 'fire' / 'smoke'. samples/agent_boids.ts and agent_slime.ts expose a live Sprite selector so you can compare.

Lowering#

buildAgentConfig(model) returns a ParticleGraphConfig. Models with:

Reach the pass through the feature: particles.pass?.setLinks(...).


Measurement & plots (reading state back)#

The model side of AgentScript's exportDataSet / live plots — read GPU state back to the CPU to count, measure, or plot it:

Both are async and synchronizing — they copy to a staging buffer and map it, so call them a few times a second for measurement, never every frame. (The underlying ParticlePass.readPatchField / readAgents take the RenderContext.)

samples/agent_lotka.ts draws a live predator/prey population plot this way: every ~0.4 s it readAgents(), counts live agents by species, and scrolls a time-series — the classic Lotka-Volterra boom-bust, measured.

TypeScript
const agents = await particles.readAgents();
let prey = 0, pred = 0;
for (let i = 0; i < agents.length; i += 16) {
  if (agents[i + 3] >= 0 && agents[i + 12] > 0) {   // alive & active
    if (agents[i + 11] > 0.7) { pred++; } else { prey++; }   // color.a = species
  }
}

AgentScript parity#

AgentScript / NetLogoTaos Engine equivalent
turtles / breedsthe particle population; breeds via color.a
turtle.forward(d) / fdagent.forward(d) / agent.moveForward(speed)
turtle.right/left(a)agent.turn(±a)
turtle.face(agent)agent.face(target)
turtle.towards(agent)agent.towards(target)
patches / patch.sproutthe patches grid + agent.deposit
patch.diffuse / decayper-field diffuse / evaporate
turtle.patchAhead(d)agent.senseAhead(field, d)
other turtles in-radius rneighbors (spatial hash) + a behaviors modifier
links / link-neighborslinks + the link_springs behavior
turtle.hatch / diereproduction + the free-list (lotka_volterra)
ask turtles [ … ]the step program (runs for all agents in one dispatch)
world.torus / box topologyworld.topology: 'torus' | 'bounded'
patches.exportDataSet / plotsreadPatchField(field) / readAgents() (measurement layer)

The headline difference: AgentScript's ask runs imperative JS per turtle on the CPU; Taos Engine compiles the equivalent program to one GPU kernel, trading arbitrary-JS-per-turtle flexibility for ~1000× the population at interactive frame rates.


Defining your own model#

The AgentModelConfig is the user-facing surface for a custom model — there's no separate plugin or registration step. You write a function that returns a config (so live UI can rebuild it), exactly as the samples do:

TypeScript
function buildMyModel(p: MyParams): AgentModelConfig {
  return {
    world: { min: [-50, -50], size: [100, 100], topology: 'torus' },
    population: p.count,
    patches: { /* … if you need an environment … */ },
    step: [ /* … your turtle program, authored with `agent` + `expr`/`action` … */ ],
    render: { shape: 'soft', blendMode: 'additive' },
  };
}

Three levers cover most models:

  1. step — the per-agent program. Compose agent verbs and the expr/ action IR. Branch on state with action.if_; store state in color.a and a spare attribute. This is your ask turtles [ … ].
  2. patches — the shared environment agents read and write (stigmergy).
  3. neighbors / links / reproduction — opt into the heavier machinery when agents must see each other, be connected, or be born/killed. The agent-to-agent logic for those currently lives in the built-in behaviors (see below); the pure-expression step cannot itself iterate neighbors.

If your interaction rule isn't covered by an existing behavior, add a new ModifierNode variant in src/particles/particle_types.ts and its WGSL in src/particles/particle_builder.ts, following boids / predator_prey as templates — it then becomes available to every model via behaviors.


Built-in behaviors#

Neighbor/structure behaviors that don't fit the pure-expression step:

BehaviorRequiresDoes
boidsneighborscohesion + alignment + separation flocking
predator_preyneighborstwo species (flag in color.a): chase / flee / herd
schellingneighborssegregation: relocate unhappy agents
link_springsneighbors + linksspring layout of a graph
lotka_volterraneighbors + reproductionpredator-prey with births + deaths

Constraints & gotchas#


Samples & tests#

Eight runnable samples under samples/, each with a live control panel:

SamplePrimitives exercised
agent_slimepatches, sense/steer/deposit (Physarum)
agent_antsper-agent state, two pheromone fields
agent_termitesstate + static-grid aggregation (piling)
agent_boidsneighbors (spatial hash), flocking
agent_predator_preyneighbors, two species
agent_schellingneighbors, position-movement relocation
agent_linkslinks + spring layout + edge rendering (AgentLinkFeature)
agent_lotkareproduction (free-list), full predator-prey cycle

Codegen unit tests: tests/agents/agent_model.test.ts. On-GPU numerical tests (npm run test:gpu): tests/gpu/ — runs a model for N steps in a real browser, reads buffers back via async map, and checks the results numerically (patch mass conservation, hash bin counts, turtle forward displacement).

See TODO/gpu-agent-based-modeling.md for the design + roadmap.