Skip to content

AI

Guards that patrol, hounds that chase and deer that flee, walking real routes over a navigation grid, plus behaviour trees and state machines for your own logic.

component: patrol component: chase component: flee Desktop · Android · iOS · Web

awake:ai:behavior has the ready-made patrol, chase and flee behaviours, which walk routes from navigation and need no scene. awake:scene:ai puts them in scenes: the patrol, chase and flee scene components, and TransformAgentPlacement, which places the behaviours' entities by their Transform. awake:scene:navigation has the navigation component they route over. awake:ai has behaviour trees and finite-state machines for logic you write yourself, in Kotlin or as JSON.

Add patrol, chase and flee

A guard walking a beat, a hound chasing the player and a deer running from it. Both forms below build the same components.

guards.scene.json
{
  "version": 1,
  "nodes": [
    {
      "name": "player",
      "transform": { "position": { "x": 0.0, "y": 0.0, "z": 0.0 } }
    },
    {
      "name": "guard",
      "transform": { "position": { "x": -4.0, "y": 0.0, "z": -4.0 } },
      "components": [
        {
          "component": "patrol",
          "stops": [
            { "x": -4.0, "y": 0.0, "z": -4.0 },
            { "x": 4.0, "y": 0.0, "z": -4.0 },
            { "x": 4.0, "y": 0.0, "z": 4.0 }
          ],
          "style": "pingPong",
          "dwellSeconds": 2.0,
          "speed": 1.5
        }
      ]
    },
    {
      "name": "hound",
      "transform": { "position": { "x": 6.0, "y": 0.0, "z": 0.0 } },
      "components": [
        { "component": "chase", "target": "player", "speed": 3.0 }
      ]
    },
    {
      "name": "deer",
      "transform": { "position": { "x": 0.0, "y": 0.0, "z": 3.0 } },
      "components": [
        { "component": "flee", "threat": "player", "panicRadius": 5.0, "safeRadius": 10.0 }
      ]
    }
  ]
}

Register the bindings. Each behaviour arrives with the PathRequest it asks for routes through. The behaviours route over a grid, which the scene carries as a navigation component:

navigation component
{ "component": "navigation", "cellSize": 1.0,
  "rows": [ "......", ".####.", "......" ] }

. is a cell an agent can stand on and # one it cannot. Played through loadProject and runProject, a scene with a behaviour and a navigation component runs the behaviours and answers their routes, with no AI code in the host; a project with a behaviour and no grid is refused at load.

Kotlin
val registry = SceneComponentRegistry().registerAiBehaviors()
val document = SceneLoader.decode(File(DOCS_SNIPPETS, "world/guards.scene.json").readText())
val world = SceneLoader.instantiate(document, componentRegistry = registry).world
// Each behaviour arrives with the PathRequest it asks for routes through.
Kotlin
world.scene {
    val player = entity("player") { transform() }
    entity("guard") {
        transform(x = -4f, z = -4f)
        with(
            PatrolBehavior(
                stops = listOf(Vec3f(-4f, 0f, -4f), Vec3f(4f, 0f, -4f), Vec3f(4f, 0f, 4f)),
                style = PatrolStyle.PingPong,
                dwellSeconds = 2f,
                speed = 1.5f,
            ),
        )
        with(PathRequest())
    }
    entity("hound") {
        transform(x = 6f)
        with(ChaseBehavior(target = player, speed = 3f))
        with(PathRequest())
    }
    entity("deer") {
        transform(z = 3f)
        with(FleeBehavior(threat = player, panicRadius = 5f, safeRadius = 10f))
        with(PathRequest())
    }
}

There are no dedicated DSL functions for behaviours yet, so attach them with with(...).

AwakeKt Studio has no Add component entry for these behaviours and does not register their scene bindings, so author them in the scene document or in Kotlin. For a behaviour already on an entity, the Inspector shows a Patrol, Chase or Flee section with its tuning fields.

Run the behaviours

Each behaviour has a system that decides where to go and writes a PathRequest; PathRequestSystem answers it from a navigation grid. A behaviour system is given an AgentPlacement, which says where an entity is and moves it; in a scene that is TransformAgentPlacement. Run the behaviour systems, then the path system:

Kotlin
val ground = Heightmap(FloatArray(21 * 21), width = 21, depth = 21, scale = Vec3f(1f, 1f, 1f))
val navMesh = NavGrid(ground.bakeNavGrid(cellSize = 1f))
val systems = listOf(
    PatrolAiSystem(TransformAgentPlacement),
    ChaseAiSystem(TransformAgentPlacement),
    FleeAiSystem(TransformAgentPlacement),
    PathRequestSystem(navMesh),
)

repeat(300) { systems.forEach { it.update(world, 1f / 30f) } }

Properties

patrol:

Property Type Default What it does
stops list of vectors empty Positions to visit in order.
style loop · pingPong · once loop Wrap to the first stop, walk back, or stop at the last one.
dwellSeconds number 1 Seconds to wait at each stop.
speed number 1.8 Metres per second.
repathInterval number 1 Seconds between path queries.
waypointRadius number 0.3 How close counts as reaching a waypoint.

