If you plan to have more than one enemy attacking the player in a 3d (or top down 2d) game, you’re going to need an attack slot system. In our last tech article, we went over building a waypoint pathing system. Let’s continue with the AI tutorials and build an attack slot system! One important detail here is that this system actually doesn’t work with a waypoint based system, so we will use Unity’s built in navmesh support.

What Are Attack Slots

Attack slot systems are generally pretty simple and can make combat look much better. Essentially, what they do is assign an attack position to each attacker so that the attackers don’t just bunch up and either end up on top of each other, or end up behind another attacker. If you only expect one attacker at a time, it’s not going to help, but 2 or more on 1 and it can be an invaluable tool.

Without Attack Slots

With Attack Slots

It should be pretty clear that with attack slots, the AI controlled entities look much smarter and behave in a more organized fashion.

Building The Scene

The first thing to do is create a simple scene along with a player and an enemy. I’ve just added a plane and some cube and cylinder based obstacles. Then created a capsule for the player and one for the enemy.

Make sure you’ve got the Navigation window open and in focus. Then to create the NavMesh, select the plane, cubes, and cylinders (but not the player and enemy), and on the Object tab of the Navigation window, check Navigation Static:

Now you can go to the Bake tab and hit the Bake button:

You should see something like this:

The next step is to add a Nav Mesh Agent component to both Enemy and Player:

Don’t worry about any of the settings if you don’t want.

The Player Controller

Let’s make a simple PlayerController script so we can move the player around:

