Godot 4.x (GDScript + C#)
Build 2D and 3D the way the engine is designed: scenes as reusable units, composition over deep node trees, signals for decoupling, static typing for speed and safety. GDScript (typed) is primary; C# parity snippets sit beside it.
Version contract — read first
This is Godot 4.x. Never emit Godot 3 APIs. Godot 4 renamed core nodes, moved annotations
behind @, replaced yield with await, and switched signal/file/tween APIs. A Godot 3 snippet
will not even parse in 4.x. Before writing or accepting any line, check it against this ban-list:
| Never (Godot 3) | Always (Godot 4.x) |
|---|---|
yield(timer, "timeout") |
await timer.timeout |
onready var x = ... |
@onready var x = ... |
export var hp = 3 |
@export var hp := 3 |
tool (script mode line) |
@tool (annotation, first line) |
KinematicBody / KinematicBody2D |
CharacterBody3D / CharacterBody2D |
Spatial |
Node3D |
Area / RigidBody / StaticBody |
Area3D / RigidBody3D / StaticBody3D |
Sprite |
Sprite2D |
scene.instance() |
scene.instantiate() |
move_and_slide(velocity, UP) (positional) |
set velocity property, then move_and_slide() — no args |
connect("hit", self, "_on_hit") |
node.hit.connect(_on_hit) (Callable) |
File.new() / Directory.new() |
FileAccess.open(...) / DirAccess.open(...) |
standalone Tween node + interpolate_property |
create_tween() → tween.tween_property(...) |
OS.get_ticks_msec for gameplay timing |
Time.get_ticks_msec() (OS timing moved to Time) |
PoolByteArray / PoolVector2Array |
PackedByteArray / PackedVector2Array |
Lifecycle overrides must chain the parent with super() (Godot 3 called it implicitly; Godot 4
does not). If you override _ready, _process, _init, etc. in a script that extends another
script defining them, call super() / super._ready() or the base logic silently never runs.
Silent-breakers (compile fine, behave wrong — the dangerous class):
Array.slice(begin, end)—endis now exclusive (was inclusive in Godot 3).[1,2,3,4].slice(1,3)→[2,3].Camera2D.zoomis inverted vs Godot 3: a larger zoom now means zoomed in (magnified).Vector2(2,2)= 2× magnification, not half.TileMapis deprecated → use oneTileMapLayernode per layer (since 4.3).- Angles are radians;
_process(delta)delta is afloat(GDScript) /double(C#) in seconds.
Full table with every rename → references/godot3-to-4-traps.md.
Project & scene organization
- The scene (
.tscn) is the reusable unit — a self-contained tree you instance many times (a Player, a Bullet, a HUD). Prefer composition: small scenes/nodes assembled, not one 60-node monolith. If a subtree has its own behavior, make it its own scene. - One responsibility per script. Attach behavior to the scene's root; child nodes are parts.
class_name Fooregisters a global type usable in the inspector and asFoo.new(). Use it for reusable scripts and custom Resources; skip it for one-off scene scripts.- Files:
snake_case.gd/snake_case.tscnfor scenes and scripts;PascalCasefor node names in the tree and forclass_name. Group by feature (player/,enemy/,ui/), not by type.
Nodes vs scenes vs scripts vs custom Resources
| You need | Use |
|---|---|
| A thing in the tree that renders / moves / collides / processes | a Node (typed subclass) |
| A reusable, instanceable bundle of nodes | a scene (.tscn) |
| Behavior attached to a node | a script (.gd / .cs) |
| Pure data (stats, items, dialogue, level config) with no place in the tree | a custom Resource (.tres) |
Custom Resources are Godot's typed, savable, inspector-editable data objects — reach for them
instead of loose Dictionaries or JSON for game data. See references/nodes-scenes-resources.md.
class_name EnemyStats extends Resource
@export var max_health: int = 30
@export var speed: float = 120.0
@export var loot_table: Array[ItemDrop] = []
Autoloads / singletons + EventBus
Register a script or scene as an autoload (Project → Project Settings → Globals/Autoload) to get one always-present instance reachable by name from anywhere. Use it for cross-cutting state (save game, audio, run config) — not as a dumping ground.
The EventBus pattern decouples unrelated systems: an autoload that owns only signals. Emitters and listeners never reference each other, just the bus.
# event_bus.gd (autoload named "Events")
extends Node
signal enemy_died(position: Vector2, xp: int)
signal score_changed(new_score: int)
# emitter # listener (anywhere)
Events.enemy_died.emit(global_position, 10)
Events.enemy_died.connect(_on_enemy_died)
Keep gameplay logic in nodes; let the bus carry the notification, not the behavior.
Node access & lifecycle
_init()runs at construction (no tree, no@onreadyyet)._ready()runs once the node and all children are in the tree — do node wiring here.@onready var x = $Pathdefers the assignment to_ready, so the child exists. Never grab children in_init.- Prefer unique names: mark a node Unique Name in Owner (
%) and access%HealthBarinstead of the fragile, refactor-breakingget_node("../../UI/HealthBar").$Foois fine for a direct child —get_node/$on a missing path returnsnulland errors. - Cache node lookups in
@onreadyvars; don't callget_nodeevery frame. - Freeing: call
queue_free()(safe, end of frame), notfree()mid-signal. Guard reused refs withis_instance_valid(node). - Never busy-wait;
await get_tree().create_timer(1.0).timeoutorawaita signal.
extends CharacterBody2D
@onready var sprite: Sprite2D = $Sprite2D
@onready var health_bar: ProgressBar = %HealthBar # unique name, position-independent
func _ready() -> void:
super() # chain the parent's _ready if the base defines one
health_bar.value = 100
Signals
Signals are Godot's decoupling primitive. In Godot 4 you connect a Callable, not strings.
- Declare with typed params; name in the past tense for facts that happened
(
health_depleted,item_collected), present-tense imperative only for requests. - Connect:
node.signal_name.connect(_on_thing)— a direct method reference, checked at parse time. AddCONNECT_ONE_SHOTfor auto-disconnect after one fire. - Disconnect discipline: a connection to a node that gets freed is cleaned up automatically, but
connections you make to long-lived objects (autoloads, the bus) from a short-lived node must be
disconnected in
_exit_tree(), or useCONNECT_ONE_SHOT, to avoid calls into freed instances.
signal health_depleted
signal health_changed(current: int, max: int)
func take_damage(amount: int) -> void:
_health -= amount
health_changed.emit(_health, _max_health)
if _health <= 0:
health_depleted.emit()
@export / @tool (inspector config)
@export exposes a variable in the Inspector so designers tune it without touching code. Use ranges,
groups, and typed exports so the inspector gives real widgets and validation.
@export var title: String = "Level 1"
@export_range(0.0, 1.0, 0.05) var volume := 0.8
@export_group("Movement")
@export var speed: float = 300.0
@export var jump_velocity: float = -400.0
@export var projectile: PackedScene # drag a .tscn in the inspector
@export var stats: EnemyStats # a custom Resource slot
@tool at the top of a script runs it in the editor too — for gizmos, procedural previews, or
validating exported data. Guard runtime-only code with if Engine.is_editor_hint(): return.
Static (typed) GDScript
Type everything. Typed GDScript is faster (the VM skips dynamic dispatch) and catches errors at parse
time. Use := when the type is inferable, : Type when it isn't, and avoid Variant/untyped.
var speed: float = 300.0 # explicit
var dir := Vector2.ZERO # inferred
var enemies: Array[Enemy] = [] # typed array
func distance_to(target: Node2D) -> float:
return global_position.distance_to(target.global_position)
func _on_body_entered(body: Node) -> void:
var enemy := body as Enemy # safe cast → null if wrong type, no crash
if enemy:
enemy.take_damage(10)
Naming: snake_case vars/funcs/signals, PascalCase types/class_name/nodes, CONSTANT_CASE
consts, tabs for indent, lines < 100 cols. Cheat-sheet → references/gdscript-style.md.
_process vs _physics_process
_physics_process(delta)— fixed tick (default 60 Hz), the same every step. All movement,move_and_slide(), forces, and collision-dependent logic go here._process(delta)— runs once per rendered frame (variable rate). Use for visuals, UI, and non-physics polish.- Always scale rate-based change by
deltaso behavior is framerate-independent.move_and_slide()andmove_and_collide()already fold indeltainternally — do not multiply the velocity you hand them bydeltaagain.
Resources & data — .tres / .tscn are strict text formats
.tscn and .tres are line-oriented text with a strict header/section grammar. Do not hand-edit
them past trivial value tweaks, and never launch on a file you hand-authored without validating —
one bad ext_resource id, [node] line, or load_steps count corrupts the whole scene and Godot
refuses to open it. Prefer editing through the editor or building Resources in code and ResourceSaver.save().
preload("res://x.tscn")resolves at parse/compile time — the dependency is baked in; use for assets you always need.load("res://x.tscn")resolves at runtime — use for dynamic/optional paths (and to avoid circular preloads). Both return aPackedScene; call.instantiate()to get a node.
2D / 3D bodies quickstart
The move-anything-controllable body is CharacterBody2D / CharacterBody3D. The Godot 4 flow is:
write the velocity property, then call move_and_slide() with no arguments.
extends CharacterBody2D
@export var speed: float = 300.0
@export var jump_velocity: float = -400.0
func _physics_process(delta: float) -> void:
if not is_on_floor():
velocity += get_gravity() * delta # get_gravity(): project-configured vector
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = jump_velocity
var dir := Input.get_axis("move_left", "move_right")
velocity.x = dir * speed
move_and_slide() # NO args in Godot 4 — reads the velocity property
3D is identical with CharacterBody3D, Vector3, and an X/Z input plane; body/area suffixes are
3D. Deeper body/physics tuning → gamedev-physics.
Language parity — GDScript ↔ C#
Same engine, same nodes; C# uses PascalCase members, partial classes, and attributes. Signals
become C# events (generated by source-gen). C# support requires the .NET (Mono) build of Godot.
# GDScript
extends Node
signal health_depleted
@export var speed: float = 300.0
func _ready() -> void:
health_depleted.connect(_on_depleted)
health_depleted.emit()
func _on_depleted() -> void:
print("dead")
// C# — same node, .NET build
using Godot;
public partial class Player : Node
{
[Signal] public delegate void HealthDepletedEventHandler();
[Export] public float Speed { get; set; } = 300.0f;
public override void _Ready()
{
base._Ready(); // chain the parent (== super())
HealthDepleted += OnDepleted; // connect via the generated event
EmitSignal(SignalName.HealthDepleted);
}
private void OnDepleted() => GD.Print("dead");
}
move_and_slide()→MoveAndSlide(), $Node→GetNode<T>("Node"), %Node→GetNode<T>("%Node"),
preload→GD.Load<T>(...). Full parity table → references/export-and-testing.md.
GDExtension / C++: for hot native code, build a godot-cpp GDExtension (.gdextension file,
GDREGISTER_CLASS, _bind_methods()) rather than a Godot module — no engine recompile, and it loads
like any other library. That is native-tooling territory → pair with cpp.
Export & testing
- Test with GUT 9.x (GDScript,
extends GutTest) or gdUnit4 (GDScript + C#). Put tests undertest/orres://tests/; assert behavior, not private state. - Run headless in CI:
godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://test -gexit(GUT). Export via templates:godot --headless --export-release "Linux/X11" build/game.x86_64. - Validate a project before shipping: open in the editor once (catches broken
.tscn/.tres), then export-check per platform. Details →references/export-and-testing.md.
Hand off to
Mechanics, loops, and feel before you script them → game-design.
.gdshader / visual shaders → gamedev-shaders. Joints, RigidBody
tuning, deep collision layers → gamedev-physics. NavigationAgent,
A*, steering → gamedev-pathing. MultiplayerSynchronizer/RPC/netcode
→ gamedev-multiplayer. Store builds, signing, platform export at
scale → gamedev-shipping. The GDExtension / godot-cpp native side
and its CMake/build tooling → cpp.