chase:

Property Type Default What it does
target node name or none none The node to pursue. None waits for a target set in code.
speed number 2.5 Metres per second.
repathInterval number 0.5 Seconds between path queries.
waypointRadius number 0.3 How close counts as reaching a waypoint.

flee:

Property Type Default What it does
threat node name or none none The node to run from.
panicRadius number 6 Start fleeing when the threat is closer than this.
safeRadius number 12 Stop once the threat is further than this. Must be more than panicRadius.
fleeDistance number 10 How far ahead to aim when picking where to run.
speed number 3.5 Metres per second.
repathInterval number 0.4 Seconds between path queries.
waypointRadius number 0.3 How close counts as reaching a waypoint.

In Kotlin, PatrolBehavior, ChaseBehavior and FleeBehavior take the same values, with target and threat as entities rather than names.

navigation:

Property Type Default What it does
rows list of text empty One string per row of cells along z; each character is a cell along x, . walkable and # blocked. Every row is the same length.
cellSize number 1 World size of one cell.
originX number 0 World x of the centre of cell (0, 0).
originZ number 0 World z of the centre of cell (0, 0).

A grid baked from terrain with Heightmap.bakeNavGrid is the same data; a tool that writes one out as a navigation component gives the runtime its single way in.

Behaviour trees

Build a tree with behaviorTree { }, attach it with BehaviorTreeComponent, and tick it with BehaviorTreeSystem. Each tick runs the tree once for every enabled component.

Kotlin
val tree = behaviorTree {
    selector {
        sequence {
            condition { ctx -> ctx.blackboard.get<Boolean>("alarm") == true }
            action { ctx ->
                ctx.blackboard["state"] = "alert"
                BehaviorStatus.SUCCESS
            }
        }
        action { ctx ->
            ctx.blackboard["state"] = "idle"
            BehaviorStatus.SUCCESS
        }
    }
}
val brain = BehaviorTreeComponent(tree)
world.add(entity, brain)

brain.blackboard["alarm"] = true
BehaviorTreeSystem().update(world, 1f / 30f)
Node What it does
sequence { } Runs children in order until one does not succeed.
selector { } Tries children in order until one does not fail.
parallel(policy) { } Ticks every child each tick. REQUIRE_ALL (default) needs all to succeed, REQUIRE_ONE needs one.
inverter { } Swaps success and failure of its child.
repeater(count) { } Runs its child until it has succeeded count times; -1 means forever.
cooldown(seconds) { } Fails for seconds after its child succeeds.
timeout(seconds) { } Fails once its child has kept running for seconds.
condition { ctx -> } Succeeds when the predicate is true.
action { ctx -> } Runs your code and returns SUCCESS, FAILURE or RUNNING.

ctx is an AiContext with the world, the entity, the frame delta and the entity's blackboard, a key-value store for the tree's state.

A forever repeater needs a child that waits

repeater() re-runs a succeeding child within the same tick. With count = -1, a child that succeeds at once never lets the tick end. Give it a child that returns RUNNING while it works.

State machines

For logic with a few clear modes, use an AiStateMachine: named AiStates with onEnter, onUpdate and onExit, and StateTransitions checked every update.

Kotlin
val machine = AiStateMachine(
    states = mapOf("idle" to AiState("idle"), "alert" to AiState("alert")),
    transitions = mapOf(
        "idle" to listOf(StateTransition("alert") { ctx -> ctx.blackboard.get<Boolean>("alarm") == true }),
    ),
    initialStateName = "idle",
)
val brain = AiStateMachineComponent(machine)
world.add(entity, brain)

brain.blackboard["alarm"] = true
AiStateMachineSystem().update(world, 1f / 30f)

Trees and machines from data

A tree or a machine can also be data: a JSON document a game authors without Kotlin. Each node names a kind from an AiRegistry, with that kind's parameters.

vent.tree.json
{
  "formatVersion": 1,
  "root": {
    "kind": "ai.selector",
    "children": [
      { "kind": "ai.sequence", "children": [
        { "kind": "ai.compare", "params": { "key": "heat", "op": ">=", "value": 30.0 } },
        { "kind": "ai.emit", "params": { "event": "vent.open" } }
      ] },
      { "kind": "ai.wait", "params": { "seconds": 2 } }
    ]
  }
}
Kotlin
val registry = AiRegistry()                     // Core's kinds, plus any you register
val compiled = registry.compileTree(AiDefinitionJson.Default.decodeTree(text))
world.add(entity, compiled.newComponent(seed = entity.packed))

compileTree checks the whole document and lists every problem, such as an unknown kind, a misspelt parameter or the wrong number of children. Register your own kinds with typed parameters through registry.action, registry.condition and registry.state. A StateMachineDefinition has states with enter, exit and tree nodes, and transitions that fire after a time, when the state's tree finishes, or when a condition node succeeds.

