Build a Flight Simulator in Godot

Simulate aerodynamic flight physics and write an autopilot in Godot.

Introduction

30 Second Summary

Every commercial aircraft uses computer systems that translate sensor data into precise movements of physical control surfaces. These autopilot systems can fly a plane toward a destination without any human input.

In this project, you will build a programmable flight simulator in Godot where each aircraft control is driven by code. You will start with manual keyboard flight, then write an autopilot script that flies the plane toward a target on its own.

What You'll Build

A Cessna-style aircraft with realistic aerodynamic physics where each control surface (ailerons, elevator, rudder) is individually scriptable via GDScript.

By the end of this project, you'll have:

  • A flyable aircraft scene with realistic wing physics including lift, drag, and stall behavior.
  • A programmable control system where each control surface is individually addressable via GDScript commands.
  • A custom autopilot using proportional control logic to steer the aircraft toward a target, with a real-time flight HUD showing airspeed, altitude, and control surface deflections.
  • Secret Mission: Add a waypoint circuit with multiple markers and make your autopilot fly the entire route autonomously.

Are there any prerequisites?

Basic familiarity with the Godot editor (adding nodes, navigating the scene tree) is helpful. No prior flight simulation or physics experience is needed.

Install Godot and the Aerodynamic Physics Plugin

Before you can build a flight simulator, you need the game engine and the physics plugin that will handle realistic aerodynamic forces.

In this step, you will install Godot 4.6.3 and add the Godot Aerodynamic Physics plugin. This plugin provides specialized nodes that simulate lift, drag, and stall without you needing to write the underlying physics math.

In this step, get ready to:
  • Download and install Godot 4.6.3 and create a new project.
  • Install and enable the Godot Aerodynamic Physics plugin.
  • Verify the plugin is working by checking for new node types.
Download and install Godot 4.6.3

Godot is a free, open-source game engine. Unlike many engines, it is fully self-contained. You download a single executable and run it directly.

macOS

  • Open the downloaded .dmg file.
  • Drag the Godot application into your Applications folder.
  • Open Godot by pressing Cmd+Space, typing Godot, and pressing Enter.

Windows

  • Extract the downloaded .zip file to a folder on your Desktop.
  • Double-click Godot_v4.6.3-stable_win64.exe inside the extracted folder to launch Godot.

Linux

  • Extract the downloaded archive to a folder on your Desktop.
  • Godot is self-contained. Run the binary directly from the extracted folder.

No installation required on Linux

Godot does not require a system-wide installation on Linux. You simply extract the archive and run the binary. You can place it anywhere that is convenient.

When Godot opens, you will see the Project Manager window.

  • Click the Create button in the top-left of the Project Manager.
  • Enter FlightSim as the project name.
  • Enable the Create Folder option so Godot creates a subfolder with that name automatically.
  • Select a renderer. The default is fine for this project.
  • Click Create to create the project and open it in the editor.

What is the Project Manager?

The Project Manager is the first window you see when you launch Godot. It lets you create, import, and manage your game projects. Once you click Create, Godot generates a project folder and opens the full editor.

Godot won't launch?

Make sure your system meets the minimum requirement of OpenGL 3.3 or Vulkan 1.0 compatible hardware. On macOS, you may also need to allow the app in System Settings > Privacy & Security if you see a security prompt.

Help me troubleshoot why Godot won't open

Install the Aerodynamic Physics plugin from the Asset Library

The Godot Aerodynamic Physics plugin provides specialized nodes like AeroBody3D and ManualAeroSurface3D that calculate lift and drag forces automatically. You will install it directly from Godot's built-in asset library.

  • In the Godot editor, click the AssetLib tab at the top center of the editor (next to 2D, 3D, and Script).
  • In the search bar, type Godot Aerodynamic Physics and press Enter.
  • Click on the Godot Aerodynamic Physics result to open its details page.
  • Click Download to begin downloading the plugin.
  • When the Package Installer window appears, confirm that the files install to the addons/ folder.
  • Click Install to confirm and complete the installation.

What is the AssetLib?

The AssetLib is Godot's built-in library of free, open-source add-ons and tools. It fetches assets compatible with your current Godot version, so you only see plugins that work with 4.6.3.

Now you need to enable the plugin so Godot registers its custom node types.

  • Click Project in the top menu bar.
  • Click Project Settings.
  • In the Project Settings window, click the Plugins tab.
  • Find Godot Aerodynamic Physics in the list and check the Enable checkbox.
  • Close the Project Settings window.

No restart needed

Godot activates plugins immediately when you check the Enable box. You do not need to restart the editor.

Plugin not showing in the list?

Make sure the plugin files landed inside addons/ in your project folder. Check the FileSystem dock in the bottom-left of the editor. You should see an addons folder containing the plugin files. If the folder is missing, re-install from the AssetLib and ensure you accept the default install path.

Help me fix my Godot Aerodynamic Physics plugin installation

Verify the plugin is working

The quickest way to confirm the plugin installed correctly is to check for its custom node types in the scene tree.

  • In the Scene dock (top-left of the editor), click the + button to open the Create New Node dialog. You can also press Ctrl+A (Windows/Linux) or Cmd+A (macOS).
  • In the search field, type AeroBody3D.

You should see AeroBody3D appear in the node list. This confirms the plugin is correctly installed and enabled.

  • Click Cancel to close the dialog without adding anything yet.

Your engine and physics plugin are ready. Next up, you will build the aircraft scene with wings, a tail, and a thruster using the plugin's custom nodes.

Build the Aircraft Scene

Your Godot project is ready and the Aerodynamic Physics plugin is enabled. Now it's time to assemble the aircraft itself.

Every real airplane has a fuselage, wings, a tail, and an engine. In this step, you'll recreate that structure using physics nodes that the plugin provides. Each node represents a physical component that generates aerodynamic forces during flight.

