Brains
Brains are a lightweight behavior tree system used by MAST labels.
In practice, a brain:
- picks one or more labels to run every tick
- interprets each label result as success or fail
- combines child results with selector or sequence rules
- stores useful blackboard-style values in agent inventory
If you are familiar with behavior trees:
- SEL is a selector (first child success wins)
- SEQ is a sequence (all children must succeed)
- a normal label is a leaf node
Mental model
Each agent can have one root brain object in inventory key BRAIN.
The root is usually a selector:
- SEL root
- child 1 (leaf or composite)
- child 2 (leaf or composite)
- ...
Each tick:
- The brain system visits every agent that has BRAIN.
- The root brain runs.
- Composite nodes execute children.
- Leaf nodes execute a MAST label and return a poll result.
Brains do not have a permanent done state. They are evaluated repeatedly.
How results drive behavior
Brains use success/fail-style outcomes:
- PollResults.BT_SUCCESS
- PollResults.BT_FAIL
- PollResults.OK_IDLE (used as reset/neutral state internally)
Composite behavior:
- Selector (SEL):
- starts as fail
- runs children in order
- first child that succeeds makes selector succeed
- if none succeed, selector fails
- Sequence (SEQ):
- runs children in order
- first child that fails makes sequence fail
- if all succeed, sequence succeeds
fail means opposite things in the two composites, and one of them is silent
Inside a Selector, yield fail is how a leaf declines its turn: the next sibling
gets a go. That is the whole point of a priority list.
Inside a Sequence, yield fail returns immediately — every sibling after it is
skipped for that pass. Nothing reports this. There is no error, nothing in the log,
and coverage looks healthy, because the leaf that failed did run; it is the ones after
it that quietly did not.
It shipped exactly once and cost a real bug: a science node yielded fail when no
hostile was in scan range — which is precisely the situation when a ship is off at a
wreck — so the comms, weapons and engineering nodes after it never ran, and the ship
sat there holding no weapons target and never fired. The helm looked perfect
throughout, because the helm was a Selector.
A node with nothing to do has succeeded at doing nothing. Under a Sequence, say
yield success. Reserve fail for Selector children and for "the whole sequence
should stop here".
Result modifiers
The runtime also supports modifier flags on a brain node:
- Invert
- AlwayFail
- AlwaySuccess
These flags alter the final node result after execution.
Leaf label execution rules
A simple brain node points at one major label.
When a leaf runs:
- On first run only, if sub label enter exists, it is executed once.
- If sub label test exists, that sub label is executed for result.
- Otherwise, execution starts at location 0 of the major label.
Runtime variables injected for the label task:
- BRAIN: current brain object
- BRAIN_AGENT: object form of the agent id
- BRAIN_AGENT_ID: raw agent id
This is why most brain labels read/write inventory through BRAIN_AGENT.
A leaf must never await
A leaf is one synchronous pass that ends in yield success or yield fail. Those
resolve to results that mark the task done, and the scheduler disposes of it.
Anything else — an await, or yield idle — leaves a live task on the scheduler,
while the brain starts a fresh one next pass. That is one immortal task per pass, for
the life of the mission, all running the same body. It also reads as not-success, so a
Selector falls through to the next sibling and the leaf appears to do nothing at all
while quietly multiplying.
The library now ends such a leaf and warns once, rather than leaking, but the leaf still has to be written correctly to mean anything.
Work that takes time goes in a task_scheduled label, gated by a flag, with a node
that holds the tree until it finishes. fleets/elite_abilities.mast in
LegendaryMissions is the worked example: elite_bt_activate schedules the ability and
stashes the handle; elite_bt_gate yields fail until elite_task.done().
How often a brain runs
Every brain re-evaluates its whole tree on a heartbeat set by BRAIN_PASS_SECONDS
(default 3). Brains are not all run in the same frame: a RollingSlicer hands back a
few each tick so a full pass over every brain in the mission completes in exactly that
period, whether there are three brains or three hundred. That keeps a big NPC count from
becoming a periodic frame spike.
Design your leaves around that period, not around the tick. A leaf sees the world as
it was when its pass came round, so a decision holds for up to BRAIN_PASS_SECONDS
before it can be revisited. That is fine for "who do I attack" and wrong for anything
needing a tight loop — steering corrections, or a throttle that should track a closing
distance. Work that needs to be smooth belongs in the engine's own steering
(target/target_pos set a destination the engine tracks every frame) rather than in
repeated leaf decisions.
This period was silently double until v1.4.0
The slice used to be sized per call, on the assumption that the host called it 30
times a sim-second. Measured against a live 1.3.7 engine it really calls it 15, so
every pass took twice its declared time — BRAIN_PASS_SECONDS = 3 meant six. The
headless mock calls it about 6 a second, so the same tree ran on a 15-second
pass there, and the two hosts disagreed with each other by 2.5x.
Nothing reported it, and nothing could: a brain running at half speed is
indistinguishable from a brain whose leaves keep declining. It surfaced only because a
brain-driven player ship almost never warped while identical logic written as a
delay_sim(1) loop warped constantly — it was re-checking its speed every fifteen
seconds instead of every three.
The slice is now sized by the elapsed tick count, the way TickTask has always
measured its own delay, so the period is honest and identical in both hosts. If you
tuned a mission's feel against the old behavior, the equivalent is
BRAIN_PASS_SECONDS = 6.
Objectives (OBJECTIVE_PASS_SECONDS, also 3) and urges (URGE_PASS_SECONDS, 30) share
the same slicer and were affected the same way.
Defining brains declaratively
Brains can be assembled from Python-friendly structures.
Supported structures:
- string: label name
- MastNode: direct label node
- list: add each item as child
- dict:
- single key starting with SEL -> selector composite
- single key starting with SEQ -> sequence composite
- otherwise use keys label and data
Common pattern:
brain:
SEQ:
- ai_fleet_init_blackboard
- SEL:
- ai_fleet_chase_best_anger
- label: ai_fleet_chase_roles
data:
test_roles: station
- label: ai_fleet_chase_roles
data:
test_roles: __player__
- ai_fleet_calc_forward_vector
- ai_fleet_scatter_formation
The ROOT is a Selector unless you ask otherwise
brain_add creates a Selector root, which stops at the first child that succeeds.
That is right for a priority list of behaviours and wrong for a set of independent
jobs — the first success starves every one after it, permanently and silently.
Pass root_type=BrainType.Sequence when every child should run each pass. It applies
only when the call CREATES the root, so a later brain_add can never re-type an
agent's existing tree.
A tree that needs both — a priority helm plus several independent consoles — nests a
SEL: inside a SEQ: root, as the example above does.
Declare the tree as YAML, never as a MAST dict literal
MAST parses line by line, so a {...} spanning lines is an unclosed brace that
desyncs the compiler for the rest of the file — and a story that does not compile runs
zero labels while still reporting PASS. The brain: metadata block has no such
problem, which is why every prefab uses it.
Notes:
- Child order matters for SEL and SEQ.
- data on a node is passed into the task context for that label.
- metadata defaults in the label and runtime data overrides are often used together.
Quick start: attach a brain
Brains are typically attached from Python when spawning/configuring an NPC or fleet controller.
from sbs_utils.procedural.brain import brain_add
brain_add(
agent_id,
{
"SEQ main": [
"ai_fleet_init_blackboard",
{
"SEL choose_target": [
"ai_fleet_chase_best_anger",
{"label": "ai_fleet_chase_roles", "data": {"test_roles": "station"}},
{"label": "ai_fleet_chase_roles", "data": {"test_roles": "__player__"}},
]
},
"ai_fleet_calc_forward_vector",
"ai_fleet_scatter_formation",
]
},
client_id=0,
)
The first call creates a default selector root if needed, then inserts your tree.
Metadata and per-node data
A brain label often declares defaults in metadata.
Example:
=== ai_fleet_chase_roles
metadata: yaml
type: brain/npc
use_arena: True
test_roles: station
exclude_roles: raider
...
Then a brain node can provide data overrides:
label: ai_fleet_chase_roles
data:
test_roles: __player__
This allows one label to be reused with different behavior.
Active brain status text
The runtime tracks active text for debugging/UI use.
For a leaf node:
- active: label name
- active_desc:
- desc inventory value if present
- optionally random choice if desc is a list
- then DisplayName inventory value if present
- otherwise label name
For a selector, when one child succeeds:
- that child becomes active
- brain_active inventory is set to child active_desc
Worked example: fleet brain
Using your fleet script shape:
- ai_fleet_init_blackboard
- resets target data
- computes lead ship and local arena
- SEL target choice
- try ai_fleet_chase_best_anger first
- if that fails, try role-based targeting variants
- ai_fleet_calc_forward_vector
- computes destination and throttle
- ai_fleet_scatter_formation
- issues per-ship target positions
This creates robust fallback logic:
- preferred tactical behavior first
- deterministic fallbacks second
- movement only after a valid target exists
Minimal authoring checklist
When writing a new brain label:
- Add metadata defaults for tunables (distance, roles, stop_dist, etc.).
- Use BRAIN_AGENT inventory as your blackboard.
- Return early with yield fail when prerequisites are missing.
- Set outputs (target, target_position, throttle, etc.).
- End with yield success.
When composing a tree:
- Use SEQ for required steps.
- Use SEL for fallback options.
- Put cheap/high-confidence options earlier.
- Keep each label focused on one responsibility.
Debugging tips
- If a brain seems idle, verify the agent has BRAIN assigned.
- If a label never runs, confirm the label name resolves correctly.
- If selector never succeeds, inspect each child for missing prerequisites.
- If targeting stalls, verify blackboard keys are populated in init step.
- Track brain_active to see which child is currently winning selection.
API touchpoints
Core procedural calls:
- brain_add(...)
- brain_clear(...)
- brains_run_all(...)
These are defined in:
- sbs_utils/procedural/brain.py
Example mission labels:
- fleets/brain_fleet.mast
Together they provide a full pattern for authoring reusable NPC decision logic.