Godot 4.x (GDScript + C#)

SkillDev tools

Use when writing, reviewing, or debugging Godot 4.x games in GDScript or C# — scenes, nodes, custom Resources, autoload/EventBus signals, typed GDScript, CharacterBody2D/3D movement, GUT/gdUnit4 — or porting Godot 3 APIs to 4. NOT Unity/Unreal (that is `unity`/`unreal`), NOT `.gdshader` code (`gamedev-shaders`), NOT GDExtension C++ tooling (`cpp`).

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Godot 4.x (GDScript + C#) skill

What this skill tells your AI

The instructions your AI receives, as published by ericrisco/rsc-harness in skills/godot/SKILL.md and read by ahel’s review.

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 / KinematicBody2DCharacterBody3D / CharacterBody2D
SpatialNode3D
Area / RigidBody / StaticBodyArea3D / RigidBody3D / StaticBody3D
SpriteSprite2D
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_propertycreate_tween()tween.tween_property(...)
OS.get_ticks_msec for gameplay timingTime.get_ticks_msec() (OS timing moved to Time)
PoolByteArray / PoolVector2ArrayPackedByteArray / 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)end is now exclusive (was inclusive in Godot 3). [1,2,3,4].slice(1,3)[2,3].
  • Camera2D.zoom is inverted vs Godot 3: a larger zoom now means zoomed in (magnified). Vector2(2,2) = 2× magnification, not half.
  • TileMap is deprecated → use one TileMapLayer node per layer (since 4.3).
  • Angles are radians; _process(delta) delta is a float (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 Foo registers a global type usable in the inspector and as Foo.new(). Use it for reusable scripts and custom Resources; skip it for one-off scene scripts.
  • Files: snake_case.gd / snake_case.tscn for scenes and scripts; PascalCase for node names in the tree and for class_name. Group by feature (player/, enemy/, ui/), not by type.

Nodes vs scenes vs scripts vs custom Resources

You needUse
A thing in the tree that renders / moves / collides / processesa Node (typed subclass)
A reusable, instanceable bundle of nodesa scene (.tscn)
Behavior attached to a nodea script (.gd / .cs)
Pure data (stats, items, dialogue, level config) with no place in the treea 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 @onready yet). _ready() runs once the node and all children are in the tree — do node wiring here.
  • @onready var x = $Path defers 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 %HealthBar instead of the fragile, refactor-breaking get_node("../../UI/HealthBar"). $Foo is fine for a direct child — get_node/$ on a missing path returns null and errors.
  • Cache node lookups in @onready vars; don't call get_node every frame.
  • Freeing: call queue_free() (safe, end of frame), not free() mid-signal. Guard reused refs with is_instance_valid(node).
  • Never busy-wait; await get_tree().create_timer(1.0).timeout or await a 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. Add CONNECT_ONE_SHOT for 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 use CONNECT_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 delta so behavior is framerate-independent. move_and_slide() and move_and_collide() already fold in delta internally — do not multiply the velocity you hand them by delta again.

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 a PackedScene; 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(), $NodeGetNode<T>("Node"), %NodeGetNode<T>("%Node"), preloadGD.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 under test/ or res://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.

Signals

GitHub stars
82
Forks
3
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
godot
Source
github.com/ericrisco/rsc-harness