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.
{
"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:
{ "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.
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.
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:
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.
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.
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.
{
"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 } }
]
}
}
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:
{ "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:
{
"sight": { "focusRange": 30, "focusAngle": 40, "peripheralRange": 8, "peripheralAngle": 160 },
"hearing": { "range": 12, "muffledBy": 0.5 },
"forgetAfter": 5,
"updateInterval": 0.25
}
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
- Navigation for the grids the behaviours walk on.
- Character controller for a player driven by input.
- Terrain for the heightmap a navigation grid is baked from.