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
def deps do
[
{:ex_operator, "~> 0.1.0"}
]
endThat'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.
- Define a behavior module.
- Build facts.
- Plan.
- Execute one step.
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 for the full walkthrough.
- How-To for practical recipes.
- Cheatsheet for a quick API map.
- Testing for safe Registry usage and isolation.
- Debugging for tracing and plan introspection.
- DSL Reference for a full HTN DSL reference.
- Architecture for system-level flow and scaling.
- Director for narrative pacing and events.
- Best Practices for pragmatic, production-oriented guidance.
- Anti-Patterns 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.
- Define one behavior module with one goal, one task, one primitive.
-
Hook
Planner.run/3into a single entity. -
Use
Executor.step/3in your game loop. - Add a facts builder and keep it deterministic.
-
Add tests with
Operator.HTN.TestHelpersandasync: false. - Add tracing while you tune behaviors, then disable it.
- Add storage only if your plans span multiple ticks.
- Add the Director only when you want global pacing.
-
Use
GoalSelector.explain/3andPlanner.explain/3to debug decisions. - 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
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
endHeads up: Functions inside
precondanddecomposeblocks are evaluated at runtime, which means your module'saliasstatements won't work inside them. Always use full module paths likeOperator.HTN.Facts.get(...)instead ofFacts.get(...).
Making Plans Happen
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
endLoop Helper (Less Boilerplate)
If you want to wire planning into a tick loop quickly:
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:
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
endEffects: 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.
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
endEffect 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:
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
endCommon Gotchas (Read This)
-
Use full module paths inside
precondanddecomposefunctions. Aliases do not work there. -
The Registry is global state. Use
async: falsefor tests that touch it. -
Facts are immutable. Always use the returned facts after
EffectorFacts.put/3. - Plans are data. If you mutate the actor, keep facts in sync.
-
Do not run
Planner.run/3inside tight loops without caching if the world state is stable. -
Use
Planner.needs_replan?/2before 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
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
endRunning the Show
{: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:
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
endRegistry API
The registry stores all registered goals, tasks, primitives, and axioms:
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:
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
endKey points:
-
Use
async: false- the Registry is global state -
Call
reset_registryin setup to ensure clean state -
Use
register_modules/1to register your behavior modules -
See
Operator.HTN.TestHelpersfor more utilities
mix testRun 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.
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
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
endTraits
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
endStorage
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
endExamples
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 behaviorsOperator.HTN.Facts- World state representationOperator.HTN.Plan- Generated plan structureOperator.HTN.Planner- High-level planning APIOperator.HTN.Executor- Plan and task executionOperator.HTN.Engine- Low-level plan expansionOperator.HTN.Loop- Tick-based planning/execution helperOperator.HTN.Registry- Goal/task/primitive storageOperator.HTN.Effect- World state modificationsOperator.HTN.Task- Task definitionsOperator.HTN.Axiom- Reusable query patternsOperator.HTN.Precondition- Logical operatorsOperator.HTN.GoalSelector- Automatic goal selectionOperator.HTN.TestHelpers- Testing utilities
Director:
Operator.Director- Event orchestration GenServerOperator.Storyteller- Storyteller behaviour
Integration:
Operator.Telemetry- Metrics callbacksOperator.Traits- Personality/genome integrationOperator.Storage- Plan persistenceOperator.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.