Write explicit static types by default
Use explicit static annotations wherever GDScript provides a useful annotation point. Types are source-level contracts for humans and coding agents, not merely information the editor can infer.
Prefer:
@export var move_speed: float = 240.0
var _input_direction: Vector2 = Vector2.ZERO
func _physics_process(delta: float) -> void:
var direction: Vector2 = _input_direction
Do not replace explicit declarations with inferred static typing solely because := can infer the same type. The annotation should preserve the narrowest truthful contract.
Enforce maximum practical static safety
Treat missing type declarations, inferred declarations that bypass G001, inference from Variant, and unsafe calls/casts/member access as first-party correctness failures.
Promote these warnings to errors in first-party code where Godot 4.7.2 supports it:
- UNTYPED_DECLARATION
- INFERRED_DECLARATION
- INFERENCE_ON_VARIANT
- UNSAFE_CALL_ARGUMENT
- UNSAFE_CAST
- UNSAFE_METHOD_ACCESS
- UNSAFE_PROPERTY_ACCESS
- UNSAFE_VOID_RETURN
Keep context-sensitive hygiene warnings advisory unless they prove enough to justify an error. Use narrow warning suppressions only at real proof or integration boundaries. Third-party addons do not become first-party code merely because they are checked into res://addons.
Follow Godot naming and give types real identity
Use:
- snake_case for files, folders, functions, variables, and signals;
- PascalCase for classes and Node names;
- CONSTANT_CASE for constants and enum members;
- singular PascalCase for enum types;
- a leading underscore for private/internal members;
- event/past-tense signal names;
- natural predicate names such as is_grounded, has_target, and can_dash.
If another first-party script needs a script as a type, that script must declare class_name.
class_name PlayerController
extends CharacterBody2D
Keep the file/class relationship obvious: player_controller.gd → PlayerController. A scene-local script may remain unnamed until global type/editor identity is actually useful.
Keep declarations in Godot's role-first order
Use this script order:
- @tool, @icon, @static_unload
- class_name
- extends
- documentation
- signals
- enums
- constants
- static variables
- exported variables
- regular variables
- @onready variables
- _static_init()
- static methods
- built-in virtual callbacks
- overridden custom methods
- remaining methods
- inner classes
Within comparable groups, public declarations come before private ones. Keep local variables near first use. Do not turn locals into members just to organize a file, and do not use regions to hide a script that owns too many responsibilities.
Acquire Node references according to relationship semantics
Use the reference form that tells the truth about the dependency.
Same-scene semantic role; hierarchy is not the contract
→ %UniqueName
Local structural relationship is intentionally part of the contract
→ $Path
Collaborator/configuration supplied from outside private scene structure
→ typed external Node reference
Dynamic NodePath itself is meaningful
→ get_node() / NodePath API
Examples:
@onready var _health_bar: ProgressBar = %HealthBar
@onready var _collision_shape: CollisionShape2D = $CollisionShape
Avoid brittle parent traversal such as ../../.. as dependency injection. Acquire stable references once; do not cache identities that are genuinely dynamic.
Export only intentional authoring configuration
An exported property is part of the Inspector-facing authoring API.
@export_range(1, 20, 1)
var max_health: int = 5
var _current_health: int = 5
Export configuration, not ordinary mutable runtime state. If one value is invariant across all instances, prefer a constant. Use range, enum, flags, unit, grouping, and other hints when they encode real authoring constraints.
Exported Resources are load dependencies. Do not export a large Resource or PackedScene merely to hold a reference when the real requirement is deferred loading.
Do not depend on Inspector overrides in _init(); serialized values are applied later.
Connect signals in code at the relationship-owning boundary
The event owner declares and emits a typed signal. The object/composition boundary that owns the emitter-receiver relationship normally establishes the connection in code.
func _ready() -> void:
_health.health_depleted.connect(_audio.play_death_sound)
Prefer first-class signal.connect() and signal.emit() APIs over string-based connection APIs.
Do not make editor-persisted signal connections the ordinary first-party source of truth. Do not add symmetric disconnect boilerplate when object lifetime already removes the connection; disconnect explicitly when the relationship changes while both objects remain alive.
Use CONNECT_ONE_SHOT for a true next-event-only relationship. Use deferred connections only when deferred timing is part of the contract.
Commands and queries are calls; events are signals
Use a direct method call when one object intentionally asks a known collaborator to do something or return information.
_weapon.fire()
var damage: int = _weapon.calculate_damage()
Use a signal when an object announces a fact that zero, one, or many listeners may observe.
signal health_depleted
func take_damage(amount: int) -> void:
# Mutate owned state first.
if _health == 0:
health_depleted.emit()
A command may cause an event: Player calls Weapon.fire(), then Weapon may emit fired. Do not disguise required one-to-one control flow as a signal just to reduce visible coupling.
Give self-contained composed game objects their own scenes
Create a saved scene when a subtree has become a coherent game/editor object that should own its internal composition, configuration, lifecycle, and root API.
Reuse strengthens the case but is not required.
door.tscn
Door
├── Sprite
├── CollisionShape
├── InteractionArea
└── OpenSound
Outside code should call Door behavior rather than reach through to manipulate those internals.
A script-only Node remains valid when a .tscn wrapper would add no meaningful declarative composition or authored object identity. A unique Boss can still deserve boss.tscn; "used twice" is not the scene threshold.
Compose by default; inheritance must prove is-a
Use composition for optional, swappable, orthogonal, or independently meaningful capabilities.
Enemy
├── Health
├── Movement
├── Targeting
└── Weapon
Use script or scene inheritance only when the derived object is genuinely a specialized form of the base object and remains valid wherever the base contract is expected.
Code reuse alone does not justify inheritance. A subclass that repeatedly overrides base behavior to do nothing is evidence that the base contract is lying. Do not create a base class or generic component for hypothetical future reuse.
Use the lightest truthful runtime representation
Choose representation by semantics:
Fundamentally belongs in the live SceneTree
→ Node
Authored/serialized Godot asset identity is fundamental
→ Resource
Continuing runtime object with identity/state/invariants but no tree or asset role
→ RefCounted
Values alone are enough
→ built-in typed data
No continuing object exists
→ function
Do not use Node because "Godot code lives on Nodes." Do not use Resource merely because something is data or might be serialized someday.
Keep authored Resource definitions separate from per-instance mutable runtime state unless sharing/localization is deliberately part of the model. Write extends RefCounted explicitly when that representation is an architectural choice.
State flows through one authoritative owner
The canonical direction is:
Writes → toward the owner through methods
Reads → through deliberate query APIs
Events → outward through signals
Prefer semantic operations:
_health.take_damage(2)
_weapon.consume_ammo(1)
_door.unlock()
over external raw property mutation.
A coordinator may orchestrate several owners without absorbing their underlying state ownership. Do not expose mutable collections in a way that silently grants parallel write authority. Keep derived state derived unless a concrete caching need exists and one owner controls invalidation.
Autoloads are exceptional global infrastructure
Use an Autoload only when the system genuinely has project/session-wide lifetime and access needs that cross ordinary scene boundaries.
Strong candidates include scene-transition coordination, save/load orchestration, or a truly persistent quest/dialogue/music system.
Do not create a generic GameManager state bucket. Global access does not weaken G012:
QuestSystem.complete_quest(quest_id)
is preferable to external mutation of QuestSystem internals.
An Autoload script does not declare a class_name matching its Autoload name; Godot 4.7.2 rejects that as hiding the singleton. Callers reach it through the Autoload name, which is the exception to G003's class_name requirement.
Use static functions/values for shared behavior that has no continuing runtime instance or Node lifecycle. Do not move a scene-local dependency to Autoload merely to avoid passing a reference.
Groups represent semantic sets, not hidden services
Use groups when membership itself is meaningful and callers genuinely want the current set.
const GROUP_ENEMIES: StringName = &"enemies"
func count_enemies() -> int:
var enemies: Array[Node] = get_tree().get_nodes_in_group(
GROUP_ENEMIES,
)
return enemies.size()
Query the group when the set is needed. A member variable initialized with get_tree() runs before the Node enters the SceneTree and fails.
Important first-party group names should use stable StringName constants.
A group broadcast is valid only when every member really shares the command contract and fan-out to all current members is the intended operation.
Do not use a one-member group as a service locator for "the player" or another singleton-like dependency. Group membership does not replace a type contract.
Give each lifecycle callback one semantic job
_init() intrinsic object construction
_enter_tree() repeated active SceneTree entry
_ready() fully composed scene initialization, normally once
_exit_tree() ending active SceneTree membership
_notification() lower-level engine event with no clearer callback
Do not read final Inspector-authored values or child dependencies in _init().
Use _enter_tree() / _exit_tree() for work that must pair with every tree membership. Use _ready() for setup that requires @onready references, children, or authored configuration.
Prefer dedicated callbacks over a giant _notification() switchboard. request_ready() is exceptional; repeated tree-entry semantics normally belong in _enter_tree().
Give each time-based responsibility one semantic clock
Fixed simulation / collision-sensitive state
→ _physics_process()
Rendered-frame presentation / nonphysics frame work
→ _process()
Discrete "after/every N seconds"
→ Timer / SceneTreeTimer
No current per-frame responsibility
→ disable that processing loop
Do not make the fixed physics tick a universal gameplay clock, and do not make render frames a universal gameplay clock.
Avoid independently mutating the same state transition from both process loops. A manual countdown is justified when its changing intermediate value itself belongs to that clock, not merely to recreate Timer behavior.
Use InputMap action names as the normal gameplay contract.
Discrete gameplay action
→ InputMap + _unhandled_input()
Held / analog state
→ Input polling in the clock that owns the resulting state
Control-owned UI input
→ Control / GUI input path
Must see event before UI
→ _input()
Physical key/button itself matters
→ concrete InputEvent type
Do not hardcode Space means jump when the domain concept is jump. Poll movement in the physics loop when movement is physics-owned. Do not create a general PlayerInput/buffering subsystem before replay/network/buffering requirements actually exist.
Use await for short, owned, linear sequences
A good await sequence has one clear owner and one clear continuation.
func play_intro() -> void:
_animation_player.play(&"intro")
await _animation_player.animation_finished
_enable_player_control()
Use persistent signal connections for ongoing observation. Use explicit state machines for long-lived, branching, interruptible, cancellable, or externally controlled temporal behavior.
If the caller needs completion/result, the caller awaits too. Calling a coroutine without await is a deliberate detached operation and should be exceptional.
An await crosses time; revalidate dependencies after suspension when they can genuinely become invalid. Do not use await inside per-frame callbacks as a normal delay mechanism.
Let loading communicate ownership and timing
Use this ladder:
Exact dependency chosen by code and always needed
→ preload()
Dependency chosen in Inspector and should load with the owner
→ typed @export Resource / PackedScene
Dependency chosen in Inspector but should load later
→ @export_file path
Runtime-selected or intentionally on-demand dependency
→ load()
Need explicit cache/type/dependency/background controls
→ ResourceLoader
Runtime load may visibly block
→ threaded ResourceLoader
A first-party script needed as a type uses class_name rather than script preload merely for naming.
Loading a PackedScene and instantiating it are separate operations. Repeated load() normally reuses Godot's Resource cache; it does not promise a fresh mutable Resource.
Spawn at an explicit ownership boundary
The boundary that decides an instance should exist owns the construction sequence:
obtain PackedScene
→ instantiate
→ configure required pre-tree state
→ add_child under the lifetime owner
If _ready() requires runtime-supplied configuration, provide it before adding the Node beneath an in-tree parent.
The semantic parent normally owns ordinary runtime lifetime. Prefer queue_free() for ordinary Node removal.
Do not introduce a universal SceneFactory unless construction policy itself becomes genuinely shared/complex. Reparenting is an ownership/lifetime change, not a visual convenience.
Use SceneTree scene-change APIs for replacing the main scene rather than rebuilding current-scene bookkeeping manually.
Distinguish bugs, expected failures, and diagnostics
Use different mechanisms for different meanings:
Programmer invariant / impossible internal state
→ assert()
Expected recoverable failure
→ Error, nullable return, or named domain result
Serious runtime diagnostic
→ push_error()
Actionable suspicious/degraded condition
→ push_warning()
Invalid external/user/data/configuration state possible in release
→ runtime validation + safe failure/recovery path
Assertions are debug-only and their expressions must have no side effects. Never make assert() the only release protection for data that can actually be invalid.
push_error() reports a problem; it does not define control flow. Use null only for expected absence, not as one value meaning failed, invalid, cancelled, and missing simultaneously.
Organize res:// by game concept and ownership
Prefer feature/domain colocation:
res://
├── characters/
│ ├── player/
│ └── enemies/
├── combat/
├── levels/
├── ui/
├── shared/
├── autoload/
├── tests/
└── addons/
Keep a scene, its script, feature-specific Resources, and local assets near one another. Move content to shared/ only after it is genuinely shared.
Do not organize a growing game primarily into global scenes/, scripts/, resources/, textures/, and audio/ silos.
Keep third-party/plugin content isolated under addons/ and do not rewrite it merely to satisfy first-party style. Feature tests stay with the feature; cross-feature tests may live at top level.
Test the smallest truthful unit.
Keep pure logic testable outside SceneTree when it does not need Node semantics. Use the smallest real scene fixture when lifecycle/composition matters. Test through public commands, queries, and signals rather than normalizing private-state mutation.
Add deterministic regression tests for bugs when reasonably automatable.
This guide is test-framework-neutral; any third-party Godot test framework must be deliberately selected and pinned for Godot 4.7.2.
Use @tool only when editor execution is itself a feature. Use Engine.is_editor_hint() when editor/runtime behavior differs. Keep editor mutations narrow because tool code can alter scenes during authoring.
Use OS.is_debug_build() for debug-only runtime instrumentation, never for release correctness or secrets.
First-party GDScript follows the Godot 4.7.2 mechanical style where this guide has not deliberately overridden it: UTF-8 without BOM, LF, tabs, trailing commas in multiline collections/enums, one statement per line, normal whitespace, double quotes by default, English and/or/not operators, and code lines under 100 characters.
Machine-enforce G001/G002 warnings and other reliably detectable rules. Verify that automation uses Godot 4.7.2.
Useful native gates include:
godot --version
godot --headless --path <project> --import
godot --headless --path <project> --script res://tests/check_scripts.gd
Do not use --check-only as a pass/fail gate. In Godot 4.7.2 it prints parse errors but still exits 0, and it does not register Autoload names, so valid Autoload calls are reported as unknown identifiers. Godot also exits 0 when the --script file itself fails to load.
A script gate loads every first-party script under the project's warning settings and exits non-zero on any failure:
extends SceneTree
## Loads first-party scripts with project warning settings; fails on any error.
const PASSED_MARKER: String = "SCRIPT_CHECK_PASSED"
const SKIPPED_DIRECTORIES: PackedStringArray = ["addons"]
func _initialize() -> void:
var own_script: Script = get_script()
var failures: PackedStringArray = []
for path: String in _find_scripts("res://"):
if path == own_script.resource_path:
continue
var script: GDScript = ResourceLoader.load(
path,
"GDScript",
ResourceLoader.CACHE_MODE_IGNORE,
)
if script == null or not (script.can_instantiate() or script.is_abstract()):
failures.append(path)
for path: String in failures:
printerr("Script check failed: ", path)
if failures.is_empty():
print(PASSED_MARKER)
quit(0 if failures.is_empty() else 1)
func _find_scripts(directory: String) -> PackedStringArray:
var paths: PackedStringArray = []
for child: String in DirAccess.get_directories_at(directory):
if child.begins_with(".") or SKIPPED_DIRECTORIES.has(child):
continue
paths.append_array(_find_scripts(directory.path_join(child)))
for file: String in DirAccess.get_files_at(directory):
if file.get_extension() == "gd":
paths.append(directory.path_join(file))
return paths
Treat the run as passed only when the exit code is 0 and the output contains SCRIPT_CHECK_PASSED.
Run the repository's real automated tests as part of validation.
Do not claim a formatter, regex, or generic lint preset can prove architectural decisions such as correct ownership, scene boundaries, signal semantics, Autoload justification, or clock choice. Those remain review obligations.
Agents preserve semantic ownership and verify reality
Before editing, inspect the owning scene, scripts, Resources, local project instructions, and relevant tests.
Then:
read local context
→ identify the real owner/boundary
→ make the smallest coherent change
→ preserve external/project contracts
→ validate with Godot 4.7.2 + repository checks
→ report deliberate exceptions
Do not introduce managers, globals, factories, base classes, generic components, Resource wrappers, or service locators merely because an agent can generate them cheaply.
Do not silently change project-wide Autoloads, InputMap, physics settings, warning levels, main scene, rendering/project settings, addons/dependencies, export presets, shared group vocabulary, or persistent schemas.
Treat .tscn/.tres as source-controlled project data: text edits are allowed when understood, but reopen/import/parse afterward. Do not invent Resource UIDs or generated import/cache content.
Never claim tests/builds passed unless they actually ran. Keep justified exceptions narrow and record why they exist.
Scope and future additions
The 1.0 core covers common first-party GDScript, scene architecture, ownership, lifecycle, loading, testing, and agent-contribution decisions for Godot 4.7.2.
Specialized multiplayer, advanced threading, editor-plugin architecture, shaders, navigation/AI-specific systems, platform integration, performance-specialized escape hatches, and addon interoperability remain additive 1.x topics unless real project evidence requires reversing a core rule.
Engine references
These references describe Godot behavior. The house choices above remain this guide's own conventions.