In this step, get ready to:
  • Create the aircraft physics body with a collision shape and visual placeholders.
  • Add four control surfaces (wings, elevator, rudder) with aerodynamic properties.
  • Add a thruster and verify the aerodynamic balance using debug visualization.
Create the aircraft body and fuselage

The AeroBody3D node is the foundation of your aircraft. It extends RigidBody3D with aerodynamic calculations, meaning it responds to lift, drag, and gravity automatically.

  • In the Godot editor, create a new scene by clicking Scene in the top menu bar, then New Scene.
  • Click Other Node in the scene creation dialog.
  • Search for AeroBody3D and select it to create the root node.
  • In the Scene dock, double-click the node name and rename it to Aircraft.
  • In the Inspector panel on the right, find the Mass property under RigidBody3D and set it to 750 (kg).

Why 750 kg?

This matches the weight of a Cessna-172, a common single-engine training aircraft. Using a realistic mass ensures the aerodynamic forces produce believable flight behavior.

If the mass is too low, the simulation can lose stability and produce extreme oscillations.

  • Right-click the Aircraft node in the Scene dock and select Add Child Node.
  • Search for CollisionShape3D and add it.
  • With the CollisionShape3D selected, find the Shape property in the Inspector and click the dropdown.
  • Select New CapsuleShape3D.
  • Click on the capsule shape resource to expand its settings.
  • Set Height to 4.0 and Radius to 0.5.

Why a capsule shape?

The collision shape represents the physical boundary of the fuselage for physics calculations. A capsule is a good approximation of an airplane body and covers the area where the wings attach.

Now add visual placeholders so you can see the aircraft shape in the viewport.

  • Right-click the Aircraft node and select Add Child Node.
  • Search for CSGBox3D and add it.
  • Rename the node to Fuselage.
  • In the Inspector, set the Size to x: 0.6, y: 0.6, z: 5.0 to create a long fuselage shape.
  • Add another CSGBox3D child to Aircraft.
  • Rename it to Wings.
  • Set the Wings size to x: 11.0, y: 0.1, z: 1.5 to create a wide flat wing shape.
  • Add one more CSGBox3D child to Aircraft.
  • Rename it to Tail.
  • Set the Tail size to x: 3.0, y: 0.1, z: 1.0.
  • Set the Tail position to z: 4.0 to place it at the rear of the fuselage.
  • Save the scene as Aircraft.tscn by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Your Scene dock should now show the Aircraft node with CollisionShape3D, Fuselage, Wings, and Tail as children.

Don't see the CSGBox3D shapes in the viewport?

Make sure you have the 3D viewport active (click 3D at the top of the editor). If the shapes appear very small, press F with the Aircraft node selected to focus the viewport on it.

Help me debug my CSGBox3D visibility issue.

Add the control surfaces

Real aircraft use control surfaces to maneuver. Ailerons on the wings control roll, the elevator on the tail controls pitch, and the rudder controls yaw.

In the plugin, each control surface is a ManualAeroSurface3D node. Its position relative to the aircraft's center of mass determines how it affects rotation.

  • Right-click the Aircraft node and select Add Child Node.
  • Search for ManualAeroSurface3D and add it.
  • Rename it to WingRight.
  • In the Inspector, set its Position under Transform to x: 2.5, y: 0, z: 0.

Now configure the wing's aerodynamic properties.

  • With WingRight selected, find the ManualAeroSurfaceConfig property in the Inspector.
  • Click the dropdown and select New ManualAeroSurfaceConfig to create the resource.
  • Expand the config and set span to 5.5.
  • Set chord to 1.5.

What are span and chord?

Span is the length of the wing from root to tip (in meters). Chord is the width of the wing from leading edge to trailing edge. Together they define the wing area, which directly affects how much lift the surface generates.

Each wing here has a span of 5.5m. Combined (left + right), that gives a total wingspan of 11.0m, matching a Cessna-172.

Repeat the process for the remaining three surfaces.

  • Add another ManualAeroSurface3D child to Aircraft.
  • Rename it to WingLeft.
  • Set its position to x: -2.5, y: 0, z: 0.
  • Create a New ManualAeroSurfaceConfig and set span to 5.5 and chord to 1.5.
  • Add another ManualAeroSurface3D child to Aircraft.
  • Rename it to Elevator.
  • Set its position to x: 0, y: 0, z: 4.0.
  • Create a New ManualAeroSurfaceConfig and set span to 2.0 and chord to 1.0.
  • Add the final ManualAeroSurface3D child to Aircraft.
  • Rename it to Rudder.
  • Set its position to x: 0, y: 0, z: 4.0.
  • Under Transform, set the Rotation Z value to 90 degrees to orient it vertically.
  • Create a New ManualAeroSurfaceConfig and set span to 2.0 and chord to 1.0.

Why rotate the rudder 90 degrees?

The rudder is a vertical surface on the tail that controls yaw (left/right turning). By default, ManualAeroSurface3D nodes are oriented horizontally like a wing. Rotating it 90 degrees on the Z-axis makes it stand upright so it generates lateral force instead of vertical lift.

  • Save the scene with Cmd+S (macOS) or Ctrl+S (Windows).
Add the thruster and verify aerodynamic balance

Your aircraft has wings and control surfaces, but no engine. The AeroThruster3D node provides forward thrust in its local -Z direction.

  • Right-click the Aircraft node and select Add Child Node.
  • Search for AeroThruster3D and add it.
  • Rename it to Thruster.
  • Set its Position to x: 0, y: 0, z: -2.0 to place it at the front of the aircraft.
  • In the Inspector, set max_thrust_force to 2.5 (kilo-newtons, which equals 2500N of thrust).

What does max_thrust_force mean?

