Appendix — Godot
Stack appendix for the Sigma Engineering Standards. Covers Godot 4.x projects in GDScript. We don't currently use C# in Godot, so it's out of scope here. Game and interactive content have failure modes that differ from services — corrupted saves, vanished assets, frame-rate cliffs — so this appendix leans into those. This is developer best practice first — how a Sigma engineer writes idiomatic GDScript by hand. It is also the bar an AI agent writing GDScript in our repos is held to: match these idioms, and surface any deviation (see §8 of the standard, AI Agent Rules of Engagement).
1. Tooling Baseline
Required for every Godot repo:
gdformat(gdtoolkit) — formatting. No diff allowed.gdlint— static analysis. Zero warnings.- GDScript static typing enabled, with strict warnings (see §2).
gdscript-toolkitin CI along with project loading via--headless --check-onlyto catch parse errors.- Editor warnings as errors for release exports — set in Project Settings → Debug → GDScript → Warnings to
Erroron the categories listed in §2. - Unit tests via GUT (Godot Unit Test) for logic; integration scenes for behaviour.
- Reproducible export presets committed to the repo (see §10).
2. GDScript Conventions
GDScript 4 is a typed language if you ask it to be. We ask.
Typing — non-negotiable
- Every variable, parameter, and return value annotated.
func attack(target: Enemy) -> int:notfunc attack(target):. :=is fine for inferred locals where the right-hand-side makes the type obvious. Explicit annotations elsewhere.Variantis the GDScript equivalent ofany— avoid in domain code; restrict to interop with built-in APIs that genuinely return Variant.- Strict warnings enabled:
UNTYPED_DECLARATION,UNSAFE_PROPERTY_ACCESS,UNSAFE_METHOD_ACCESS,UNSAFE_CAST,UNSAFE_CALL_ARGUMENT,RETURN_VALUE_DISCARDED,INTEGER_DIVISION— all set to Error. (We deliberately do not raiseINFERRED_DECLARATIONto error —:=for obviously-typed locals is allowed per the Style rules below, andUNTYPED_DECLARATIONalready catches the genuinely untyped case.)
Style
- Names:
snake_casefor variables, functions, signals;PascalCasefor classes (class_name);SCREAMING_SNAKEfor constants. class_namefor any script that defines a reusable type. This gives autocomplete and type-checking in editor.@onready var foo := %FooNodewithUnique Names in Owner(%) — not deep$Path/To/Nodestrings, which break on refactor.- Preload over load for assets known at script-load time.
const SCENE := preload("res://..."). @exportevery value designers should tune in the editor;@export_range,@export_file,@export_enumwith constraints — these are validation as well as ergonomics.
class_name PlayerController extends CharacterBody2D
@export_range(50.0, 500.0, 1.0, "or_greater") var max_speed: float = 200.0
@export var jump_height: float = 64.0
@onready var _sprite: AnimatedSprite2D = %Sprite
@onready var _hitbox: Area2D = %Hitbox
const FOOTSTEP := preload("res://sfx/footstep.ogg")
signal died(reason: StringName)
3. Scene & Node Architecture
Godot's strength is composition. Use it.
- Composition over inheritance. A "player that can swim" is a
Playerwith aSwimmerchild component, not aSwimmingPlayersubclass. - Scenes are the unit of reuse. Each meaningful behaviour lives in its own scene with a clear root, predictable child structure, and
class_name. - One responsibility per scene. If a scene's script is >300 lines (intentionally stricter than the main standard's 500-line file bar — scene scripts sprawl fast) or its node tree has multiple unrelated subsystems, split it.
- Public API of a scene is its signals and exported properties. Don't reach into children from outside — emit signals up, configure via exports down.
- No
get_parent().get_parent().some_method(). Connect a signal or use a service locator (an autoload, with §6's caveats). - Groups (
add_to_group(...)) for "all enemies", "all interactables" — use sparingly and document the contract for each group name.
4. Resource & Signal Lifecycle
This is where Godot bugs live. Internalise these.
Freeing nodes
queue_free()for nodes — always. Neverfree()directly on a node that may be in the scene tree or referenced this frame.is_instance_valid(node)before touching a possibly-freed reference.WeakReffor long-lived references to nodes you don't own.
Signal hygiene
- Connect explicitly in code or via the editor — be consistent within a project.
CONNECT_ONE_SHOTfor signals that should fire once (timer-based reveals, achievements).- Disconnect on cleanup when a signal is connected to a node that may outlive the emitter. The framework cleans up child-to-parent connections automatically; cross-tree connections do not.
- No business logic in
_processif a signal can express it. Event-driven > frame-driven.
RefCounted / Resource
RefCountedsubclasses (the default forextends RefCountedor no extends) auto-manage.Resourceis aRefCounted— cached by default, shared between consumers; if you need a fresh copy,.duplicate().- Be aware of shared Resource state. Two scenes referencing the same
.tressee the same instance unless duplicated. This is a feature; it bites if forgotten.
Lifecycle callbacks
_enter_tree— first available to the tree; for setup that needs parent context._ready— children are ready, scene is composed; default place for initialisation._exit_tree— cleanup, disconnect cross-tree signals, stop timers you own._process(delta)/_physics_process(delta)— frame and physics ticks. Keep them small. If a node doesn't need a tick,set_process(false)/set_physics_process(false).
5. Async / await
GDScript 4's await is convenient and also a footgun.
- Every
awaithas a defined wake-up condition. A signal, aTimer, orget_tree().create_timer(...).timeout. - Bound it. For awaits that could hang (network, file IO, user input), use a
Timerrace or checkis_inside_tree()after the wake-up — the node may have been freed. - Don't
awaitin_process/_physics_process— these are tick callbacks, not coroutines. Spawn a separate task. - Re-validate state after
await. The world may have changed: nodes freed, scene unloaded, player respawned.
func play_intro() -> void:
_ui.fade_in()
await _ui.fade_complete
if not is_inside_tree(): return # scene may have ended
_voiceover.play()
var timer := get_tree().create_timer(5.0)
await timer.timeout
if not is_inside_tree(): return
_start_gameplay()
6. Autoloads (Singletons) — Sparingly
Autoloads are global state. They are sometimes the right answer (an audio bus manager, a save service, an analytics hub). They are usually the wrong answer.
- Document every autoload in a
docs/autoloads.mdwith its responsibility and rationale. - Autoloads expose signals, not setters. A global
EventBusautoload that other scenes connect to is far cleaner than ten scenes calling into mutable state. - No business-logic state in autoloads. Configuration, services, and event routing only.
- Reset on scene reload if the autoload holds session-scoped state.
7. Save State & Persistence
For Nanisca and any project where progress matters to a real user, save state robustness is a feature.
Hard requirements
- Atomic writes. Write to
user://save.tmp, thenDirAccess.rename_absolutetouser://save.dat. Never write directly to the live file. - Schema versioning. Every save begins with
{"version": 3, ...}. The loader migrates older versions or rejects with a clear UI message. - Validation on load. Type-check every field. Out-of-range, missing, or unknown fields → fall through to defaults, log, never crash.
- Corruption recovery. Keep a rolling backup (
save.dat,save.bak). On load failure, try the backup before the user sees an error. - Encrypted only if there's a real reason. Save tampering is rarely a real threat for educational/single-player apps. Plain JSON or
ConfigFileis fine, easier to debug, easier to support. - Never store secrets in saves. Authentication tokens belong in the OS keychain, not in the save file.
const SAVE_PATH := "user://progress.json"
const BACKUP_PATH := "user://progress.bak.json"
const SAVE_VERSION := 3
func save(state: Dictionary) -> Error:
var data := {"version": SAVE_VERSION, "state": state}
var tmp := SAVE_PATH + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
if f == null:
return FileAccess.get_open_error()
f.store_string(JSON.stringify(data))
f.close()
if FileAccess.file_exists(SAVE_PATH):
DirAccess.rename_absolute(SAVE_PATH, BACKUP_PATH)
return DirAccess.rename_absolute(tmp, SAVE_PATH)
8. Asset Loading & Failure Modes
Games crash when assets vanish. We don't crash.
- Preload critical assets at scene-load time so missing assets fail loudly during development.
- Lazy load large, optional assets (cinematics, level data) with explicit failure handling.
ResourceLoader.load_threaded_request(...)for assets that would block the main thread. Show a loading state in the UI.- Missing asset → soft fail. A placeholder texture, a silent audio clip, a logged error — never a crash dialog in front of a user (especially a child).
.importfiles for every asset committed. Import settings are part of the source of truth.
func load_level(path: String) -> PackedScene:
if not ResourceLoader.exists(path):
push_error("missing level: %s" % path)
return preload("res://levels/fallback.tscn")
var res := ResourceLoader.load(path) as PackedScene
if res == null:
push_error("could not load level: %s" % path)
return preload("res://levels/fallback.tscn")
return res
9. Performance Budget
Games have hard real-time constraints — 60 FPS = 16.6ms per frame. We budget proactively.
- Profile early.
Monitorand the built-in profiler are free; use them when behaviour is new, not just when something feels slow. _processis expensive at scale. A hundred nodes each running_processadds up. Pool, batch, or event-drive.set_process(false)/set_physics_process(false)when a node doesn't need ticking.- Object pooling for spawned-and-despawned entities (bullets, particles, enemies). Don't allocate per frame.
- Texture atlases and
MultiMeshfor many similar instances. - Audio bus configuration committed; avoid
AudioStreamPlayerper shot at high rates — use a pool. - Target device budget is the low-end device, not the dev machine. For Nanisca: tablet-grade hardware, not desktop GPU.
10. Build & Export
- Export presets (
export_presets.cfg) committed to the repo. Per-target presets named clearly (android-prod,android-staging, etc.). - Signing keys never in the repo. CI injects them from the secret manager. (Android)
- Version code and version name managed through a single source — a
version.cfgor build script — not edited per preset. - Debug code excluded from release builds (see §11).
.import/committed for deterministic imports.- Reproducible builds — same source + same Godot version → same export hash. Pin the Godot version (
.godot-versionor similar).
11. Debug vs Production
Code that is fine in the editor will embarrass us in production.
OS.is_debug_build()guards anywhere you have a debug overlay, cheat keys, or verbose logging.- Asserts (
assert(cond)) are stripped from release builds — they're for catching bugs in dev, not for runtime safety. Usepush_errorand a soft-fail path for runtime checks that matter in release. print/print_debugwrapped in a project logger that respectsOS.is_debug_build().- Editor-only addons declared as such in
plugin.cfg. - No
print(...)of user data or secrets — even in dev, this leaks into crash reports.
12. Game-Specific Resilience
Some patterns are specific enough to gaming that they don't fit the main standard §5.
- Frame-rate independence. Use
deltaeverywhere. Code that assumes 60 FPS will be wrong on a Steam Deck running at 40. - Pause-aware logic. Decide per-node whether
process_modeisINHERIT,PAUSABLE,WHEN_PAUSED, orALWAYS. UI, audio fades, and reminders often needALWAYS. - Input rate limiting. Children mash buttons. Debounce or rate-limit destructive actions.
- Battery awareness. On mobile (Nanisca), reduce
Engine.max_fpswhen backgrounded or idle. Stop_processon offscreen nodes. - Crash telemetry, opt-in and PII-stripped, captures the last 100 log lines and a scene-tree snapshot. Helps reproduce what we won't see in the QA lab.
- Network is optional. For offline-first apps, the game must be fully functional without a network. Network calls are explicitly tried-and-soft-failed, never blocking gameplay.
13. Dependencies (Asset Library / Addons)
Godot's asset library is the equivalent of npm. Apply the same discipline.
- Vetted, narrow, active. Same bar as main standard §6.
- Vendored into
addons/rather than fetched at build time. The version is what's in the repo. - Read the source of every addon at least once. They run with full project permissions.
- Default: zero. The engine does a great deal on its own. Reach for an addon only when justified.
14. Anti-Pattern Quick List
- Untyped GDScript in any non-prototype project.
get_node("/root/...")with deep absolute paths in code.free()on a Node (usequeue_free()).- Modifying a Resource you got from
preloadwithout.duplicate()when you needed an instance. - Business logic in
_processthat could be signal-driven. - Storing references to freed nodes (always
is_instance_validfirst). - Saving directly to the live file without an atomic rename.
- Crashing on a missing asset.
- Cheat keys, debug overlays, or
prints shipped to release. - Network calls without a soft-failure path in an offline-first app.
15. The Checklist (PR-time)
- [ ]
gdformat,gdlint, headless project check, GUT tests all green - [ ] All new GDScript is fully typed; strict warnings still pass as errors
- [ ] Signals disconnected on cleanup; no leaked timers
- [ ] No
free()on nodes;is_instance_validbefore touching long-held refs - [ ] Save format unchanged or version bumped + migration written + tested
- [ ] Missing-asset path tested for any new asset reference
- [ ] No
print/ debug overlays / cheat keys in code paths that ship to release - [ ]
set_process(false)on nodes that don't need it - [ ] Profiled on target-tier device for any change in a hot path
- [ ] No new addon, or justified in PR description against §13
References
Authoritative references for the Godot stack:
Sigma Godot Appendix — v1.2 · pairs with main standard v1.3