Extending operators (three channels)
HTN’s extension principle is “extend via data/reflection—never modify HtnPlanner source”. There are three channels for new tasks/operators/conditions, and their compiled output is fully isomorphic (the planner cannot tell them apart):
| Channel | Audience | Compile? | Use |
|---|---|---|---|
| ① C++ operator/predicate registration | Engine/Gem programmers | Yes | Actually invoke engine capabilities (navigation, animation, firing); planning-time predicates |
② .htnnode JSON presets/macros | Technical designers | No | Wrap existing operators with pre-bound parameters; reuse small decision patterns |
| ③ Script operators | Gameplay/script authors | No | Implement operator bodies in Lua/ScriptCanvas |
Channel ①: C++ operators and predicates
Implementing an operator body: IHtnOperator
Operators are “stateless factory + stateful instance”, one instance per executing plan step. Interface in the public header HtnPlanner/IHtnOperator.h:
class IHtnOperator
{
public:
AZ_RTTI(IHtnOperator, "{.}");
enum class Status : uint8_t { Running, Succeeded, Failed };
struct HtnOperatorContext
{
AZ::EntityId m_entityId; // host agent entity
AZStd::span<const HtnValue> m_args; // arguments evaluated at plan time
AZStd::span<const HtnOperatorSetting> m_settings; // operator-private configuration
HtnWorldState* m_worldState; // real state (read/write)
uint32_t m_operationToken; // generation token for async completion
};
virtual Status Begin(const HtnOperatorContext& context) = 0; // may return Succeeded immediately
virtual Status Tick(const HtnOperatorContext& context, float dt); // defaults to immediate Succeeded
virtual void Abort(const HtnOperatorContext& context); // called on preemption/Stop
};
Example: an instant operator
class OpenDoorOperator : public HtnPlanner::IHtnOperator
{
public:
AZ_RTTI(OpenDoorOperator, "{your-uuid}", IHtnOperator);
Status Begin(const HtnOperatorContext& ctx) override
{
// Call your project's door capability (EBus / component interface)
DoorBus::Event(ctx.m_entityId, &DoorRequests::Open);
return Status::Succeeded;
}
};
Async operators return Running from Begin and poll in Tick until Succeeded / Failed (or bridge to script—see channel ③).
Network boundary: HTN has no built-in multiplayer sync.
Begin/Tick/Abortexpress only the local execution lifecycle; operators that execute over the network should call your project’s RPCs themselves and use their own tokens/generations to filter late remote replies.
Registering operators
In your own Gem’s system component Activate(), register the factory over HtnOperatorRegistryRequestBus (public header HtnPlanner/HtnOperatorRegistryBus.h); at edit time also register a node descriptor so it shows in the Canvas palette:
using namespace HtnPlanner;
// Runtime: register the body factory
HtnOperatorRegistryRequestBus::Broadcast(
&HtnOperatorRegistryRequests::RegisterOperator,
AZ::Crc32("MyGem.OpenDoor"),
[]() { return AZStd::make_unique<OpenDoorOperator>(); });
// Edit time (in the editor module's system component): register the node descriptor
HtnNodeDescriptor desc;
desc.m_nodeId = AZ::Crc32("MyGem.OpenDoor");
desc.m_baseOperatorId = desc.m_nodeId; // C++ nodes: baseOperator == nodeId
desc.m_nodeIdName = "MyGem.OpenDoor";
desc.m_displayName = "Open Door";
desc.m_category = "MyGem/Interaction";
desc.m_kind = HtnNodeDescriptor::Kind::Operator;
desc.m_source = HtnNodeSource::Cpp;
HtnOperatorRegistryRequestBus::Broadcast(
&HtnOperatorRegistryRequests::RegisterNodeDescriptor, desc);
HtnNodeDescriptor can also carry m_params (parameter signature), m_builtinConditions / m_builtinEffects (condition/effect templates baked into the node), and m_defaultSettings. Registering the same id twice keeps the first registration and logs a warning.
Registering planning-time predicates
When a condition cannot be expressed as a simple comparison, implement IHtnPredicate (public header HtnPlanner/IHtnPredicate.h):
class HasLineOfSightPredicate : public HtnPlanner::IHtnPredicate
{
public:
AZ_RTTI(HasLineOfSightPredicate, "{your-uuid}", IHtnPredicate);
bool Evaluate(const HtnWorldState& state, AZStd::span<const HtnValue> args,
AZ::EntityId entityId) const override
{
// Read-only state / args; pure function
return /* . */;
}
};
HtnOperatorRegistryRequestBus::Broadcast(
&HtnOperatorRegistryRequests::RegisterPredicate,
AZ::Crc32("MyGem.HasLineOfSight"),
AZStd::make_unique<HasLineOfSightPredicate>(),
predicateDescriptor /* Kind::Predicate */);
Predicate contract (mandatory): it must be a pure function of (simulated state, args, entityId)—no side effects, no hidden state, deterministic—because the planner re-evaluates it on every backtrack. It must also be microsecond-cheap. Expensive checks (e.g., path reachability) belong in a sensor component that asynchronously writes a WorldState key; the predicate only reads that key.
Channel ②: .htnnode JSON presets and macros
.htnnode is a JSON serialization envelope with ClassName DynamicHtnNodeConfig. Drop it into the project asset tree and it takes effect—no compilation. Two kinds:
Operator presets
Wrap a registered baseOperator with pre-bound settings / conditions / effects. Sample common_short_wait.htnnode:
{
"Type": "JsonSerialization",
"ClassName": "DynamicHtnNodeConfig",
"ClassData": {
"id": "Common.ShortWait",
"kind": "Operator",
"baseOperator": "Wait",
"displayName": "Short Wait",
"category": "Common",
"description": "One-second pause.",
"settings": { "seconds": 1.0 }
}
}
| Field | Required | Meaning |
|---|---|---|
id | yes | Globally unique node id (Gem.Name convention) |
kind | yes | "Operator" |
baseOperator | yes | Name of a registered C++ operator |
displayName / category / description / icon | no | Palette metadata |
params | no | [{ "name", "type", "default"? }] parameter signature |
conditions | no | [{ "key", "op", operand }] baked-in conditions |
effects | no | [{ "key", "op", "phase", operand }] baked-in effects |
settings | no | { key: value } overrides of the baseOperator’s default settings |
Operands are one of three: literal / worldKey (key reference) / param (reference to this node’s parameter). methods is not allowed in operator presets (macro-only).
CompoundMacro macros
Small decision patterns reusable across Domains: declare methods, and the macro is inlined into a regular compound task at compile time. Sample common_cautious_advance.htnnode:
{
"Type": "JsonSerialization",
"ClassName": "DynamicHtnNodeConfig",
"ClassData": {
"id": "Common.CautiousAdvance",
"kind": "CompoundMacro",
"methods": [
{
"conditions": [ { "key": "EnemyVisible", "op": "Equal", "literal": true } ],
"subtasks": [ { "node": "Common.ShortWait" } ]
},
{ "subtasks": [] }
]
}
}
Key points:
methodsmust be non-empty; a method withoutconditionsis an “always-true fallback”, andsubtasks: []is a legal empty decomposition.subtasks[].nodemust reference the id of a registered operator/operator preset—macros cannot reference macros (V10).- WorldState keys referenced by macro method conditions are declared by the host Domain. Mind the namespace: the example references the unprefixed
EnemyVisible, and the host domain must declare exactly that name (the guard sample itself usesGuard.EnemyVisible—a different key).
Channel ③: script operators
Implement operator bodies without C++: bind the built-in bridge operator Script.SendOperatorEvent to a primitive in the graph and set an operatorName in its settings; the script listens for HtnScriptOperatorNotificationBus::OnOperatorBegin(name, token, args), does the work, and reports completion via HtnCompleteCurrentOperator(entityId, token, success).
The full script-side sequence, the generation-token contract, and Lua examples are in
WorldState and scripting. Typically you also add a thin .htnnode operator preset wrapping Script.SendOperatorEvent with the operatorName pre-bound, so the graph gets a semantic script-operator node to drag in.
Built-in operator reference
All registered by the system, category Built-in:
| Operator id | Behavior | Settings |
|---|---|---|
Wait | Waits the given seconds: arg[0] as Float takes precedence, otherwise the seconds setting; counts down in Tick | seconds (float) |
SetWorldState | Writes arg[0] to the key named by the key setting; instant | key (string) |
LogMessage | Prints the message setting to the console; instant success | message (string) |
SendScriptEvent | Broadcasts OnHtnEvent to the agent entity (one-shot, no completion contract); instant success | eventName (string) |
Script.SendOperatorEvent | Broadcasts OnOperatorBegin/Tick/Aborted and stays Running until the script reports completion | operatorName (string) |
When Navigation3D is also enabled, that Gem’s optional HTN adapter can register Nav3D.* operators (see
Navigation3D); HtnPlanner itself has no hard dependency on Navigation3D.