The plugin measures thrust in kilo-newtons. A value of 2.5 means 2,500 newtons of maximum forward force. This is appropriate for a light single-engine aircraft.

The default value is 10 kN, which is far too much for a Cessna-sized plane. Using 2.5 keeps the flight behavior realistic.

Finally, enable the debug visualization to verify that your aircraft is aerodynamically balanced.

  • Click the Aircraft root node in the Scene dock to select it.
  • In the Inspector, find the show_debug property and enable it (check the box).
  • Look at the 3D viewport. You should see a yellow checker diamond (center of mass) and a light-blue checker line (center of lift).
  • Verify that the center of lift (blue line) is positioned slightly behind and slightly above the center of mass (yellow diamond).

Why does the center of lift need to be behind the center of mass?

This arrangement creates natural stability. If the nose pitches up, the rearward center of lift pushes the tail up and corrects the pitch. If the center of lift were in front, any disturbance would amplify instead of self-correcting.

  • Save the scene one more time with Cmd+S (macOS) or Ctrl+S (Windows).

Can't see the debug markers?

Make sure show_debug is enabled on the Aircraft (AeroBody3D) node specifically, not on a child node. The debug visualization only appears when you have at least one ManualAeroSurface3D child with a valid config.

If the blue line appears in front of the yellow diamond, try moving your wing surfaces slightly backward (increase their Z position by 0.2-0.5) until the balance looks correct.

Help me debug my AeroBody3D debug visualization.

Your aircraft structure is complete. Next up, you'll write a GDScript controller that maps keyboard inputs to each of these control surfaces, making the plane actually flyable.

Create the Programmable Control Script

Your aircraft scene is built and ready for flight. The wings, elevator, rudder, and thruster are all in place. Now you need a brain to drive them.

In this step, you will write a GDScript controller that translates simple command values (-1 to +1) into physical control surface deflections. This is the foundation that makes your aircraft programmable.

In this step, get ready to:
  • Create the controller script with exported command variables.
  • Write functions that translate commands into control surface rotations.
  • Map keyboard inputs and verify the plane flies.
Create the script and define exported control variables

Every control input to your aircraft will flow through a single script attached to the AeroBody3D root node. This script needs the @tool annotation so the plugin's debug vectors render in the editor, and it must call super(delta) in _physics_process to let the aerodynamic plugin run its calculations.

  • In the Scene dock, right-click on the Aircraft (AeroBody3D) node.
  • Select Attach Script.
  • In the Attach Node Script dialog, change the Template field to Object: Empty.
  • Set the Path to res://aircraft_controller.gd.
  • Click Create.
  • The Script Editor opens with an empty file. Replace its contents with this code that defines the script header and exported control variables:
@tool
extends AeroBody3D

# Control commands (-1.0 to 1.0, throttle 0.0 to 1.0)
@export_range(-1.0, 1.0) var pitch_command: float = 0.0
@export_range(-1.0, 1.0) var roll_command: float = 0.0
@export_range(-1.0, 1.0) var yaw_command: float = 0.0
@export_range(0.0, 1.0) var throttle_command: float = 0.0

# Control surface deflection limit in radians
@export var max_surface_deflection: float = deg_to_rad(25.0)

What does this code do?

The @tool annotation makes the script run in the editor so the plugin's debug lines stay visible. The extends AeroBody3D line inherits all the aerodynamic physics the plugin provides.

Each @export_range variable represents one flight control axis. External scripts (like an autopilot) can set these values to steer the plane without touching individual surfaces directly. The max_surface_deflection caps how far any surface rotates (25 degrees).

  • Add the node references and main physics loop by pasting this code at the bottom of the file:
# Node references
@onready var wing_right: ManualAeroSurface3D = $WingRight
@onready var wing_left: ManualAeroSurface3D = $WingLeft
@onready var elevator: ManualAeroSurface3D = $Elevator
@onready var rudder: ManualAeroSurface3D = $Rudder
@onready var thruster: AeroThruster3D = $Thruster

var autopilot_active: bool = false

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

	if not autopilot_active:
		_read_inputs()

	_apply_controls()

What does this code do?

The @onready variables grab references to each control surface and the thruster when the node enters the scene tree. This avoids repeated get_node() calls every frame.

In _physics_process, the super(delta) call is critical. It tells the plugin to calculate lift, drag, and all aerodynamic forces before your code runs. The autopilot_active flag lets you skip manual input reading when an autopilot takes over later.

  • Save aircraft_controller.gd with Ctrl+S (Windows) or Cmd+S (macOS).
  • In the Scene dock, click on the Aircraft node. You should see the exported variables (Pitch Command, Roll Command, etc.) appear in the Inspector panel.

Not seeing the exported variables in the Inspector?

Make sure you saved the script file. Godot reloads tool scripts on save. If the variables still don't appear, check that the first line says @tool and the second line says extends AeroBody3D.

Help me debug why my exported variables aren't showing.

Write the control surface and throttle functions

The command variables hold values from -1 to +1. Now you need functions that translate those values into actual rotation angles on each control surface.

  • Add the _apply_controls() function at the bottom of aircraft_controller.gd:
func _apply_controls() -> void:
	# Elevator: pitch control
	elevator.rotation.x = pitch_command * max_surface_deflection

	# Ailerons: differential roll control
	wing_right.rotation.x = roll_command * max_surface_deflection
	wing_left.rotation.x = -roll_command * max_surface_deflection

	# Rudder: yaw control
	rudder.rotation.x = yaw_command * max_surface_deflection

	# Throttle
	thruster.throttle = throttle_command

Why are the ailerons differential?

Real aircraft roll by deflecting ailerons in opposite directions. When the right aileron tilts up, it reduces lift on that wing. The left aileron tilts down, increasing lift. This imbalance creates the rolling moment that banks the plane.

