Introduction

This is part of a series of posts about my Bush 522 Game jam project. The root post is here. The version of the engine is Godot 4.5.

There are 3 main ways to implement flight mechanics for a game that I can think of:

  • Simplify all interacting forces and apply them at a single point.
  • Model the propeller, body and wing surfaces separately and apply forces locally.
  • A hybrid approach, where some forces are simplified, and others are more detailed.

In this post, I’ll mainly focus on the first approach, which is what I used for Bush 522. My goal is good gameplay, so I don’t care about realistic modelling, I only care about how it feels to fly. Towards the end of this post, I’ll go over some potential improvements of my simplified model, using a more hybrid approach.


Aircraft Scene Components and Structure

As per best practices, I make a scene named plane. In retrospect it would be better to name it something like aircraft to avoid naming conflicts with Godot’s built-in Plane class.

The complete structure looks like this:

Aircraft (RigidBody3D)
 ├─ Mesh
 │
 ├─ CollisionShape3D_1
 ├─ ... A bunch of collision shapes
 ├─ CollisionShape3D_N
 │
 ├─ FrontSuspensionFrameLeft (RigidBody3D)
 │   ├─ Mesh
 │   ├─ CollisionShape3D
 │   └─ HingeJoint3D
 ├─ FrontSuspensionFrameRight (RigidBody3D)
 │   └─ ... Same as left ...
 │
 ├─ FrontWheelLeft (RigidBody3D)
 │   ├─ Mesh
 │   ├─ CollisionShape3D
 │   └─ HingeJoint3D
 ├─ FrontWheelRight (RigidBody3D)
 │   └─ ... Same as left ...
 │
 ├─ Pivot Camera
 └─ Audio players

The core rigid body

I make the Aircraft’s weight 500kg and I change its center of mass to 0,0,0 to make calculations easier.

The suspension

The suspension’s purpose is to provide some cushion between the wheels and the aircraft. In the game, it’s a rigid body, containing a mesh, collision shape, and a hinge joint.

The collision shape is just a small sphere that looks nothing like the mesh. That’s because of where the suspension is located: between the wheels and the main body. This position makes it almost impossible to be hit, therefore there’s no point in spending time on making an accurate collision model.

The HingeJoint is more interesting. It builds the connection between the aircraft and the suspension. It’s positioned right between the two. It’s configured as follows:

  • Params -> Bias = 0.99
  • Angular Limit -> Enable = On
  • Angular Limit -> Upper = 5
  • Angular Limit -> Lower = -5
  • Angular Limit -> Bias = 0.5
  • Angular Limit -> Softness = 0.1
  • Angular Limit -> Relaxation = 0.5

I also tried using a SliderJoint, but it would jiggle around uncontrollably when maneuvering the aircraft.

The wheels

Each wheel is a rigid body attached to its suspension via a HingeJoint. With default settings, the angular limits are disabled, and the wheel spins freely. The collision shape is a very short cylinder.


Disabling Godot Default Damping

By default Godot applies physical damping to all rigid bodies. This means that it applies forces to reduce their movement, effectivelly acting like air resistance and friction for regular objects. Planes are no regular objects, they have a very specific behaviour. Besides gravity, we’ll be doing the complete physics modelling ourselves, so the default damping has to go. There are 2 ways to disable it.

Project level damping settings

The quick and easy way to disable damping is on project level. This is applicable in cases where the only rigid body in the game is the airplane.

To disable the damping for all rigid bodies in the project:

  1. Open Project -> Project Settings.
  2. Turn on the Advanced Settings at the top right.
  3. Find Physics -> 3D.
  4. Set Default Linear Damp to 0.
  5. Set Default Angular Damp to 0.

RigidBody damping override

A cleaner way to disable damping, that would allow for normal behaviour of other phyisical objects in the game, would be to disable damping for specific bodies. This setting is not inherited by child nodes, so it has to be applied to all of the rigid bodies that the plane has. For example, in Bush 522, the airplane has soft suspension and wheels, which are all rigid bodies. Overriding the default damping has to be done for all of them.

