New

dot-npc-ai

How an NPC decides — a behaviour tree whose base enforces the rule hand-written trees get wrong, a state machine, a blackboard that forgets, crowd steering, and a characteristics table instead of a difficulty setting.

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.RUNNING

Two rules about trees

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.