That is why wing_left uses a negated roll_command. If roll is +1 (roll right), the right wing goes up and the left wing goes down.

  • Add the set_control() public API function below _apply_controls():
# Public API for autopilot or external scripts
func set_control(pitch: float, roll: float, yaw: float, throttle: float) -> void:
	pitch_command = clamp(pitch, -1.0, 1.0)
	roll_command = clamp(roll, -1.0, 1.0)
	yaw_command = clamp(yaw, -1.0, 1.0)
	throttle_command = clamp(throttle, 0.0, 1.0)

What is set_control() for?

This function is the public interface other scripts will use to fly the plane programmatically. An autopilot, an AI agent, or a test harness can call aircraft.set_control(0.5, -0.2, 0.0, 1.0) to command a climb with a slight left bank at full throttle. The clamp() calls ensure no value exceeds the valid range regardless of what the caller passes.

  • Save aircraft_controller.gd.

Seeing a parser error?

If Godot flags an error on the _read_inputs() call inside _physics_process, that is expected. You have not written that function yet. The error will clear once you add _read_inputs() in the next substep.

Help me fix this parser error in my script.

Add keyboard inputs and test flight

Before your script can read keyboard presses, you need to register named input actions in Godot's Input Map. These map physical keys to logical names your code references.

  • Click Project in the top menu bar.
  • Select Project Settings.
  • Click the Input Map tab at the top of the dialog.

You need to create nine actions. For each action, type the name in the Add New Action field at the top and press Enter (or click Add). Then click the + button next to the action, press the key on your keyboard, and click OK.

  • Create action pitch_down and bind it to the W key.
  • Create action pitch_up and bind it to the S key.
  • Create action roll_left and bind it to the A key.
  • Create action roll_right and bind it to the D key.
  • Create action yaw_left and bind it to the Q key.
  • Create action yaw_right and bind it to the E key.
  • Create action throttle_up and bind it to Shift.
  • Create action throttle_down and bind it to Ctrl.
  • Create action toggle_autopilot and bind it to the T key.
  • Close Project Settings.

What is the Input Map?

The Input Map decouples physical keys from your game logic. Your script references action names like pitch_up instead of specific keycodes. This means you can rebind controls later (or add gamepad support) without touching any GDScript.

  • Add the _read_inputs() function above _apply_controls() in aircraft_controller.gd:
func _read_inputs() -> void:
	# Pitch: W (nose down) / S (nose up)
	pitch_command = Input.get_axis("pitch_down", "pitch_up")

	# Roll: A (left) / D (right)
	roll_command = Input.get_axis("roll_left", "roll_right")

	# Yaw: Q (left) / E (right)
	yaw_command = Input.get_axis("yaw_left", "yaw_right")

	# Throttle: Shift (increase) / Ctrl (decrease)
	if Input.is_action_pressed("throttle_up"):
		throttle_command = clamp(throttle_command + 0.01, 0.0, 1.0)
	if Input.is_action_pressed("throttle_down"):
		throttle_command = clamp(throttle_command - 0.01, 0.0, 1.0)

	# Toggle autopilot
	if Input.is_action_just_pressed("toggle_autopilot"):
		autopilot_active = !autopilot_active

What does this code do?

The Input.get_axis() function returns a value from -1.0 to 1.0 based on which of the two actions is pressed. If neither is pressed, it returns 0. This gives smooth transitions for pitch, roll, and yaw.

Throttle works differently. It increments or decrements by 0.01 each physics frame while held, simulating a physical throttle lever that stays where you leave it.

  • Save aircraft_controller.gd.

Before you can test flight, you need a ground plane and a camera. Create a quick test scene to run the project in.

  • Click Scene in the top menu, then New Scene.
  • Select 3D Scene as the root type (this creates a Node3D root).
  • Right-click the Node3D root, select Instantiate Child Scene, and pick your Aircraft.tscn.
  • Right-click the root again, select Add Child Node, and add a StaticBody3D.
  • Add a CollisionShape3D child to the StaticBody3D and set its shape to a BoxShape3D with size 1000 x 1 x 1000.
  • Move the StaticBody3D down to y: -0.5 so the ground surface sits at y=0.
  • Add a Camera3D node to the root and position it behind and above the aircraft (try z: 10, y: 3) with it looking toward the origin.
  • Save the scene as main.tscn.
  • Press F5 to run the project. When prompted, select main.tscn as the main scene.
  • Hold Shift to increase throttle, then press W to pitch the nose down (gain speed) and S to pitch up for takeoff.
  • Test A/D for roll and Q/E for yaw once airborne.

You should see the aircraft accelerate along the ground and lift off once it reaches sufficient speed. All four control axes respond to your keyboard inputs.

Aircraft won't take off?

If the plane slides but never lifts, check that your WingRight and WingLeft nodes have their AeroSurfaceConfig with span 5.5 and chord 1.5 from the previous step. Without enough wing area, lift never overcomes weight.

Also verify that the AeroBody3D mass is 750 (kg). A heavier mass needs more speed before takeoff.

Help me debug why my aircraft won't take off.

✔️ Awesome, I've got everything!

Great. Double check you have saved aircraft_controller.gd and main.tscn.

ⓧ I'd like to double check the full code

Here is the complete aircraft_controller.gd for reference:

@tool
extends AeroBody3D

# Control commands (-1.0 to 1.0, throttle 0.0 to 1.0)
@export_range(-1.0, 1.0) var pitch_command: float = 0.0
@export_range(-1.0, 1.0) var roll_command: float = 0.0
@export_range(-1.0, 1.0) var yaw_command: float = 0.0
@export_range(0.0, 1.0) var throttle_command: float = 0.0

# Control surface deflection limit in radians
@export var max_surface_deflection: float = deg_to_rad(25.0)