PlayerController.cs using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class PlayerController : MonoBehaviour { // Use this for initialization void Start () { } // Update is called once per frame void Update () { if (Input.GetMouseButtonDown (0)) { var mpos = Input.mousePosition; mpos.z = 10; var ray = Camera.main.ScreenPointToRay (mpos); RaycastHit hit; if (Physics.Raycast (ray, out hit)) { GetComponent<NavMeshAgent> ().destination = hit.point; } } } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 using System . Collections ; using System . Collections . Generic ; using UnityEngine ; using UnityEngine . AI ; public class PlayerController : MonoBehaviour { // Use this for initialization void Start ( ) { } // Update is called once per frame void Update ( ) { if ( Input . GetMouseButtonDown ( 0 ) ) { var mpos = Input . mousePosition ; mpos . z = 10 ; var ray = Camera . main . ScreenPointToRay ( mpos ) ; RaycastHit hit ; if ( Physics . Raycast ( ray , out hit ) ) { GetComponent < NavMeshAgent > ( ) . destination = hit . point ; } } } }

Pretty straight forward. In Update, we’re just checking if the left mouse button was pressed this frame. If so, cast a ray through the screen to the mouse’s position. If it hits anything, direct the player to move to that point. So now we can just left click to move the player. Attach this component to the Player.

An Initial Enemy Controller

It’s time to create a simple EnemyController:

EnemyController.cs using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; public class EnemyController : MonoBehaviour { GameObject target = null; float pathTime = 0f; // Use this for initialization void Start () { target = GameObject.Find ("Player"); } // Update is called once per frame void Update () { pathTime += Time.deltaTime; if (pathTime > 0.5f) { pathTime = 0f; var tpos = target.transform.position; var offset = (transform.position - tpos).normalized * 1.5f; GetComponent<NavMeshAgent> ().destination = tpos + offset; } } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 using System . Collections ; using System . Collections . Generic ; using UnityEngine ; using UnityEngine . AI ; public class EnemyController : MonoBehaviour { GameObject target = null ; float pathTime = 0f ; // Use this for initialization void Start ( ) { target = GameObject . Find ( "Player" ) ; } // Update is called once per frame void Update ( ) { pathTime += Time . deltaTime ; if ( pathTime > 0.5f ) { pathTime = 0f ; var tpos = target . transform . position ; var offset = ( transform . position - tpos ) . normalized * 1.5f ; GetComponent < NavMeshAgent > ( ) . destination = tpos + offset ; } } }

First, in Start, we’re just caching the player as our target. Then in Update, every 0.5 seconds, we get the player’s position, calculate an offset that is 1.5 units from them in our direction and then set that to be the pathing destination. Attach this component to the enemy. This is how things might look without an attack slot system.

That’s just with one enemy. It doesn’t look too bad. Where it breaks down is with multiple enemies:

That generally makes your enemies look unintelligent, and if you’re gating whether they can attack based on distance to target, the enemies in the back are probably not going to be attacking. It’d be much better if they could all swing at the player so they can kill him quicker! This is where our attack slot manager will come in.

Building The Slot Manager

We can start a new file for SlotManager and add in some initialization code:

SlotManager.cs using System.Collections; using System.Collections.Generic; using UnityEngine; public class SlotManager : MonoBehaviour { private List<GameObject> slots; public int count = 6; public float distance = 2f; void Start() { slots = new List<GameObject> (); for (int index = 0; index < count; ++index) { slots.Add (null); } } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 using System . Collections ; using System . Collections . Generic ; using UnityEngine ; public class SlotManager : MonoBehaviour { private List < GameObject > slots ; public int count = 6 ; public float distance = 2f ; void Start ( ) { slots = new List < GameObject > ( ) ; for ( int index = 0 ; index < count ; ++ index ) { slots . Add ( null ) ; } } }

The SlotManager really just contains a list of slots for GameObjects and then parameters to define how many slots to create and how far from the defender they are. In Start, we just initialize the slots to null. The way this system works, if a slot is null, then it is empty. When it is set to a GameObject, it is considered in use or full.

Getting The Position Of Slots

We’ll need a function to return the location of a slot. We want the slots arranged in a circle around the GameObject like this:

Each wireframe sphere represents a slot, and the first slot is the top one. The slots go clockwise around after that. Here’s the code for GetSlotPosition:

public Vector3 GetSlotPosition(int index) { float degreesPerIndex = 360f / count; var pos = transform.position; var offset = new Vector3 (0f, 0f, distance); return pos + (Quaternion.Euler(new Vector3(0f, degreesPerIndex * index, 0f)) * offset); } 1 2 3 4 5 6 7 public Vector3 GetSlotPosition ( int index ) { float degreesPerIndex = 360f / count ; var pos = transform . position ; var offset = new Vector3 ( 0f , 0f , distance ) ; return pos + ( Quaternion . Euler ( new Vector3 ( 0f , degreesPerIndex * index , 0f ) ) * offset ) ; }

So, first we calculate the number of degrees per each index to determine how far apart each slot is (angle-wise). Then we just rotate a new vector pointing in the z direction by the number of degrees for this slot and add that to our position to get the slot position.

Reserving A Slot

Now to make a method to reserve a slot for an attacker:

public int Reserve(GameObject attacker) { var bestPosition = transform.position; var offset = (attacker.transform.position - bestPosition).normalized * distance; bestPosition += offset; int bestSlot = -1; float bestDist = 99999f; for (int index = 0; index < slots.Count; ++index) { if (slots [index] != null) continue; var dist = (GetSlotPosition (index) - bestPosition).sqrMagnitude; if (dist < bestDist) { bestSlot = index; bestDist = dist; } } if (bestSlot != -1) slots [bestSlot] = attacker; return bestSlot; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public int Reserve ( GameObject attacker ) { var bestPosition = transform . position ; var offset = ( attacker . transform . position - bestPosition ) . normalized * distance ; bestPosition += offset ; int bestSlot = - 1 ; float bestDist = 99999f ; for ( int index = 0 ; index < slots . Count ; ++ index ) { if ( slots [ index ] != null ) continue ; var dist = ( GetSlotPosition ( index ) - bestPosition ) . sqrMagnitude ; if ( dist < bestDist ) { bestSlot = index ; bestDist = dist ; } } if ( bestSlot != - 1 ) slots [ bestSlot ] = attacker ; return bestSlot ; }

This gets a little more complex. You might recognize the first 3 lines from the initial code in EnemyController:

// EnemyController.cs var tpos = target.transform.position; var offset = (transform.position - tpos).normalized * 1.5f; GetComponent<NavMeshAgent> ().destination = tpos + offset; // SlotManager.cs var bestPosition = transform.position; var offset = (attacker.transform.position - bestPosition).normalized * distance; bestPosition += offset; 1 2 3 4 5 6 7 8 9 // EnemyController.cs var tpos = target . transform . position ; var offset = ( transform . position - tpos ) . normalized * 1.5f ; GetComponent < NavMeshAgent > ( ) . destination = tpos + offset ; // SlotManager.cs var bestPosition = transform . position ; var offset = ( attacker . transform . position - bestPosition ) . normalized * distance ; bestPosition += offset ;

As in earlier, we’re finding a position that is near the defender in the direction of the attacker. This will be the optimal position for the slot that we want as ideally it means not having to walk around to the other side of the defender which generally looks bad. Next up, we go through all the slots and find the closest slot that is not currently in use. If one exists, we fill it with the attacker so nobody else can take it. That’s it!

Releasing A Slot

Wait a second, that’s not quite it! What happens if an attacker dies or decides to go do something else? For that, we need a way to release the reservation. Otherwise their slot will be considered filled forever. Here’s the implementation of Release:

public void Release(int slot) { slots [slot] = null; } 1 2 3 4 public void Release ( int slot ) { slots [ slot ] = null ; }

If you were following closely, this shouldn’t be a surprise. All that needs to be done is to set the slot to null, which signals Reserve that it is empty again for the next attacker to take.

Adding Some Debug Display

There’s one more thing we can do if desired, which is add some Gizmos like in the image above. To do that, we can define OnDrawGizmosSelected:

void OnDrawGizmosSelected() { for (int index = 0; index < count; ++index) { if (slots == null || slots.Count <= index || slots [index] == null) Gizmos.color = Color.black; else Gizmos.color = Color.red; Gizmos.DrawWireSphere (GetSlotPosition (index), 0.5f); } } 1 2 3 4 5 6 7 8 9 10 11 void OnDrawGizmosSelected ( ) { for ( int index = 0 ; index < count ; ++ index ) { if ( slots == null || slots . Count <= index || slots [ index ] == null ) Gizmos . color = Color . black ; else Gizmos . color = Color . red ; Gizmos . DrawWireSphere ( GetSlotPosition ( index ) , 0.5f ) ; } }

Essentially, what this does while in the editor is to show each slot. In play mode, it will color slots red if they’ve been reserved.

Updating The Enemy Controller

The last thing to do is to add some code to EnemyController that will utilize this new system. We’ll need to add a new private variable to hold the currently reserved slot and then change the Update function:

public class EnemyController : MonoBehaviour { GameObject target = null; float pathTime = 0f; int slot = -1; // Use this for initialization void Start () { target = GameObject.Find ("Player"); } // Update is called once per frame void Update () { pathTime += Time.deltaTime; if (pathTime > 0.5f) { pathTime = 0f; var slotManager = target.GetComponent<SlotManager> (); if (slotManager != null) { if (slot == -1) slot = slotManager.Reserve (gameObject); if (slot == -1) return; var agent = GetComponent<NavMeshAgent> (); if (agent == null) return; agent.destination = slotManager.GetSlotPosition (slot); } } } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public class EnemyController : MonoBehaviour { GameObject target = null ; float pathTime = 0f ; int slot = - 1 ; // Use this for initialization void Start ( ) { target = GameObject . Find ( "Player" ) ; } // Update is called once per frame void Update ( ) { pathTime += Time . deltaTime ; if ( pathTime > 0.5f ) { pathTime = 0f ; var slotManager = target . GetComponent < SlotManager > ( ) ; if ( slotManager != null ) { if ( slot == - 1 ) slot = slotManager . Reserve ( gameObject ) ; if ( slot == - 1 ) return ; var agent = GetComponent < NavMeshAgent > ( ) ; if ( agent == null ) return ; agent . destination = slotManager . GetSlotPosition ( slot ) ; } } } }

Now, Update is again only executing this every 0.5 seconds, but now it’s getting the SlotManager on the target. Then, if no slot has been assigned, it attempts to reserve one. If that fails, there’s nothing else we can do, so we return out. If it has a slot, then it just directs the navigation destination to the position of the slot using GetSlotPosition. That’s all there is to it!

Wrapping Up And Future Improvements

Before this will work, you’ll have to attach the SlotManager component to the player, but once you do that, here’s what it should look like with several enemies:

One thing to be careful of is using attack slots on a pair of entities that are attacking each other. If they both have attack slots, they will likely try to circle around each other forever. Instead, you’ll want to check for this case and let whoever wanted to attack first get an attack slot. The other participant should aim to position themselves so that the attacker’s slot matches up with the attacker’s position.

We could also add some improvements such as disabling slots that are off the navmesh or on the other side of walls, automatically increasing the number of slots to accommodate the number of attackers, or we could add multiple attack slot rings based on different attack ranges. For now, I’ll leave those up to you to implement!

Download The Code!

About Tricky Fast Studios

Tricky Fast Studios is a US-based game studio featuring long-time industry veterans. We provide a full spectrum of game development services including bug fixing, feature development, porting, temporary staffing, and complete development. Our recent work includes The Walking Dead: March To War for Disruptor Beam, Poptropica Worlds for StoryArc Media, the Star Trek: Timelines Facebook and Steam ports for Disruptor Beam, and Wheel of Fortune Slots Casino for The Game Show Network. We’re here to build your story!