Coding Standards
Keep the codebase consistent, readable, and safe for contributors who don't know Godot.
Comments: Explain WHY, Not WHAT
Bad: States the obvious.
# Set velocity to speed
velocity = speed
Good: Explains the design decision.
# Use lerp for deceleration instead of instant stop — feels more natural
# and prevents jittery micro-movements at low input values.
velocity = velocity.lerp(Vector2.ZERO, friction)
Rule: If the code is self-evident, skip the comment. If it encodes a design choice, document it.
Naming Conventions
| Thing | Convention | Example |
|---|---|---|
| Classes / Scripts | PascalCase | EnemyData, WaveDirector |
| Functions | snake_case | take_damage(), _on_body_entered() |
| Variables | snake_case | current_hp, move_speed |
| Private variables | _leading_underscore | _is_dead, _sprite |
| Constants | SCREAMING_SNAKE_CASE | TILE_SIZE, SEPARATION_RADIUS |
| Signal callbacks | _on_{emitter}_{signal} | _on_player_damaged, _on_wave_started |
Private vs Public
- Private (
_prefix): Internal state, helper functions, cached refs. Don't access from other scripts. - Public (no prefix): The script's API. Other scripts can call these.
_physics_process vs _process
Use _physics_process(delta) | Use _process(delta) |
|---|---|
Movement with move_and_slide() | UI updates (labels, progress bars) |
| Collision detection | Visual effects (particles, tweens) |
| Input that affects physics | Animations that don't affect gameplay |
| Anything involving CharacterBody2D | Camera follow (if smoothing enabled) |
Why:_physics_processruns at a fixed timestep (60 Hz)._processruns every rendered frame (variable rate). If you move in_processand collide in_physics_process, you get 1-frame jitter.
Caching Node References
Bad: Looks up the node EVERY frame.
func _physics_process(delta: float) -> void:
$Sprite2D.position += velocity * delta # Tree search every frame!
Good: Cache once in _ready() with @onready.
@onready var _sprite: Sprite2D = $Sprite2D
func _physics_process(delta: float) -> void:
_sprite.position += velocity * delta # Direct reference — O(1)
Rule: Never use$NodeNameorget_node()inside_process,_physics_process, or hot loops. Cache in@onreadyor_ready().
Signals vs Direct References
Prefer Signals
Use the Events autoload for cross-system communication:
# Enemy doesn't know the player exists
Events.player_damaged.emit(10, "enemy.swarmer")
# Player listens without knowing about enemies
func _ready() -> void:
Events.player_damaged.connect(_on_player_damaged)
When Direct References Are OK
- Parent → child communication (the parent owns the child)
- Sibling communication through a shared parent
- One-off initialization in
_ready()
Constants vs Magic Numbers
velocity = velocity.lerp(Vector2.ZERO, 0.15)
_spawner.wave_interval = 9999.0
const FRICTION := 0.15
const DISABLED_INTERVAL := 9999.0
velocity = velocity.lerp(Vector2.ZERO, FRICTION)
_spawner.wave_interval = DISABLED_INTERVAL
Rule: Any number that isn't 0 or 1 should probably be a named constant, especially if it appears more than once.
Type Safety
Godot 4's typed GDScript catches bugs at compile time. Use it everywhere.
@export var speed: float = 200.0
func take_damage(amount: float) -> void:
var enemies: Array[Node] = get_tree().get_nodes_in_group("enemies")
Caution with Variant
# BAD: json.get_data() returns Variant, := can't infer
var data := json.get_data() # Parse error!
# GOOD: explicit type or untyped
var data = json.get_data() # Option 1: untyped, check with 'is'
var data: Dictionary = json.get_data() # Option 2: explicit cast
Anti-Patterns
1. load() or preload() in Hot Paths
func _fire_rocket() -> void:
var scene = load("res://scenes/rocket.tscn") # Hitches!
var rocket = scene.instantiate()
const ROCKET_SCENE: PackedScene = preload("res://scenes/rocket.tscn")
func _fire_rocket() -> void:
var rocket = ROCKET_SCENE.instantiate()
2. get_nodes_in_group() Every Frame
func _process(delta: float) -> void:
var enemies = get_tree().get_nodes_in_group("enemies")
_count_label.text = str(enemies.size())
var _enemy_count: int = 0
func _ready() -> void:
Events.enemy_spawned.connect(_on_enemy_spawned)
Events.enemy_died.connect(_on_enemy_died)
func _on_enemy_spawned(_id: String, _pos: Vector2) -> void:
_enemy_count += 1
3. is_instance_valid() on Group Results
for enemy in get_tree().get_nodes_in_group("enemies"):
if is_instance_valid(enemy): # Redundant!
...
for enemy in get_tree().get_nodes_in_group("enemies"):
enemy.do_something() # Safe — Godot filters freed nodes
Exception: Useis_instance_valid()AFTER anawaitor when storing node references long-term.
4. await Without Safety Checks
await get_tree().create_timer(1.0).timeout
self.scale = Vector2.ZERO # Crash if self was freed!
await get_tree().create_timer(1.0).timeout
if not is_instance_valid(self):
return
self.scale = Vector2.ZERO
5. Engine.time_scale Direct Mutation
# In player.gd:
Engine.time_scale = 0.3 # Death slow-mo
# In TimeController:
Engine.time_scale = 0.2 # COMMAND mode
# Conflict! Last write wins unpredictably.
TimeController.set_time_scale(0.3, "death_slowmo")
# TimeController arbitrates and can blend/stack effects
Quick Checklist Before Committing
- No
$NodeNameorget_node()in_process/_physics_process - No
load()in hot paths (cached in_ready()orpreload()) - No
get_nodes_in_group()every frame (use signals) - No magic numbers (use
const) - Type hints on all functions, exports, and variables
- Comments explain WHY, not WHAT
- Private helpers use
_prefix - Signal callbacks use
_on_{emitter}_{signal}naming awaitfollowed byis_instance_valid(self)check- GUT tests pass