Custom decorator nodes — same shape as action nodes.
Type map — mlua type to serialized
MODNativeType.type
plus Blackboard
ObjectValue
shape.
UUIDs come from real
.codeblock
files in the project — the spec never invents them.
@HideFromInspector
properties are filtered out automatically. Fixed authoring rules, file skeletons, and validation checklists live in this skill's
references/
rather than in the generated spec.
After (re)building, read the freshly written
<ProjectRoot>/.behaviourDocs/bt-spec.md
and continue with the steps below. The compact spec intentionally lists only property names; when constructing
nodeProperties
, resolve each property's mlua type/default from the paired
.mlua
file, then use the type map in
bt-spec.md
§4 for
propertyType.type
.
Also read
references/skeleton-minimal.json
for the smallest valid tree,
references/skeleton-full.json
for a Composite+Decorator+Action+Blackboard example with all optional fields populated,
references/node-catalog.md
for fixed graph rules, and any existing
.behaviourtree
in the project (
**/*.behaviourtree
) to mirror conventions. Replace
{CORE_VERSION}
in the skeletons with the
CoreVersion
from
bt-spec.md
— both at the top level and inside every
MOD.Core.*
type string in Blackboard variables and
nodeProperties
.
1. Collect input from the user
Confirm via context, or ask via AskUserQuestion if anything is ambiguous:
Item
Description
Example
name
Display name for the tree
"PatrolAndChase"
Save path
.behaviourtree
location (relative to project root)
RootDesk/MyDesk/PatrolAndChase.behaviourtree
Tree shape
Intended node graph (root composite + children)
Sequence → [Chase, MoveTo]
Custom nodes
Action/decorator codeblocks the tree references
Chase
,
MoveTo
,
Jump
Blackboard variables
Variable name + type + initial value
TargetEntity: Entity
,
MoveSpeed: number = 10.0
Node properties
For each custom node, which property maps to which Blackboard variable
Chase.TargetEntityKey = "TargetEntity"
Custom-node existence check (mandatory): every custom action/decorator name the user mentions must appear in
bt-spec.md
§2 / §3. If a referenced node is not in the spec, stop and ask the user — do not invent a UUID, do not assume a node exists by name, and do not skip rerunning Step 0.
(script component). Mirror an existing serialized example in the project.
Numeric
ObjectValue
s use float literal form (
3.0
, not
3
).
4.5 Resolve node property values
For each custom node that needs
nodeProperties
:
Confirm the
propertyKey
exists in
bt-spec.md
§2 / §3 for that node.
Find the paired
.mlua
by searching for
script <NodeName> extends ActionNode
or
script <NodeName> extends DecoratorNode
under the project. If multiple files match, prefer the one whose sibling
.codeblock
has the exact
definitionId
UUID from
bt-spec.md
; if still ambiguous, ask the user.
Read the visible
property
declarations in that
.mlua
, ignoring
@HideFromInspector
properties. This gives the mlua type and default value.
Include a
nodeProperties
entry only when the user provided a value, the behavior requires a non-default value, or a
*Key
property must point at a Blackboard variable. Omit optional properties that can safely use the
.mlua
default.
For
*Key
string properties, set
propertyValue
to the Blackboard variable name. Infer the variable by name and getter usage when obvious (
MoveSpeedKey
->
MoveSpeed
,
TargetEntityKey
->
TargetEntity
). If more than one Blackboard variable could match, ask.
For literal properties, use the user-provided value. If no value is provided and the
.mlua
default is meaningful, omit the property instead of serializing a guessed value.
If
OnBehave
checks a property for
nil
, empty string, or invalid enum and no value can be inferred, ask the user before writing the tree.
nodeProperties
entry shape:
json
{"propertyKey":"<property name>","propertyType":{"$type":"MODNativeType","type":"<type from bt-spec.md §4>"},"propertyValue": <value>
}
5. Assemble Nodes
Hard graph constraints (validate before writing):
RootNode is not a parent node. It must not have
childNodes
. It only stores
startNodeId
, and
startNodeId
points to exactly one node in
Nodes
.
If the tree needs several top-level behaviors, use either one Composite as the single
startNodeId
, or one Decorator as the single
startNodeId
whose
decoChildNodes
wraps a Composite or another Decorator chain that eventually wraps a Composite. Put the multiple behaviors under that Composite's
childNodes
.
Exactly one node in
Nodes
may have
nodeParentId: ""
: the node referenced by
RootNode.startNodeId
. Do not create multiple root-level Action/Composite/Decorator nodes.
Composite (
btNodeType: 1
) is the only node category that can own multiple children through
childNodes
.
Decorator (
btNodeType: 2
) is only a wrapper/parent for exactly one Action, Composite, or Decorator node. It can also be the child of another Decorator, so Decorator-to-Decorator chains are valid. It must use singular
decoChildNodes
(a single
nodeId
string) for that one child, not
childNodes
; the wrapped child must also record the Decorator's id in its
nodeParentId
.
Decorators applying to the same Action MUST be chained — never flattened as siblings. Each decorator owns exactly one downstream subtree. If two or more decorators are meant to gate/modify the same Action, build a single chain
Composite → ADeco → BDeco → CDeco → Action
where each decorator's
decoChildNodes
points to the next decorator (and finally the Action). Concretely: within one chain leading to a single Action, no two decorators may share the same
nodeParentId
— each decorator's parent is the previous decorator, and only the topmost decorator's parent is the Composite. Sibling decorators under one Composite are still valid when each wraps a different downstream subtree. ✅
Composite → ADeco → BDeco → CDeco → Action
(chain — every decorator has a unique parent within the chain). ❌
(decorators flattened — they don't wrap the Action and are effectively orphaned).
Action (
btNodeType: 0
) is a leaf — never has children.
Node-write invariants:
Every
nodeId
is unique within the file.
nodeParentId
of every non-root node points to a real
nodeId
that is a Composite or Decorator. It must never point to
RootNode
, because
RootNode
is not represented as a node in
Nodes
.
If a node's parent is a Composite, that Composite must include the node id in
childNodes
.
If a node's parent is a Decorator, that Decorator's
decoChildNodes
must equal that node's
nodeId
. This is valid even when both parent and child are Decorators.
Composite
childNodes
↔ child
nodeParentId
is bidirectionally consistent.
Action nodes omit
childNodes
. Decorator nodes omit
childNodes
and use exactly one
decoChildNodes
(single string
nodeId
) instead.
Never write
probability
. The editor strips this field on round-trip, and the supported composites (
SequenceNode
,
SelectorNode
,
ParallelNode
) do not consume per-child weights. Older generated trees in the project may still carry
"probability": 1.0
on every node; treat that as legacy on read but do not write it on new nodes.
Decorator nodes (
btNodeType: 2
) omit
nodePosition
. The editor positions a Decorator automatically relative to the child it wraps, and writes no
nodePosition
field for it on save. Only Composites and Actions carry
nodePosition
. The
RootNode
block also carries its own
nodePosition
(separate from the start node).
Empty collection fields are omitted, not serialized as
[]
. A Composite with no children yet should omit
childNodes
entirely; a node with no overrides should omit
nodeProperties
entirely. Empty arrays are an editor-draft artifact — do not author them.
Decorator child field is
decoChildNodes
(canonical — this is what the editor preserves on save;
ChildNodeId
is silently stripped on round-trip). It is a single string holding the wrapped child's
nodeId
(not an array). When reading legacy files you may still encounter
ChildNodeId
on hand-authored decorators; treat it as the same field. When writing, always emit
decoChildNodes
.
RootNode.startNodeId
references one of the
nodeId
s — an Action, Composite, or Decorator — and that node is the only node with
nodeParentId: ""
.
*Key
-suffix String properties carry the name of a Blackboard variable (resolved at runtime via
BlackBoard:GetXxx
). Non-
Key
properties carry the literal value.
6. nodePosition format
nodePosition
is a JSON object with numeric
x
/
y
:
json
"nodePosition":{"x":0.0,"y":0.0}
Use float literals (
0.0
, not
0
). The legacy string form
"(0.000, 0.000)"
may still appear in older hand-authored trees — read it as equivalent, but always write the object form (the BT editor canonicalizes to this shape on save, so the string form re-serializes to a noisy diff the first time the file is opened).
Editor axes: the BT editor uses a math-convention canvas — +x is right, +y is up (upper-right quadrant is positive). So a child placed at a higher y than its parent appears above the parent on screen.
Layout rule — draw the tree downward: depth grows along −y (children sit below their parent), and siblings spread along ±x around the parent's x. Typical spacing: 200 units between depth levels and 200 units between siblings.
RootNode
block vs the start node — do not stack them at the same position.
RootNode.nodePosition
is the canvas anchor and stays at
{ "x": 0.0, "y": 0.0 }
. The start node (the node referenced by
startNodeId
) must sit one level below that anchor — putting it at
(0, 0)
makes it visually overlap the RootNode marker on the editor canvas. Treat the RootNode anchor as depth 0 and the start node as depth 1.
RootNode.nodePosition
:
{ "x": 0.0, "y": 0.0 }
(fixed anchor — never moves)
Start node (depth 1, referenced by
startNodeId
):
{ "x": 0.0, "y": -200.0 }
Single child of the start node (depth 2):
{ "x": 0.0, "y": -400.0 }
Two children of the start node (depth 2):
{ "x": -100.0, "y": -400.0 }
and
{ "x": 100.0, "y": -400.0 }
Each additional level: parent.y − 200
Never place a child at a y greater than or equal to its parent's y — that draws upward and overlaps the parent visually. The same rule applies between
RootNode
and the start node: the start node must be at
y ≤ -200
(strictly below the anchor).
Decorator nodes do not carry
nodePosition
. The editor lays them out automatically relative to the wrapped child. Omit the field on every
btNodeType: 2
node; it appears only on
RootNode
, Composites (
btNodeType: 1
), and Actions (
btNodeType: 0
).
7. Write and validate
Write the JSON file, then run this checklist. In particular:
EntryKey
is
behaviourtree://{uuid}
and matches
ContentProto.Json.id
exactly.
Top-level
Id
,
GameId
,
Content
are
""
.
Usage
,
UseService
,
DynamicLoading
are
0
.
UsePublish
is
1
.
CoreVersion
matches the project (
Environment/config
).
StudioVersion
is
0.1.0.0
.
ContentType
is
x-mod/behaviourtree
.
ContentProto.Use
is
Json
.
RootNode
has no
childNodes
;
RootNode.startNodeId
matches exactly one
nodeId
in
Nodes
; that start node has
nodeParentId: ""
; and no other node has
nodeParentId: ""
.
Every
nodeParentId
is
""
or an existing
nodeId
.
All
nodeId
values are unique.
For every Composite, the set of
childNodes
IDs equals the set of nodes whose
nodeParentId
is this Composite.
Every Action has no
childNodes
. Every Decorator has no
childNodes
, has exactly one
decoChildNodes
(single
nodeId
string —
ChildNodeId
is the legacy variant; the editor strips it on round-trip), and that id points to exactly one Action, Composite, or Decorator child whose
nodeParentId
points back to the Decorator. Decorator-to-Decorator parent/child chains are valid and must be checked with the same
decoChildNodes
↔
nodeParentId
rule.
Decorator chain rule: when multiple decorators apply to the same Action, they form a single chain (
Composite → ADeco → BDeco → … → Action
). Verify by walking each Action upward to its enclosing Composite: the decorators encountered along that one path must all have unique
nodeParentId
values (i.e. each decorator's parent is the previous decorator, never another decorator that already appeared in the chain). Two decorators in the same chain sharing a
nodeParentId
is invalid. (Sibling decorators under one Composite that wrap different downstream subtrees are fine — uniqueness is per-chain, not global.)
No node serializes
"probability"
. (Legacy
1.0
values may appear on read but are never authored.)
Every Composite and Action carries
nodePosition
in object form
{ "x": <num>, "y": <num> }
with float literals — no legacy
"(x.xxx, y.yyy)"
strings on write. Decorator nodes carry no
nodePosition
at all.
Start node is not stacked on the RootNode anchor.
RootNode.nodePosition
is
{ "x": 0.0, "y": 0.0 }
and the node referenced by
startNodeId
has
y ≤ -200.0
(typically
{ "x": 0.0, "y": -200.0 }
). If the start node is a Decorator (no
nodePosition
), the first wrapped Composite/Action down the chain must satisfy this offset instead.
No node serializes empty arrays — a Composite with no children omits
childNodes
; a node with no overrides omits
nodeProperties
. Do not write
"childNodes": []
or
"nodeProperties": []
.
Every custom node's
definitionId
is copied from
bt-spec.md
(never invented).
Every
nodeProperties[].propertyKey
matches a property in
bt-spec.md
for that node.
Every
*Key
property's
propertyValue
matches a
Blackboard.Variables[].Name
of the right type.
Every type string is copied verbatim from
bt-spec.md
§4 — version-tagged, typo-fragile.
Version cross-check: every
MOD.Core.*
type string's
Version=X.Y.Z.Z
substring (in
Blackboard.Variables[].Type.type
and
Nodes[].nodeProperties[].propertyType.type
) equals the file's top-level
CoreVersion
. Mismatch silently breaks deserialization — common when