Skip to content

Something that chases you

orblit_agent keeps two things apart that are usually conflated:

Steering answers where to go: a force, computed from the world, applied this frame. Behaviour trees answer what to want, meaning which of those forces should be running at all.

Build them as one thing and you get a state machine that needs rewriting every time a state is added. Here they are separate, and the tree chooses between behaviours.

lib/main.dart
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:orblit_agent/orblit_agent.dart';
import 'package:orblit_filament/orblit_filament.dart';
import 'package:vector_math/vector_math_64.dart' hide Colors;
void main() => runApp(const ChaseApp());
class ChaseApp extends StatefulWidget {
const ChaseApp({super.key});
@override
State<ChaseApp> createState() => _ChaseAppState();
}
class _ChaseAppState extends State<ChaseApp>
with SingleTickerProviderStateMixin {
static const _count = 12;
static const _noticeRange = 9.0;
/// The thing being chased, moving on its own path.
final Steerable _player = Steerable(maxSpeed: 5, maxForce: 12);
late final List<Steerable> _hunters = [
for (var i = 0; i < _count; i++)
Steerable(
position: Vector3(math.cos(i * 0.9) * 14, 0, math.sin(i * 1.3) * 14),
maxSpeed: 3.4,
maxForce: 7,
),
];
/// One tree, shared by every hunter. A hundred guards patrolling run one
/// tree and a hundred of these.
late final Node _tree = Selector([
// Chase, if there is anything to chase.
Sequence([
Check('sees the player', (tick) => tick.blackboard['seen'] == true),
Do('chase', (tick) {
(tick.blackboard['steer'] as void Function(Steering))(
Arrive(_player.position, slowingRadius: 2.5),
);
return Status.running;
}),
]),
// Otherwise mill about.
Do('wander', (tick) {
(tick.blackboard['steer'] as void Function(Steering))(
Wander(seed: tick.blackboard['seed']! as int, at: tick.seconds),
);
return Status.running;
}),
]);
/// Each hunter's own place in the shared tree.
late final List<Brain> _brains = [
for (var i = 0; i < _count; i++) Brain(_tree),
];
late final Ticker _clock = createTicker(_tick)..start();
double _last = 0;
void _tick(Duration elapsed) {
final now = elapsed.inMicroseconds / 1e6;
final delta = (now - _last).clamp(0.0, 0.1);
_last = now;
if (delta == 0) return;
_player.integrate(
Seek(Vector3(math.cos(now * 0.5) * 10, 0, math.sin(now * 0.7) * 10))
.force(_player),
delta,
);
for (var i = 0; i < _count; i++) {
final hunter = _hunters[i];
Steering? wanted;
_brains[i].tick(
now,
blackboard: {
'seed': i,
'seen':
(hunter.position - _player.position).length < _noticeRange,
'steer': (Steering behaviour) => wanted = behaviour,
},
);
// Whatever the tree asked for, plus the thing that is always true:
// do not walk through your neighbours. Separation goes first, because
// a sum longer than the agent can push is dominated by what is at the
// front of it.
final force = Blend([
(behaviour: Separate(_hunters, radius: 1.8), weight: 1.6),
if (wanted != null) (behaviour: wanted!, weight: 1.0),
]).force(hunter);
hunter.integrate(force, delta);
}
setState(() {});
}
@override
void dispose() {
_clock.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(body: OrblitView(scene: _scene())),
);
}
OrblitScene _scene() {
return OrblitScene(
camera: OrblitCamera(
position: Vector3(0, 22, 26),
target: Vector3.zero(),
),
objects: [
OrblitObject(
key: 1,
transform: Matrix4.identity()
..setTranslation(_player.position + Vector3(0, 0.6, 0))
..scaleByDouble(0.5, 0.6, 0.5, 1),
colour: Vector3(0.9, 0.75, 0.2),
),
for (var i = 0; i < _count; i++)
OrblitObject(
key: 100 + i,
transform: Matrix4.identity()
..setTranslation(_hunters[i].position + Vector3(0, 0.5, 0))
// A mover with no speed has no heading, so there is nothing to
// point at and the last one would be a lie.
..rotateY(_facing(_hunters[i]))
..scaleByDouble(0.4, 0.5, 0.6, 1),
colour: Vector3(0.85, 0.3, 0.2),
),
OrblitObject(
key: 2,
transform: Matrix4.identity()
..setTranslation(Vector3(0, -0.1, 0))
..scaleByDouble(50, 0.1, 50, 1),
colour: Vector3(0.08, 0.09, 0.1),
castShadows: false,
),
],
lights: [
OrblitLight(
key: 1,
kind: OrblitLightKind.directional,
direction: Vector3(-0.4, -1, -0.5)..normalize(),
intensity: 80000,
),
],
sky: OrblitSky(ambient: 14000),
);
}
double _facing(Steerable agent) {
final heading = agent.heading;
return heading == null ? 0 : math.atan2(heading.x, heading.z);
}
}

Forces add, and that is the whole composition model

Section titled “Forces add, and that is the whole composition model”

A steering behaviour answers one question and nothing else: which way, and how hard. It does not move anything, it does not decide whether it should be running, and it does not know the others exist.

Which is why Blend is a list of weighted behaviours rather than an algorithm. A flock isn’t a flocking implementation. It’s Separate + Align + Cohere with three weights, and changing the weights gets you a different animal.

The ordering in that list matters in exactly one way, and it is easy to miss: everything is summed and the total is then clamped to maxForce. When the sum is longer than the agent can push, what survives is dominated by whatever contributed most. So the forces that must not be ignored, like separation and obstacle avoidance, go first and go heavier.

The hunters above are Steerable(maxSpeed: 3.4, maxForce: 7), and those two numbers do more for how they read than anything else on the page.

The ratio between them is the entire character of a mover. High force against low speed is something nimble that turns on the spot. Low force against high speed is something with mass, that commits to a direction and arcs.

Tuning an agent that “feels wrong” is almost always these two numbers rather than the behaviour attached to them.

The tree is shared; the place in it is not

Section titled “The tree is shared; the place in it is not”

Node trees are const-constructible and stateless. One tree serves every hunter, and Brain holds the per-agent memory of where in it that agent had got to.

That matters because of Status.running. Without it every node would have to finish inside one frame, which rules out walking anywhere, waiting for anything, or playing an animation to its end. With it, a node can say “still working, ask me again”, and then something has to remember which node that was. Brain is that something.

Selector runs its children until one succeeds; Sequence runs them until one fails. The tree above is therefore “chase if you can see them, otherwise wander”, which reads in that order because that is the order it runs in.

Things that decide for themselves covers the rest of the behaviours and decorators.