Syntax Cache
BlogMethodFeaturesHow It WorksBuild a Game
  1. Home
  2. GDScript
  3. GDScript Best Practices
GDScript0 exercises

GDScript Best Practices

GDScript best practices for Godot 4: static typing, signal conventions, code ordering, scene composition, performance pitfalls, Inspector organization, and error handling patterns.

Common ErrorsQuick Reference
On this page
  1. 1What is the official GDScript code ordering?
  2. 2Should you always use static typing?
  3. 3What are the signal naming conventions?
  4. 4How should you organize scenes and nodes?
  5. 5What are the most common performance mistakes?
  6. 6What are the biggest anti-patterns in GDScript?
  7. 7How do you organize exports in the Inspector?
  8. 8How does error handling work in GDScript?
What is the official GDScript code ordering?Should you always use static typing?What are the signal naming conventions?How should you organize scenes and nodes?What are the most common performance mistakes?What are the biggest anti-patterns in GDScript?How do you organize exports in the Inspector?How does error handling work in GDScript?

The official GDScript style guide covers naming and file ordering. This page goes further: the patterns that prevent bugs, the anti-patterns that cause them, and the performance habits that separate smooth games from stuttery ones.

Most GDScript problems fall into three buckets: untyped code that catches errors too late, signals wired in the wrong direction, and uncached node lookups in hot paths. Fix those three and you eliminate the majority of GDScript headaches. For the type system in depth, see types. For signal patterns and the signal bus, see signals. For Timer recipes, see timers & signals.

Related GDScript Topics
GDScript Types and Static TypingGDScript SignalsGDScript FoundationsGDScript ExportsGDScript Resources

