Intended Audience
This tutorial is meant for people who are familiar to C#/C++ and who need a cool neat trick in their arsenal. This can be used for long ranged boss abilities or just a way to make the player feel empowered when they dodge the “bullet tracking” attack.
Games That Use Tracking Bullets
Let’s quickly look at a few games that do this that were successful in doing this. In Dark Souls 3, Alrdich, The Devour of Gods, uses blue magic casts that follow the player after a moment. In Kingdom Hearts 2, the king phases had tracking lasers that followed the player.
What I Call Handler and Projectile Relationship
This may be a programming paradigm, or it may not be but I have found what I believe to be a decent way of handling any spawned object which I call Handler and Projectile Relationship. The idea is you have a Handler for the spawned objects. Let’s start by creating the Handler. The handler is responsible for spawning objects from a specific location and a specific amount of objects and that is its only purpose.
Handler Setup
public class BulletTrackingHandler: MonoBehaviour
{
//object to be spawned multiple times
[SerializeField] private GameObject m_FireCastProjectile;
//number of projectiles to be spawned
[SerializeField] private int m_NumberOfBullets = 10;
void Update()
{
//testing handler function with a keycode press
if (Input.GetKeyDown(KeyCode.H) == true)
{
//calling the spawning function
CastRangeBurst(m_NumberOfBullets);
}
}
//function to spaw the projectiles
void CastRangeBurst(int NumberOfBullets)
{
//spawn projectiles from location
for (int i = 0; i < NumberOfBullets; ++i)
{
//spawn the projectile object
//this object will contain the projectile script
//which handles the heavy lifting of the bullet
GameObject firebullet_spawn =
Instantiate(m_FireCastProjectile, this.transform.position, Quaternion.identity);
}
}
}
Projectile Setup
This is the class that hard carries everything. Recently I have been into Finite State Machines for this system. A Finite State Machine is simple to understand and we will be utilizing its capabilities. We are going to use the update loop for the projectile as a state machine. The idea is we are going to have the bullet go through phases which are states in this case. One for linearly interpolating to set positions, another for waiting in place, and lastly going towards the player.
public class BulletTrackingProjectile : MonoBehaviour
{
//this vector is used for lerping objects to random directions within a unit circle scaled
[SerializeField] private Vector3 m_Vec3Scale = new Vector3(4.0f, 1.0f, 4.0f);
//speed of the bullet
[SerializeField] private float m_BulletSpeedInDirectionOfPlayer = 2.0f;
//timer for waiting when the projectiles reach its target position
[SerializeField] private float m_Timer = 1.0f;
//destroy projectile after it spawns
[SerializeField] private float m_DestroyAfterTime = 7.5f;
//this is the special sauce for projectiles to feel good
[SerializeField] private float m_StopTrackingDistance = 5.0f;
//this is a scaled unit circle
private Vector3 random_within_circle_radius;
//current position of projectile
private Vector3 currentPos;
//rigidbody for the projectile
private Rigidbody m_Rigid;
//lerp timer
private float TimeTillTarget = 0.0f;
//state checking for bullet interpolation
private bool m_BulletLerpEnded = false;
//state checking for the timer
private bool m_TimerDone = false;
// Start is called before the first frame update
void Start()
{
//this is setting up the scaled unit sphere, this is the point in which the projectiles will go through
random_within_circle_radius =
RandomScaledVector3(Random.insideUnitSphere, m_Vec3Scale);
//current position of the projectile
currentPos = this.transform.position;
//setting up the rigidbody component for the projectile
m_Rigid = GetComponent<Rigidbody>();
}
//Vector3 component-wise scaling
Vector3 RandomScaledVector3(Vector3 A, Vector3 B)
{
return new Vector3(A.x * B.x, A.y * B.y, A.z * B.z);
}
// Update is called once per frame
void Update()
{
//bullet state
TimeTillTarget += Time.deltaTime;
if (TimeTillTarget < 1.0f)
{
//lerp the bullet to the positions within the random circle
transform.position =
Vector3.Lerp(currentPos, currentPos + random_within_circle_radius, TimeTillTarget);
}
else
{
//lerp state has finished
m_BulletLerpEnded = true;
}
//timer state
if (m_Timer > 0.0f)
{
//setting the velocity to 0 to be safe but also so the bullet doesnt fly off after the timer is up
m_Rigid.velocity = Vector3.zero;
//tick down the timer
m_Timer -= Time.deltaTime;
}
else
{
//set the timer state as it is done now
m_TimerDone = true;
}
//add bullet speed state
if (m_BulletLerpEnded == true && m_TimerDone == true)
{
//track the players movements
//(the addition of the vector here is for aiming at the center of the player)
Vector3 direction = Player.BaseInstanceObject.transform.position +
new Vector3(0.0f, .6f, 0.0f) - this.transform.position;
//if the distance between the bullet and player is less than m_StopTrackingDistance
//direction of the bullet is now its velocity
if (Vector3.Distance(gameObject.transform.position,
Player.BaseInstanceObject.transform.position) < m_StopTrackingDistance)
{
//set direction the bullet was facing in (special sauce for making it feel good)
direction = gameObject.GetComponent<Rigidbody>().velocity;
}
//set the velocity of the bullet
Vector3 accel = (direction).normalized * m_BulletSpeedInDirectionOfPlayer;
m_Rigid.velocity = accel;
//destroy after X secodns after some amount of time
Destroy(this.gameObject, m_DestroyAfterTime);
}
}
}
The Special Sauce, Let’s Talk Design
At first when I was writing this piece of code, I had it where the bullets always tracked the player. It was almost impossible for the player to dodge the projectiles. So I thought, how could I make it where it feels threatening without cheating the player? And that is when I figured it out. We want to make the player feel threatened enough until the very last moment. They key here is making them “feel” something at the end of the attack.
Let’s look through the states of dodging this specific attack. The first state is running away either directly left or right. This will be a neutral action for the player. It will be satisfying to dodge the attack this way but there is no risk and no reward. You are exactly in the state as were before the attack. It will not feel empowering. The second state is running away from the attack which is the worst action the player can take. You may or may not dodge the attack this way. The third way is to run through towards the cast. This comes with high risk and high reward however this is one of the most empowering feelings the player could ever feel. And this is what we want.
Programming the Special Sauce
How do we quantify design in lines of code. For each line we write, it may hold paragraph and paragraphs of design work. Putting it into cohesive words that be can be transcribed into code is what makes design hard. In this case, after a lot of trial and error, I conceived 4 magical lines of code that really gave the feeling to this attack.
if (Vector3.Distance(gameObject.transform.position, Player.BaseInstanceObject.transform.position) < m_StopTrackingDistance)
{
//set direction the bullet was facing in (special sauce for making it feel good)
direction = gameObject.GetComponent<Rigidbody>().velocity;
}
That’s it. A small distance check was what I needed to change the feeling of the whole attack. Allowing designers to change the m_StopTrackingDistance gave the attack a whole new dynamic and was exactly what we needed.
Improvements That Can Be Made
- Bullets pathing via Bezier Curve towards the player
- Rotating bullets in the direction they are going
- Having an option for bullets to go one at a time instead of all at the same time
These are a few improvements that I would personally like to add for future use. Thank you for reading this tutorial on Tracking Bullets and how to make them feel good! If you have any questions or improvements, email m at amirazmi0830@gmail.com!