The idea sounds a bit odd right? Isn't that just flying? But I'd argue that it's a bit more nuanced than that. When you drive a car for example, you are by and large only moving in two dimensions. Back and forth, as well as left and right. Cars are, usually, not supposed to leave the road. However what happens to that when you add the extra dimension of up and down? For most this is flying, but what I want to explore is whether this type of freedom of movement can be utilized for some truly creative racing where the impractical nature of it all is at least half the point.
I set out to find games like this at first. But what I keep finding just doesn't hit the right itch for me. There are some good candidates out there:
So what these games do right is often the speed (paramount for a good racing game), the futuristic vibes and the way it feels to control the vehicle in two dimensions. It's high octane action, the music really puts you in the space of high stakes racing and the cars as well as tracks are fantastical and super impractical. All of them also convenienly supports the use of a Joystick. That is something I really want to encourage for my game because it's a type of play that I miss. The Gamepad we are all used to has become ubiqutous and for good reason. It abstracts anything away just enough that you can still link it to gameplay for almost any game fairly easily. But you also lose almost all sense of the controller being part of the game. It's just an interface for you. I wanna change that and make my game Joystick first, with gamepad secondary. It also makes mapping 3D movement onto a controller much easier to have a joystick!
Towards this goal I'm currently working on a project that aims to have hover car racing but the cars can also fly. Basically 6 degrees of freedom (6DOF). Most often 6DOF is associated with Virtual Reality controllers and flight simulators, however I wish to bring that type of freedom to a racing game. I want this freedom to be used for some real high-octane action and so I will be drawing inspiration from game franchises like F-Zero and Wipeout, the animated NASCAR show, as well as movies like Redline (2009) and Speed Racer (2008).
The above are excellent at showcasing fanstically impractical worlds where the form is much more important than the function.
To get right into it I booted up Unity and started prototyping the best I could. I have a couple of things I need to achieve:
It's a bit of tall order and some of these points are harder to achieve than others. I am using an Extreme 3D Pro Joystick from Logitech as my controller for this, so I have 3 axes to work with out of the box (as the joystick can twist!) and the joystick itself is very inexpensive (I got it for €52/$60). Despite this joystick being from 2003 (Yes it's 22 years old!) it's still an amazing value and works really well! Incredibly budget friendly Joystick controller.
In my first iteration of this controller I want the bare minimum which is just movement and rotation. No physics, no collisions, no custom gravity. I found a video from long ago that already does this (although they used actual physics, which we won't for this iteration) and I took the parts I wanted and left out of the rest. You can find the video here: https://www.youtube.com/watch?v=qsfIXopyYHY
So the resulting controller looks like this:
// From Input System
private Vector2 steeringInput; // 2D Axis reading
private float yawInput; // 1D Axis reading, a value between -1 and 1
private Vector2 liftInput; // 2D D-Pad reading
// Adjustable variables
private float pitchRotSpeed, yawRotSpeed, rollRotSpeed;
private float forwardSpeed, liftSpeed;
private void Update()
{
float deltaTime = Time.deltaTime * 1.2f;
// Pitch (X)
transform.rotation *= Quaternion.AngleAxis(steeringInput.y * pitchRotSpeed * deltaTime, Vector3.right);
// Yaw (Y)
transform.rotation *= Quaternion.AngleAxis(yawInput * yawRotSpeed * deltaTime, Vector3.up);
// Roll (Z)
transform.rotation *= Quaternion.AngleAxis(steeringInput.x * rollRotSpeed * deltaTime, Vector3.forward);
transform.position += transform.up * liftInput.y * liftSpeed * Time.deltaTime;
transform.position += transform.forward * forwardSpeed * Time.deltaTime;
}
Fairly simple little update loop which first updates the Rotation according to the input, adjusts height if needed and then pushes in that direction every frame. That looks like this:
This is already quite fun to play around with which is always a good sign! It takes a bit to get used to this type of 3D movement, however once you do (which only took me a couple of minutes) you really feel in control and like you could do anything. And we don't even have all the bits in yet that makes amazing! A good sign.
We have confirmed that the basic idea is quite fun. So let's build on this a bit more. My next iteration of this is only a simple change: I wanna give the player control over the forward speed of the controller. Currently it's moving forward at a constant rate. This is luckily a fairly simple operation as the Joystick I'm using already has a small throttle I can use to let the player decide how much or how they little they should accelerate:
// From Input System
private Vector2 steeringInput; // 2D Axis reading
private float yawInput; // 1D Axis reading, a value between -1 and 1
private Vector2 liftInput; // 2D D-Pad reading
private float accelerationInput; // 1D Axis reading, a value between -1 and 1 which I normalize to a 0-1 range.
// Adjustable variables
private float pitchRotSpeed, yawRotSpeed, rollRotSpeed;
private float forwardSpeed, liftSpeed;
private void Update()
{
float deltaTime = Time.deltaTime * 1.2f;
// Pitch (X)
transform.rotation *= Quaternion.AngleAxis(steeringInput.y * pitchRotSpeed * deltaTime, Vector3.right);
// Yaw (Y)
transform.rotation *= Quaternion.AngleAxis(yawInput * yawRotSpeed * deltaTime, Vector3.up);
// Roll (Z)
transform.rotation *= Quaternion.AngleAxis(steeringInput.x * rollRotSpeed * deltaTime, Vector3.forward);
transform.position += transform.up * liftInput.y * liftSpeed * Time.deltaTime;
transform.position += transform.forward * Mathf.Max(0f, accelerationInput * forwardSpeed * Time.deltaTime);
}
I do a "clamp" on the input so that it can't go into the negatives as I don't want the player to be able to use their thrusters to go in reverse, just in case. That might come later, but for now I don't want that behaviour so I cap it off. Either the value I calculate is less than 0f and thus gets floored to 0 instead or it's above 0 because the player has chosen to accelerate and all is good. That looks like this:
Having this type of control over the vehicle is already a lot more fun and it's such a simple change!
Now we have tested that rotation and movement is quite fun and feels good even before we have introduced any physics! So it's time to do that. Introduce physics. It'll be more involved because this is actually quite complicated. It turns out that doing things like hovering, while the physics equations are quite simplistic, achieving it in a videogame is a different story. Luckily I'm not alone in approaching this! I found this old series by Unity themselves introducing their audience to the Unity Engine by making Hovercars. How convenient! You can find the playlist here: https://www.youtube.com/playlist?list=PLX2vGYjWbI0SvPiKiMOcj_z9zCG7V9lkp
Now there is one problem. This is from 2018. A lot has changed since then, including Unity's editor and after I had to dig through Google to find a link to the files that were otherwise no longer available from that training session, I tried to start up that project in a modern Unity editor and....as expected it didn't work. Lots of broken textures and otherwise models and scripts throwing errors and warnings around. But not to fear because after having watched the videos I found a few code parts that I could use! The Unity people have made a little blackbox script called a PIDController. It's a complicated mathematical model that attempts to find a target value based on a current value. Okay, but what for? The value the PIDController finds is a force presented as a percentage! That makes it very useful for us, because calculating the opposing forces to have a vehicle stay hovering above ground as it moves around is no easy feat.
So what we do is pass in the desired value we want (height off the ground) and then how far we are currently away from the ground. The PIDController will then calculate the force needed to stay above ground at the desired height. Neat! Below is that script (You shouldn't mess with the values unless you know what you are doing):
[System.Serializable]
public class PIDController
{
//Our PID coefficients for tuning the controller
public float pCoeff = .8f;
public float iCoeff = .0002f;
public float dCoeff = .2f;
public float minimum = -1;
public float maximum = 1;
//Variables to store values between calculations
float integral;
float lastProportional;
//We pass in the value we want and the value we currently have, the code
//returns a number that moves us towards our goal
public float Seek(float seekValue, float currentValue)
{
float deltaTime = Time.fixedDeltaTime;
float proportional = seekValue - currentValue;
float derivative = (proportional - lastProportional) / deltaTime;
integral += proportional * deltaTime;
lastProportional = proportional;
//This is the actual PID formula. This gives us the value that is returned
float value = pCoeff * proportional + iCoeff * integral + dCoeff * derivative;
value = Mathf.Clamp(value, minimum, maximum);
return value;
}
}
And in our code we can use it like this to get the force as a percentage value to apply to our hover forces: float forcePercent = pidController.Seek(hoverDistance, height);
I also took the opportunity to be inspired by some of their controller code which made making this version a lot easier than initially thought!
With the above code in hand we can move on to making a 3rd version of our controller that makes use of physics to drive part of the interactions instead of being an incoporeal body that floats through space unaffected by something as flimsy as reality.
For this iteration we update the Root object of our Player controller with a Rigidbody
and then we change the following settings:
In a future iteration we don't want to freeze all rotation axes but for now we do to simplify this version and make sure it can't rotate in weird unpredictable ways that we didn't want it to. The colliders for this vehicle are not currently necessary however if you do want to add colliders, you can add them as a child object along with your hover cars model to separate logic and graphics more neatly.
First, we change our Update Loop slightly:
private void Update()
{
float deltaTime = Time.deltaTime * 1.2f;
// Pitch (X)
if (flightMode) transform.rotation *= Quaternion.AngleAxis(steeringInput.y * pitchFactor * pitchRotSpeed * deltaTime, Vector3.right);
// Yaw (Y)
transform.rotation *= Quaternion.AngleAxis(yawInput * yawFactor * yawRotSpeed * deltaTime, Vector3.up);
// Roll (Z)
if (flightMode) transform.rotation *= Quaternion.AngleAxis(steeringInput.x * rollFactor * rollRotSpeed * deltaTime, Vector3.forward);
forwardThrust = Mathf.Clamp(accelerationInput * forwardSpeed * Time.deltaTime, 0f, forwardSpeed);
liftThrust = liftInput.y * liftSpeed * Time.deltaTime;
}
With this we can control all movement and rotation forces about our vehicle in real-time and we now have a flightMode
boolean which only lets us update the rotation of the vehicle along the X and Z axis if we are flying because otherwise you'll be making barrel rolls on the asphalt which doesn't make much sense!
From here we move on to including FixedUpdate
for our Physics updates and the code here is a bit complicated but nothing too major now that we have Unity's PIDController
to help us out:
private void FixedUpdate()
{
rb.AddForce(transform.forward * forwardThrust, ForceMode.Force);
Vector3 groundNormal;
Ray ray;
RaycastHit hitInfo;
ray = new(transform.position, Vector3.down);
if (Physics.Raycast(ray, out hitInfo, int.MaxValue, groundLayer)) lastGroundPosition = hitInfo.point;
ray = new(transform.position, -transform.up);
isOnGround = Physics.Raycast(ray, out hitInfo, maxGroundDistance, groundLayer);
if (flightMode)
{
rb.AddForce(transform.up * liftThrust, ForceMode.Force);
}
else
{
if (isOnGround)
{
float height = hitInfo.distance;
groundNormal = hitInfo.normal.normalized;
float forcePercent = pidController.Seek(hoverDistance, height);
Vector3 force = groundNormal * hoverForce * forcePercent;
Vector3 gravity = -groundNormal * hoverGravity * height;
rb.AddForce(gravity, ForceMode.Acceleration);
rb.AddForce(force, ForceMode.Acceleration);
Quaternion lookRotation = Quaternion.LookRotation(transform.forward, groundNormal);
if (Quaternion.Dot(transform.rotation, lookRotation) < 0.9f)
{
transform.rotation = Quaternion.Slerp(transform.rotation, lookRotation, Time.deltaTime);
}
}
else
{
groundNormal = Vector3.up;
Vector3 gravity = -groundNormal * fallGravity;
rb.AddForce(gravity, ForceMode.Acceleration);
transform.rotation = Quaternion.LookRotation(transform.forward, groundNormal);
}
// Hacky fix to put player back on the ground when they go under.
if (transform.position.y < 0f)
{
float forcePercent = pidController.Seek(lastGroundPosition.y + hoverDistance, transform.position.y);
rb.AddForce(hoverForce * 4f * forcePercent * Vector3.up, ForceMode.Acceleration);
}
}
}
A lot is happening here so I'll try and take the parts that are not immediately obvious:
rb.AddForce(transform.forward * forwardThrust, ForceMode.Force);
Vector3 groundNormal;
Ray ray;
RaycastHit hitInfo;
ray = new(transform.position, Vector3.down);
if (Physics.Raycast(ray, out hitInfo, int.MaxValue, groundLayer)) lastGroundPosition = hitInfo.point;
This piece of code first sets up some variables that we are gonna be using in multiple places anyway, so might as well declare them ahead of time. Then it finds out what the lastGroundPosition
was and stores it. This will be used later in the FixedUpdate loop to determined how to orient the player when they switch back to drive mode from flying because if you are upside down and land on the track like that it's gonna be very disorienting and bad for gameplay. We'll get to that part later.
ray = new(transform.position, -transform.up);
isOnGround = Physics.Raycast(ray, out hitInfo, maxGroundDistance, groundLayer);
Then we find out whether the player is actually on the ground or not. maxGroundDistance
is a variable we can change in our inspector. The value represents how far off the ground the vehicle can be to be considered still "on the ground". This gives us a bit of wiggle room to allow for more dynamic feeling visuals later.
if (flightMode)
{
rb.AddForce(transform.up * liftThrust, ForceMode.Force);
}
Now if we are in the air we currently don't care about hovering forces at all. We only care about flying forward. If you let go of the thruster the vehicle will be stationary in mid-air as if gravity doesn't affect you. This could likely be changed later to give a better and more realistic feel to the vehicle but for now this will do.
if (isOnGround)
{
float height = hitInfo.distance;
groundNormal = hitInfo.normal.normalized;
float forcePercent = pidController.Seek(hoverDistance, height);
Vector3 force = groundNormal * hoverForce * forcePercent;
Vector3 gravity = -groundNormal * hoverGravity * height;
rb.AddForce(gravity, ForceMode.Acceleration);
rb.AddForce(force, ForceMode.Acceleration);
Quaternion lookRotation = Quaternion.LookRotation(transform.forward, groundNormal);
if (Quaternion.Dot(transform.rotation, lookRotation) < 0.9f)
{
transform.rotation = Quaternion.Slerp(transform.rotation, lookRotation, Time.deltaTime);
}
}
Here is one of the first interesting blocks of code in my opinion. If we are on the ground we wish to stay above the ground to give a sense of us "hovering" and to do that, we include the height of our previous isOnGround
raycast and get the groundNormal
so we know what way is considered "up" right now (the player could be hanging upside down for all we know). We then use the PIDController
to calculate the forces needed to stay at our desired height (hoverDistance
). We calculate the force
that will push us up and then we calculate the gravity
that will push us down. We add that as a form of acceleration to our Rigidbody
and then we calculate a rotation to stay level with the ground regardless of whether there are bumps or otherwise.
The code at the end to level you with the ground is not great. It needs to be updated in a later iteration as currently it's not good enough at handling the cases it needs to.
For the hoverGravity
value we use a value of 20f so that the car really sticks to the ground when it lands and handles. Makes for more dramatic sticking. When we fall to the ground we apply a fallGravity
of 80 so that we get to the ground quite fast, further playing into this feeling fast and snappy.
{
groundNormal = Vector3.up;
Vector3 gravity = -groundNormal * fallGravity;
rb.AddForce(gravity, ForceMode.Acceleration);
transform.rotation = Quaternion.LookRotation(transform.forward, groundNormal);
}
If you are not on the ground but still in driving mode (you might have just deactivated flying mode to drop to the ground), we simple add gravity to push you towards the Global Vector3.up
. In a later iteration this gravity modifier needs to be made such that you could "drop upwards" seeing as you might be closer to a track that is upside down or even sideways than one that is directly below you.
// Hacky fix to put player back on the ground when they go under.
if (transform.position.y < 0f)
{
float forcePercent = pidController.Seek(lastGroundPosition.y + hoverDistance, transform.position.y);
rb.AddForce(hoverForce * 4f * forcePercent * Vector3.up, ForceMode.Acceleration);
}
Lastly if we somehow fall under the world (which is defined as less than 0f in Global Space) we use the PIDController
to adjust upwards until we get to the last known ground position (this is where lastGroundPosition
comes in because you cannot raycast to a surface from the back of the surface and get a hit). It's a bit of a hacky fix as a real approach would likely be some kind of teleportation or blackscreen transition whereafter the player is back on track. But for now we have more of a toy example than a real game, so we are okay with this as a "fix".
And after all that, what do we get? A physics controller which can skate around on the ground, fly and return to the ground as well!
The sense of control you have where you need to actually utilise the gravity to your advantage to make sharp turns and whatnot feels quite good! And the ability to take flight whenever you want is also really nice. Gives you a sense of freedom that other racers don't.
Looking back at our checklist from earlier:
PIDController
implementing hovering was a matter of using that blackbox to get us a force percentage and apply gravity as an opposing force.Rigidbody
and uses Unity's physics engine to achieve forward momentum.So all in all fairly good run so far in just three iterations! There is a lot more to do especially in the "feel" and "vibe" departments but also just a lot more code is needed to make for a satisfying controller. With some assets I had lying around from Kenney I quickly setup a race track and made a prototype player controller so I could present the game at our local Dev ➤ Test ➤ Repeat event and get some much needed feedback on the idea. The toy example looked like this:
And at the event people were quite happy with the concept. They especially liked the transition from flight to ground and back. That you could do that whenever you wanted. At the event I had also set up a lap counter so I could see how fast people did a whole lap. My own best time was 45 seconds. The times from testers ranged from 01:09 to 06:02 (minutes:seconds) and quite a few of them had never played flight sims or racing games much before. So it was very nice to see that within a couple of minutes people actually could wrap their head around 3D movement despite how alien it is to our minds in general. We are so used to 2D movement that adding that extra dimension can be a bit of a head scratcher. It gave me the confidence to continue with the concept.
So here at the end what's next?
My next goal is to perfect the player controller so that it feels really good to use and then I can work on the rest of the facets after that like race tracks, ranking, multiplayer, story, etc. I have plans for this but I need to keep the scope small while I work this out. A friend has already helped me out a lot by taking a model I was working on and not only making it look better but also animating it! You can see the result below:
Look forward to the next posts in the series. I wish to try and document my journey with this one!