Scenario Godot Architecture
From skills by @scenario-labs · View on GitHub
Use when structuring Godot 4.7 game code: folders, scenes and nodes, signals or an event bus autoload, custom Resources for item data, components (health, hitbox, hurtbox), an inventory, save and load, InputMap and key rebinding for keyboard, mouse and gamepad, GDScript static typing and style, or GDScript vs C#; or when code is spaghetti, a folder scan breaks in the export, a save loads wrong, the gamepad does nothing, or @onready is null.
This skill ships inside the skills package. Install the package to get this skill plus everything else in the bundle.
sv install scenario-labs/skillsGodot architecture (gameplay programmer and architect)
Target: Godot 4.7.2.stable, macOS Apple Silicon.
Expert architecture in Godot means scenes that run alone, data in Resources, behavior in small components, and every boundary (save file, input, export) proven by a test. This skill holds the expert rules from the sources, corrected where Godot 4.7.2 behaves differently, plus a tested G1 core (inventory, health and damage, save and load, input with rebinding) and the tools that build and audit it. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.
REQUIRED BACKGROUND: scenario-godot-expert (channels, gd_run, the review loop, 4.7 traps).
Status (2026-10-02, round 2): everything below ran in Godot 4.7.2 here. 12 live tests: 11 pass, the C# check was not run. 30 GUT tests and 17 offline lint checks also pass; round-2 probes are P15 to P17 in references/procedures.md.
Stance (the expert delta)
- Every scene runs alone; the owner injects. A scene that reaches up (
get_node("/root/..."),../) is coupled to one tree. Use "call down, signal up, event out" (Eric Peterson, yB3Wv-Lr7pg [00:05:54]; Godot docs, Scene organization). An event bus autoload holds signals and no state (Peterson [00:17:36]). State is owned high in the tree and injected downward, because_readyruns children first (Mark Wilson, BgrHnJDt2ww [00:12:29]).arch_audit.gdinstantiates each scene alone and flags errors, NodePaths that do not resolve and node exports left empty. - Definitions are Resources; per-instance state is not. Keep item databases in typed custom Resources, not Dictionaries: no id typos, and paths follow file moves (Godotneers, 4vAkTHeoORk [00:12:57]). A loaded Resource is one shared instance: a change through one reference is seen by the next
load()(verified), so treat definitions as read-only. Stacks, health and cooldowns go in RefCounted or Node objects. For a per-instance copy (durability, rolled stats) useduplicate()when the Resource is flat, andduplicate_deep(Resource.DEEP_DUPLICATE_ALL)when it holds external sub-resources:duplicate(true)kept those shared (verified). One Resource class per file, and a default for every_initparameter (Godot docs, Resources): an inner-class Resource reloaded its field as null, and_init(p_hp)without a default lost its fields (verified). - Never list folders at runtime; build a registry at edit time. Folder scans work in the editor and break in exports (Godotneers [01:13:35]). In an exported
.pck,DirAccess.get_files_atreturned only*.tres.remap(a naive scan loaded 0 of 3), whilelist_directoryand theItemCatalogfrombuild_catalogfound all 3 (verified). The pack is case-sensitive: a wrong-case path loaded on macOS with only a "Case mismatch" warning and did not exist in the pack, so name every file and folder in lowercase snake_case (Godot docs, Project organization; lint A11, A12). A.gdignoremust be empty and takes no patterns (lint A15). It hides a folder from the importer, not fromload(): an.svginside failed with "No loader found", while a text.tresstill loaded (verified [added]). - A save file is data, never code. Verified 4.7.2 [added]:
- a
.treswritten touser://with an embedded GDScript ran that script onload(); str_to_varandConfigFile.parseboth built a Node fromObject(Node,...)text.
Use a JSON envelope (format, version, data) with JSON.from_native. It keeps int, StringName and Vector3, while plain JSON returns 3 as 3.0. It refuses objects: it logs an error and writes null. Write atomically (.tmp, .bak, rename) and migrate by version. Plan saving from the start (Wilson [00:17:03]).
- Stable roots, one level entry point, spawns under a stable parent.
MainGamecoordinates fixed containers, and the level exposes spawn markers and never owns the player (FAT Earth Studios, V4SO7foDoW4 [00:04:56]). Projectiles go underEntityRootorEffectRoot, because freeing a tower freed its children (Godotneers, W8gYHTjDCic [01:05:16]). Change levels at idle time (FAT Earth [00:10:13]). Changing level insidebody_enteredlogs "Removing a CollisionObject node during a physics callback";request_level()defers and is clean. Fix to the source'sinstantiate() as BaseLevel[added]: a wrong type gave null and leaked the instance after the old level was gone, so checkis BaseLevelfirst and free it on refusal (verified). - Input is one table, written by a tool.
InputActions.defaults()feedssetup_input, which writes[input]intoproject.godot. Every event usesdevice = -1. Verified: a joypad event built in code defaults to device 0 and does not match pad 1. Keyboard reports device 16 and mouse 32 in 4.7 (version deltas). Bind keys byphysical_keycode[added]. Store runtime rebinds as plain JSON inuser://, never as InputEvent resources. - Static typing is a gate, not a style. Typing warnings are off by default (Godot docs, Static typing). At warn level (1) nothing prints headless, so
typing_gatesets them to error (2) on an APFS clone. Typed code is faster by less than Boon's 116% (mNa0m2fvGOc [00:01:39]; see Numbers). Cast policy (docs, Static typing):x as Treturns null silently, while a typed assignmentvar l: Label = nodefails loudly ("Trying to assign value of type 'Button' to a variable of type 'Label'") and aborts the function. Useisand a typed local, or the typed assignment where a wrong type is a bug; keepasfor a null you handle.var n := get_node("X")infers plainNode, and the gate rejectsn.position("not present on the inferred type Node"): writevar n: Node2D = $X. Nested typed collections do not compile ("Nested typed collections are not supported");Array[Array]andDictionary[StringName, Array]do. Use typed math:clampfran 1.70x to 1.74x faster thanclampover 1M calls, andclamp()on Colors is a parse error, so useColor.clamp()(verified). - Flip the sprite, not the body. FAT Earth's rule (zLdTvkLsmgA [00:04:30]) holds, but the cause differs. Verified in 2D: shapes followed the mirror, but
move_and_sliderewrote scale (-1, 1) as rotation 180 and scale.y -1, so per-framescale.x = -1flipped the body every frame. Useflip_hand mirror the marker positions. preloadis a strong reference held by the script (FAT Earth, zLdTvkLsmgA [00:05:34]). Verified: a preloaded.treswas cached as soon as its holder script loaded, with no instance, and stayed cached after the probe dropped the script; aload()in_readywas released when its node was freed. A permanent node preloading menu > cutscene > level keeps the chain alive: preload small pieces next to their user, load levels and menus on demand (lint A09).
Also from the sources (short rules)
| Rule | Source | Checked here |
|---|---|---|
Open sets talk by duck typing: has_method(&"take_damage"), groups as classifiers, propagate_call(&"on_game_over"), call_group. Your own components use is Hurtbox3D | Godotneers, W8gYHTjDCic [00:19:36, 00:38:12] | propagate_call reached a grandchild |
| A silent collision is a layer or mask bug first | W8gYHTjDCic [01:03:09] | arch_audit flags layer 0 with mask 0; unmatched_masks (P16) flags mask bits no object uses as a layer |
A state Resource as source of truth lets you dump and reload the whole game to retest a bug. Dev builds only: never load a player-supplied .tres (stance 4) | Szuflita, 1qHPKg_Xovs [00:06:11] | dump to user://debug/, reload with CACHE_MODE_IGNORE: values intact |
top_level = true, or a plain Node between two Node2D, cuts transform inheritance | Godot docs, Scene organization | parent moved to x=100: both children stayed at x=10, a normal child went to 110 |
ItemList.select() emits no item_selected: call the handler. The list requests 0x0: set custom_minimum_size (250 px in the source) | 4vAkTHeoORk [00:53:45, 00:47:32] | 0 signals, minimum size (0, 0) |
| Craft checks copy the recipe before erasing from it | 4vAkTHeoORk [01:07:14] | Inventory.has_all test |
A dialog in a captured-mouse 3D game sets MOUSE_MODE_VISIBLE and restores the previous mode on close | 4vAkTHeoORk [00:37:01] | headless reads mode 0 whatever you set: test windowed or by hand |
| Build a debug god mode early (no gravity, no hurtbox, faster) | zLdTvkLsmgA [00:02:34] | concept, not run |
Git: ignore .godot/, commit .uid sidecars, LFS patterns in .gitattributes, commit cleanly before an add-on or a Godot upgrade | BatteryAcidDev, c3Jf-av_5NE [00:02:40, 00:22:20] | git check-attr routes png to lfs; scripts get .gd.uid, text .tres files do not (git-lfs not installed) |
Establish first
- 2D or 3D, genre, single or local multiplayer (local multiplayer needs one action set per device, not
device = -1). - Target platforms (exports are case-sensitive; C# cannot export to the web).
- Language: GDScript by default; C# needs the .NET build plus the SDK (check for a
.csproj; note inreferences/expert-notes.md). - Save needs (slots, autosave, versioning, cloud), devices (pads, rebinding), and whether a GUI editor has the project open (then use scenario-godot-expert's MCP channel).
Defaults when nobody says otherwise: typed GDScript, the layered layout, a signals-only Events autoload, Resources for definitions, JSON saves at version 2, input built from the table, and GUT tests.
Workflow
Setup: import gd_architecture as A. P is your own clone from gd_env.base_project("3d", ...).
- Project. Run
A.unique_user_dir(P, "<unique name>")(all Base3D clones share oneuser://otherwise, observed), thenA.scaffold_folders(P),A.install_core(P)andA.register_autoload(P, "Events", "res://core/events/events.gd"), followed bygd_run.import_project(P). GATE:project.godotcontains3d/physics_engine="Jolt Physics"and the stretch lines, with at most a handful of autoloads. - Main scene. Run
A.build_main_scene(P, props={"player_scene": ...}). It builds Main (always) > Systems; World (pausable) > LevelRoot, EntityRoot, EffectRoot; CanvasLayers HUD 10, PauseLayer 20 (when paused), TransitionLayer 30 and DebugLayer 40 (always), with gaps for later layers (FAT Earth). GATE:A.audit_scenes(P)has no flags; the pause test shows World still and TransitionLayer running. - Data. Write
ItemDefinition.tresfiles (id, max_stack, tags, icon, world_scene). RunA.build_catalog(P), thenA.validate_resources(P, root=..., cls="ItemDefinition", required=(...)). GATE: 0 problems, unique ids, and the reloaded count equals the file count. - Components.
HealthComponent(Node: signals, resistances,diedexactly once),Hurtbox3DandHitbox3D(Areas; the hurtbox has an exportedhealthand a configuration warning),Inventory(RefCounted: stacks, leftover, all-or-nothing remove), and a thin Player script as glue. Keep components dumb (Firebelley, rCu8vQrdDDI [00:03:42]). GATE: the GUT tests, including a real physics overlap that deals 25 damage. - Save and load. Add a
PersistComponent(save_id, properties, the target'ssave_state()) to each saved entity.SaveService.collect,write,readandapplydo the rest. GATE: tests for the type round trip, the.bakfallback after corruption, the v1 to v2 migration (int restored), and the whole game (inventory, health, position). - Input. Edit the
InputActionstable, runA.setup_input(P), and useInputRemapat runtime. GATE: 13 actions, every eventdevice -1; tests forparse_input_event, a pad 1 match, conflict moves, and the bindings round trip. - Gates. Run
A.typing_gate(P),A.lint(P), the bite test (break one guard and watch exactly one test fail), andA.pack_smoke(P, out, args). GATE: all green. See Quality gates.
Numbers
| Value | Measured here (4.7.2 editor binary, this Mac) | Relative to |
|---|---|---|
| Typed vs untyped bubble sort, n=2000 | 82.5 vs 124.1 ms (1.50x; 1.46x to 1.50x over 3 runs) | Boon 4.1: 116% faster |
Array.sort, n=200,000 | 23.5 vs 23.5 ms (1.00) | typing does not help built-ins |
| GUT suite, G1 | 30 tests, 129 asserts, 0.8 s | bite test: 126 of 129 |
Exported .pck, G1 | 1.59 MB, smoke run 0.3 s | no export templates needed |
Strict gate hits (unsafe_call_argument) | 33 hits across 8 files | default gate: 0 over 22 files |
clamp vs clampf, 1M calls | 29.5 to 30.9 vs 17.1 to 17.9 ms (1.70x to 1.74x, 4 runs) | typed math helpers |
Quality gates
- Measurable:
typing_gatereturns 0 warnings as errors;lintreturns no error findings (A03 folder scan, A04 user://.tres, A05str_to_var, A07 inner Resource, A08_initwithout defaults, A11 path case);audit_scenesreturns no flags;validate_resourcesreturns no problems;- GUT is green and the bite test goes red;
pack_smokeshowslist_directoryand the catalog loading inside the pack;project.godotis checked by text for Jolt, the main scene and the autoloads.- Visual: headless renders nothing. For any inventory or debug UI on this core, capture windowed with
gd_run.capture_scene(P, "res://ui/inventory_debug.tscn", out="captures/inv.png", size=(1280, 720)), runimage_checksand open the image (P17: 11 frames, 0.17 s). Layout and feel belong to scenario-godot-ui and scenario-godot-gameplay. In the GUI editor, Scene dock warning icons appear only where intended.
Common mistakes
| Mistake | What it looks like | Fix |
|---|---|---|
Plain JSON.parse_string save | count becomes 3.0, Vector3 becomes a string | from_native and to_native, or _restore_ints for v1 |
ProjectSettings.save() from a job | header comment and default keys dropped (observed) | expected; re-check Jolt and stretch afterwards |
load(path).instantiate() | unsafe method access in the gate | var ps: PackedScene = load(path) or preload |
@onready read before add_child | null | set fields in the constructor; read nodes after add_child (Godotneers [00:33:06]) |
GUT 9.7.1 assert_engine_error(1) | test fails, "Use assert_engine_error_count" | assert_push_error("text") or assert_engine_error_count(n) |
Handoffs
- Receives from: scenario-godot-expert (the brief, channels, toolkit); scenario-* skills (icons and models referenced by
ItemDefinition.iconandworld_scene). - Delivers to: scenario-godot-gameplay (state machines, combat feel on these components); scenario-godot-ui (HUD on
EventsandInventory.changed, rebinding viaInputRemap.rebind); scenario-godot-2d and scenario-godot-3d-world (BaseLevelscenes with spawn markers); scenario-godot-multiplayer (authority over health and inventory); scenario-godot-pipeline-automation (CI for the gates); scenario-godot-performance-export (real exports). - Form: the
core/folder,project.godotentries, the tests, and the evidence JSON.
Godot 4.7 notes
- Typed Dictionaries (4.4) are used in the core.
.uidsidecars (4.4) must move with their scripts (lint A14).ResourceLoader.list_directory(4.4) andduplicate_deep(4.5) exist. FileAccess.store_*returns bool (4.4): check it before the rename.class_nameresolves only after--import. A script that preloads its own scene fails to parse until the.tscnexists (verified).- Agent-written
project.godotdefaults to GodotPhysics3D. Write Jolt explicitly, and re-read the file after anyProjectSettings.save().
References
references/expert-notes.md(principles by expert, timestamps, live corrections),procedures.md(every tool, test and recorded result),critique.md(self-review rubric),gui-paths.md(editor GUI steps),sources.md(URLs).scripts/gd_architecture.py,scripts/agentkit/architecture/*.gd,scripts/templates/core/: the tools and the tested G1 core.