5 GDScript Mistakes Every Beginner Makes (and How to Fix Them)
You've picked up Godot, written your first few scripts, and things mostly work — until they don't. You get cryptic errors, things move at the wrong speed, signals fire but nothing happens, and you're not sure why.
Don't worry. Every single Godot developer has been here. These are the five mistakes I see most often from beginners, and once you understand why they happen, you'll never make them again.
1. Forgetting to Multiply by Delta
This is the classic. You write movement code like this:
func _process(_delta: float) -> void:
position.x += speed
It works on your machine. Then you test on a different computer and your character moves at a completely different speed. Why?
_process runs once per frame. If your machine runs at 120 FPS, that code runs 120 times per second. On a 60 FPS machine, it runs 60 times — half the movement.
The fix: Multiply by delta, which is the time elapsed since the last frame. This makes movement frame-rate independent.
func _process(delta: float) -> void:
position.x += speed * delta
Now speed means "units per second" regardless of frame rate. This applies to anything time-based: movement, rotation, timers, animations.
Pro tip: For physics-related movement (characters, projectiles), use
_physics_processinstead of_process. It runs at a fixed rate (60 times/second by default) and gives you more predictable results with Godot's physics engine.
2. Using get_node With Hardcoded Paths
Beginners love get_node. It's simple, it works, and it breaks the moment you rename anything.
# Fragile — breaks if you rename or move the node
var health_bar = get_node("../UI/HealthBar")
If you restructure your scene tree (which you will), every hardcoded path breaks silently or crashes at runtime.
The fix: Use @onready with $ for nodes within the same scene, and @export for references to nodes you might move around.
# For nodes in the same scene — short and clean
@onready var health_bar: ProgressBar = $UI/HealthBar
# For references that might change — drag-and-drop in the Inspector
@export var health_bar: ProgressBar
@export is the gold standard for anything you want to be flexible. You set it in the Inspector by dragging the node into the field, and it survives scene tree changes.
3. Connecting Signals in Code but Forgetting to Disconnect
You connect a signal in _ready:
func _ready() -> void:
$Button.pressed.connect(_on_button_pressed)
Seems fine. But if this node gets freed and recreated (common in menus, level transitions, or object pooling), you might connect the signal again — or worse, the old connection tries to call a method on a freed object.
The fix: For signals between nodes in the same scene, connect them in the Editor (the "Node" tab → Signals). They're automatically managed.
For signals you must connect in code, disconnect them in _exit_tree:
func _ready() -> void:
$Button.pressed.connect(_on_button_pressed)
func _exit_tree() -> void:
if $Button.pressed.is_connected(_on_button_pressed):
$Button.pressed.disconnect(_on_button_pressed)
Or use the CONNECT_ONE_SHOT flag if you only need the signal to fire once:
$Button.pressed.connect(_on_button_pressed, CONNECT_ONE_SHOT)
4. Putting Everything in One Giant Script
Your player script starts small. Movement. Then you add health. Then inventory. Then combat. Then dialogue. Then save/load. Before you know it, you have an 800-line Player.gd that does everything and is impossible to debug.
The fix: Break things into components. Godot's node system is designed for this.
Instead of one Player.gd, use child nodes with their own scripts:
CharacterBody3D (Player)
├── HealthComponent → handles HP, damage, death
├── InventoryComponent → handles items, equipment
├── CombatComponent → handles attacks, hitboxes
└── StateMachine → handles state transitions
Each component is a small, focused script. They communicate through signals. You can test them independently, reuse them on enemies, and debug without scrolling through hundreds of lines.
# HealthComponent.gd — clean, focused, reusable
class_name HealthComponent
extends Node
signal health_changed(new_health: int)
signal died
@export var max_health := 100
var health: int
func _ready() -> void:
health = max_health
func take_damage(amount: int) -> void:
health = max(health - amount, 0)
health_changed.emit(health)
if health == 0:
died.emit()
This approach scales. A 20-minute game and a 20-hour game both work with the same pattern.
5. Not Using Type Hints
GDScript is dynamically typed by default. This works:
var speed = 5
var name = "Player"
var items = []
But it means Godot can't catch errors until runtime, your autocomplete is broken, and you'll waste time debugging type mismatches.
The fix: Always use type hints. It costs a few extra characters and saves hours of debugging.
var speed: float = 5.0
var player_name: String = "Player"
var items: Array[Item] = []
func take_damage(amount: int) -> void:
health -= amount
func get_speed() -> float:
return speed * speed_multiplier
With type hints:
- The editor catches type errors before you run the game
- Autocomplete works properly (it knows
itemscontainsItemobjects) - Your code is self-documenting — you can read what a function expects without guessing
- Performance improves slightly in some cases (Godot can optimize typed code)
Get in the habit early. Future you will be grateful.
The Pattern Behind All Five Mistakes
Notice something? Every mistake comes from taking a shortcut that works right now but breaks later:
- No delta → breaks on different hardware
- Hardcoded paths → breaks when you refactor
- Unmanaged signals → breaks when nodes are freed
- God scripts → breaks when complexity grows
- No type hints → breaks when you debug
Good GDScript isn't about writing clever code. It's about writing code that still works in three weeks when you've forgotten how it works.
If you want a structured path through these concepts — building real systems with proper patterns from the start — check out the quest board. Every course is designed to teach these fundamentals through actual game projects, not isolated exercises.