# Node references
@onready var wing_right: ManualAeroSurface3D = $WingRight
@onready var wing_left: ManualAeroSurface3D = $WingLeft
@onready var elevator: ManualAeroSurface3D = $Elevator
@onready var rudder: ManualAeroSurface3D = $Rudder
@onready var thruster: AeroThruster3D = $Thruster

var autopilot_active: bool = false

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

	if not autopilot_active:
		_read_inputs()

	_apply_controls()


func _read_inputs() -> void:
	# Pitch: W (nose down) / S (nose up)
	pitch_command = Input.get_axis("pitch_down", "pitch_up")

	# Roll: A (left) / D (right)
	roll_command = Input.get_axis("roll_left", "roll_right")

	# Yaw: Q (left) / E (right)
	yaw_command = Input.get_axis("yaw_left", "yaw_right")

	# Throttle: Shift (increase) / Ctrl (decrease)
	if Input.is_action_pressed("throttle_up"):
		throttle_command = clamp(throttle_command + 0.01, 0.0, 1.0)
	if Input.is_action_pressed("throttle_down"):
		throttle_command = clamp(throttle_command - 0.01, 0.0, 1.0)

	# Toggle autopilot
	if Input.is_action_just_pressed("toggle_autopilot"):
		autopilot_active = !autopilot_active


func _apply_controls() -> void:
	# Elevator: pitch control
	elevator.rotation.x = pitch_command * max_surface_deflection

	# Ailerons: differential roll control
	wing_right.rotation.x = roll_command * max_surface_deflection
	wing_left.rotation.x = -roll_command * max_surface_deflection

	# Rudder: yaw control
	rudder.rotation.x = yaw_command * max_surface_deflection

	# Throttle
	thruster.throttle = throttle_command


# Public API for autopilot or external scripts
func set_control(pitch: float, roll: float, yaw: float, throttle: float) -> void:
	pitch_command = clamp(pitch, -1.0, 1.0)
	roll_command = clamp(roll, -1.0, 1.0)
	yaw_command = clamp(yaw, -1.0, 1.0)
	throttle_command = clamp(throttle, 0.0, 1.0)

Your aircraft now has a fully programmable control layer. Every surface responds to simple float commands, and the keyboard mapping proves it works. Next up, you will build a HUD so you can see exactly what is happening in real time.

Build the Flight HUD

Your aircraft is now flyable with full keyboard controls. The problem is you have no idea what's happening. How fast are you going? What altitude are you at? Are you about to stall?

In this step, you'll build a heads-up display (HUD) that shows real-time flight telemetry. The HUD reads directly from the aircraft's physics state every frame, giving you live feedback on airspeed, altitude, angle of attack, and control surface positions.

In this step, get ready to:
  • Create a HUD scene with labels for airspeed, altitude, angle of attack, and throttle.
  • Add control surface deflection readouts and an artificial horizon.
  • Write a script that pulls live data from the aircraft every frame.
Create the HUD scene with telemetry labels

A CanvasLayer renders 2D UI elements on top of the 3D viewport. This is where your flight instruments will live.

  • In the Godot editor, create a new scene by clicking Scene > New Scene in the top menu.
  • Click Other Node and search for CanvasLayer. Select it and click Create.
  • Right-click the CanvasLayer and select Add Child Node. Search for Control and add it.
  • Select the Control node. In the Inspector, set its Layout to Full Rect so it spans the entire screen.
  • Right-click the Control node and add a Label child node. Rename it to AirspeedLabel.
  • Position AirspeedLabel in the top-left corner (e.g., position x: 20, y: 20).
  • Add three more Label children to the Control node. Rename them to AltitudeLabel, AOALabel, and ThrottleLabel.
  • Stack these labels vertically below AirspeedLabel with about 25 pixels of spacing between each.

What is a CanvasLayer?

A CanvasLayer draws its children on a separate 2D rendering layer. This means your HUD stays fixed on screen regardless of how the 3D camera moves.

The Control node acts as a container for all UI elements. Setting it to Full Rect ensures it fills the viewport at any resolution.

Add deflection readouts and an artificial horizon

Pilots need to see both where the plane is (telemetry) and what the controls are doing (deflections). A second column of labels shows how much each control surface is deflected.

  • Add four more Label children to the Control node. Rename them PitchLabel, RollLabel, YawLabel, and ModeLabel.
  • Position these labels in a second column to the right of your telemetry labels (e.g., position x: 200, starting at y: 20).
  • Add a ColorRect child to the Control node. Rename it to HorizonRect.
  • Set HorizonRect's size to about 100x10 pixels. Position it in the center of the screen.
  • Set its color to a bright green or cyan so it stands out against the sky.

What is the artificial horizon?

In real cockpits, the attitude indicator (artificial horizon) shows the aircraft's roll and pitch relative to the ground. Your simplified version is a ColorRect that rotates based on the aircraft's roll angle. When the plane banks left, the bar tilts right, giving you an immediate visual cue.

  • Save the scene as hud.tscn in your project folder.

Your scene tree should now look like this: CanvasLayer > Control > AirspeedLabel, AltitudeLabel, AOALabel, ThrottleLabel, PitchLabel, RollLabel, YawLabel, ModeLabel, HorizonRect.

Labels not appearing in the scene?

Make sure all Label and ColorRect nodes are children of the Control node, not direct children of the CanvasLayer. The script expects paths like $Control/AirspeedLabel.

Also confirm each node is named exactly as shown (case-sensitive). Double-click a node in the scene tree to rename it.

Help me troubleshoot my HUD scene tree in Godot.

Write the HUD script

The HUD needs a script that reads the aircraft's physics state every frame and updates the labels. It also computes angle of attack by transforming velocity into local space and measuring the angle between airflow direction and the aircraft's nose.

  • Select the CanvasLayer root node in your hud.tscn scene.
  • In the Inspector, click the Attach Script button (scroll icon) to create a new script.
  • Name the script hud.gd and click Create.
  • Replace the default script content with the following class declaration, exports, and node references:
