dot-npc-ai is the decision half of dot-npc: a
behaviour tree with real running-state memory, a state machine for the many
cases a tree is overkill for, a per-NPC blackboard that forgets, and the
steering a crowd needs at a doorway.
Requires dot-core and dot-npc. One file in it names dot-npc.
Usage
A brain builds a machine, a tree, or both:
extends "res://addons/dot_npc_ai/runtime/dot_npc_ai_brain.gd"
func _build() -> void:
initial_state = &"idle"
tree = DotNpcAiSelector.new(&"root", [
DotNpcAiSequence.reactive_with(&"chase", [
DotNpcAiLeaf.Condition.new(&"has a target", func(_c): return npc.has_target()),
DotNpcAiLeaf.Action.new(&"walk at it", _chase),
]),
DotNpcAiLeaf.Action.new(&"wander", _wander),
])
Example: a state machine and an action
machine = DotNpcAiMachine.new()
machine.add(DotNpcAiState.make(&"idle")
.when(func(_c): return npc.has_target(), &"chasing"))
machine.add(DotNpcAiState.make(&"chasing")
.when(func(_c): return not npc.has_target(), &"searching"))
machine.add(DotNpcAiState.make(&"searching")
.when(func(_c): return npc.has_target(), &"chasing")
.after(3.0, &"idle"))func _chase(ctx: DotNpcAiContext) -> int:
ctx.put(&"last_seen", target_position(), 6.0) # remembered for six seconds
steer_with_spacing(target_position(), 4.0, ctx.delta)
return DotNpcAiNode.Status.RUNNINGTwo rules about trees
A node that returned RUNNING is resumed, not restarted
Get it wrong and nothing errors: an NPC re-opens the same door sixty times a second, or an attack never finishes because its wind-up keeps restarting. Everything looks alive and nothing completes.
tick() is not overridable here, and the composites own the resume index, so no
node can forget.
A guard over a never-finishing action needs a reactive sequence
Otherwise the guard is asked exactly once. DotNpcAiSequence.reactive_with() is
that; a plain sequence is for steps in order.
Map annotations
A bot on an imported map walks to the edge of a balcony and stops, because the mesh has no polygon in the air between the balcony and the floor. DotNpcAiNavAnnotator (API 2) measures what the map has once it is loaded, a baked NavigationMesh and the physics space, and needs no authoring. It finds links the mesh does not join (a drop, a jump up, a jump across a gap), hiding and cover spots beside walls, and the chokepoints routes crowd through. It keeps only floors a body can reach (seeds, roof and solid tests).
var job := DotNpcAiNavAnnotator.new(nav.mesh, map.get_world_3d())
var cached := DotNpcAiNavAnnotations.load_file("user://nav/%s.json" % job.cache_key(), job.cache_key())
if cached.ok:
annotations = cached.value
else:
scheduler.submit(job)
var res: DotResult = await job.finished
if res.ok:
annotations = res.value
annotations.save("user://nav/%s.json" % annotations.key)
It is a DotJob stepped in slices, because a physics query may only be made on the main thread and that is the only thread a browser has. Each step also ends after slice_ms (4 by default); without that one step took 40 to 80 ms on a large map, and the old fixed slice cost ten times as much on a dense map as on a sparse one. It is deterministic, so the cache key (a SHA-256 over the mesh, colliders, settings and random_seed) means a cached file and a fresh run agree. DotNpcAiNavAnnotations answers hiding_spots(), cover_from(threat, near) and links_of(kind); DotNpcAiNavDebug draws it. Assign los_fn for a game whose glass or foliage blocks sight but not rays, override _keep_link and _hide_score, or declare() the links geometry cannot see (ladders, teleports). The settings are also the cvars npc_nav_jump_up, npc_nav_drop, npc_nav_gap and npc_nav_spot_spacing.
Squads ask once per tick
surround used to have every waiting member walk every member and copy each holder list, which is quadratic at eighty zombies. The squad now keeps a per-clock-reading answer (DotNpcAiSquad.memo / remember), so the first waiter works out the waiting group, their positions and the target’s facing and the rest reuse it; holder_ids() lists the holders. A brain that makes no headway for stuck_seconds (1.5) while steering with spacing asks dot-npc’s DotNpcPath.go_rough for unsmoothed corners, and tolerates a dot-npc that predates it.
Character, not difficulty
A tree decides what an NPC does. It does not make two of them feel like different people — same tree, same NPC: they notice at the same instant and shoot with the same accuracy.
brain.character = DotNpcAiCharacter.hard().with_seed(npc.seed)
# In the "shoot at it" branch:
if not ctx.has_reacted(npc.engaged_at):
return DotNpcAiNode.Status.RUNNING # seen it, not acted on it yet
var at := ctx.character.aim_point(muzzle, target.position, target.velocity, 900.0, shot)
DotNpcAiCharacter sets reaction time, aim accuracy, aim skill, view turn rate, aggression, self-preservation, vengefulness and a tendency to camp, among others, with four presets: easy(), normal(), hard() and nightmare(). The character is the difficulty, per NPC, so a game can mix them.
What a server operator gets on top is an adjustment, not a difficulty setting that replaces those numbers: DotNpcAiSkill (the npc_skill cvar, -1 to 1 or a preset name, 0 being as authored) moves every character toward easy() or nightmare() by a fraction, so a brute on a hard server is still slower to notice than a stalker on the same server.
Everything random in it is a hash of the character’s seed and a number you pass,
so the same shot always misses the same way and a replay agrees with the server
that recorded it. Give each NPC its own seed, or twenty of them fire one
volley. Use npc.seed, the spawner’s per-spawn number (needs dot-npc API 2), not the object id: an object id moves when anything else is allocated first, so every NPC’s choices moved with unrelated changes. The brain’s own rolls (the strafe period in hold_range, the patrol pick) draw from it too.
