On The Wings Of An Angle

Developing a Simple Ricochet System

Recently I’ve been undertaking the production of project currently named ‘Ricochet Continuum’ (a name in desperate need of improvement), as part of Handsome Dragon Games. I say as part of HDG, although it’s a project I’m undertaking the entire project solo. Regardless, in this game, the player must carefully aim their shots to ricochet them off walls and ceilings to hit specific targets in the level. But the key twist is that certain points in the level will shift time forwards or backwards by a specific margin when crossed, changing the position or state of the targets.

Now there are plenty of flaws and talking points in the design of this game, but I’m going to be covering them another time. Right now I’m going to be looking at how it works. Or more specifically, how I pieced together the Ricochet System. Perhaps it will be useful if you’re looking to implement a simply ricochet system yourself.

NOTE: For reference, all of this was done in Unity 5.4, using C#.
Also, brace yourself, this going to be a long and technical one.

The core idea behind the Ricochet System is that whenever a bullet hits a surface that isn’t a target, it rebounds in a directions relative to A. its current heading, and B. the angle of the surface it hit. What I aimed to achieve was to find a new angle that was mirrored across the normal of (ie. direction perpendicular to) the side of the object hit. From here, I’ll be calling this normal the “Mirror Line”.

Mirror Line Example

What I was aiming for: Different shots, different trajectories, different ricochets.

For the sake of keeping the system simple to implement and understand, I set a restriction in advance that all objects that could be ricocheted off in the level were some form of rectangle. Whilst it does limit the kinds of shapes I can use in the levels to an extent, had I not done this, determining the angle of the hit surface would become a much more complicated ordeal.

So, let’s say the player has fired a bullet and it’s collided with a generic surface at an particular angle. First things first, we need to determine what side of the object has been hit. This is because the system is designed to determine the Mirror Line using the hit object’s local direction vectors (positive or negative up/right). This is why all objects are rectangles, as it means these four vectors will always be perpendicular to one of the object’s surfaces.

Relative Normal Example

The four possible Mirror Lines taken from the object’s normals, regardless of rotation.

More importantly, how do we figure out which one to use? First we circlecast out from the bullet, at the size of the bullet’s collider, and find the point at which it hits the object (at first I used a raycast for this, but major issues arose when the collision trigger hit the object, but the raycast missed). Once we have that point, we use Transform.InverseTransformPoint() to convert it to local space, and normailse that vector to essentially give us a direction.

//Get hit point in local space.
 Vector2 localPoint = hit.transform.InverseTransformPoint(hit.point);
 Vector2 localDir = localPoint.normalized;

Once we have that direction, we compare it to unit vectors going up and right (relative to the world) using dot products, to see which side of the object its coming from. If we first  compare the absolute values of direction dot up and direction dot right, the larger one tells us which is most similar to the direction of the hit and thus if we should be looking at left vs right, or above vs below.

Lastly, we compare the original dot products. If the direction dot right is greater than 0, it’s more right than left, and if direction dot up is greater than 0 it’s more above than below.  With these two checks, we can determine which normal of the object we need to be using.

//Find the dot products of the unit vector and the directions we're checking.
 float upDot = Vector2.Dot(localDir, Vector2.up);
 float rightDot = Vector2.Dot(localDir, Vector2.right);
 float absUpDot = Mathf.Abs(upDot);
 float absRightDot = Mathf.Abs(rightDot);

 //Find if we're checking left vs right, or above vs below.
 if (absUpDot > absRightDot)
//Find if it's above or below.
    if(upDot > 0)
        //Bullet is above.
        //Bullet is below.
//Find if it's left or right.
    if(rightDot > 0)
        //Bullet is to the right.
        //Bullet is to the left.

Now we know what side we’ve hit and what Mirror Line we’re using, it’s time for us to work out where we’re rotating this bullet to. Firstly, we get the current rotation of the bullet in radians, and use that to convert it into a unit vector for its direction.

//Find the direction the bullet is coming in at as a vector.
 float radAngle = bullet.transform.rotation.eulerAngles.z * (Mathf.PI / 180);
 Vector2 directionVec = new Vector2(Mathf.Cos(radAngle), Mathf.Sin(radAngle));

Following that we find the angle between it and the Mirror Line. Let’s call that Theta for now. By using Vector2.Angle(), we can get the absolute angle easily.

Now, we know that for this to be mirrored across the Mirror Line, it needs to be at the same angle, but on the other side of the Line (and then rotate 180 degrees to face the other way). So we know the angle of the bullet needs to change by (2 * Theta + 180).

However, we don’t know what direction that change needs to be, since we only got the absolute angle before. By calculating the Vector3 cross product between the Mirror Line and direction vector of the bullet, we can determine if the angle we need is positive or negative based on if the Z value of the resulting vector is positive or negative. If the Z value is positive, the angle is negative, and vice versa.

//Determine if the angle is positive or negative.
 int angleDirection = 1;
 Vector3 crossVec = Vector3.Cross(mirrorLine, directionVec);
 if (crossVec.z > 0)
    angleDirection = -1;

So, now we end up with:

//Calculate Final Angle
 float ricochetAngle = (2 * theta * angleDirection) + 180.0f;

And the final step is to simply set the bullet’s rotation to this new value.

This system isn’t perfect and there have been some issues with its implementation. As I mentioned earlier, I initially was using a raycast rather than a circlecast to determine the side hit, which caused issues when the collision trigger skimmed the edge of a surface and the raycast subsequently missed. Using a circlecast the same size as the trigger has resolved this issue, but can result in some odd, and unexpected ricochet angles, that did not occur with a raycast. As it stands, I’m not entirely sure why circlecast creates these issues, but I intend to investigate and will update this post with any new info I find.

I’ve considered switching the system over entirely to raycast (rather than using any triggers at all), though that would either make the effective hitbox on the bullet exceptionally small, or would require me to set up a system that uses multiple simultaneous raycasts to create a hitbox of sorts (which I may still do).

This system is also limited in that it is not equipped to handle any non-flat surfaces, and to even make use of non-rectangular shapes would require a alternate method of determining the Mirror Line that does not rely on the objects local vectors.

Regardless, for the game I implemented it in, whilst it may not be perfect, it works exceptionally well for the most part.I’ll be sure to work on improving it further, and will update this post with anything more I manage to do with it. Expect to hear more about this project as I’m set to analyse and explain all kinds of aspects of it, and should even have a demo available for you at some point. Keep an eye out for it!


One comment

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s