Why I moved Briarthorn from Godot to Qt Quick
Written by, Funnan on June 14, 2026
The screenshot up top is Briarthorn’s tactical HUD running as a native Qt Quick app. A couple months ago that same HUD was a Godot scene. This post is about why I moved it, and what I picked up about declarative versus imperative game code along the way.
Godot was an experiment. I’d never used it. I wanted a cheap answer to one question: how fast can I get a playable demo on screen? Pretty fast, it turns out. Godot’s a solid engine. Within a few evenings I had ownship flying around, contacts painting on the sensor, and weapons firing. The architecture post I wrote about that prototype still holds up. The separation of concerns was clean, and I’m happy with how it turned out.
Two things kept grinding on me, though, and both came from the same place. I think in C++.
What bugged me
First, GDScript. It’s a fine language, and for a lot of people it’s the whole appeal. Coming from C++ I kept reaching for things that weren’t there. Stricter typing. The template and RAII patterns I lean on. The compile-time guarantees I use to keep a refactor honest. None of that is a dealbreaker on its own. Put together, it meant I was always translating how I think into someone else’s idioms instead of writing the code I already knew how to write.
Second, the editor. This one mattered more than I expected. My logic lived in VS Code. My scenes lived in the Godot editor. So I was constantly alt-tabbing between two apps that each had their own idea of what “the project” was. I could make it work. I just didn’t enjoy it, and it broke my flow. It made streaming awkward too, since now there were two windows to wrangle on screen instead of one. A broken workflow is a tax you pay on every change, and it adds up.
Why Qt Quick
I’ve spent years writing C++ with Qt. So when I stepped back and asked what I actually needed from an engine for this game, a 2D, HUD-heavy tactical sim, the answer was mostly a list Qt Quick already covers. A retained scene graph. A reactive property system. Vector drawing and animation. A real type system underneath it all. And C++ for the parts that should be C++.
Qt Quick gave me one more thing Godot couldn’t: a single window. The official QML extension for VS Code brings the QML language server, the syntax tooling, and qmlpreview for live reload. Put that together with a Dev Container holding the whole toolchain (Qt, CMake, the compiler, SDL) and my entire dev loop happens in one VS Code window. Edit QML and watch it hot-reload. Edit C++ and rebuild. It’s all in one place. My streaming setup got a lot simpler the day I switched, since there’s now exactly one thing to put on screen.
The rest of this post is the part I found most interesting. Porting the prototype put an imperative engine and a declarative one side by side, and for prototyping the declarative side kept winning.
Declarative vs imperative, one real example
Here’s the clearest example I’ve got. In the Godot version, the player’s visual node polls the model every physics frame and assigns its own transform by hand:
# player.gd
func _physics_process(_delta: float) -> void:
var heading: float = Briarthorn.model_entities().get_heading(Briarthorn.player_id)
rotation = deg_to_rad(heading)
var pos: Vector3 = Briarthorn.model_entities().get_position(Briarthorn.player_id)
position = Briarthorn.zoom_position(Vector2(pos.x, -pos.y))
Every frame: grab the heading, convert it, write rotation. Grab the position, transform it, write position. It works. But the relationship between the model and the view is implicit. It only exists inside a function that has to run on a schedule, and if that function stops running, the view quietly goes stale.
In QML that relationship is the code. A contact’s symbol just declares that its position and rotation are a function of the model:
// view/ViewTrack.qml — the per-contact delegate
delegate: Item {
required property Track modelData
readonly property Track track: modelData
// Bearing rotated into the heading-up frame: ownship's heading -> top.
readonly property real screenAngle: track.azimuth - viewTrack.observer.heading
x: viewTrack.width / 2 + Geo.offsetX(screenAngle, pix)
y: viewTrack.height / 2 + Geo.offsetY(screenAngle, pix)
rotation: track.heading - viewTrack.observer.heading
}
There’s no _process here. There’s no “now go update the view” step anywhere. When track.heading changes, or ownship’s heading changes, the binding engine recomputes rotation and re-plots x and y for me. The dependency is the declaration. I can’t forget to call it, because there’s nothing to call.
In one delegate that difference is small. Across a whole HUD it’s huge. The Godot version of the situation display was a pile of “on change, go update that other thing” wiring. Signals coming off models, slots wired up in scenes, callbacks re-reading state. The QML version is a graph of bindings the engine keeps in sync for me. For prototyping that’s the biggest speedup I found. I describe what the picture is, not when and how to repaint it, and a lot of the bookkeeping I used to write by hand just goes away.
Defining the game data
The same thing shows up in how each version defines its game data.
In Godot, a type’s schema is GDScript. Its data is a separate .tres resource I fill out in the inspector:
# database/types/type_entity.gd — the schema
class_name TypeEntity extends Resource
@export var category: TypeEnums.Category = TypeEnums.Category.UNKNOWN
@export var side: TypeEnums.Side = TypeEnums.Side.UNKNOWN
@export var scene: PackedScene = null
@export var weapon: TypeWeapon = null
@export var stats: TypeStats = null
# database/entities/Missile.tres — the data (abridged)
[sub_resource type="Resource" id="stats"]
kinetic = 10
maneuver = 8
durable = 4
[resource]
category = 12 # WEAPON_AIR_MISSILE
scene = ExtResource("Missile.tscn")
stats = SubResource("stats")
So that’s two formats and two editors. The schema in the script editor, the values in the inspector. It’s a nice workflow once you’re in the engine. The catch is it’s only nice in the engine, which is exactly where I didn’t want to be.
In QML the schema and the data are the same language. A base Data type declares what every entry has to provide. The required property keyword means qmllint rejects a malformed definition before the thing ever runs:
// database/Data.qml — the schema
QtObject {
required property int classification // the Database table's key
required property var outline // [x, y] points, nose toward -y
required property int family // what a sensor coarsens this to
required property bool coarsens
required property string label // HUD readout
}
// database/DataFighter.qml — the data
DataEntity {
classification: Classification.Fighter
outline: [[0, -0.5], [0.12, -0.05], [0.45, 0.3], /* ... */]
family: Classification.Aircraft
coarsens: true
label: "STRIKE"
stats: ({
kinetic: 5,
maneuver: 6,
durable: 5,
compute: 6,
sensor: 7,
stealth: 4
})
behavior: Component { ManeuverPursue {} }
}
A DataFighter is a DataEntity is a Data. The presentation (outline), the gameplay (stats), and the AI hook (behavior, a deferred Component) all live in a single QML object in one file. No inspector round-trip. No second file format. And the type system checks the data the same way it checks everything else.
The index
Both versions put a single lookup in front of the data so the rest of the game never touches a file path. In Godot that’s a preloaded dictionary:
# database/database_types.gd
const _ENTITIES: Dictionary[TypeEnums.Category, TypeEntity] = {
TypeEnums.Category.WEAPON_AIR_MISSILE: preload("res://database/entities/Missile.tres"),
TypeEnums.Category.VEHICLE_AIR_FIGHTER: preload("res://database/entities/Fighter.tres"),
# ...
}
In QML it’s a singleton that instantiates every type into a registry list and derives an O(1) table from it:
// database/Database.qml (singleton)
readonly property list<Data> registry: [
DataUnknown {}, DataAircraft {}, DataAircraftFighter {},
DataMissile {}, DataMissileGuided {}, DataFighter {}, /* ... */
]
// Classification -> Data, derived once from the registry.
readonly property var table: {
const t = {};
const defs = database.registry;
for (let i = 0; i < defs.length; ++i)
t[defs[i].classification] = defs[i];
return t;
}
Adding a type takes the same three steps in both. One enum value, one definition, one line in the index. That’s what I wanted. The shape of the architecture survived the port. What’s different underneath is that the QML registry is type-checked QML I can write from VS Code, and a completeness test pins registry.length to the enum count. Forget an entry and the build fails, instead of it failing quietly at runtime.
Systems and the game loop
Godot hands you the loop. A system is a Node, you override _physics_process, and the engine calls it on a fixed timestep:
# src/systems/system_track_motion.gd
class_name SystemTrackMotion extends Node
func _physics_process(_delta: float) -> void:
for entity: DataEntity in Briarthorn.model_entities.get_entities():
_update_tracks(entity)
Qt Quick doesn’t give you a game loop at all. It gives you FrameAnimation, which is vsync-paced and hands you a variable frame delta. So I wrote the clock myself. One SimulationClock holds the cadence and the run order, and the systems are passive objects with a single step(dt) method:
// systems/SimulationClock.qml
FrameAnimation {
// The systems to step each frame, in the order they must run — e.g.
// maneuver (decide commands) -> movement (integrate) -> pickup (resolve).
property var systems: []
property real maxFrameSeconds: 0.1 // clamp so a stall eases forward, never teleports
onTriggered: {
const dt = Math.min(frameTime, maxFrameSeconds);
if (dt <= 0) return;
for (let i = 0; i < systems.length; ++i)
systems[i].step(dt);
}
}
// systems/SystemMovement.qml — a passive system, no timer of its own
function step(dt: real): void {
for (let i = 0; i < entities.length; ++i)
_advance(entities[i], dt);
}
This wasn’t a reason I switched. It’s just a cost I knew I was taking on. Godot’s _physics_process already runs on a fixed timestep. FrameAnimation doesn’t, so a proper fixed-step loop with an accumulator is something I’ll build myself down the line. The maxFrameSeconds clamp above keeps a stalled frame from teleporting everything across the gap, but it’s a stopgap, not the real thing. Given my background that’s fine. It’s one more piece of plumbing I’ll own, and that suits me.
Input, and the gamepad I had to build
This is the spot where Qt asked the most of me.
Godot’s input system is great. You declare named actions in an InputMap, each action binds to keyboard and joypad events, and your code just asks for the result:
# scripts/controller.gd
func _process(_delta: float) -> void:
command_roll.roll = Input.get_axis("player_right", "player_left")
command_throttle.throttle = Input.get_action_strength("player_throttle")
Briarthorn.command(command_roll)
Briarthorn.command(command_throttle)
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("player_range_in"):
Briarthorn.range_in()
elif event.is_action_pressed("player_weapon_fire"):
Briarthorn.weapon_fire()
The gamepad just works. The joypad axes and buttons sit in the InputMap next to the keys, deadzones and all, and Godot polls the hardware for you.
Qt Quick gives you Keys and MouseArea out of the box. For gamepads it gives you nothing. (There was a QtGamepad module in Qt 5, but it was dropped in Qt 6.) So I built the device layer myself with SDL3. A small C++ class wraps SDL’s gamepad subsystem and exposes it to QML as an attached property. I shaped it after Keys on purpose, so it reads naturally from QML:
// core/Gamepad.h
class Gamepad : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Gamepad is only usable as an attached property (Gamepad.on...)")
QML_ATTACHED(Gamepad)
public:
enum class Axis : std::int8_t { LeftX = SDL_GAMEPAD_AXIS_LEFTX, /* ... */ };
Q_ENUM(Axis)
signals:
void connected(int deviceId);
void disconnected(int deviceId);
void buttonPressed(int deviceId, Button button);
void axisChanged(int deviceId, Axis axis, double value);
};
Behind it, one engine-owned source drains SDL’s event queue on a timer. It opens and closes controllers on hotplug, normalizes the raw axis range to [-1, 1], and re-emits everything as typed Qt signals:
// core/Gamepad.cpp — inside the poll timer
case SDL_EVENT_GAMEPAD_ADDED:
gamepads_.emplace(id, SDL_OpenGamepad(id));
emit connected(static_cast<int>(id));
break;
case SDL_EVENT_GAMEPAD_AXIS_MOTION: {
const double value = std::clamp(event.gaxis.value / kAxisScale, -1.0, 1.0);
emit axisChanged(static_cast<int>(event.gaxis.which), event.gaxis.axis, value);
break;
}
That’s the part Qt didn’t hand me. The payoff is what sits on top. Once the device exists, the mapping layer gets to be as declarative as everything else. My InputProfile lists actions. Each action lists the controls that drive it, gamepad and keyboard together, and dispatches the result straight onto a command:
// input/InputProfile.qml
actions: [
InputMapping {
name: InputAction.Throttle
bindings: [
InputBinding { control: InputControl.GpRightTrigger; deadzone: 0.03 },
InputBinding { control: InputControl.KeyW }
]
onValueChanged: Commands.throttle(profile.ownship, value)
},
InputMapping {
name: InputAction.Steer
bindings: [
InputBinding { control: InputControl.GpLeftX; deadzone: 0.08 },
InputBinding { control: InputControl.KeyD },
InputBinding { control: InputControl.KeyA; invert: true }
]
onValueChanged: Commands.steer(profile.ownship, value)
}
]
The trigger and the W key both feed Throttle, so either one flies the aircraft. In the end I rebuilt most of Godot’s InputMap on top of my SDL3 layer: deadzones, multi-device bindings, edge-detected pulses, rebindable actions. That took a while. But it’s my input system now, in my language, doing what I want. Given the background I have, that trade was an easy one.
What each one gives you for free
Here’s roughly what each framework handed me, and what I had to build myself for this particular game:
| Capability | Godot | Qt Quick / QML |
|---|---|---|
| Scene/object tree & lifecycle | ✅ Node, _ready | ✅ Item, Component.onCompleted |
| Reactive property bindings | ⚠️ signals, setget | ✅ first-class, the core idea |
| Per-frame game loop | ✅ _process / _physics_process | ⚠️ FrameAnimation, wire your own clock |
| Data definitions + editor | ✅ .tres + inspector | ⚠️ plain QML objects (one language, lint-checked) |
| Keyboard & mouse | ✅ InputMap | ✅ Keys, MouseArea |
| Input action map (deadzones, rebinds) | ✅ InputMap | ❌ built it myself |
| Gamepad | ✅ built-in joypad | ❌ built it with SDL3 |
| 2D vector drawing | ✅ _draw, Polygon2D | ✅ Canvas, Shapes |
| Animation / tweening | ✅ AnimationPlayer, Tween | ✅ Behavior, states, transitions |
| Physics | ✅ 2D/3D, Jolt | ❌ roll your own (I only needed kinematics) |
| Audio | ✅ built-in | ⚠️ separate QtMultimedia module |
| Live reload / preview | ✅ editor live edit | ✅ qmlpreview |
| Logic language | GDScript (or C#/C++) | QML + JS, with C++ underneath |
| Editor | ✅ dedicated Godot editor | your IDE (the whole point, for me) |
| Build system | engine-managed | CMake (you own it) |
On paper Godot bundles more. A game loop, a gamepad-aware input map, physics, a data editor. Those are real conveniences and I don’t want to pretend otherwise. For me, though, most of the ❌ and ⚠️ marks on the Qt side aren’t losses. They’re places I get to make the call in C++ or in declarative QML, inside the one workflow I actually want to be in. I’ve got a decade of Qt muscle memory. Trading a few built-ins for that fit was worth it.
Where this leaves Briarthorn
The Godot experiment did its job. It proved the game out, and the architecture it left me with was clean enough that moving to a completely different framework was a translation, not a rewrite. The layers came across intact. The imperative “go update that” wiring turned into declarative bindings, the data became type-checked QML, and the whole thing now builds and runs from a Dev Container without me leaving the editor.
None of this means Godot is the wrong pick. For a lot of people it’s a great one, and it’s hard to beat on time-to-demo. It just wasn’t the right fit for me. I already knew Qt, and Qt Quick lets me write the C++ I think in, describe the HUD declaratively instead of repainting it by hand, and keep my code, the live preview, the build, and the stream all in one window.
Next up is getting combat and the sensor model fully ported, and seeing how far the declarative approach holds up as the picture gets busier. So far it’s held up well. Every time the HUD got more complicated, the binding graph took most of it and I wrote less code than I expected.