GDScript Crash Course for Unity C# Developers

Learn GDScript quickly if you're coming from Unity's C#. A practical syntax comparison with examples to get you productive in Godot fast.

GDScript code comparison with C# showing syntax differences

Coming from Unity's C#? GDScript will feel familiar in many ways — it's Python-inspired, designed specifically for game development, and optimized for the kind of code you write every day as a game developer. The core programming concepts are identical; only the syntax and some patterns change. This crash course maps every C# concept you already know to its GDScript equivalent so you can start building in Godot immediately.

Basic Syntax Differences

The biggest visual difference: GDScript uses indentation instead of curly braces, and lines don't end with semicolons. If you've ever written Python, this will feel natural. If not, most editors (including Godot's built-in one) handle indentation automatically.

gdscript
# GDScript uses indentation instead of braces
func _ready():
    var message = "Hello, Godot!"
    print(message)

# Compare to C#:
# void Start() {
#     string message = "Hello, Unity!";
#     Debug.Log(message);
# }

Other syntax differences: func replaces void/return-type, extends replaces class inheritance with a colon, and there's no new keyword — you call ClassName.new() instead. Print output goes to print() rather than Debug.Log().

Variables and the Type System

GDScript supports both dynamic and static typing. You can start with untyped code for quick prototyping, then add type hints as your project matures. Typed GDScript provides editor autocompletion, catches errors before runtime, and can improve performance.

gdscript
# Dynamic typing (quick prototyping)
var health = 100
var player_name = "Hero"

# Static typing (recommended for production)
var health: int = 100
var player_name: String = "Hero"
var velocity: Vector2 = Vector2.ZERO

# Type inference with :=
var speed := 200.0  # Inferred as float
var position := Vector2(100, 200)  # Inferred as Vector2

# Constants
const MAX_HEALTH: int = 100
const GRAVITY: float = 980.0

The := operator is GDScript's type inference — it assigns the value and locks the type, similar to C#'s var keyword. One key difference from C#: GDScript has no access modifiers (public, private, protected). All variables are public by convention. Prefix with underscore (_my_var) to signal 'private by convention,' but it's not enforced by the language.

Lifecycle Methods

Godot's lifecycle methods are virtual functions prefixed with underscores. Here's the direct mapping from Unity. The concepts are identical — only the names change.

gdscript
# Unity -> Godot lifecycle mapping
# Awake()       -> _init()              # Constructor, called first
# Start()       -> _ready()             # Called when node enters tree
# Update()      -> _process(delta)      # Called every frame
# FixedUpdate() -> _physics_process(delta) # Fixed timestep (60/sec)
# OnDestroy()   -> _exit_tree()         # Called when removed from tree
# OnEnable()    -> _enter_tree()        # Called when added to tree

# Note: delta is already passed as a parameter — no Time.deltaTime needed!

A nice quality-of-life improvement: delta is passed directly as a parameter to _process() and _physics_process(), so you never need to type Time.deltaTime. Also, _ready() is guaranteed to fire after all children are ready, so you can safely reference child nodes — unlike Unity's Start() which has no ordering guarantees between sibling objects.

Signals: Godot's Event System

Signals are Godot's equivalent to C# events/delegates, but they're a first-class engine feature. Every built-in node emits signals (button pressed, animation finished, body entered area), and you can define custom signals on any script. Signals promote decoupled architecture — the emitter doesn't need to know who's listening.

gdscript
# Define a custom signal
signal health_changed(new_health: int)
signal died

# Emit signals
func take_damage(amount: int):
    health -= amount
    health_changed.emit(health)
    if health <= 0:
        died.emit()

# Connect in code
func _ready():
    player.health_changed.connect(_on_health_changed)
    player.died.connect(_on_player_died)

func _on_health_changed(new_health: int):
    health_bar.value = new_health

func _on_player_died():
    show_game_over_screen()

You can also connect signals in the Godot editor's Node panel (similar to dragging UnityEvents in the Inspector). For C# developers, think of signals as built-in events with engine-level support — no need to manually define delegates, event handlers, or worry about unsubscribing to prevent memory leaks.

Node References and @export

In Unity, you use [SerializeField] or public fields to expose variables in the Inspector, and GetComponent() to find components. Godot uses @export for Inspector exposure and the $ shorthand (or get_node()) for referencing child nodes in the scene tree.

gdscript
# Get child nodes (like GetComponent but for scene tree)
@onready var sprite = $Sprite2D           # Direct child
@onready var health_bar = $UI/HealthBar   # Nested path
@onready var anim = $AnimationPlayer      # Animation player

# Export to Inspector (like [SerializeField] in Unity)
@export var speed: float = 200.0
@export var jump_force: float = 400.0
@export var max_health: int = 100

# Exports with ranges and categories
@export_range(0, 100, 5) var volume: int = 50
@export_enum("Sword", "Bow", "Staff") var weapon: int = 0
@export_group("Movement")
@export var acceleration: float = 800.0
@export var friction: float = 1200.0

The @onready annotation is equivalent to initializing in Start() — it runs when the node enters the scene tree. Without @onready, the $ references would fail because child nodes don't exist yet during script loading. The @export_group, @export_range, and other annotations give you Inspector organization similar to Unity's [Header] and [Range] attributes.

Coroutines and Async: await vs StartCoroutine

Unity uses coroutines (IEnumerator with yield return) or async/await. GDScript uses the await keyword with signals, which is cleaner and more intuitive. Any signal can be awaited, making asynchronous game logic straightforward.

gdscript
# Wait for a timer (like yield return new WaitForSeconds)
func flash_damage():
    sprite.modulate = Color.RED
    await get_tree().create_timer(0.2).timeout
    sprite.modulate = Color.WHITE

# Wait for an animation to finish
func play_death_animation():
    anim.play("death")
    await anim.animation_finished
    queue_free()

# Wait for a signal from another node
func wait_for_player_input():
    var choice = await dialog_box.choice_made
    process_choice(choice)

This is one area where GDScript feels more modern than Unity's C#. Instead of managing Coroutine references and StopCoroutine(), you just await any signal. The function suspends at that line and resumes when the signal fires — no state machines or callback pyramids needed.

Classes and Inheritance

In Unity, every script is a class that inherits from MonoBehaviour. In Godot, every script extends a node type. The extends keyword replaces C#'s colon inheritance syntax. One major difference: GDScript uses single-file classes by default — each .gd file is one class.

gdscript
# Every script extends a node type (like inheriting MonoBehaviour)
extends CharacterBody2D
class_name Player  # Optional: registers as a global type

# Inner classes (less common)
class Inventory:
    var items: Array[String] = []

    func add_item(item: String):
        items.append(item)

# Using class_name lets you reference it like a type:
# var player: Player = Player.new()
# if node is Player: ...

Common Gotchas for C# Developers

  • No null — Godot uses null for missing nodes, but freed objects return as . Use is_instance_valid(obj) instead of obj != null to check if a node was freed with queue_free()
  • No try-catch — GDScript doesn't have exception handling. Use push_error() and push_warning() for logging. Check return values and use assert() during development
  • Array and Dictionary are reference types — assigning one to another creates a shared reference, not a copy. Use .duplicate() for deep copies
  • Enums are just ints — GDScript enums are syntactic sugar over integers, unlike C#'s type-safe enums. They work for simple cases but don't provide the same type safety
  • No method overloading — GDScript doesn't support multiple methods with the same name but different parameters. Use default parameter values instead: func attack(damage: int = 10, is_critical: bool = false)

Next Steps

You now have a solid map from C# to GDScript. The concepts you know — variables, functions, classes, events, coroutines — all transfer directly. The syntax is different, but the thinking is the same. The best way to build fluency is to recreate a simple Unity project in Godot. Start with a basic player controller, add some UI, wire up signals, and you'll feel at home within a day.

For a deeper dive, try our interactive Code Lab which shows side-by-side C# and GDScript comparisons, or explore the GDScript Cheat Sheet for a quick-reference guide to every language feature.

👤
Godot Learning Team Helping developers transition to Godot with practical tutorials and comparisons.
Interactive Guide

Unity to Godot Guide

Practice what you've learned with our hands-on interactive tutorial.