GDScript style
Idioms a competent Godot dev ships: static typing, signals over polling, Resources for data.
How to write GDScript a competent Godot dev would ship. Patterns here are durable;
for any exact API/signature, confirm against the live engine with
engine class-info --class <X> / engine search rather than trusting memory.
Check the style, don’t eyeball it#
Most of this page is machine-checkable. After writing or editing a script, run:
script lint --path res://path/to/file.gdscript lint returns structured findings (path, line, rule, severity, message)
against the official GDScript style guide, covering 17 rules: the 9 naming rules below run at
severity error, the rest as warnings. It needs no external tool.
There is no auto-formatter. Write to the guide as you go and let the linter catch the rest; the rules below are the whole contract.
Two things to know before trusting a clean result:
- A clean lint is not a passing compile. Style rules read source, so they still report
on a file that doesn’t parse, and zero findings there read exactly like a pass. A single-file
run reports
syntax_valid; a directory run is style-only.script validateis the compile check. max-line-length(default 100) is the noisy rule. Silence it with--disable max-line-length, raise it with--max-line-length, or pass0to turn it off when a project deliberately runs long lines.
Suppress a single line in the source itself, which survives outside the tool:
# gdlint-ignore-next-line variable-namevar LegacyName := 1
var OtherName := 2 # gdlint-ignore variable-nameIndentation is tabs, per the official guide. The examples below use spaces for readability in Markdown, so indent the real file with tabs rather than copying the spacing.
Type everything#
Static typing catches errors at parse time, runs faster, and gives the editor real autocomplete. Untyped GDScript is a smell.
var speed: float = 300.0var _targets: Array[Node] = []@onready var sprite: Sprite2D = $Sprite2D
func take_damage(amount: int) -> void: health -= amount- Annotate every
var, parameter, and return type. Use:=for inferred locals (var dir := Input.get_vector(...)is already typed). - Type your arrays:
Array[Vector2],Array[Enemy]. -> voidon functions that return nothing.
Naming#
snake_casefor variables, functions, files (player_controller.gd), and signals.PascalCaseforclass_name, node names in the tree, and enum types.CONSTANT_CASEcoversconst MAX_SPEED := 600.0and enum members._leading_underscoremarks private members and helpers (_velocity,_update_ui()).
Every one of these is enforced by script lint, as the rules variable-name,
function-name, function-argument-name, loop-variable-name, signal-name,
class-name, enum-name, enum-member-name, and constant-name. All nine are severity
error, and variable-name covers locals, not just members. The remaining rules catch
real defects rather than naming: duplicated-load, standalone-expression,
unnecessary-pass, unused-argument, comparison-with-itself, private-access,
no-else-return, and max-line-length. Passing an unknown name to --disable returns
an error listing all seventeen.
One exception: a const bound to preload()/load() accepts either case,
because both are idiomatic. PascalCase fits a script used as a type
(const NodeUtils := preload(...), called as NodeUtils.foo()). CONSTANT_CASE fits an
asset (const PLAYER_SCENE := preload(...)).
Node references: never get_node("../../X")#
Brittle path chains break the moment the tree changes. In order of preference:
- Child of self:
@onready var hp: HealthComponent = $HealthComponent. - Scene-unique name (
%): mark a node “Access as Unique Name” in the editor, then@onready var bar: ProgressBar = %HealthBar, which survives reparenting. @exporta reference:@export var target: Node2D, wired in the inspector (and settable vianode.set --property target --value <NodePath>). Best for cross-branch references.
Expose data with @export#
Anything a designer (or you, via node.set) should tweak belongs in the inspector,
not hard-coded in _ready().
@export var speed: float = 300.0@export_range(0.0, 1.0) var friction: float = 0.1@export var projectile: PackedScene@export_enum("Idle", "Patrol", "Chase") var start_state: intThis is why the tools prefer node.set over writing values in code: it keeps them
visible and editable.
Lifecycle: pick the right callback#
_ready()runs one-time setup after children exist._process(delta)handles per-frame visuals, non-physics input polling, and UI._physics_process(delta)handles movement, physics, and anything needing a fixed timestep. Do movement here, not in_process._unhandled_input(event)takes gameplay input that UI didn’t consume.
Always scale motion by delta so it’s frame-rate independent: position += velocity * delta
(or use move_and_slide(), which handles it).
Signals over polling#
When something happens, emit a signal; don’t make other nodes check state every frame. That decouples them, because the emitter doesn’t know or care who listens.
signal health_changed(current: int, max: int)signal died
func take_damage(amount: int) -> void: health = max(0, health - amount) health_changed.emit(health, max_health) if health == 0: died.emit()The HUD connects to health_changed; the spawner connects to died. Wire connections
with node.connect. Prefer typed signal args.
Data as Resources#
For stats, items, and configs, define a custom Resource rather than a Dictionary or
constants buried in code. Designers edit .tres files; code stays generic.
class_name EnemyStatsextends Resource
@export var max_health: int = 10@export var speed: float = 80.0@export var damage: int = 1Create instances with resource.create --type EnemyStats, assign with
node.add_resource / node.set.
Use class_name and autoloads sparingly#
- Add
class_name Foowhen a type is reused across scenes or referenced by name (it registers globally, which also makes the type discoverable viaengine.script_classes). - Autoloads (singletons) are for true globals: a
GameState, anAudioManager, aSceneLoader. Don’t reach for them to avoid passing references, since that creates hidden coupling. Most state belongs in the scene that owns it.
Control flow & misc#
- Prefer
matchover longif/elifladders (states, enums). queue_free()to remove a node, notfree()(defers to end of frame, safe).- Wait a frame/time with
await get_tree().process_frameorawait get_tree().create_timer(0.5).timeout, never a busy loop. - Guard external lookups:
if is_instance_valid(target):before using a node that may have been freed. - Don’t allocate per-frame in hot paths (no
Array/Dictionarychurn in_physics_process).