extends CanvasLayer

@export var aircraft_path: NodePath

# References to HUD label nodes
@onready var airspeed_label: Label = $Control/AirspeedLabel
@onready var altitude_label: Label = $Control/AltitudeLabel
@onready var aoa_label: Label = $Control/AOALabel
@onready var throttle_label: Label = $Control/ThrottleLabel
@onready var pitch_label: Label = $Control/PitchLabel
@onready var roll_label: Label = $Control/RollLabel
@onready var yaw_label: Label = $Control/YawLabel
@onready var mode_label: Label = $Control/ModeLabel
@onready var horizon_rect: ColorRect = $Control/HorizonRect

var aircraft: Node

func _ready() -> void:
	aircraft = get_node(aircraft_path)

What does this code do?

  • @export var aircraft_path: NodePath creates an editable field in the Inspector where you'll point the HUD at the aircraft node.
  • The @onready variables grab references to each label the moment the scene enters the tree. The paths match the scene hierarchy you built ($Control/AirspeedLabel etc.).
  • _ready() resolves the aircraft_path into an actual node reference stored in aircraft. This avoids resolving the path every frame.
  • Add the _process function below _ready() to update all HUD elements every frame:
func _process(_delta: float) -> void:
	if aircraft == null:
		return

	# Telemetry: speed from velocity magnitude
	var airspeed: float = aircraft.linear_velocity.length()
	airspeed_label.text = "SPD: %.1f m/s" % airspeed

	# Altitude from global Y position
	altitude_label.text = "ALT: %.1f m" % aircraft.global_position.y

	# Angle of attack: transform velocity to local space
	var local_velocity: Vector3 = aircraft.global_transform.basis.inverse() * aircraft.linear_velocity
	var aoa: float = rad_to_deg(atan2(local_velocity.y, -local_velocity.z))
	aoa_label.text = "AOA: %.1f\u00b0" % aoa

	# Throttle percentage
	throttle_label.text = "THR: %d%%" % int(aircraft.throttle_command * 100)

	# Control surface deflections as percentages
	pitch_label.text = "Pitch: %+.0f%%" % (aircraft.pitch_command * 100)
	roll_label.text = "Roll: %+.0f%%" % (aircraft.roll_command * 100)
	yaw_label.text = "Yaw: %+.0f%%" % (aircraft.yaw_command * 100)

	# Flight mode indicator
	if aircraft.autopilot_active:
		mode_label.text = "MODE: AUTOPILOT"
	else:
		mode_label.text = "MODE: MANUAL"

	# Artificial horizon: rotate opposite to aircraft roll
	var roll_angle: float = aircraft.global_rotation.z
	horizon_rect.rotation = -roll_angle

What does this code do?

  • aircraft.linear_velocity.length() gives the total speed regardless of direction. This is your airspeed.
  • The angle of attack calculation first transforms world velocity into the aircraft's local coordinate system. Then atan2(local_velocity.y, -local_velocity.z) measures how far the airflow is tilted from the nose direction. High AOA means the wing is approaching stall.
  • The deflection labels read pitch_command, roll_command, and yaw_command directly from the aircraft controller script you wrote in the previous step.
  • The horizon rotates opposite to the aircraft's roll (-roll_angle) so that it stays level relative to the ground, just like a real attitude indicator.
  • Save hud.gd.
  • Add the hud.tscn scene as a child of your main scene (drag it from the FileSystem dock into the scene tree, or use Instance Child Scene).
  • Select the instanced HUD node. In the Inspector, find the Aircraft Path export and set it to point at your Aircraft (AeroBody3D) node.
  • Run the project. You should see labels in the top-left showing live airspeed, altitude, AOA, and throttle values as you fly.

Labels showing but values not updating?

Check that the Aircraft Path export in the Inspector points to the correct AeroBody3D node. If the path is empty, the aircraft == null check will exit _process immediately.

Also verify that your label node names match exactly. For example, AirspeedLabel is case-sensitive and must match the @onready paths in the script.

Help me debug my Godot HUD that isn't updating values.

✔️ Awesome, I've got everything!

Great. Double check you've saved hud.gd and that your HUD displays live telemetry when you fly.

ⓧ I'd like to double check the full code

Here is the complete hud.gd file for comparison:

extends CanvasLayer

@export var aircraft_path: NodePath

@onready var airspeed_label: Label = $Control/AirspeedLabel
@onready var altitude_label: Label = $Control/AltitudeLabel
@onready var aoa_label: Label = $Control/AOALabel
@onready var throttle_label: Label = $Control/ThrottleLabel
@onready var pitch_label: Label = $Control/PitchLabel
@onready var roll_label: Label = $Control/RollLabel
@onready var yaw_label: Label = $Control/YawLabel
@onready var mode_label: Label = $Control/ModeLabel
@onready var horizon_rect: ColorRect = $Control/HorizonRect

var aircraft: Node

func _ready() -> void:
	aircraft = get_node(aircraft_path)


func _process(_delta: float) -> void:
	if aircraft == null:
		return

	var airspeed: float = aircraft.linear_velocity.length()
	airspeed_label.text = "SPD: %.1f m/s" % airspeed

	altitude_label.text = "ALT: %.1f m" % aircraft.global_position.y

	var local_velocity: Vector3 = aircraft.global_transform.basis.inverse() * aircraft.linear_velocity
	var aoa: float = rad_to_deg(atan2(local_velocity.y, -local_velocity.z))
	aoa_label.text = "AOA: %.1f\u00b0" % aoa

	throttle_label.text = "THR: %d%%" % int(aircraft.throttle_command * 100)

	pitch_label.text = "Pitch: %+.0f%%" % (aircraft.pitch_command * 100)
	roll_label.text = "Roll: %+.0f%%" % (aircraft.roll_command * 100)
	yaw_label.text = "Yaw: %+.0f%%" % (aircraft.yaw_command * 100)

	if aircraft.autopilot_active:
		mode_label.text = "MODE: AUTOPILOT"
	else:
		mode_label.text = "MODE: MANUAL"

	var roll_angle: float = aircraft.global_rotation.z
	horizon_rect.rotation = -roll_angle