To disable the default damping for individual bodies, do this for the aircraft’s root rigid body and all rigid bodies that make it up:

  1. Select the rigid body node.
  2. Set RigidBody3D -> Linear -> Damp Mode to Replace.
  3. Make sure the Damp value is 0.0
  4. Set RigidBody3D -> Angular -> Damp Mode to Replace.
  5. Again, make sure the Damp value is 0.0
  6. Repeat this for all rigid body nodes that make up the aircraft.

This has the side effect of making the plane’s wheels spin infinitely, but it’s something that can be easily fixed by adding angular damp to each wheel’s rigid body.


Basic Controls

Before getting the aircraft up in the sky, it’s important to be able to control it and taxi it on the ground.

The Ctrl key is not yours (in the browser)

The Ctrl key is reserved for browser shortcuts and is not suitable for ingame inputs. I used it in the first builds of the game and I would often close the tab with the Ctrl + W shortcut.

Throttle and thrust

Unlike driving, where the car engine needs a responsive throttle for very frequent changes in acceleration, in flying the throttle rarely changes. Due to this, it’s better to have two buttons for shifting the throttle between 0% and 100%.

var throttle: float
const throttle_increment: float = 1.

func increase_throttle(delta: float) -> void:
	throttle = min(1., throttle + throttle_increment * delta)

func decrease_throttle(delta: float) -> void:
	throttle = max(0., throttle - throttle_increment * delta)

func _ready() -> void:
	throttle = 0.

func _process(delta: float) -> void:
	if Input.is_action_pressed("throttle_up"):
		increase_throttle(delta)
	if Input.is_action_pressed("throttle_down"):
		decrease_throttle(delta)

In real life, when in action, a single propeller plane’s rotor, besides forward force, would also produce a slight torque along the plane’s forward facing axis, slightly rolling the plane to the opposite site. The pilot would add aileron trim to combat this.

I think this gameplay would be too realistic and not much fun. The player would have to constantly make small corrections to the roll, or I’d have to add a trim tab.

Thankfully, it’s actually easier to just model the forward forces produced by the propeller.

const engine_power: float = 5000.

func apply_engine_force() -> void:
	apply_central_force(global_transform.basis.x * engine_power * throttle)

func _physics_process(delta: float) -> void:
	apply_engine_force()

Angular controls

In a realistic model, the aircraft would be controllable due to the way control surfaces interact with the air stream. Being very short on time, I didn’t implement this approach. Instead I just applied angular force based on user input. I tuned the values based on what felt nice and didn’t research any real aircraft’s manuever capabilities.

const pitch_force: float = 5000.
const roll_force: float = 10000.
const yaw_force: float = 5000.

func _physics_process(delta: float) -> void:
	if Input.is_action_pressed("pitch_up"):
		apply_torque(global_transform.basis.z * pitch_force)
	if Input.is_action_pressed("pitch_down"):
		apply_torque(global_transform.basis.z * -pitch_force)
	if Input.is_action_pressed("yaw_left"):
		apply_torque(global_transform.basis.y * yaw_force)
	if Input.is_action_pressed("yaw_right"):
		apply_torque(global_transform.basis.y * -yaw_force)
	if Input.is_action_pressed("roll_left"):
		apply_torque(global_transform.basis.x * -roll_force)
	if Input.is_action_pressed("roll_right"):
		apply_torque(global_transform.basis.x * roll_force)

Achieving Flight

Now that the basic controls are ready, it’s time to model the aircraft’s behaviour in the sky.

Lift

The lift is the upwards force generated by the plane’s wings when moving forward. In this game I model it as a simple linear function of the plane’s absolute speed in its local forward axis.

const wing_lift_coef: float = 50

func get_forward_speed() -> float:
	var vel: Vector3 = linear_velocity
	var local_x_speed = vel.dot(global_transform.basis.x)
	return local_x_speed

func get_lift_force() -> float:
	return abs(get_forward_speed()) * wing_lift_coef

func apply_lift() -> void:
	apply_central_force(global_transform.basis.y * (get_lift_force()))

func _physics_process(delta: float) -> void:
	apply_lift()