Kind What it does
ai.sequence, ai.selector, ai.parallel As their DSL twins; parallel takes policy: all or one.
ai.inverter, ai.succeeder Swap success and failure; succeed whenever the child finishes.
ai.repeat, ai.cooldown, ai.timeout As their DSL twins, with count or seconds.
ai.wait Runs for seconds, then succeeds.
ai.compare Compares the value under key with value or otherKey, by op: ==, !=, <, <=, >, >=.
ai.has Succeeds when key is set.
ai.chance Succeeds with probability, from the tree's seed.
ai.status Always returns status.
ai.set, ai.copy, ai.remove, ai.add Write the blackboard.
ai.emit Sends event with a payload to the system's AiEventSink.

registerSpatialKinds(placement) from awake:ai:behavior adds kinds that move and place agents over the navigation grid, through the agent's PathRequest. A target is a blackboard key holding an entity or a position, such as perception.target, or a fixed point:

Kind What it does
ai.move-to Walks to target; succeeds within arriveRadius, fails when it cannot get there.
ai.follow Keeps within distance of target; runs until the target is gone.
ai.flee Runs from from until safeDistance away, trying other directions when blocked.
ai.wander Walks to a seeded random point within radius of where it started, then succeeds.
ai.face Turns the agent toward target.
ai.distance Compares the ground distance to target with distance, by op.
ai.in-area Succeeds when the agent, or who, is in a circle (center, radius) or a box (min, max).

In a scene, a node names a definition file with a behavior_tree or state_machine component:

scene node
{ "name": "Keeper", "components": [ { "component": "behavior_tree", "asset": "ai/keeper.tree.json" } ] }

A played project reads and checks every file its scene names when it loads, refusing one with a problem and saying which file and node. It runs the trees and machines with Core's kinds and the spatial kinds, moving agents as patrol and chase do, and routes them over the scene's navigation. Outside a project, load files into an AiDefinitionLibrary and run AiDefinitionAttachSystem.

Trees, machines and blackboards snapshot to data and restore exactly, and a running tree reports what each node returned on the latest tick, for an editor's live view.

Perception

A Perceiver sees in a focus cone and a wider peripheral cone, hears SoundEvents, remembers what it sensed until it forgets, and regards others by a FactionTable. All of it is @Serializable settings:

sentry.perception.json
{
  "sight": { "focusRange": 30, "focusAngle": 40, "peripheralRange": 8, "peripheralAngle": 160 },
  "hearing": { "range": 12, "muffledBy": 0.5 },
  "forgetAfter": 5,
  "updateInterval": 0.25
}
Kotlin
world.add(guard, Perceiver(FactionId("watch"), settings))
world.add(thief, Perceivable(FactionId("thieves")))
val perception = PerceptionSystem(space, factions, lineOfSight, maxSightUpdates = 100)
perception.hear(SoundEvent(position, loudness = 2f, source = thief))

space tells it where entities are and which way they face; in a scene that is TransformPerceptionSpace. lineOfSight decides what blocks sight and muffles sound: PhysicsLineOfSight casts physics rays, seeing past the viewer's own body, and NavFieldLineOfSight uses a navigation grid's blocked cells. Run it before the behaviour systems. Each look reads only the entities in nearby cells, and maxSightUpdates and sightBudget cap how many perceivers look in a frame, by count and by time.

In a scene, give agents perceiver and targets perceivable, and put the faction table in a factions component. A played project runs perception for them, seeing through the physics world when it has one and the navigation grid otherwise:

json title="scene nodes" { "name": "Map", "components": [ { "component": "factions", "relations": [ { "from": "watch", "to": "thieves", "relation": "hostile" } ] } ] }, { "name": "Guard", "components": [ { "component": "perceiver", "faction": "watch", "settings": { "sight": { "focusRange": 15 } } }, { "component": "behavior_tree", "asset": "ai/guard.tree.json" } ] }, { "name": "Thief", "components": [ { "component": "perceivable", "faction": "thieves" } ] } It writes perception.target, perception.target.position, perception.sees-hostile, perception.hostiles and perception.noise.position to the agent's blackboard, and trees and machines can ask ai.perception.sees, ai.perception.remembers and ai.perception.heard.

How it works

The three behaviours share one route follower. On an interval each asks for a route to its goal: the next stop, the target's position, or a point away from the threat. When an answer arrives it steps along the waypoints at its speed. An unreachable goal clears the route instead of walking into a wall; a patrol skips an unreachable stop, and a fleeing entity tries directions further off the straight line.

No PathRequest, no movement

Every behaviour reads a PathRequest on the same entity and does nothing without one. The scene bindings add it, so a behaviour that comes from a scene document has one. A behaviour you attach yourself, in the scene DSL or in Kotlin, needs its own PathRequest.

Behaviours move on X and Z only

Navigation waypoints carry no height, so the behaviours leave Transform.position.y alone. Put the entity back on the ground yourself, for example from Heightmap.heightAtWorld.

Targets are node names

target and threat name another node in the same scene document. Loading fails if no node has that name, and saving fails if the target entity has no Name.

See also