Building 5 Flyable Planes from Code — No 3D Models Needed
The moment I stopped worrying about Blender
I spent an embarrassing amount of time trying to model a low-poly airplane in Blender before asking myself: what if I just… didn't? Godot has BoxMesh, CylinderMesh, SphereMesh, and ConeMesh built in. A fuselage is a box. A wing is a flat box. A propeller blade is two crossed boxes. Once I accepted that, the whole project sped up by weeks.
Every plane in Oliver's Planes is generated at runtime from code. Zero external 3D assets. The _shape variable tracks which airframe is currently built, and apply_engine() swaps it whenever you buy a new plane from the garage.
The shape switcher
When you equip a different engine, apply_engine() reads the engine catalog and checks whether the shape has changed:
func apply_engine() -> void:
var e: Dictionary = EngineCatalog.get_engine(GameState.equipped)
var shape := str(e.get("shape", "trainer"))
if shape != _shape:
_build_mesh(shape)
_shape = shape
_thrust = float(e.thrust)
_max_speed = float(e.max_speed)
_lift = BASE_LIFT * float(e.lift)
_pitch_rate = BASE_PITCH_RATE * float(e.handling)
_roll_rate = BASE_ROLL_RATE * float(e.handling)
_yaw_rate = BASE_YAW_RATE * float(e.handling)If the shape string changed — say from "trainer" to "jet" — the old _model node gets freed and an entirely new airframe is constructed. The collision box is separate and sits directly on the CharacterBody3D, so it's untouched by these visual rebuilds. That separation was an aha moment. Early on I was rebuilding collision too, and it caused a single-frame physics glitch every time you switched planes mid-air. Oops.
The five shapes
Each shape has its own _build_* method. Here's the match statement at the heart of _build_mesh():
func _build_mesh(shape: String) -> void:
if _model and is_instance_valid(_model):
_model.free()
prop = null
_model = Node3D.new()
_model.name = "Model"
add_child(_model)
body_mat = _mat(Color(0.86, 0.15, 0.18), 0.35, 0.45)
match shape:
"sport": _build_sport()
"warbird": _build_warbird()
"jet": _build_jet()
"apex": _build_apex()
_: _build_trainer()Five distinct silhouettes, all from the same primitive toolbox:
- Trainer — the classic high-wing Cessna look. Big rectangular wings mounted above the fuselage, a boxy cockpit, gold-tipped wingtips. Spinning prop up front.
- Sport — a low-wing racer with a bubble canopy. Sleeker proportions, slightly swept wings. Still has a prop but with shorter blades (
blade := 2.6). - Warbird — chunky radial-engine fighter. Wide elliptical-ish wings (two panels per side — a fat inner and a tapered outer). The biggest prop in the game (
blade := 3.4). - Jet — swept-wing twin-tail with nacelles and glowing afterburner exhausts. No propeller at all. The burn meshes use emission:
var burn_mat := _mat(Color(0.9, 0.5, 0.2), 0.4, 0.4)
burn_mat.emission_enabled = true
burn_mat.emission = Color(1.0, 0.5, 0.15)
burn_mat.emission_energy_multiplier = 2.0- Apex (Delta Rocket) — a needle-nosed delta with a single purple-glowing thruster. The most extreme shape. Huge single delta wing spanning the rear half of the fuselage, leading-edge trim panels in near-white for contrast.
The helper functions that make it bearable
Building a fuselage from 10+ boxes would be painful without some shorthand. Two helpers carry the load:
func _mat(color: Color, metallic := 0.2, rough := 0.6) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.albedo_color = color
m.metallic = metallic
m.roughness = rough
return m
func _box(parent: Node3D, name: String, size: Vector3, pos: Vector3, mat: StandardMaterial3D) -> MeshInstance3D:
var mi := MeshInstance3D.new()
mi.name = name
var bm := BoxMesh.new()
bm.size = size
mi.mesh = bm
mi.material_override = mat
mi.position = pos
parent.add_child(mi)
return mi_mat() stamps out materials. _box() stamps out positioned box meshes. Every single piece of every plane goes through these two functions. That consistency means I can tweak metallic/roughness globally and every plane updates.
The propeller trick
Prop planes get a spinning propeller. Jets don't. The prop variable is either a Node3D (with a hub + two crossed blade boxes) or null.
func _add_prop(z: float, blade := 3.0) -> void:
var prop_mat := _mat(Color(0.2, 0.2, 0.22), 0.6, 0.3)
prop = Node3D.new()
prop.name = "Prop"
prop.position = Vector3(0, 0, z)
_model.add_child(prop)
_box(prop, "Hub", Vector3(0.3, 0.3, 0.3), Vector3.ZERO, prop_mat)
_box(prop, "BladeA", Vector3(0.18, blade, 0.06), Vector3.ZERO, prop_mat)
_box(prop, "BladeB", Vector3(blade, 0.18, 0.06), Vector3.ZERO, prop_mat)Then in _physics_process:
if prop:
prop.rotate_z((4.0 + throttle * 60.0) * delta)When prop is null (jets), the line just skips. No branching, no special jet mode. The prop's spin rate scales with throttle — idle is a lazy whirl, full power is a blur. It's a tiny detail but it sells the whole thing.
What I'd do differently
If I were starting over, I'd add CylinderMesh for the fuselage instead of boxes. Boxes give everything a Minecraft vibe, which I honestly like, but cylinders would let me do rounded engine cowlings on the warbird that look more convincing. I might also try CSGCombiner3D to boolean-subtract the cockpit glass area from the fuselage, giving it a true canopy cutout. But honestly? Boxes work. Nobody has complained. And the build time from "I want a new plane shape" to "it's flying in-game" is about 30 minutes.
Written by Oliver
Indie game developer building 3D web experiences in Godot and Next.js. I write about creative coding, procedural generation, and game feel.