In real life, I wouldn’t expect most wings to generate lift flying backwards. Here, I don’t care. I want lift to be generated when flying in either direction. That’s more fun, and games should be fun!

To get the wing_lift_coef, I just experiment with different values and see what feels good in the game.

Now the plane will take off going forward… but any maneuver feels super weird. That’s because besides lift, the wing should also produce drag based on the incoming wind direction. It can also reach crazy speeds, as there’s no drag to counteract the forward propulsion.

Forward drag

To limit the aircraft’s forward speed, we can introduce drag. At some speed the drag should be equal to the propultion force: this is the maximum forward speed of the aircraft.

In my opinion it is best to use an exponential function as this would make the speed more fun to balance for the player. At low speeds there would be pretty much no drag, so this would allow for gliding in emergencies. A pretty high speed could be reached with medium throttle, allowing for good cruising mechanics. High throttle would only make sense when climbing.

func apply_forward_drag() -> void:
	var fwd_speed = get_forward_speed()
	var direction_multiplier = -1. if fwd_speed > 0 else 1.
	var drag = direction_multiplier * ((fwd_speed/1.5) ** 2)
	var drag_vector = global_transform.basis.x * drag
	apply_force(drag_vector, x_forces_application_point.position)

Angle of attack drag

Relative to the wing, the angle of attack (short AoA) is the angle between the wing itself and the incoming air stream. In the simplified model for this game, the AoA is the angle between the aircraft and the incoming airstream.

As you can imagine, the closer a wing is to being perpendicular to the airflow, the more surface area will be exposed to the wind, producing more drag.

To make calculations easier, the angle of attack can be measured separately on the xy and xz axes, one representing the wings and horizontal stabilizers, the other representing the vertical stabilizer.

A simple sin function can be used to determine the direction and magnitude of the induced drag. A drag coeficient can be used to tune how it feels in the game.

const wing_drag_coef: float = 25.
const vert_stabilizer_drag_coef = 20.

func get_wing_drag() -> float:
	var vel: Vector3 = linear_velocity
	var local_x_speed = vel.dot(global_transform.basis.x)
	var local_y_speed = vel.dot(global_transform.basis.y)
	var xy_velocity = Vector2(local_x_speed, local_y_speed)
	return -sin(xy_velocity.angle()) * wing_drag_coef * xy_velocity.length_squared()

func apply_lift() -> void:
	apply_central_force(global_transform.basis.y * (get_lift_force() + get_wing_drag()))

func get_vertical_stabilizer_drag() -> float:
	var vel: Vector3 = linear_velocity
	var local_x_speed = vel.dot(global_transform.basis.x)
	var local_z_speed = vel.dot(global_transform.basis.z)
	var xz_velocity = Vector2(local_x_speed, local_z_speed)
	return -sin(xz_velocity.angle()) * vert_stabilizer_drag_coef * xz_velocity.length_squared()

func apply_vertical_stabilizer_force() -> void:
	apply_central_force(global_transform.basis.z * get_vertical_stabilizer_drag())

func _physics_process(delta: float) -> void:
    apply_lift()
	apply_vertical_stabilizer_force()

Now the aircraft should feel good to control.

Potential improvements

This model was perfect for the 2-week game jam. It allowed me to quickly get past the flight mechanics and focus on more important gameplay elements like the procedural terrain and objectives.

However, there are key improvements that can be made.

The following are ideas that have a lot of potential to make the gameplay feel more consistent and the development easier, but I haven’t implemented yet.

Wing surfaces

Instead of thinking about the physical forces acting upon the aircraft as a whole, we can go one level deeper and think about the forces acting on each individual wing surface. This would localize the calculation of drag and lift for each individual wing.

Using a composition of abstract wing surfaces to build the aircraft would actually simplify the code, as the wing surface can be moved into a separate scene, with its own calculation script.

Control surface joints

Going one step further, wing surfaces can be attached to joints, which can move depending on player input. This would effectively create realistic control surfaces.

This would also solve the problem of the aircraft being able to maneuver at any speed. Stalls could become much more dangerous and would require actual piloting skills to get out of.