Your HUD is now streaming real-time flight data every frame. Next up, you'll write an autopilot that uses these same values to fly the plane toward a target without any keyboard input.

Write the Autopilot

Your aircraft flies and your HUD displays real-time telemetry. Now comes the fun part: making the plane fly itself.

In this step, you will create an autopilot script that uses proportional control to steer the aircraft toward a target waypoint. The autopilot calculates the direction to the target in the aircraft's local coordinate space, then commands pitch and roll to close the gap.

In this step, get ready to:
  • Place a target waypoint in the main scene.
  • Write an autopilot script that navigates toward the target using proportional control.
  • Add a toggle key and test autonomous flight.
Place the target waypoint and create the autopilot node

The autopilot needs something to fly toward. A Marker3D node is a lightweight position indicator that shows as a cross in the editor without adding any visual to the game.

  • Switch to your main scene in the Godot editor.
  • Right-click the root Node3D in the scene tree and select Add Child Node.
  • Search for Marker3D and add it.
  • Rename the new node to Target.
  • In the Inspector, set the Transform > Position to x: 0, y: 50, z: -200. This places the waypoint 200m ahead and 50m above the runway.
  • Right-click the root Node3D again and select Add Child Node.
  • Search for Node (the base Node type) and add it.
  • Rename this node to Autopilot.

Why a separate Node for the autopilot?

The autopilot is a controller that reads the aircraft's state and issues commands. Keeping it on its own Node separates navigation logic from the aircraft's physics code in aircraft_controller.gd. This makes it easy to swap navigation strategies later without touching the flight model.

Now create the script that will drive the autopilot logic.

  • Select the Autopilot node in the scene tree.
  • Click the Attach Script button (scroll icon) at the top of the scene tree panel.
  • Name the file autopilot.gd and click Create.
  • Replace the default script content with the following code:
extends Node

@export var aircraft_path: NodePath
@export var target_path: NodePath
@export var pitch_gain: float = 2.0
@export var roll_gain: float = 1.5
@export var target_reach_distance: float = 20.0

var aircraft: Node
var target: Node3D

func _ready() -> void:
	aircraft = get_node(aircraft_path)
	target = get_node(target_path)

What does this code do?

  • aircraft_path and target_path are exported NodePaths so you can point the script at the Aircraft and Target nodes from the Inspector.
  • pitch_gain and roll_gain control how aggressively the autopilot corrects errors. Higher values mean sharper corrections.
  • target_reach_distance defines how close the aircraft must get before declaring the target reached (20m).
  • _ready() resolves the paths into actual node references at startup.
  • Save autopilot.gd.
  • With the Autopilot node selected, look in the Inspector panel. You should see the exported properties: Aircraft Path, Target Path, Pitch Gain, Roll Gain, and Target Reach Distance.
  • Set Aircraft Path to point to your Aircraft node (click the Assign button and select the Aircraft node in the scene tree).
  • Set Target Path to point to the Target Marker3D node.

Don't see the exported properties?

Make sure you saved autopilot.gd after pasting the code. Godot only reads exported variables from the saved version of the script. If properties still don't appear, check that the first line reads extends Node with no typos.

Why aren't my exported properties showing in the Inspector?

Write the navigation logic

The autopilot needs to calculate where the target is relative to the aircraft's nose, then command pitch and roll to aim toward it. This is called proportional control: the correction is proportional to the error.

  • In autopilot.gd, add the following _physics_process function below the _ready() function:
func _physics_process(delta: float) -> void:
	if not aircraft.autopilot_active:
		return

	if target == null:
		return

	# Calculate direction to target in world space
	var direction_to_target: Vector3 = (target.global_position - aircraft.global_position).normalized()

	# Transform to local space
	var local_direction: Vector3 = aircraft.global_transform.basis.inverse() * direction_to_target

	# Pitch error: angle in the local YZ plane
	var pitch_error: float = atan2(local_direction.y, -local_direction.z)

	# Yaw error: angle in the local XZ plane
	var yaw_error: float = atan2(-local_direction.x, -local_direction.z)

	# Bank-to-turn: use yaw error to command roll, reduce direct yaw
	var computed_pitch: float = clamp(pitch_error * pitch_gain, -1.0, 1.0)
	var computed_roll: float = clamp(yaw_error * roll_gain, -1.0, 1.0)
	var computed_yaw: float = clamp(yaw_error * 0.3, -1.0, 1.0)
	var computed_throttle: float = 1.0

	# Apply commands
	aircraft.set_control(computed_pitch, computed_roll, computed_yaw, computed_throttle)

	# Check if target reached
	var distance: float = aircraft.global_position.distance_to(target.global_position)
	if distance < target_reach_distance:
		print("Target reached!")

What does this navigation logic do?

The autopilot first checks whether it is active. If not, it does nothing and lets manual controls work as normal.

When active, it calculates a world-space direction vector from the aircraft to the target, then transforms it into the aircraft's local coordinate space using global_transform.basis.inverse(). This tells the autopilot whether the target is above, below, left, or right of the nose.

The atan2 calls convert those local direction components into angular errors. The pitch error drives the elevator. The yaw error drives roll (bank-to-turn) with a small direct yaw component for coordination.

  • Save autopilot.gd.

Seeing errors about null references?

If Godot reports that aircraft or target is null, double-check that you assigned both Aircraft Path and Target Path in the Inspector. The paths must point to nodes that exist in the scene tree at runtime.

