Context Steering
How agents can chase, flee and wander at the same time by weighing interest and danger in every direction. Interactive maps and the Unity code behind the bots in Grapple.
Introduction
I recently read an article about context steering, and I wanted to implement it in Unity. Here’s the original article, you should definitely check it out: https://andrewfray.wordpress.com/2013/03/26/context-behaviours-know-how-to-share/
You might also want to check out steering behaviors in general, you can read the original paper from Craig W. Reynolds here: https://www.red3d.com/cwr/steer/gdc99/
This is an interesting approach where each entity has directions it can move towards which make up a direction array. Each entity will also have interest and danger arrays with each index corresponding to a direction.
Direction Map
To make things simple, we will be using a 2D world but things generalize to 3D pretty much the same.
Let’s say we have an agent, and it can travel in N directions. Let’s define a direction array:
_directionMap = new Vector2[directionMapSize];
for (int i = 0; i < directionMapSize; i++)
{
var angle = i * 360f / directionMapSize;
var direction = Quaternion.Euler(0f, 0f, angle) * Vector2.up;
_directionMap[i] = direction;
}
This will start from the up direction, and create directions the agent can move towards by rotating it by angle degrees.
Behaviors
I want to talk about behaviors just a little bit before moving on to the interest and danger maps. Behaviors represent what an agent can do in the world they live in, it might simply be wandering around, seeking something, escaping something or anything you can think of.
These behaviors aren’t mutually exclusive, meaning that the agent doesn’t select one and do that for some time, then select something else. Instead, they are evaluated every time, and they will use the interest and danger maps to change how the agent acts.
This means that even when escaping from a danger, the agent will still consider chasing that food they see and if the situation allows it they will actually take that food while escaping, and I think that makes it really fascinating to see when the agents interact so naturally.
We will be visiting the behaviors once again when we get the interest and danger maps down.
Interest and Danger Maps
Both of these maps will be as large as the direction map. Each element of the interest and danger maps will have a floating number value that represent the interest and danger for that specific direction.
Let’s say we evaluate what the agent will do every 0.3 seconds, then every agent will basically check for what is around them and for each element they find, they will:
- Classify that element
- Let the relevant behavior evaluate it
- Update whatever it is doing using the interest and danger maps
The bulk of the work is in the second step, so let’s take a closer look at it. Let’s say our agents have 3 behaviors; wander, seek and enemy handling. We will take a look at the wander behavior first:
Array.Clear(_interestMap, 0, _interestMap.Length);
Array.Clear(_dangerMap, 0, _dangerMap.Length);
// Detect whatever is around us
var detectedCount = Physics2D.OverlapCircle(transform.position, detectionRadius, _contactFilter, _detectedColliders);
HandleWander();
HandleDetectedObjects(detectedCount);
private void HandleWander()
{
var distanceToPoint = Vector2.Distance(_wanderPoint, transform.position);
if (distanceToPoint < WanderArrivalDistance)
SelectWanderPoint();
_wanderDirection = (_wanderPoint - (Vector2)transform.position).normalized;
for (int j = 0; j < directionMapSize; j++)
{
var alignment = Vector2.Dot(_wanderDirection, _directionMap[j]);
var contribution = alignment / (1f + distanceToPoint);
_interestMap[j] = Mathf.Max(_interestMap[j], contribution);
}
}
So the most important part in the above code is the inside of the for loop. As you can we first compute the vector that points from us to the wander point. Then for each direction we can move towards:
- Clear each map every time we evaluate.
- We measure how aligned it is with our _wanderDirection vector.
- Calculate its contribution by adjusting the alignment with our distance to the wander point.
- Write to that direction’s interest only if the contribution is higher than the existing interest in that direction.
The point of taking the max is to prevent lots of smaller interests or dangers over powering a strong one. A visualization will make it even more clear.
You can see how each direction’s interest changes as you move the agent’s or the wander point’s position. The wander behavior is a little special in this case because the agent should know where to wander towards even if that point isn’t in its detection radius.
Alright, let’s see seek and handle enemy together. Here’s what that looks like in code:
// From previous code block
HandleDetectedObjects(detectedCount);
// Pretty similar to wander
private void HandleSeek(Collider2D detected)
{
var directionToSeekedObject = (detected.transform.position - transform.position).normalized;
var distance = Vector2.Distance(detected.transform.position, transform.position);
for (int j = 0; j < directionMapSize; j++)
{
var alignment = Mathf.Max(0f, Vector2.Dot(directionToSeekedObject, _directionMap[j]));
var contribution = alignment / (1f + distance);
_interestMap[j] = Mathf.Max(_interestMap[j], contribution);
}
}
private void HandleEnemy(Collider2D detected, float selfPower, float enemyPower)
{
var directionToEnemy = (detected.transform.position - transform.position).normalized;
var distance = Vector2.Distance(detected.transform.position, transform.position);
var difference = selfPower - enemyPower;
for (int j = 0; j < directionMapSize; j++)
{
var alignment = Vector2.Dot(directionToEnemy, _directionMap[j]);
if (difference > 0)
{
var contribution = alignment / (1f + distance);
_interestMap[j] = Mathf.Max(_interestMap[j], contribution);
}
else if (difference < 0)
{
var contribution = alignment / (1f + distance);
_dangerMap[j] = Mathf.Max(_dangerMap[j], contribution);
}
else
{
var contribution = Mathf.Max(0f, alignment) / (1f + distance);
_dangerMap[j] = Mathf.Max(_dangerMap[j], contribution);
}
}
}
There’s a pretty important thing that we should notice when we handle the enemy, it actually writes to the interest map if the agent knows that it can overpower the enemy. So a behavior doesn’t have to use either interest or danger maps, it can and very often does use both! We increase danger just like interest, only if it is even more dangerous than the current danger in that direction.
Here’s a visualization again:
Choosing a Direction
Now we have two maps filled with numbers, but the agent still doesn’t know where to go. This last step is where context steering really shines.
With classic steering behaviors, every behavior produces a vector and you add them together. Imagine there’s food to the left of our agent and an enemy slightly behind the food. Seek pulls the agent left, flee pushes it right, and the two vectors cancel each other out, so the agent freezes or picks some weird compromise direction.
Context steering doesn’t sum anything. Instead, the danger map acts as a mask over the interest map:
- Find the directions with the lowest danger.
- Among those, find the highest interest, and keep every direction that is close to it.
- From the directions that are left, pick the one most similar to where the agent is already heading.
private void UpdateDirection()
{
// 1. Directions with the lowest danger
var lowestDanger = _dangerMap.Min();
var safeIndices = new List<int>();
for (int i = 0; i < directionMapSize; i++)
{
if (_dangerMap[i] <= lowestDanger + dangerTolerance)
safeIndices.Add(i);
}
// 2. The most interesting ones among them
var highestInterest = safeIndices.Max(i => _interestMap[i]);
var candidates = safeIndices.Where(i => _interestMap[i] >= highestInterest - interestTolerance);
// 3. The one most similar to our current direction
var bestDirection = Vector2.zero;
var bestAlignment = float.MinValue;
foreach (var index in candidates)
{
var alignment = Vector2.Dot(Direction, _directionMap[index]);
if (alignment > bestAlignment)
{
bestAlignment = alignment;
bestDirection = _directionMap[index];
}
}
Direction = bestDirection.normalized;
}
The tolerances matter here. Most directions are at least slightly aligned with an enemy, so without dangerTolerance the agent would have very few options left. And interestTolerance turns “the single most interesting direction” into “a few good enough directions”, which is what makes the last step possible.
That last step is what keeps the agent from jittering. If two directions are almost equally interesting, the most interesting one can flip between them on every evaluation, and the agent would snap back and forth. By picking the candidate most similar to the agent’s current direction, it sticks to what it’s already doing unless something clearly better shows up.
Let’s go back to the food and enemy example. The directions pointing towards the enemy get masked, so even though the food is very interesting, the agent won’t go straight for it. But the directions to the left and slightly up or down are still safe, and still partially aligned with the food. So the agent curves around the enemy and goes for the food anyway. This is exactly the “taking the food while escaping” behavior I mentioned earlier, and nobody had to write any code specifically for it!
Conclusion
So we now know what Context Steering is and how it works!
Also, please don’t limit the behaviors or how you use the interest and danger maps to the examples I gave, this is a truly flexible algorithm that can be used for a lot of things.
As always please reach out to me if you have any questions, or if you have noticed anything wrong with the article. Stay safe!
Here are the bots in Grapple, my mobile arena prototype, using everything we covered in this article: