Writing Behavior Trees

Behavior trees are the core abstraction that drives swarmtest agents. Each agent executes a behavior tree on every tick, deciding which game actions to perform based on the current game state.

Tree Node Types

swarmtest behavior trees are JSON objects with a discriminated type field. There are eight node types:

sequence

Runs children in order. Stops on the first failure. Returns success only if all children succeed.

{
  "type": "sequence",
  "children": [
    { "type": "action", "name": "move", "params": { "direction": "north" } },
    { "type": "wait", "ticks": 5 },
    { "type": "action", "name": "move", "params": { "direction": "south" } }
  ]
}

selector

Tries children in order. Stops on the first success. Returns failure only if all children fail. Useful for fallback logic.

{
  "type": "selector",
  "children": [
    { "type": "action", "name": "battle_attack", "params": { "move_index": 0 } },
    { "type": "action", "name": "move", "params": { "direction": "__random__" } }
  ]
}

repeat

Repeats a child node a fixed number of times, or forever.

{ "type": "repeat", "count": "forever", "child": { "type": "action", "name": "move", "params": { "direction": "__random__" } } }

random_selector

Picks a random child based on weights. Useful for creating varied agent behavior.

{
  "type": "random_selector",
  "children": [
    { "type": "action", "name": "move", "params": { "direction": "__random__" } },
    { "type": "action", "name": "interact_npc" },
    { "type": "wait", "ticks": 3 }
  ],
  "weights": [5, 2, 1]
}

action

A leaf node that sends a game action through the adapter. If the action is not currently available (as determined by adapter.getAvailableActions()), the node returns failure.

{ "type": "action", "name": "move", "params": { "direction": "north" } }

Use "__random__" as a parameter value to randomly select from the available options defined by the adapter’s ParamSpec:

{ "type": "action", "name": "move", "params": { "direction": "__random__" } }

wait

Waits for a specified number of ticks before returning success. Returns running until the wait is complete.

{ "type": "wait", "ticks": 10 }

condition

Branches based on a predicate evaluated against the agent’s game state. The available predicates are:

PredicateDescription
in_battleAgent is currently in a battle
exploringAgent is in the exploring phase
in_tradeAgent is in a trade
in_npc_dialogAgent is talking to an NPC
evolution_pendingA creature evolution is pending
move_learn_pendingA new move can be learned
has_teamAgent has at least one team member
team_healthyAt least one team member has HP > 0
has_moneyAgent has money > 0
{
  "type": "condition",
  "predicate": "in_battle",
  "then": { "type": "action", "name": "battle_attack", "params": { "move_index": "__random__" } },
  "else": { "type": "action", "name": "move", "params": { "direction": "__random__" } }
}

probability

Executes the child node with a given probability (0-1). Returns failure if the random check fails.

{ "type": "probability", "chance": 0.3, "child": { "type": "action", "name": "interact_npc" } }

The Random Walk Fallback

When LLM generation fails or no behavior tree is assigned, agents fall back to the built-in random walk tree:

{
  "type": "repeat",
  "count": "forever",
  "child": {
    "type": "sequence",
    "children": [
      {
        "type": "random_selector",
        "children": [
          { "type": "action", "name": "move", "params": { "direction": "__random__" } },
          { "type": "action", "name": "move", "params": { "direction": "__random__" } },
          { "type": "action", "name": "move", "params": { "direction": "__random__" } },
          { "type": "wait", "ticks": 3 }
        ],
        "weights": [3, 3, 3, 1]
      }
    ]
  }
}

LLM-Generated Trees

When LLM generation is enabled, swarmtest prompts Claude with:

  1. The game context (from adapter.describeGameContext())
  2. All available actions and their parameters (from adapter.describeAvailableActions())
  3. The full TreeNode JSON schema
  4. Summaries of existing trees in the library (to encourage diversity)
  5. A focus area (e.g., “battle system stress”, “rapid connect/disconnect cycling”)

Claude returns a valid JSON behavior tree that is validated against the TreeNode schema before use.

Writing Handwritten Trees

For adapter-specific behavior, you can write trees by hand. Export a function that returns a TreeNode:

import type { TreeNode } from 'swarmtest';

export function myCustomTree(): TreeNode {
  return {
    type: 'repeat',
    count: 'forever',
    child: {
      type: 'selector',
      children: [
        {
          type: 'condition',
          predicate: 'in_battle',
          then: {
            type: 'sequence',
            children: [
              { type: 'action', name: 'battle_attack', params: { move_index: '__random__' } },
              { type: 'wait', ticks: 2 },
            ],
          },
        },
        {
          type: 'random_selector',
          children: [
            { type: 'action', name: 'move', params: { direction: '__random__' } },
            { type: 'action', name: 'interact_npc' },
          ],
          weights: [3, 1],
        },
      ],
    },
  };
}

Then pass handwritten trees to the swarm config:

await swarm.run({
  // ...
  handwrittenTrees: [myCustomTree()],
});

Saved Trees and the Tree Library

Behavior trees that trigger findings with severity crash, bug, or jank are automatically recorded and saved to the tree library on disk (as JSON files in ./trees/<game>/). These saved trees include metadata:

  • id – Unique identifier (e.g., tree-1771647225060-tgbfcs)
  • game – Which game adapter the tree was used with
  • sourcellm, handwritten, or recorded
  • findings – IDs of findings this tree triggered
  • actionSequenceHash – Hash for deduplication
  • tags – Categories from associated findings (e.g., crash, desync)

On subsequent runs, these saved trees are loaded as regression tests.