Help me fix null reference errors in my autopilot script.

Add the toggle input and test autonomous flight

The aircraft_controller.gd already has code that toggles autopilot_active when the toggle_autopilot action fires. You need to map that action to the T key in the Input Map.

  • Click Project in the top menu bar.
  • Select Project Settings.
  • Click the Input Map tab at the top of the settings window.
  • In the Add New Action text field at the top, type toggle_autopilot and press Enter (or click Add).
  • Click the + button next to the new toggle_autopilot action.
  • Press the T key on your keyboard and click OK.
  • Close the Project Settings window.

What is the Input Map?

Godot's Input Map lets you define named actions and bind keys or buttons to them. Your scripts reference the action name (like toggle_autopilot) instead of a specific key. This means you can rebind controls later without changing any code.

Time to test the full system.

  • Press F5 (or click the play button) to run the project.
  • Use your throttle controls to increase power and take off manually.
  • Once airborne, press T to activate the autopilot.
  • Watch the HUD switch to MODE: AUTOPILOT and observe the aircraft banking and pitching toward the target marker.
  • When the plane gets within 20m of the target, check the Output panel at the bottom of the editor for Target reached!.

Aircraft not turning toward the target?

Make sure autopilot_active is toggling. Check the HUD mode indicator changes to MODE: AUTOPILOT when you press T. If it does not change, verify that toggle_autopilot exists in your Input Map and is bound to the T key.

If the mode changes but the plane flies erratically, check that the Target node position is set to z: -200 (negative Z is forward in Godot's coordinate system).

Help me debug my autopilot not steering the aircraft.

✔️ Awesome, I've got everything!

Great. Double check you have saved autopilot.gd and your main scene.

ⓧ I'd like to double check the full code

Here is the complete autopilot.gd file:

extends Node

@export var aircraft_path: NodePath
@export var target_path: NodePath
@export var pitch_gain: float = 2.0
@export var roll_gain: float = 1.5
@export var target_reach_distance: float = 20.0

var aircraft: Node
var target: Node3D

func _ready() -> void:
	aircraft = get_node(aircraft_path)
	target = get_node(target_path)


func _physics_process(delta: float) -> void:
	if not aircraft.autopilot_active:
		return

	if target == null:
		return

	# Calculate direction to target in world space
	var direction_to_target: Vector3 = (target.global_position - aircraft.global_position).normalized()

	# Transform to local space
	var local_direction: Vector3 = aircraft.global_transform.basis.inverse() * direction_to_target

	# Pitch error: angle in the local YZ plane
	var pitch_error: float = atan2(local_direction.y, -local_direction.z)

	# Yaw error: angle in the local XZ plane
	var yaw_error: float = atan2(-local_direction.x, -local_direction.z)

	# Bank-to-turn: use yaw error to command roll, reduce direct yaw
	var computed_pitch: float = clamp(pitch_error * pitch_gain, -1.0, 1.0)
	var computed_roll: float = clamp(yaw_error * roll_gain, -1.0, 1.0)
	var computed_yaw: float = clamp(yaw_error * 0.3, -1.0, 1.0)
	var computed_throttle: float = 1.0

	# Apply commands
	aircraft.set_control(computed_pitch, computed_roll, computed_yaw, computed_throttle)

	# Check if target reached
	var distance: float = aircraft.global_position.distance_to(target.global_position)
	if distance < target_reach_distance:
		print("Target reached!")

Your aircraft can now fly itself to a target. You have built a complete programmable flight simulator with manual controls, a real-time HUD, and an autonomous navigation system. In the Secret Mission, you will extend this into a multi-waypoint circuit that the autopilot flies continuously.

Secret mission

Waypoint Circuit

Your autopilot can fly toward a single target. Can you make it fly an entire circuit? In this secret mission, you will create a multi-waypoint route, build a cycling autopilot that advances through each marker, add a lap counter to the HUD, and leave a visible trail behind the aircraft as it flies.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources for future experimentation, or delete them to free up disk space.

Resources you used:

  • The FlightSim Godot project folder (all scenes, scripts, and plugin files).
  • Godot 4.6.3 application (standalone binary or app bundle).

Keep everything running

No action needed. Choose this if you want to keep experimenting with your flight simulator or extend the autopilot further.

  • Your FlightSim project stays on disk wherever you saved it.
  • Godot stays installed and ready for your next project.

Pause - I'll come back to this later

Close Godot to free up memory. Your project files stay on disk for when you return.

  • Close the Godot editor window.
  • Your FlightSim project folder remains intact. Reopen it from Godot's Project Manager when you're ready.

Delete - I don't want to use this again

Remove all project files and optionally the Godot application itself.

  • Close Godot if it is still open.
  • Delete the FlightSim project folder. On macOS/Linux, open a terminal and run the command below. On Windows, right-click the folder in File Explorer and select Delete.
rm -rf ~/Desktop/FlightSim
  • To remove Godot itself, delete the Godot application. On macOS, drag Godot from your Applications folder (or wherever you saved it) to the Trash. On Linux, delete the extracted binary. On Windows, delete the Godot_v4.6.3-stable_win64.exe file (or the folder you extracted it to).
  • Godot is a self-contained application with no system-wide installer. Deleting the binary or app bundle fully removes it.

Nice Work!

Nice Work!

You built a programmable flight simulator in Godot. Your aircraft flies using real aerodynamic physics, and every control surface is driven by code you wrote.

You've learned how to:

  • Build a flyable aircraft with realistic aerodynamic physics including lift, drag, and stall behavior.
  • Make each control surface individually addressable and scriptable via GDScript.
  • Write a custom autopilot using proportional control logic to fly the aircraft toward a target.
  • Build a waypoint circuit with autonomous multi-point navigation and lap tracking.

Ready to quiz yourself?