Hello World & Output
Hello, World
Byte-identical, and both columns run for real — the GDScript in an actual Godot engine, the Swift compiled and executed. Swift allows statements at the top level of a file, so there is no
main to write.print("Hello, World!")print("Hello, World!")That top-level-statements property is why this page can show small examples without ceremony, unlike the C++ page where every row carries an
int main().Putting a value in a message
GDScript formats with
% and an array. Swift puts the expression inside the string with \(…), so there is no second list to keep in step and no placeholder to get wrong.var player_name := "Robi"
var score := 1200
print("%s scored %d" % [player_name, score])let playerName = "Robi"
let score = 1200
print("\(playerName) scored \(score)")Anything that can be printed can go inside the parentheses, including a full expression. Note
let rather than var — see the next section, because that is the default here.Variables & Types
let is the default, var is the exception
Both keywords exist and both mean roughly what you expect — but the convention is inverted. Swift code reaches for
let first and uses var only where something genuinely changes.var health := 100
health -= 30
print(health)
const MAX := 100
print(MAX)var health = 100
health -= 30
print(health)
let max = 100
print(max)The compiler warns when a
var is never mutated, which keeps the habit honest. Constants are ordinary let bindings rather than a separate const keyword.A condition must be a Bool
GDScript treats
0 and an empty string as false. Swift has no truthiness at all — a condition must already be a Bool, so you say what you meant.var health := 0
var name := ""
if not health:
print("no health")
if not name:
print("no name")let health = 0
let name = ""
if health == 0 {
print("no health")
}
if name.isEmpty {
print("no name")
}The condition needs no parentheses but the body always needs braces, which is the same shape Rust uses.
isEmpty is a property rather than a method, so it takes no parentheses either.Numbers do not convert themselves
GDScript promotes an integer to a float when it meets one. Swift refuses: mixing
Int and Double is a compile error until you write the conversion, which is spelled as a constructor call.var count := 3
var scale := 1.5
print(count * scale)
print(7 / 2)let count = 3
let scale = 1.5
print(Double(count) * scale)
print(7 / 2)
print(7.0 / 2.0)Integer division truncates in both, so
7 / 2 is 3 either way. What Swift removes is the accidental promotion — you cannot drift into floating point without typing the conversion.Optionals Replace null
A value that might be missing
Any GDScript variable can hold
null. In Swift a type is non-optional unless you write ?, and an optional cannot be used as though it were the thing inside it.var target = null
print(target == null)
target = "found"
print(target)
print(target.length())var target: String? = nil
print(target == nil)
target = "found"
print(target ?? "nothing")
print(target!.count)?? supplies a fallback and ! asserts there is a value, crashing if there is not. The ! is deliberately ugly: it marks every place you have overruled the compiler, so they are all greppable.if let, and the early return you keep writing
guard let unwraps and, if there is nothing, runs its else — which must leave the scope. It is exactly the early-return shape if x == null: return keeps almost expressing.func describe(node) -> String:
if node == null:
return "nothing here"
return "found %s" % node
print(describe(null))
print(describe("a chest"))func describe(_ node: String?) -> String {
guard let node else {
return "nothing here"
}
return "found \(node)"
}
print(describe(nil))
print(describe("a chest"))The difference is that after a
guard, node is a plain non-optional String for the rest of the function — the unwrapping is permanent rather than something you re-check. if let is the same idea scoped to a block instead.Reaching through something that might be nothing
A Swift dictionary lookup already returns an optional, so the "might be missing" is in the type rather than in a convention.
map applies a transformation only when there is something there.var config := {"volume": 0.8}
var volume = config.get("volume")
if volume != null:
print(volume * 100)
else:
print("unset")
var missing = config.get("brightness")
if missing != null:
print(missing * 100)
else:
print("unset")let config = ["volume": 0.8]
if let volume = config["volume"] {
print(volume * 100)
} else {
print("unset")
}
let missing = config["brightness"].map { $0 * 100 }
print(missing.map { "\($0)" } ?? "unset")$0 is the first argument of a closure when you have not named it — extremely common in Swift and worth recognizing on sight. The chain stays optional all the way through, so the empty case cannot be skipped by accident.Strings
Length, case, and searching
These line up closely.
count is a property rather than a method, and split takes a labelled argument — Swift labels arguments by default, which is the syntax you will notice most.var title := "Crystal Cavern"
print(title.length())
print(title.to_upper())
print(title.contains("Cave"))
print(title.split(" ").size())let title = "Crystal Cavern"
print(title.count)
print(title.uppercased())
print(title.contains("Cave"))
print(title.split(separator: " ").count)🚨
count counts characters, meaning grapheme clusters, so an emoji built from several code points counts as one. That correctness costs speed: a Swift string cannot be indexed by integer at all, which is why slicing needs String.Index rather than a number.Arrays, Dictionaries & Sets
The everyday array
The literal syntax is identical and the element type is inferred, so this is one of the closest correspondences on the page. Only the method names change.
var loot := ["sword", "shield", "potion"]
loot.append("rope")
print(loot.size())
print(loot[0])
print(loot.has("shield"))var loot = ["sword", "shield", "potion"]
loot.append("rope")
print(loot.count)
print(loot[0])
print(loot.contains("shield"))
print(loot.last ?? "empty")There is no negative indexing —
last is the spelling, and it returns an optional because the array might be empty. That is the optional system showing up in the most ordinary place imaginable.Dictionaries, and a set you did not have
A dictionary lookup returns an optional, so reading one needs a fallback or an unwrap — the compiler will not let you forget that the key might be absent.
var stats := {"strength": 12, "agility": 8}
stats["luck"] = 3
print(stats["strength"])
print(stats.has("agility"))
var seen := {}
for tag in ["fire", "ice", "fire"]:
seen[tag] = true
var unique := seen.keys()
unique.sort()
print(unique)var stats = ["strength": 12, "agility": 8]
stats["luck"] = 3
print(stats["strength"] ?? 0)
print(stats.keys.contains("agility"))
var seen = Set<String>()
for tag in ["fire", "ice", "fire"] {
seen.insert(tag)
}
print(seen.sorted())Set is a real type, where GDScript fakes one with a dictionary of throwaway values. It brings union, intersection and subtracting, which is the reason to reach for one beyond deduplication.Filtering and transforming
The accumulate-into-an-empty-array loop is one chained line in Swift, and the trailing-closure syntax means the braces sit outside the parentheses.
var scores := [42, 91, 7, 68, 15]
var high := []
for score in scores:
if score > 40:
high.append(score * 10)
print(high)let scores = [42, 91, 7, 68, 15]
let high = scores.filter { $0 > 40 }.map { $0 * 10 }
print(high)reduce, compactMap (which drops nils) and sorted(by:) round out the set. GDScript has map and filter taking lambdas, so the idea is familiar; what is new is how little punctuation it takes.struct Copies, class Shares
The Vector2-versus-Array distinction, declared
You already feel this in GDScript without a name for it —
Vector2 copies on assignment and an Array does not. Swift gives the rule a name and lets you choose it for your own types.var first := Vector2(1, 2)
var second := first
second.x = 99
print(first.x)
var list_first := [1, 2]
var list_second := list_first
list_second.append(3)
print(list_first.size())struct Point { var x: Int; var y: Int }
class Box { var value: Int; init(_ value: Int) { self.value = value } }
var first = Point(x: 1, y: 2)
var second = first
second.x = 99
print(first.x)
let boxFirst = Box(1)
let boxSecond = boxFirst
boxSecond.value = 99
print(boxFirst.value)struct copies, class shares. Note the Swift array behaves like Vector2, not like a GDScript Array: arrays are structs here, so assigning one copies it. That single sentence is the most likely source of surprise when porting.Control Flow & switch
switch, checked for exhaustiveness
switch is checked for exhaustiveness, so the catch-all is unnecessary and usually a mistake — and there is no fall-through, so no break.enum State { IDLE, WALKING, RUNNING }
func describe(state: State) -> String:
match state:
State.IDLE:
return "standing still"
State.WALKING, State.RUNNING:
return "moving"
_:
return "unknown"
print(describe(State.WALKING))enum State {
case idle, walking, running
}
func describe(_ state: State) -> String {
switch state {
case .idle: return "standing still"
case .walking, .running: return "moving"
}
}
print(describe(.walking))Delete a case and the build fails naming the missing one, which means adding a state to the enum turns every unhandled place into a compile error.
.walking with no type name works wherever the type is already known — a small convenience you will see everywhere.Matching on shape and condition
Swift's
switch matches ranges, tuples and conditions, not just equality. 0...40 is a closed range used directly as a pattern.var reading := 42
if reading > 40:
print("a big number: %d" % reading)
elif reading >= 0:
print("a number: %d" % reading)
else:
print("negative")let reading = 42
switch reading {
case let value where value > 40:
print("a big number: \(value)")
case 0...40:
print("a number: \(reading)")
default:
print("negative")
}The
where clause attaches a condition to a pattern, and case let value binds the matched value to a name. GDScript's match can bind with var, but has no range patterns and no where.Loops & Sequences
Walking and counting
0..<3 is the half-open range and excludes its end, exactly as range(3) does; 0...3 is the closed form GDScript has no spelling for.var party := ["Ari", "Bex", "Cyd"]
for member in party:
print(member)
for i in range(3):
print(i)
for i in range(2, 8, 2):
print(i)let party = ["Ari", "Bex", "Cyd"]
for member in party {
print(member)
}
for i in 0..<3 {
print(i)
}
for i in stride(from: 2, to: 8, by: 2) {
print(i)
}stride is the stepped version, and its argument labels say which end is included — to: excludes, through: includes. That is more typing than range(2, 8, 2) and it is unambiguous when you read it back.Needing the index too
The GDScript habit is to loop over indices and index back in.
enumerated() pairs each element with its position, so there is no indexing left to get wrong.var waves := ["bats", "slimes", "boss"]
for i in range(waves.size()):
print("wave %d: %s" % [i + 1, waves[i]])let waves = ["bats", "slimes", "boss"]
for (index, wave) in waves.enumerated() {
print("wave \(index + 1): \(wave)")
}With
zip, which walks two sequences together, this removes most reasons to write an index at all — and Swift arrays trap on an out-of-range index rather than returning nothing, so removing the index removes a crash.Functions & Closures
Arguments have labels
Swift labels arguments at the call site by default, and the label is part of the function's name. The underscore on the first parameter is how you opt out of one.
func spawn(kind: String, count: int = 1, elite: bool = false) -> String:
return "%d %s%s" % [count, kind, " (elite)" if elite else ""]
print(spawn("bat"))
print(spawn("golem", 3))
print(spawn("slime", 2, true))func spawn(_ kind: String, count: Int = 1, elite: Bool = false) -> String {
return "\(count) \(kind)\(elite ? " (elite)" : "")"
}
print(spawn("bat"))
print(spawn("golem", count: 3))
print(spawn("slime", count: 2, elite: true))So
spawn(_:count:elite:) is the real name, and two functions differing only in labels are different functions. It is more to type and it makes a call readable without looking up the declaration — the opposite trade from GDScript's positional arguments.Closures and trailing syntax
A GDScript lambda is a
Callable invoked with .call(). A Swift closure is invoked like a function, and its parameters go inside the braces before in.var factor := 3
var scale := func(value: int) -> int: return value * factor
print(scale.call(7))
var numbers := [1, 2, 3, 4]
var doubled := numbers.map(func(value): return value * 2)
print(doubled)let factor = 3
let scale = { (value: Int) -> Int in value * factor }
print(scale(7))
let numbers = [1, 2, 3, 4]
let doubled = numbers.map { $0 * 2 }
print(doubled)When a closure is the last argument it can move outside the parentheses — the trailing-closure form — and when the body is one expression the
return disappears too. That is how map { $0 * 2 } gets that short.Enums That Carry Data
An enum case can carry data
This is the feature with no GDScript equivalent at all. A GDScript enum value is an integer, so carrying data alongside it means a dictionary and a convention. A Swift case carries its own payload, and different cases can carry different things.
var event := {"kind": "damage", "amount": 12}
match event["kind"]:
"damage":
print("took %d" % event["amount"])
"heal":
print("healed %d" % event["amount"])
_:
print("unknown")enum Event {
case damage(amount: Int)
case heal(amount: Int)
case died
}
let event = Event.damage(amount: 12)
switch event {
case .damage(let amount): print("took \(amount)")
case .heal(let amount): print("healed \(amount)")
case .died: print("gone")
}The state and its data travel together and cannot come apart — there is no
died event with a stray amount, because died has no amount to hold. Combined with exhaustiveness, this is the single most useful thing Swift offers a codebase full of state machines.Protocols & Extensions
Protocols instead of a base class
A protocol declares what a type must provide, and an
extension on the protocol can supply a default implementation — which is what Statue takes without writing anything.class Enemy:
func speak() -> String:
return "..."
class Bat extends Enemy:
func speak() -> String:
return "screech"
var creature: Enemy = Bat.new()
print(creature.speak())protocol Speaks {
func speak() -> String
}
extension Speaks {
func speak() -> String { "..." }
}
struct Bat: Speaks {
func speak() -> String { "screech" }
}
struct Statue: Speaks {}
let creatures: [Speaks] = [Bat(), Statue()]
for creature in creatures {
print(creature.speak())
}A
struct can conform, so shared behavior does not force you into reference semantics the way a base class does. This is why Swift code tends to have shallow type hierarchies where GDScript has deep extends chains.Adding methods to a type you did not write
An extension adds members to an existing type, including one from the standard library. GDScript has no way to do this — a helper has to be a free function or live on a class you own.
func clamp_health(value: int) -> int:
return clamp(value, 0, 100)
print(clamp_health(150))
print(clamp_health(-20))extension Int {
var clampedHealth: Int {
Swift.min(Swift.max(self, 0), 100)
}
}
print(150.clampedHealth)
print((-20).clampedHealth)The addition is a real member:
150.clampedHealth reads as though Int always had it. This is how Swift libraries extend built-in types without wrappers, and it is the feature that most changes how Swift code is organized.Errors You Can Finally Throw
GDScript cannot raise; Swift throws — and says so
GDScript has no exceptions —
push_error writes to the debugger and execution continues, so a failed operation must return something and hope the caller checks.func withdraw(balance: int, amount: int) -> int:
if amount > balance:
push_error("insufficient funds")
return balance
return balance - amount
print(withdraw(100, 30))
print(withdraw(100, 500))enum BankError: Error {
case insufficientFunds(short: Int)
}
func withdraw(balance: Int, amount: Int) throws -> Int {
if amount > balance {
throw BankError.insufficientFunds(short: amount - balance)
}
return balance - amount
}
print(try withdraw(balance: 100, amount: 30))
do {
print(try withdraw(balance: 100, amount: 500))
} catch BankError.insufficientFunds(let short) {
print("short by \(short)")
}Swift puts
throws in the signature and try at every call, so a call that can fail is visible in the calling code rather than only in the declaration. The error is an enum with associated values, so the catch site gets the data — here, exactly how short the balance was.SwiftGodot — Joining the Engine
Registering a class the editor can see
SwiftGodot uses Swift macros for the registration:
@Godot makes the class a type the engine knows, and @Callable exposes a method to GDScript and the editor. The lifecycle callbacks keep their names and are overrides.extends Node2D
class_name Spinner
func spin(delta: float) -> float:
rotation += delta
return rotation
func _ready() -> void:
print(spin(0.5))
print(spin(0.25))import SwiftGodot
@Godot
class Spinner: Node2D {
@Callable
func spin(delta: Double) -> Double {
rotation += delta
return rotation
}
override func _ready() {
GD.print(spin(delta: 0.5))
GD.print(spin(delta: 0.25))
}
}Compared with GDExtension C++, where every exposed member needs a line in
_bind_methods(), the declaration sits on the thing it describes. As with every binding on this anchor, there is no hot reload — changing this means rebuilding and restarting the editor.Exports and signals
@export becomes the @Export macro and the field still appears in the inspector. Signals are declared with the #signal macro, which generates the name the emit site refers to.extends Node
class_name Bell
signal rung(times: int)
@export var volume: float = 0.5
func ring(times: int) -> void:
rung.emit(times)import SwiftGodot
@Godot
class Bell: Node {
#signal("rung", arguments: ["times": Int.self])
@Export var volume: Double = 0.5
@Callable
func ring(times: Int) {
emit(signal: Bell.rung, times)
}
}Everything the engine needs to see is a macro attached to the declaration, so nothing can drift out of step with a separate registration table — the same argument the Rust page makes for gdext attributes over C++'s
_bind_methods().Two directions: extend Godot, or embed it
This row exists because the choice has no GDScript counterpart. A GDScript file is always loaded by the engine; SwiftGodot keeps that arrangement, and its sibling SwiftGodotKit inverts it.
# Godot is always the host. A GDScript file is loaded BY the engine,
# and there is no arrangement in which your program starts and the
# engine is a library you call into.
extends Node
func _ready() -> void:
print("the engine started me")// SwiftGodot: your code is a library the ENGINE loads (as above).
//
// SwiftGodotKit inverts it — the engine becomes a library YOUR app loads,
// so a SwiftUI app can host a Godot view inside itself.
import SwiftGodotKit
// A Swift application starts, and Godot runs inside it rather than
// the other way round. This is the arrangement GDScript has no
// equivalent for at all.🚨 Check the version pairing before committing to either. Verified 2026-09-01: SwiftGodot tracks Godot 4.6 on its main branch with older engines on branches, while this site pins 4.7.2 — and its platforms are iOS, macOS, Linux and Windows, with no web export and other platforms explicitly described as untested.
Drawing It, Side by Side
A color wheel, drawn and printed
The GDScript column below runs in a real Godot engine and draws a color wheel into the canvas. The Swift column runs too, and prints — because the Swift here is compiled as a plain program, with no engine and no canvas.
extends Control
func _ready() -> void:
queue_redraw()
func _draw() -> void:
var pane := get_viewport_rect().size
var middle := pane / 2.0
var span := minf(pane.x, pane.y) * 0.42
var wedges := 18
for i in wedges:
var from := TAU * i / wedges
var to := TAU * (i + 1) / wedges
var points: PackedVector2Array = [middle]
for step in 9:
var angle := lerpf(from, to, step / 8.0)
points.append(middle + span * Vector2(cos(angle), sin(angle)))
draw_colored_polygon(points, Color.from_hsv(float(i) / wedges, 0.62, 0.98))let wedges = 18
for i in 0..<wedges {
let hue = Double(i) / Double(wedges)
let bar = String(repeating: "#", count: 2 + Int(hue * 26))
let rounded = (hue * 100).rounded() / 100
print("\(rounded) \(bar)")
}Both walk the same eighteen hues; only the output device differs. With SwiftGodot the Swift column would call the same
drawColoredPolygon the left column calls, because it is the same engine method — which is the point of a binding, and also why there is nothing language-specific to learn in the drawing itself.