This episode gets your game truly release-ready: building an automated build pipeline with headless export, running QA and regression testing with the GUT testing framework, choosing distribution channels and update strategies, and setting up analytics, crash reporting, and a feedback loop.

In episode 18, you tidied up the project architecture: modular components, signals as the glue between systems, and reusable mechanics. That clean code was only half the journey — the other half is delivering it to the real world. Many games fail not because the code is bad, but because the release process is messy: the build that was tested differs from the build that was sold, bugs slip into production, and there's no way to know why players stopped playing.
Episode 19 covers operational readiness: build pipelines for release, QA and regression testing, packaging and distribution strategies, and the analytics, crash reporting, and feedback loop that make a release not the end, but the beginning of an improvement cycle.
Exporting through the Editor menu every release is fragile — it's easy to miss a step, and who guarantees the build on your laptop is the same one testers use? The answer: headless export via the command line. Godot can run without a GUI and export according to presets already saved in export_presets.cfg.
Presets are created once via Project > Export, then called again from the terminal:
godot --headless --export-release "Android" build/game.apkFor several platforms at once, put them all in a single pipeline script:
godot --headless --export-release "Linux" build/linux/game.x86_64
godot --headless --export-release "Windows" build/windows/game.exe
godot --headless --export-release "Web" build/web/index.htmlWith this pipeline, builds can be triggered from CI — for example on every commit to a release branch. The build-all.sh script ensures all platforms come from the same version of the code, eliminating the "it works on my laptop" mystery.
Presets are the key to reproducibility. There are two things that must be configured in them. First, file exclusions: don't export .godot, raw assets, or config files containing dev secrets. Second, per-platform templates: Android needs a keystore and uses AAB for the Play Store, Web needs the right embed mode. These differences should be recorded as separate presets, not manually changed every release. All of this configuration lives in export_presets.cfg and is version-controlled — auditing export changes is just a diff of that file.
Build variables are also separated from code. Instead of hardcoding endpoints or API keys, read them from the environment when the game runs:
extends Node
const ENDPOINT := "https://api.example.com/v1"
const BUILD_NUMBER := "1.2.0"
func _ready() -> void:
print("Running build %s against %s" % [BUILD_NUMBER, ENDPOINT])Writing BUILD_NUMBER to the screen or a log file sounds trivial, but when a bug comes from a player, that number is the only clue about which build they're running. Remote debugging always starts from build identity — you can't trace a bug whose version is unclear.
QA starts inside the editor. Godot provides a debugger that can pause execution, inspect variables, and step between frames. For bugs that only appear while playing, enable the remote scene tree: through the Debugger tab, you can see the node tree of the running game on a device or browser — inspecting live values without printing hundreds of print lines.
What shouldn't be delayed is regression testing — making sure old features don't break when new ones arrive. In Godot, the best habit is writing unit tests for pure logic (scores, damage formulas, state machines) and integrating them into CI. The most popular framework is GUT (Godot Unit Test), which can be downloaded as a plugin.
GUT wraps game logic and calls it from tests. For example, we have a damage formula in a component used by both players and enemies:
extends Node
@export var base_damage: int = 10
func calculate_damage(critical: bool) -> int:
var dmg := base_damage
if critical:
dmg *= 2
return dmgIts tests live in separate files in the res://test/ folder and are executed by the GUT runner:
extends GutTest
func test_normal_damage() -> void:
var comp := preload("res://systems/damage_component.gd").new()
comp.base_damage = 10
assert_eq(comp.calculate_damage(false), 10)
func test_critical_damage() -> void:
var comp := preload("res://systems/damage_component.gd").new()
comp.base_damage = 10
assert_eq(comp.calculate_damage(true), 20)Notice that this test calls calculate_damage without involving a scene — it tests pure logic, so the result is deterministic and fast. That's why we separated logic from nodes in episode 18: whatever can be tested separately, is tested separately.
In CI, GUT runs headless:
godot --headless --script res://addons/gut/gut_cmdln.gd -gdir=res://test -gexitNow every change to damage logic, like adding armor, will immediately be caught by test_critical_damage if the numbers are off. Bug hunting still needs humans, but boring regression can be left to machines.
Warning
Don't write tests just to chase a count. Prioritize logic that has broken before or is touched most often: damage formulas, score systems, state machines, and save file parsers. Ten meaningful tests beat a hundred tests that only validate trivia.
Once the build is green and tests pass, you face distribution channels. Each channel has its own conventions: Steam and itch.io accept zip binaries for desktop, Google Play requires a signed AAB, the App Store demands notarization. Research each store's requirements long before release — waiting until the build is done to read the rules is a recipe for being late.
The update strategy follows the channel:
git and a version tag; Steam manages automatic updates from the build you upload.Version number consistency matters. Use semver — major.minor.patch — and record changes in a changelog. Players reporting bugs from an old version should be directed to the latest build, not diagnosed against a build that's been replaced.
A release isn't a finish line; it starts the feedback loop. Without data, every decision (raise difficulty? shorten level 2?) is just a guess. Two instruments are essential:
HTTPRequest.Error.report_callstack() or push_error() to track errors. Combine it with a logger that writes to a file, then send automatically on the next startup.This minimal script catches unhandled errors and writes them to a local file:
extends Node
func _init() -> void:
ErrorHandler.install(self)
func report(message: String, stack: String) -> void:
var file := FileAccess.open("user://crash.log", FileAccess.WRITE)
file.store_line(message)
file.store_line(stack)The feedback loop is the cycle: analytics show where players stop, crash logs show the bugs that slipped through, regression tests ensure fixes don't break anything else, and the next build starts the cycle again. Mature teams ship small versions regularly with this rhythm, not release once and pray.
Success
Start analytics with just five events: game start, level start, level complete, game over, and session end. These five events are already enough to find player drop-offs at the top of the funnel — the rest are added when there's a specific hypothesis.
Episode 19 completes your production mode: a build pipeline triggerable from CI via headless export, export presets stored as the source of truth, QA with the remote scene tree and GUT unit tests for regression, packaging and update strategies per distribution channel, and the analytics, crash reporting, and feedback loop that close the improvement cycle.
The key takeaways:
godot --headless --export-release and call it from CI so all platforms come from the same code version.With an automated pipeline and flowing data, you're ready to release with confidence. In the next episode, episode 20, we go down from process to context: real-world use cases & project types — how your architecture and workflow change for 2D platformers, puzzles, roguelikes, and simulations, plus planning, MVP, monetization, and game jam patterns. See you there!