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

ThingConventionExample
Classes / ScriptsPascalCaseEnemyData, WaveDirector
Functionssnake_casetake_damage(), _on_body_entered()
Variablessnake_casecurrent_hp, move_speed
Private variables_leading_underscore_is_dead, _sprite
ConstantsSCREAMING_SNAKE_CASETILE_SIZE, SEPARATION_RADIUS
Signal callbacks_on_{emitter}_{signal}_on_player_damaged, _on_wave_started

Private vs Public

_physics_process vs _process

Use _physics_process(delta)Use _process(delta)
Movement with move_and_slide()UI updates (labels, progress bars)
Collision detectionVisual effects (particles, tweens)
Input that affects physicsAnimations that don't affect gameplay
Anything involving CharacterBody2DCamera follow (if smoothing enabled)
Why: _physics_process runs at a fixed timestep (60 Hz). _process runs every rendered frame (variable rate). If you move in _process and 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 $NodeName or get_node() inside _process, _physics_process, or hot loops. Cache in @onready or _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

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: Use is_instance_valid() AFTER an await or 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