The Godot style guide specifies a 17-item ordering for every script file. Following it makes your codebase predictable, so anyone opening a file knows exactly where to look.

  1. @tool, @icon, @static_unload annotations
  2. class_name
  3. extends
  4. Documentation comments (##)
  5. Signals
  6. Enums
  7. Constants
  8. Static variables
  9. @export variables
  10. Regular variables
  11. @onready variables
  12. _static_init()
  13. Other static methods
  14. Built-in virtual methods (_init, _enter_tree, _ready, _process, _physics_process, etc.)
  15. Custom overridden methods
  16. Remaining methods
  17. Inner classes

Within each section, public members come before private (underscore-prefixed) members. Use two blank lines between functions and one blank line inside functions to separate logical sections. Tabs for indentation, never spaces.

Ready to practice?

Start practicing GDScript Bests with spaced repetition

Yes. Static typing catches errors at parse time instead of during gameplay, gives you autocomplete in the editor, and generates faster bytecode. Independent benchmarks show 28-59% speedups depending on the operation. There is no good reason to skip it.

Use := when the type is obvious from the right-hand side: var speed := 50.0. Use explicit type annotations for get_node() results and anything ambiguous: @onready var timer: Timer = $Timer. The casting form also works: @onready var timer := $Timer as Timer.

To enforce typing across your project, enable the UNTYPED_DECLARATION warning in Project Settings. This flags every variable, parameter, and return type that lacks an annotation. Also enable UNSAFE_PROPERTY_ACCESS and UNSAFE_CAST for maximum safety.

For the full type system (Variant, typed arrays, typed dictionaries, casting behavior), see types.

Signal names use past tense to describe something that just happened: health_changed, died, door_opened, item_collected. For actions with a clear start and end, use _started and _finished suffixes: animation_started, level_loading_finished.

Callback functions follow the pattern _on_NodeName_signal_name. For self-connections where you listen to your own signal, omit the node name: _on_button_pressed.

The most important signal rule is directional. Signals go up the tree (child emits, parent listens). Direct method calls go down (parent calls child). This is the "call down, signal up" principle. When a child node directly references its parent with get_parent(), that is almost always a design mistake. Emit a signal instead and let the parent connect to it.

For signal connection syntax, see signals. For Timer patterns, see timers & signals.

Godot's node tree is a composition system. Build entities by combining specialized child nodes rather than writing monolithic scripts:

# Player (CharacterBody2D)
#   |- Sprite2D
#   |- CollisionShape2D
#   |- AnimationPlayer
#   |- HitboxComponent (Area2D)
#   |- HealthComponent (Node)

Each node handles one responsibility. Reusable behaviors become their own scenes (HealthComponent.tscn) that you instance wherever needed. One script per node. If a script file grows past 200-300 lines, that is a signal to split behavior into child nodes.

Don't reinvent what Godot gives you for free. Signals are the observer pattern. The node tree is composition. Autoloads are singletons. State machines are worth building when you have three or more states, but you don't need a framework for it.

What to avoid: object pooling (GDScript uses reference counting, not garbage collection), full ECS architectures (don't replace Godot's node system), and over-engineering with design patterns when a simple approach works.

Uncached node lookups in _process(). Every $Node or get_node() call traverses the scene tree. In _process(), that runs 60+ times per second. Cache references with @onready:

# Cache once
@onready var sprite: Sprite2D = $Sprite2D

func _process(delta: float) -> void:
	sprite.rotation += delta  # Uses cached reference

Physics code in _process(). The _process() callback runs at the monitor's refresh rate, which varies. Movement, collisions, and raycasts belong in _physics_process(), which runs at a fixed rate (default 60 Hz). Mixing these up causes inconsistent behavior across different machines.

String comparisons instead of StringName. Regular string comparison checks every character. StringName (&"name") uses pointer comparison, which is instant. Use StringName for input actions, animation names, and any string compared frequently: Input.is_action_pressed(&"jump").

distance_to() when you only need comparison. The distance_to() method computes a square root. If you just need to compare distances, use distance_squared_to() and compare against a squared threshold.

Building nodes in code instead of instancing scenes. preload("res://enemy.tscn").instantiate() is faster than creating nodes one by one with add_child(). Let the engine do the work in optimized C++ code.

Spaghetti references. Reaching deep into other scenes with paths like get_node("../../World/Enemies/Boss") creates fragile code that breaks when you rearrange the tree. Use signals or pass references through exported variables.

God scripts. A 500-line script handling movement, combat, inventory, and UI is hard to debug and impossible to reuse. Split responsibilities into child nodes with their own scripts.

Missing delta. Writing position.x += 5 in _process() makes speed depend on frame rate. Always multiply by delta: position.x += speed * delta.

Autoload overuse. Having GameManager, AudioManager, UIManager, EnemyManager, and ScoreManager as autoloads means everything is global. Reserve autoloads for truly global state: audio bus management, save/load, scene transitions. Pass references for everything else.

Type-unsafe node access. Using var player = get_node("Player") without a type gives you no autocomplete and no compile-time safety. Cast it: var player := get_node("Player") as Player.

GDScript has three levels of Inspector organization. @export_category("Player") creates a bold, non-collapsible header. @export_group("Movement") creates a collapsible section. @export_subgroup("Jump Settings") nests within the current group. End a group with @export_group("").

Use categories sparingly for major divisions. Groups and subgroups handle most organization needs. Put the most-tweaked properties at the top of each group.

For the full export annotation reference, see exports tuning.

GDScript has no try/catch. The engine philosophy is "keep the game running." Error handling relies on guard clauses, logging functions, and defensive checks.

Guard clauses handle invalid input at the top of a function:

func take_damage(amount: int) -> void:
	if amount <= 0:
		return
	if not is_alive:
		return
	health -= amount

Logging functions report problems without stopping execution. push_error() logs errors to the output panel. push_warning() logs non-critical issues. As of Godot 4.4, both print the calling function name automatically.

assert() validates invariants during development. Assertions only run in debug builds and are stripped from release exports. Use them for conditions that should never be violated: assert(max_health > 0, "max_health must be positive").

is_instance_valid() checks whether a node reference is still valid. Nodes freed with queue_free() become invalid, and accessing them crashes the game. Always check before using a stored reference that might have been freed. Similarly, get_node_or_null() returns null instead of crashing when a node path does not exist.

What You'll Practice: GDScript Bests

Following official code orderingUsing static typing consistentlyNaming signals and callbacks correctlyCaching node references for performanceOrganizing scenes with composition

Common GDScript Bests Pitfalls

  • Forgetting @onready and calling get_node() or using $ every frame in _process()
  • Signals going the wrong direction (parent emitting to child instead of "call down, signal up")
  • Using _process() for physics code (movement, collisions) instead of _physics_process()
  • Comparing regular strings instead of StringName (&"name") in hot paths
  • Reading @export values in _init() instead of _ready(), getting script defaults instead of Inspector values

GDScript Bests FAQ

What naming convention does GDScript use?

snake_case for functions and variables. PascalCase for classes (class_name) and node names. CONSTANT_CASE for constants and enum members. Booleans use is_, can_, or has_ prefixes. Signals use past tense (health_changed, died). Signal callbacks follow _on_NodeName_signal_name.

Should I use @onready or get the node in _ready()?

Either works. @onready var timer: Timer = $Timer is shorthand for assigning in _ready(). Both execute at the same time (right before _ready() runs). @onready is shorter and keeps the declaration next to other variables at the top of the file.

What is the difference between _process and _physics_process?

_process(delta) runs every rendered frame at a variable rate tied to the display. _physics_process(delta) runs at a fixed interval, 60 times per second by default. Use _physics_process for movement, collisions, raycasts, and anything involving move_and_slide(). Use _process for UI updates, animations, and visual effects.

How do I organize a large GDScript project?

Split behavior into child nodes with focused scripts. Use autoloads sparingly for truly global systems (audio, save/load). Use class_name to create shared types that other scripts can reference. Keep scripts under 200-300 lines. If a script grows beyond that, it is doing too much.

Does GDScript have a linter?

The Godot editor has a built-in warning system. Enable UNTYPED_DECLARATION to flag untyped variables and functions. Enable UNSAFE_PROPERTY_ACCESS and UNSAFE_CAST for stricter type checking. These warnings appear as yellow underlines in the script editor and in the output panel.

What is the difference between preload and load in GDScript?

preload() loads the resource at script parse time and requires a string literal path. load() loads at runtime and accepts dynamic paths from variables. Use preload for small, always-needed resources like bullet scenes. Use load for conditional or dynamically-determined resources. For large resources, use ResourceLoader.load_threaded_request() to avoid blocking the main thread.

How does error handling work in GDScript?

GDScript has no try/catch. Use guard clauses with early return for invalid input. Use push_error() and push_warning() to log issues without stopping execution. Use assert() for debug-only invariant checks (stripped in release builds). Use is_instance_valid() to check if a freed node reference is still safe to access.

When should I use class_name?

Use class_name when other scripts need to reference the type for static typing, type checking with is, or casting with as. Skip it for scripts that are only attached to a single node and never referenced by type elsewhere. Every class_name script loads at engine startup, so keep them lightweight.

What does the @tool annotation do?

@tool makes a script run inside the Godot editor, not just during gameplay. This lets you create custom Inspector previews, in-editor visualizations, and editor plugins. Place @tool as the very first line of the script. Be careful with _process() in @tool scripts since it runs in the editor too.

How do I share data between nodes in GDScript?

Follow the "call down, signal up" rule. Parents call methods on their children directly. Children emit signals that parents connect to. For truly global data, use an autoload singleton. For shared configuration, use custom Resources that multiple nodes reference.

GDScript Bests Syntax Quick Reference

Code ordering template
class_name Player
extends CharacterBody2D

## Player controller with movement and combat.

signal died
signal health_changed(new_value: int)

enum State { IDLE, RUN, JUMP }

const MAX_SPEED: float = 300.0

@export var speed: float = 200.0
@export var jump_force: float = 400.0

var health: int = 100
var _internal_timer: float = 0.0

@onready var sprite: Sprite2D = $Sprite2D
@onready var anim: AnimationPlayer = $AnimationPlayer


func _ready() -> void:
	health = 100


func _physics_process(delta: float) -> void:
	move_and_slide()


func take_damage(amount: int) -> void:
	health -= amount
	health_changed.emit(health)
Typed variable patterns
var health: int = 100
var speed := 50.0
var direction: Vector2 = Vector2.ZERO
var enemies: Array[Node2D] = []

func heal(amount: int) -> void:
	health = mini(health + amount, max_health)
Signal naming convention
signal health_changed(new_value: int)
signal died
signal animation_started
signal animation_finished

func _on_Player_died() -> void:
	queue_free()

func _on_Timer_timeout() -> void:
	can_shoot = true
Caching with @onready
# Cache node references once
@onready var timer := $CooldownTimer as Timer
@onready var sprite := $Sprite2D as Sprite2D
@onready var health_bar := $UI/HealthBar as ProgressBar

func _process(delta: float) -> void:
	sprite.rotation += delta  # Uses cached reference
@export organization
@export_category("Character")

@export_group("Movement")
@export var speed: float = 200.0
@export var acceleration: float = 50.0

@export_subgroup("Jump Settings")
@export var jump_height: float = 5.0
@export var coyote_time: float = 0.1

@export_group("Combat")
@export var max_health: int = 100
@export var damage: int = 10

@export_group("")
@export var debug_mode: bool = false

Further Reading

  • GDScript Dictionary map() and map_in_place12 min read
  • Facade Pattern in Godot 4 GDScript: Taming "End Turn" Spaghetti12 min read

Start practicing GDScript Bests

Free daily exercises with spaced repetition. No credit card required.

← Back to GDScript Syntax Practice
Syntax Cache

Build syntax muscle memory with spaced repetition.

Product

  • Pricing
  • Our Method
  • Daily Practice
  • Design Patterns
  • Interview Prep

Resources

  • Blog
  • Compare
  • Cheat Sheets
  • Vibe Coding
  • Muscle Memory

Languages

  • Python
  • JavaScript
  • TypeScript
  • Rust
  • SQL
  • GDScript

Legal

  • Terms
  • Privacy
  • Contact

© 2026 Syntax Cache

Cancel anytime in 2 clicks. Keep access until the end of your billing period.

No refunds for partial billing periods.