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):

ChannelAudienceCompile?Use
① C++ operator/predicate registrationEngine/Gem programmersYesActually invoke engine capabilities (navigation, animation, firing); planning-time predicates
.htnnode JSON presets/macrosTechnical designersNoWrap existing operators with pre-bound parameters; reuse small decision patterns
③ Script operatorsGameplay/script authorsNoImplement 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/Abort express 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 }
 }
}
FieldRequiredMeaning
idyesGlobally unique node id (Gem.Name convention)
kindyes"Operator"
baseOperatoryesName of a registered C++ operator
displayName / category / description / iconnoPalette metadata
paramsno[{ "name", "type", "default"? }] parameter signature
conditionsno[{ "key", "op", operand }] baked-in conditions
effectsno[{ "key", "op", "phase", operand }] baked-in effects
settingsno{ 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:

  • methods must be non-empty; a method without conditions is an “always-true fallback”, and subtasks: [] is a legal empty decomposition.
  • subtasks[].node must 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 uses Guard.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 idBehaviorSettings
WaitWaits the given seconds: arg[0] as Float takes precedence, otherwise the seconds setting; counts down in Tickseconds (float)
SetWorldStateWrites arg[0] to the key named by the key setting; instantkey (string)
LogMessagePrints the message setting to the console; instant successmessage (string)
SendScriptEventBroadcasts OnHtnEvent to the agent entity (one-shot, no completion contract); instant successeventName (string)
Script.SendOperatorEventBroadcasts OnOperatorBegin/Tick/Aborted and stays Running until the script reports completionoperatorName (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.


Copyright © 2026 DawnEngine. All rights reserved.

DawnEngine is a commercial 3D engine distributed under the DawnEngine end-user license agreement. Engine binaries and source are proprietary and are not covered by the licenses below.

Documentation only: the prose and templates on this site are a derivative work of Open 3D Engine (O3DE) documentation by the O3DE Contributors, used under CC BY 4.0 (documentation content), Apache 2.0 (site code), and the MIT license (inline code samples).

The open-source 3D engine that DawnEngine is built on top of is Open 3D Engine . DawnEngine is not affiliated with, endorsed by, or sponsored by The Linux Foundation or the O3DE project. “O3DE” and “Open 3D Engine” are trademarks of The Linux Foundation.