Created May 2022

Video

What is it?

  • Engine: Unity
  • Language: C#

Twin-Stick Hexer was made as a University project to demonstrate making a quick game prototype of a top-down shooter.

It also includes my own implementation of “context steering” AI, which is a steering behaviour that follows an optimal path with some added world context to avoid obstacles, find new targets, and more.

In this case, the enemies are given an initial direction by an A* pathfinding addon from the asset store.

How was the AI done?

A paper on context steering can be found here, by Andrew Fray. For now, here’s the basics on how I implemented it.

Firstly, I initialised three arrays: one representing directions around the player in a rough circle, one representing danger levels in those directions, and another representing interest in a similar way. There is also one vector representing the chosen direction of the AI agent:

// Context Steering
private Vector2[] rayDirections;
private float[] interest;
private float[] danger;
private Vector2 chosenDirection;

private void Awake()
{
	[...]

	// Initialise context steering
	// Resize arrays to match the number of arrays chosen in the inspector.
	Array.Resize(ref rayDirections, numberOfRays);
	Array.Resize(ref interest, numberOfRays);
	Array.Resize(ref danger, numberOfRays);
	
	// Set the directions surrounding the player, equally apart in angles
	for (int i = 0; i < rayDirections.Length; i++)
	{
		float angle = i * 2 * Mathf.PI / numberOfRays; // In radians
		rayDirections[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)); // Returns vector using radian input
		rayDirections[i].Normalize();
	}
}

The AI stays inactive until the player is seen. To check this, it performs a raycast to the player every physics tick to check if they are behind a wall, while also checking if the player is within a certain distance. If both conditions are met, the AI is activated.

A path to the player is generated by the A* Pathfinding script, and from there the context steering follows that path by generating interest in the direction of the waypoints along it.

Running in FixedUpdate() (every physics tick):

// Context Steering
// Clean interest and danger
for (int i = 0; i < numberOfRays; i++)
{
	interest[i] = 0;
	danger[i] = 0;
}

[...]

// - Path interest
Vector2 directionToWaypoint = ((Vector2) path.vectorPath[currentWaypoint] - rigidBody.position).normalized;
for (int i = 0; i < numberOfRays; i++)
{
	// Get the strength of the interest's direction using the dot product
	float dot = Vector2.Dot(rayDirections[i], directionToWaypoint);
	interest[i] += Mathf.Max(dot * pathWeight, 0);
}

The advantage of using a vague direction of where the player wants to go is that it provides a normalised value that can provide a single chosen direction with a minimal amount of checks just by normalising the values.

Eye path

The green lines represent the interest values in those directions. The blue line is the chosen direction.

Danger can be applied to the AI however we want, as it simply subtracts from the interest. Here, I’m making sure enemies don’t hit each other and don’t bump into walls by applying “danger” values to other enemies:

// - Obstacles danger
for (int i = 0; i < numberOfRays; i++)
{
	// If the raycast in the direction of the ray hits something, mark that direction as dangerous.
	RaycastHit2D hit = Physics2D.Raycast(transform.position, rayDirections[i], minimumObstacleDistance, layerMask);
	danger[i] += hit.collider ? obstacleDangerWeight : 0;

	// Subtract interest using danger
	interest[i] = Mathf.Max(0, interest[i] - danger[i]);
	
	// Add interest in opposing directions
	int opposingIndex = (i + numberOfRays / 2) % numberOfRays;
	interest[opposingIndex] += danger[i];
}

Now we finally choose a direction:

// - Choose normalised direction by adding the interests together.
chosenDirection = Vector2.zero;
for (int i = 0; i < numberOfRays; i++)
{
	Vector2 iDirection = rayDirections[i] * interest[i];
	chosenDirection += iDirection;
}
chosenDirection.Normalize();

Strafing is done by checking if the the player is within a certain direction and using a different interest algorithm instead of following the waypoint. This one directly manipulates the interest direction to go in a circle around the player.

if (isStrafing)
{
	Vector2 directionToPlayer = ((Vector2)playerTransform.position - rigidBody.position).normalized;
	
	// Generate interest in a direction perpendicular to the player
	Vector2 directionLeft = new Vector2(-directionToPlayer.y, directionToPlayer.x);
	Vector2 directionRight = new Vector2(directionToPlayer.y, -directionToPlayer.x);

	// Choose which direction to go based on whichever is closer to the direction the agent is currently moving in
	// Basically, they're lazy.
	Vector2 directionToGo = Vector2.Angle(chosenDirection, directionLeft) < Vector2.Angle(chosenDirection, directionRight)
		? directionLeft : directionRight;
	
	// Check distance to player and apply varying danger based on percentage
	float playerDistancePercent = distanceToPlayer / minimumStrafeDistance;

	for (int i = 0; i < numberOfRays; i++)
	{
		// Apply interest perpendicular to player
		float dot = Vector2.Dot(rayDirections[i], directionToGo);
		interest[i] += Mathf.Max(dot * pathWeight, 0);
		
		// Apply danger towards player to stay at a distance.
		float antiDot = Vector2.Dot(rayDirections[i], directionToPlayer);
		danger[i] += Mathf.Max((1 - playerDistancePercent) * antiDot * obstacleDangerWeight, 0);
	}
}

Credits