Collaborative Project: 2D Boids

Upon deciding on an underwater environment for the game, Xinyu and I both agreed we would need fish – and not just a single floating fish, as Xinyu had already drawn, but schools and shoals of fish.

Xinyu got to work making some different fish sprites, and I revisited Zans’ tutorial on boids. Boids are objects that react to each others’ position, rotation and speed to create formations, and depending on the variables entered they will appear to move like birds, fish, cars, meteors, pedestrians… The possibilities are endless!

I created a new scene to experiment with making boids, but immediately ran into a problem – the boids I had created for Zans’ tutorial were 3D boids, not 2D boids. 3D boids use different levels of torque to reorient themselves towards alignment, cohesion and separation vectors, with the averaging out of these forces returning a forward direction. Applying torque onto a 3D object just means reading its position and direction as Vector3s, but finding the angle towards which to apply 2D torque requires a lot more maths. I spent half a day trying to brute force my way through this translation, but to no avail. I would need to start from scratch.

I watched two tutorial series to get a grasp on the practical differences between 2D and 3D boids. Obviously, the principles of Alignment, Cohesion and Separation remained constant, but how they were calculated differed.

Renaissance Coders, Unity 2D Artificial Intelligence Flocking Introduction Available at: https://www.youtube.com/watch?v=YLxV5L6IaFA (Accessed: 13 February 2021).
Boards To Bits Games, Flocking Algorithm in Unity, Part 1: Introduction, Available at: https://youtu.be/mjKINQigAE4 (Accessed: 13 February 2021).

By following the tutorials, I gained a practical understanding of how to alter the velocity of 2D objects by combining functions for Alignment, Cohesion and Separation. The results were pretty good, but I thought there was room for a lot of improvement:

Boundaries

The tutorial I had followed left me with a wrap around environment – fish would hit a horizontal or vertical limit and be instantly transported to the opposite limit.

This was easy to code, but I wanted something more natural.

Now that I had a grasp on how applying different vectors worked, it didn’t take me long to come up with a solution. If the distance between the boid and the tether point became greater than 90% of the tether radius, then a vector pointing back to the tether would be applied at a strength increasing proportional to the distance (by multiplying this strength by itself, I read, a more natural-looking effect could be achieved, as smaller values would affect the object more slowly, and greater values faster).

By maintaining my code as separate functions, as suggested by the tutorial and by Zans, I found it very easy to add in new variables. It was just a matter of writing this new function called Tether(), outputting a Vector3, then referencing that in the Combine() function.

Avoidance

The final piece of the puzzle was implementing more natural avoidance. So far, boids reacted to an ‘enemy’ object by reversing their trajectory, or ‘doing a 180’ (you can see this in the above video). This seemed unrealistic for ocean-going bodies, so I did some quick research on calculating different angles from 2D objects.

By using cross product of the boid and enemy’s positions I was able to determine on which side of the member the enemy object stood; then, it was just a matter of creating a perpendicular velocity vector in the opposite direction (so 90 degrees left if the enemy object is on the right, and vice versa if on the left).

I put this code into a new RunAround() function, and made sure the Avoidance() function pointed to RunAround() instead of RunAway() – separating out my functions like this happily meant I only had to replace one declaration in the code!

After a bit more weighting, the resultant avoidant behaviour seemed much more realistic – fish no longer handbrake-turned away, as they would from a shark, but rather swam around the player. This had the added benefit of bringing the fish generally closer to the player, allowing more of them to be caught in-camera.

Clustering

Finally, I wanted the fish to be able to ‘cluster’ around a point or an object, primarily as a way to direct the player towards a collectible object. This was simple to achieve: with the object or position as a child of the fish controller, and a Circle Collider acting as a trigger, I could send a signal to the fish controller that the school’s ‘behaviour’ (a string variable, though it could just as easily be a bool – I left it as a string in case I want to re-use this code for more complex behaviour in the future) has changed, and also let the MemberConfig script know that a change has taken place with a public Switch() function.

Within the MemberConfig script, if Switch() was called, the variables would be set equal to the ‘free’ or ‘cluster’ versions, depending on which behaviour had been set by the fish controller script. Because all of the variables were applied using priority weights, and were generally <1 in size, the effect of changing behaviour appeared quite natural.

Conclusion

I was really pleased with the results, and glad that I spent the time understanding these algorithms. Giving environmental elements behaviour that reacts dynamically to player position and input can really heighten immersion, and make for a much more compelling aesthetic than just animating fish into an unresponsive background layer.