# Operator
Welcome to Operator. We're done here.
Just kidding. But seriously, if you've ever watched an NPC walk into a wall for six hours because someone forgot to tell it that doors exist, you know why this library exists. We built Operator because game AI deserves better than a pile of if-statements held together with prayers and energy drinks.
Operator gives you two things:
- **HTN Planning** - Your NPCs will actually *think*. Goals break down into tasks, tasks break down into actions, and suddenly your village blacksmith stops trying to forge swords in the middle of a lake.
- **Director** - A narrative orchestration system that decides when interesting things should happen. Think Left 4 Dead's AI Director, but you're the one holding the reins.
## Installation
```elixir
def deps do
[
{:ex_operator, "~> 0.1.0"}
]
end
```
That's it. No C dependencies. No NIFs that only compile on a full moon. Just pure Elixir.
## Quick Start (5 Minutes)
This is the smallest end-to-end loop that proves the library works. You can copy it
into a scratch module and expand from there.
1. Define a behavior module.
1. Build facts.
1. Plan.
1. Execute one step.
```elixir
defmodule MyGame.QuickstartBehavior do
use Operator.HTN.DSL
goal :patrol do
precond fn facts ->
Operator.HTN.Facts.get(facts, {:self, :energy}, 0) > 10
end
decompose do
task :move_to, :waypoint_1
task :look_around
task :move_to, :waypoint_2
task :look_around
end
metadata priority: 3, domain: :routine
end
primitive :move_to, waypoint do
run fn actor, _facts ->
{:ok, %{actor | position: waypoint}}
end
end
primitive :look_around do
run fn actor, _facts ->
{:ok, actor}
end
end
end
alias Operator.HTN.{Executor, Facts, Planner}
facts = Facts.from_perception(%{self: %{energy: 50}})
traits = %{archetype: :guard}
actor = %{id: 1, position: :start}
{:ok, plan} = Planner.run(:patrol, facts, traits)
{:ok, :continue, actor, facts, remaining} = Executor.step(plan, actor, facts)
```
If that runs, you are up and planning. Everything else is just richer behaviors.
## Guides And References
For fast adoption, start here:
- [Getting Started](guides/getting_started.md) for the full walkthrough.
- [How-To](guides/howto.md) for practical recipes.
- [Cheatsheet](guides/cheatsheet.md) for a quick API map.
- [Testing](guides/testing.md) for safe Registry usage and isolation.
- [Debugging](guides/debugging.md) for tracing and plan introspection.
- [DSL Reference](guides/dsl_reference.md) for a full HTN DSL reference.
- [Architecture](guides/architecture.md) for system-level flow and scaling.
- [Director](guides/director.md) for narrative pacing and events.
- [Best Practices](guides/best_practices.md) for pragmatic, production-oriented guidance.
- [Anti-Patterns](guides/anti_patterns.md) for common failure modes and fixes.
The generated API docs live in `doc/` after `mix docs`.
## Adoption Checklist
Treat this as a ruthless, practical path to production.
1. Define one behavior module with one goal, one task, one primitive.
1. Hook `Planner.run/3` into a single entity.
1. Use `Executor.step/3` in your game loop.
1. Add a facts builder and keep it deterministic.
1. Add tests with `Operator.HTN.TestHelpers` and `async: false`.
1. Add tracing while you tune behaviors, then disable it.
1. Add storage only if your plans span multiple ticks.
1. Add the Director only when you want global pacing.
1. Use `GoalSelector.explain/3` and `Planner.explain/3` to debug decisions.
1. Add planning budgets once you scale beyond a handful of agents.
## HTN Planning (or: Teaching Rocks to Think)
HTN stands for Hierarchical Task Network. The idea came from some very smart people who got tired of writing behavior trees that looked like spaghetti painted by Jackson Pollock.
Here's the deal: you define **goals** (what the NPC wants), **tasks** (how to break that down), and **primitives** (the actual buttons to press). The planner figures out the rest.
### Defining Behavior
```elixir
defmodule MyGame.NPCBehavior do
use Operator.HTN.DSL
# "I want data and I want it now"
goal :acquire_data do
# IMPORTANT: Use full module paths in precond/decompose functions!
# Aliases from your module header don't work here - these functions
# are evaluated at runtime in a different scope.
precond fn facts ->
not Operator.HTN.Facts.has?(facts, {:self, :has_data})
end
decompose do
task :go_to_terminal
task :download_data, "target_server"
end
metadata priority: 5, domain: :infiltration
end
# "Getting there is half the battle"
task :go_to_terminal do
precond fn facts ->
Operator.HTN.Facts.has?(facts, {:self, :can_move})
end
decompose fn facts ->
terminal = Operator.HTN.Facts.get(facts, {:world, :nearest_terminal})
[{:move_to, [terminal]}]
end
cost 2.0
end
# "The part where things actually happen"
primitive :download_data, target do
run fn actor, _facts ->
# Your game logic here. We're not picky.
{:ok, actor}
end
metadata action_type: :interact
end
end
```
> **Heads up:** Functions inside `precond` and `decompose` blocks are evaluated at runtime, which means your module's `alias` statements won't work inside them. Always use full module paths like `Operator.HTN.Facts.get(...)` instead of `Facts.get(...)`.
### Making Plans Happen
```elixir
alias Operator.HTN.{Facts, Plan, Planner}
# What does your NPC know about the world?
facts = Facts.from_perception(%{
self: %{can_move: true, has_data: false},
world: %{nearest_terminal: :server_room}
})
# Or start with empty facts
facts = Facts.new()
# What kind of NPC is this?
traits = %{archetype: :infiltrator, traits: [:stealthy]}
# Let's see what we've got
case Planner.run(:acquire_data, facts, traits) do
{:ok, plan} ->
IO.inspect(plan.tasks)
# => [{:move_to, [:server_room]}, {:download_data, ["target_server"]}]
# Look at that. A real plan. Made by a computer.
{:error, :preconditions_not_met} ->
# Can't get blood from a stone
:retry_later
{:error, :goal_not_found} ->
# You asked for a goal that doesn't exist. Classic.
:unknown_goal
end
```
### Loop Helper (Less Boilerplate)
If you want to wire planning into a tick loop quickly:
```elixir
alias Operator.HTN.Loop
result = Loop.tick(entity.plan, entity, facts, traits, goal: :patrol)
```
### Executing Plans
Plans are just data - sequences of primitives to execute. The `Executor` module handles the messy business of actually running them:
```elixir
alias Operator.HTN.{Executor, Planner}
# Generate a plan
{:ok, plan} = Planner.run(:patrol, facts, traits)
# Execute step by step (recommended for game loops)
case Executor.step(plan, npc, facts) do
{:ok, :completed, npc, facts, _plan} ->
# All done!
{:idle, npc, facts}
{:ok, :continue, npc, facts, remaining_plan} ->
# More to do - store remaining plan for next tick
{:running, %{npc | plan: remaining_plan}, facts}
{:error, reason, npc, facts, _plan} ->
# Something went wrong - maybe replan
{:failed, %{npc | plan: nil}, facts}
end
# Or run the whole plan at once (useful for turn-based games)
case Executor.run_plan(plan, npc, facts) do
{:ok, npc, facts} ->
# Everything worked
:done
{:error, reason, npc, facts, remaining} ->
# Failed partway through
:partial_failure
end
```
### Effects: The Secret Sauce
Here's where it gets spicy. When the planner is figuring out what to do, it can *simulate* the effects of actions. Your NPC can reason about unlocking a door *before* it tries to walk through it.
```elixir
defmodule MyGame.DoorBehavior do
use Operator.HTN.DSL
goal :enter_locked_room do
precond fn facts ->
not Operator.HTN.Facts.get(facts, {:world, :in_room}, false)
end
decompose do
task :unlock_door
task :enter_room
end
end
primitive :unlock_door do
run fn actor, _facts ->
# Unlock animation, key consumption, etc.
{:ok, actor}
end
# This effect is applied DURING PLANNING so :enter_room knows
# the door will be unlocked by the time it runs
effect Operator.HTN.Effect.new(:plan_and_execute, {:world, :door_unlocked}, true)
end
primitive :enter_room do
# This precondition passes during planning because :unlock_door's
# effect has already been applied to the planning state
precond fn facts ->
Operator.HTN.Facts.get(facts, {:world, :door_unlocked}, false)
end
run fn actor, _facts ->
{:ok, %{actor | location: :room}}
end
effect Operator.HTN.Effect.new(:plan_and_execute, {:world, :in_room}, true)
end
end
```
**Effect flavors:**
- `:plan_only` - "Let's pretend this happened" (planning only, ignored during execution)
- `:plan_and_execute` - "This will actually happen" (applied during both planning and execution)
- `:permanent` - "This happened and nothing can undo it" (persists even on task failure)
### Automatic Goal Selection
Don't want to micromanage which goal your NPC pursues? Let the `GoalSelector` handle it:
```elixir
alias Operator.HTN.{GoalSelector, Planner}
case GoalSelector.pick_goal(facts, traits) do
{:ok, goal_name} ->
Planner.run(goal_name, facts, traits)
:none ->
# Nothing to do. Time to stand around looking mysterious.
:idle
end
```
## Common Gotchas (Read This)
- Use full module paths inside `precond` and `decompose` functions. Aliases do not work there.
- The Registry is global state. Use `async: false` for tests that touch it.
- Facts are immutable. Always use the returned facts after `Effect` or `Facts.put/3`.
- Plans are data. If you mutate the actor, keep facts in sync.
- Do not run `Planner.run/3` inside tight loops without caching if the world state is stable.
- Use `Planner.needs_replan?/2` before throwing away a plan.
## The Director (or: Playing God, Responsibly)
Ever play a game where nothing happens for twenty minutes and then everything happens at once? That's bad directing. The Director system lets you control the *pacing* of your simulation.
You write a **Storyteller** that decides when and what events should fire based on the current world state. Tension too low? Spawn a wandering merchant. Tension too high? Maybe hold off on that dragon attack.
### Writing a Storyteller
```elixir
defmodule MyGame.DramaticStoryteller do
@behaviour Operator.Storyteller
@impl true
def init(opts) do
%{
last_event_tick: 0,
tension_threshold: Map.get(opts, :tension_threshold, 0.7)
}
end
@impl true
def pick_event(tick, world_state, state) do
tension = Map.get(world_state, :tension, 0.0)
if tension > state.tension_threshold do
event = %{
type: :dramatic_confrontation,
location: pick_location(world_state),
severity: 4
}
{event, %{state | last_event_tick: tick}}
else
{nil, state} # Sometimes the best event is no event
end
end
defp pick_location(world_state) do
%{district: "downtown"} # Your logic here
end
end
```
### Running the Show
```elixir
{:ok, _pid} = Operator.Director.start_link(
storyteller: MyGame.DramaticStoryteller,
on_event: fn event ->
MyGame.EventHandler.process(event)
end
)
# Every tick, feed it the world state
Operator.Director.tick(%{
tick: current_tick,
tension: world_tension,
summary: %{total_entities: 150}
})
```
### Director + HTN Integration
The Director generates world events; HTN planning lets NPCs react to them:
```elixir
defmodule MyGame.AILoop do
alias Operator.HTN.{Executor, Facts, GoalSelector, Planner}
def tick(entity, world_state, director_events) do
# Build facts from perception + any director events
facts = build_facts(entity, world_state, director_events)
traits = entity.traits
case entity.current_plan do
nil ->
# No plan - pick a goal and make one
case GoalSelector.pick_goal(facts, traits) do
{:ok, goal} ->
case Planner.run(goal, facts, traits) do
{:ok, plan} -> %{entity | current_plan: plan}
{:error, _} -> entity
end
:none ->
entity # Idle
end
plan ->
# Execute one step of current plan
case Executor.step(plan, entity, facts) do
{:ok, :completed, entity, _facts, _plan} ->
%{entity | current_plan: nil}
{:ok, :continue, entity, _facts, remaining} ->
%{entity | current_plan: remaining}
{:error, _reason, entity, _facts, _plan} ->
# Plan failed - will replan next tick
%{entity | current_plan: nil}
end
end
end
end
```
## Registry API
The registry stores all registered goals, tasks, primitives, and axioms:
```elixir
alias Operator.HTN.Registry
# Get specific items by name
goal = Registry.get_goal(:patrol)
task = Registry.get_task(:move_to)
primitive = Registry.get_primitive(:attack)
axiom = Registry.get_axiom(:enemy_nearby)
# List all registered names
Registry.list_goal_names() # => [:patrol, :attack, :flee]
Registry.list_primitive_names() # => [:move, :strike, :block]
# Get the full registry map
registry = Registry.all()
# => %{goals: %{...}, tasks: %{...}, primitives: %{...}, axioms: %{...}}
# Stats
Registry.stats()
# => %{goals: 5, tasks: 12, primitives: 8, axioms: 3}
```
## Testing
The Registry uses `persistent_term` for fast lookups, which means tests need some care:
```elixir
defmodule MyApp.BehaviorTest do
use ExUnit.Case, async: false # Important!
import Operator.HTN.TestHelpers
alias Operator.HTN.{Facts, Plan, Planner}
# Reset registry before each test
setup :reset_registry
# Register your behavior module(s)
setup do
register_modules([MyApp.NPCBehavior])
:ok
end
test "patrol goal generates valid plan" do
facts = Facts.from_perception(%{
self: %{on_duty: true, can_move: true}
})
{:ok, plan} = Planner.run(:patrol, facts, %{})
assert Plan.has_tasks?(plan)
assert_has_task(plan, :walk_to_waypoint)
end
end
```
Key points:
- Use `async: false` - the Registry is global state
- Call `reset_registry` in setup to ensure clean state
- Use `register_modules/1` to register your behavior modules
- See `Operator.HTN.TestHelpers` for more utilities
```bash
mix test
```
Run it. Keep it green.
## Performance Notes
The planner is optimized for many reads and few writes. Registry reads are
`persistent_term` lookups and are effectively free. Planning cost scales with
the breadth of your decomposition and the number of preconditions. Keep your
facts small, avoid heavy IO inside tasks, and use tracing only while debugging.
## Configuration
Operator is pluggable. Don't like how we do something? Swap it out.
```elixir
config :ex_operator,
telemetry_module: MyApp.OperatorTelemetry, # Your metrics, your way
traits_module: MyApp.OperatorTraits, # Custom genome/personality system
storage_module: Operator.HTN.Storage, # Where plans live (default: ETS)
rationalization_module: MyApp.OperatorRationalization, # Plan annotation
# Weight certain trait+goal combinations
htn_trait_weights: %{
{:aggressive, :attack} => 5,
{:cautious, :scout} => 3
}
```
## Behaviours
We expose several behaviours so you can integrate Operator with whatever bizarre architecture you've already committed to.
### Telemetry
```elixir
defmodule MyApp.OperatorTelemetry do
@behaviour Operator.Telemetry
@impl true
def emit_goal_selected(goal_name, measurements, metadata) do
:telemetry.execute([:my_app, :htn, :goal_selected], measurements, metadata)
end
@impl true
def emit_htn_plan_generated(goal, task_count, duration_ms) do
:telemetry.execute([:my_app, :htn, :plan_generated],
%{task_count: task_count, duration: duration_ms},
%{goal: goal})
end
@impl true
def emit_director_event(event_type, tick) do
:telemetry.execute([:my_app, :director, :event],
%{count: 1},
%{type: event_type, tick: tick})
end
end
```
### Traits
```elixir
defmodule MyApp.OperatorTraits do
@behaviour Operator.Traits
@impl true
def traits(genome), do: Map.get(genome, :traits, [])
@impl true
def trait_affinity_score(genome, metadata) do
# How much does this agent want to do this thing?
0
end
@impl true
def archetype_affinity_score(genome, metadata) do
# Warriors gonna war, healers gonna heal
0
end
end
```
### Storage
```elixir
defmodule MyApp.PlanStorage do
@behaviour Operator.Storage
@impl true
def persist_plan(entity_id, plan) do
MyApp.Cache.put({:plan, entity_id}, plan)
:ok
end
@impl true
def fetch_plan(entity_id) do
MyApp.Cache.get({:plan, entity_id})
end
@impl true
def clear_plan(entity_id) do
MyApp.Cache.delete({:plan, entity_id})
:ok
end
@impl true
def list_plans do
MyApp.Cache.list_by_prefix(:plan)
end
end
```
## Examples
Check out the `examples/` directory. We've got:
- **game_npc** - Combat, patrol, survival behaviors
- **web_scraper** - Yes, you can use HTN for web scraping. We won't judge.
- **job_worker** - Background job orchestration
- **chatbot** - Conversation flow management
- **simulation** - Multi-agent chaos with the Director
## Module Index
**Core HTN:**
- `Operator.HTN.DSL` - Macro-based DSL for defining behaviors
- `Operator.HTN.Facts` - World state representation
- `Operator.HTN.Plan` - Generated plan structure
- `Operator.HTN.Planner` - High-level planning API
- `Operator.HTN.Executor` - Plan and task execution
- `Operator.HTN.Engine` - Low-level plan expansion
- `Operator.HTN.Loop` - Tick-based planning/execution helper
- `Operator.HTN.Registry` - Goal/task/primitive storage
- `Operator.HTN.Effect` - World state modifications
- `Operator.HTN.Task` - Task definitions
- `Operator.HTN.Axiom` - Reusable query patterns
- `Operator.HTN.Precondition` - Logical operators
- `Operator.HTN.GoalSelector` - Automatic goal selection
- `Operator.HTN.TestHelpers` - Testing utilities
**Director:**
- `Operator.Director` - Event orchestration GenServer
- `Operator.Storyteller` - Storyteller behaviour
**Integration:**
- `Operator.Telemetry` - Metrics callbacks
- `Operator.Traits` - Personality/genome integration
- `Operator.Storage` - Plan persistence
- `Operator.Rationalization` - Plan annotation
## Why "Operator"?
Because your NPCs are finally going to operate like they have a brain cell or two. Also it sounds cool.
## License
MIT. Do whatever you want. Make something great.