PONYλM2Modula-2

Rust.CodeCompared.To/GDScript

An interactive executable cheatsheet comparing Rust and GDScript

Rust 1.99.0 GDScript 4.7 (Godot 4.7.2)
Hello World & Running
Hello, World
Both columns run for real — the GDScript on the right executes in an actual Godot engine compiled to WebAssembly. A script does not need a main because the engine is already running and loads the script into it.
fn main() { println!("Hello, World!"); }
extends SceneTree func _init(): print("Hello, World!")
extends SceneTree is what makes this runnable outside a game: it is the root object the engine creates, and _init is called when the script is attached to it. In a real project you would far more often write extends Node2D or extends Control and let the engine call _ready. Note there is no macro and no format-string checking — print takes any values and stringifies them.
Nothing is checked before it runs
GDScript is more checked than a typical scripting language — the parser catches undefined identifiers when the script loads, not when the line runs — and much less checked than Rust, which reads every branch and every type.
fn main() { let total: i32 = [3, 1, 4, 1, 5].iter().sum(); if total > 1000 { // println!("{}", mispelled_name); // ← uncomment: it will not build } println!("total: {}", total); }
extends SceneTree func _init(): var total := 0 for value in [3, 1, 4, 1, 5]: total += value if total > 1000: # A misspelled name here is caught when the script is PARSED, # which is better than most dynamic languages — but a type # error inside it is not caught until the line runs. print(str(total) + " is large") print("total: " + str(total))
The distinction that matters for a game: a type error inside a branch taken only when the boss spawns is found by GDScript when the boss spawns. Static typing helps and is opt-in — var speed: float = 5.0 is checked at parse time and runs faster than an untyped variable, so annotate everything in a real project. What is genuinely gone is exhaustiveness, lifetimes, and the guarantee that a compiling program has no type errors anywhere.
ECS vs The Scene Tree
🚨 The scene tree is not an ECS
This is the row that decides whether the rest of the page is interesting. Bevy separates data from behavior and lets a scheduler run systems in parallel; Godot puts both in a node and arranges the nodes in a tree.
// Bevy: entities are ids, components are plain data, and behavior // lives in SYSTEMS that query for whatever combination they need. // Nothing owns anything; the World owns everything. // // #[derive(Component)] struct Position(Vec2); // #[derive(Component)] struct Velocity(Vec2); // // fn movement(mut query: Query<(&mut Position, &Velocity)>, // time: Res<Time>) { // for (mut position, velocity) in &mut query { // position.0 += velocity.0 * time.delta_seconds(); // } // } // // A system is a function. It is scheduled, it can run in PARALLEL // with any system whose queries do not conflict, and it has no idea // which entities exist. fn main() { println!("data and behavior, held apart"); }
extends SceneTree # Godot: a RETAINED TREE of objects. A node IS its data and its # behavior, it has one parent, and it is addressed by its place in # the tree. This is closer to a DOM than to an ECS. # # Main # ├─ Player (script: velocity, _process moves it) # ├─ Enemies # │ ├─ Bat # │ └─ Slime # └─ HUD func _init(): var root_node := Node.new() root_node.name = "Main" var player := Node.new() player.name = "Player" root_node.add_child(player) print(root_node.get_child_count()) print(root_node.get_node("Player").name) root_node.free()
The consequences that actually bite. Composition in an ECS is adding a component; in Godot it is adding a child node, which is why a Godot scene has a Sprite2D, a CollisionShape2D and an AnimationPlayer hanging off one body. Cross-cutting behavior is a system in Bevy and either a parent node or an autoload singleton in Godot. And parallelism is automatic in Bevy and manual here — Godot runs _process on the main thread, and using WorkerThreadPool is a deliberate act. Godot 4 does have a small ECS-ish escape hatch in RenderingServer, and it is not the way games are written in it.
What the engine hands you
The trade in one row. Bevy is a library you build a game out of; Godot is an application you build a game inside, and the difference is the editor and everything downstream of it.
// What a Rust game developer is usually weighing. Bevy gives you: // // an ECS that is genuinely excellent // the borrow checker, all the way down // one language, one build, cargo // // and leaves you to assemble, from crates or your own code: // // an editor // an animation and state-machine authoring workflow // a scene format artists can open // a tilemap editor, a particle editor, a shader graph // console export fn main() { println!("a library, not an application"); }
extends SceneTree # Godot is an APPLICATION you open, and that is most of the argument: # # a scene editor artists can use # an animation editor with a state machine # tilemaps, particles, a shader graph, a UI system # one-click export to Windows, macOS, Linux, Android, iOS and web # a debugger, profiler and remote scene inspector # # It asks you to give up static guarantees, and to accept an engine # with opinions about how a game is structured. func _init(): print("an application, not a library")
The part that is easy to under-weigh from a Rust perspective: the editor is not a convenience, it is the collaboration surface. A scene file an artist can open, rearrange and hand back is what lets a non-programmer contribute, and it is the reason "we will build tooling later" tends not to survive contact with a schedule. What is easy to over-weigh is the language change — for a great deal of game code, GDScript being dynamic costs less than it sounds, and the parts where it costs most are exactly the parts gdext is for.
Nodes, Scenes & Signals
Signals are events, wired at run time
A signal is Godot's event, and the important difference from Bevy's is when the wiring happens: a connection is made at run time, and very often in the editor rather than in code at all.
// Bevy's version is an event type plus a writer and a reader: // // #[derive(Event)] struct PlayerDied { score: u32 } // // fn die(mut events: EventWriter<PlayerDied>) { // events.send(PlayerDied { score: 42 }); // } // // fn on_death(mut events: EventReader<PlayerDied>) { // for event in events.read() { println!("{}", event.score); } // } // // The types are checked, the wiring is the schedule, and an event // nobody reads is dropped at the end of the next frame. fn main() { println!("events are types"); }
extends SceneTree # A signal is declared on the emitter and connected by whoever cares. # The connection is made at run time — often in the EDITOR, by # dragging — so a node need not know who is listening. signal player_died(score: int) func _on_player_died(score: int) -> void: print("score was ", score) func _init(): player_died.connect(_on_player_died) player_died.emit(42)
That is a real trade. Editor-wired signals are how a designer connects a button to a behavior without touching code, and they are also invisible to a code search — a handler with no callers in the source is normal in a Godot project, and finding what emits a signal is a job for the editor rather than the compiler. Signals are checked for arity at connect time and typed loosely; a renamed handler fails when the signal fires. Coming from Rust, the discipline worth keeping is to connect in code where the relationship is structural, and leave the editor for genuinely designer-owned wiring.
A scene is a prefab, and a file
A scene is a file on disk describing a subtree of nodes with their properties set. Instancing one is preload plus instantiate, and the file is what an artist opens.
// Bevy's nearest equivalent is a spawned bundle, in code: // // fn spawn_enemy(mut commands: Commands, assets: Res<AssetServer>) { // commands.spawn(( // Sprite::from_image(assets.load("bat.png")), // Enemy { health: 3 }, // Transform::from_xyz(100.0, 0.0, 0.0), // )); // } // // Reusable, versionable, refactorable — and only editable by // someone who can compile Rust. fn main() { println!("a prefab is a function"); }
extends SceneTree # A scene is a .tscn FILE: a tree of nodes with their properties, # saved from the editor. Instancing one is two lines, and the file # can be edited by anyone who can open the editor. # # var enemy_scene := preload("res://enemies/bat.tscn") # var bat := enemy_scene.instantiate() # add_child(bat) # # A scene may contain other scenes, so a level is a scene of scenes. func _init(): var bat := Node2D.new() bat.name = "Bat" bat.position = Vector2(100, 0) print(bat.name, " at ", bat.position) bat.free()
Two properties of that design are worth noticing from a Rust perspective. .tscn is a text format, so it diffs and merges in version control — badly for large scenes, but genuinely rather than not at all. And scene inheritance exists: a scene can extend another and override properties, which is prefab variants without writing a builder. What you give up is that the "constructor" for an entity is now data an editor produced rather than a function a compiler checked, so a renamed property fails at load time.
The Game Loop
The loop calls you
The same fixed-versus-variable-timestep split exists in both: _process is Bevy's Update and _physics_process is FixedUpdate, with physics belonging in the second.
// Bevy: you add systems to a schedule and the runner calls them. // // App::new() // .add_plugins(DefaultPlugins) // .add_systems(Update, movement) // .add_systems(FixedUpdate, physics) // .run(); // // Update runs once per frame; FixedUpdate runs at a fixed rate, // which is where physics belongs. fn main() { let delta = 1.0_f32 / 60.0; let mut position = 0.0_f32; for _ in 0..3 { position += 120.0 * delta; } println!("{:.1}", position); }
extends SceneTree # Godot calls the callbacks it finds on your node: # # _ready() once, when the node enters the tree # _process(delta) once per frame # _physics_process(delta) at a fixed rate — physics goes here # _input(event) when input arrives # _draw() when the node needs repainting func _init(): var delta := 1.0 / 60.0 var position := 0.0 for _index in 3: position += 120.0 * delta print("%.1f" % position)
The structural difference is that Godot finds these by name on your script rather than by registration, so there is no schedule to declare and no ordering to configure — and correspondingly no way to say "this must run after that" except through the tree order and process_priority. Coming from Bevy's explicit system ordering, that is the loss to plan around: cross-node ordering in Godot is an architectural question you answer with the tree, not a constraint you declare.
Types & Nullability
Typing is optional, and worth doing anyway
:= is inferred-and-then-static, which is Rust's let. A bare = with no annotation makes the variable a Variant — dynamically typed, and slower.
fn main() { let speed: f32 = 5.0; let name = "player"; // inferred, still fixed let mut health = 100; health -= 30; println!("{} {} {}", speed, name, health); }
extends SceneTree func _init(): var speed: float = 5.0 # declared var name := "player" # := infers, and the type is then FIXED var loose = 100 # no annotation: this one is a Variant loose -= 30 print(speed, " ", name, " ", loose)
The practical advice, which experienced Godot programmers give too: annotate everything. A statically typed GDScript variable is checked when the script is parsed, produces better editor completion, and compiles to faster bytecode — the interpreter can skip the type dispatch. What you do not get is any of Rust's deeper guarantees: no lifetimes, no mut distinction, no exhaustiveness, and generics only in the loose sense of Array[int].
🚨 null is back
🚨 There is no Option. A function that may find nothing returns null, and nothing makes the caller check.
fn find_even(values: &[i32]) -> Option<i32> { values.iter().copied().find(|value| value % 2 == 0) } fn main() { // The compiler will not let this be used without handling None. match find_even(&[1, 3, 5]) { Some(value) => println!("found {}", value), None => println!("nothing found"), } }
extends SceneTree func find_even(values: Array[int]): for value in values: if value % 2 == 0: return value return null func _init(): var found = find_even([1, 3, 5]) if found == null: print("nothing found") else: print("found ", found) # Nothing forces that check. Omit it and you get the error every # Godot developer has read: "Invalid call on base Nil".
This is the largest single safety loss on the page, and it has a specific Godot flavor: get_node("Player") returns null when the path is wrong, so a renamed node in the editor produces a null dereference at run time rather than a build error. The habits that help: use @onready var player: Node = $Player so the failure is at scene load rather than deep in a frame, use is_instance_valid() before touching anything that might have been freed, and lean on the typed Array[Node] forms so the parser can catch what it can.
Ownership Becomes Reference Counting
The borrow checker is gone
Arrays and dictionaries are reference types in GDScript, so assignment aliases rather than moving or copying — and duplicate() is the explicit copy.
fn main() { let first = vec![1, 2, 3]; let second = first; // MOVED — first is no longer usable // println!("{:?}", first); // ← uncomment: use after move let shared = second.clone(); println!("{:?} {:?}", second, shared); }
extends SceneTree func _init(): var first := [1, 2, 3] var second := first # a second NAME for one array second.append(4) print(first) # [1, 2, 3, 4] — there was one array var copied := first.duplicate() copied.append(5) print(first, " ", copied)
What replaces the borrow checker is two rules to hold in your head. Arrays, dictionaries and objects are references; everything else is a value — so Vector2, Color, String and the numbers all copy on assignment, which surprises people the other way round (node.position.x = 5 does nothing, because position returned a copy). And a freed object leaves a dangling reference that reports as "previously freed" when touched, which is a use-after-free with a good error message rather than undefined behavior.
Two lifetimes, and which to use
The distinction to learn on day one: Node and Object are freed by hand or by their parent, while RefCounted — and therefore every Resource — is reference counted.
use std::rc::Rc; fn main() { // Rc is reference counting when you need shared ownership; // ordinary ownership is the default and costs nothing. let shared = Rc::new(vec![1, 2, 3]); let alias = Rc::clone(&shared); println!("{} references", Rc::strong_count(&shared)); println!("{:?}", alias); }
extends SceneTree # Godot has two lifetime models and picking the wrong one leaks: # # Object / Node MANUAL. Freed by free() or queue_free(), or by # its parent when the parent is freed. A Node you # create and never add to the tree is a leak. # # RefCounted reference counted, freed when the last reference # goes. Resource extends this, which is why # textures and scenes need no explicit free. func _init(): var counted := RefCounted.new() # freed automatically var manual := Node.new() # NOT freed automatically manual.free() # so this line matters print("one counted, one freed by hand")
The specific leak this causes: Node.new() that is never added to a tree and never freed. The editor reports orphaned nodes on exit, which is how you find them. queue_free() defers the deletion to the end of the frame and is what you want for a node that is currently in the middle of doing something; free() is immediate and will crash if anything is still using it. Cycles of RefCounted leak exactly as Rc cycles do, and Godot has no Weak equivalent beyond WeakRef.
There Is No Result
Error codes, and nothing enforcing them
There is no Result, no Option and no exceptions either — so a function that can fail returns a sentinel, sets a flag, or calls push_error and returns something.
fn parse_count(text: &str) -> Result<i32, String> { text.parse::<i32>().map_err(|_| format!("not a number: {}", text)) } fn main() { match parse_count("oops") { Ok(value) => println!("parsed {}", value), Err(message) => println!("failed: {}", message), } }
extends SceneTree func parse_count(text: String) -> int: if not text.is_valid_int(): # push_error(...) would log it to the debugger and carry on. return -1 # a sentinel, because there is # no Result and no exception return text.to_int() func _init(): var value := parse_count("oops") if value < 0: print("failed: not a number: oops") else: print("parsed ", value)
The engine's own convention is an Error enum returned as an integer: var error := file.open(path, FileAccess.READ) gives you OK or a code, and nothing makes you look at it. Coming from Rust, the discipline to import is to check every one of those and to define your own result shape where it matters — a small class or a two-element array of [value, error]. push_error writes to the debugger and does not stop execution, so it is a log line rather than a failure.
Collections
Vec and HashMap become Array and Dictionary
Array[int] is a typed array and is checked at parse time where it can be; a bare Array holds anything. Dictionary is the hash map, and it preserves insertion order.
use std::collections::HashMap; fn main() { let mut values: Vec<i32> = vec![3, 1, 4]; values.push(1); values.sort(); println!("{:?}", values); let mut counts: HashMap<&str, i32> = HashMap::new(); counts.insert("apple", 2); println!("{:?}", counts.get("apple")); println!("{:?}", counts.get("fig")); }
extends SceneTree func _init(): var values: Array[int] = [3, 1, 4] values.append(1) values.sort() print(values) var counts := {"apple": 2} print(counts.get("apple")) print(counts.get("fig")) # null, not a KeyError
Two things a Rust programmer will look for. Iterator chains are absent — there is no .iter().filter().map().collect(), so a transformation is a for loop or one of the few array methods (filter, map, reduce do exist and take a Callable). And counts.get("fig") returns null rather than None, with counts["fig"] raising an error instead — so the null discipline from the Types section applies here too. PackedInt32Array and friends are the tightly-packed variants worth using for large numeric data.
Drawing It, Side by Side
The same figure, drawn by the engine
The GDScript column runs in a real Godot engine and the orbits below are that engine drawing — the dots move. The Bevy column is the same figure as a gizmo system; read them as one program in two spellings.
// The Bevy equivalent, as a gizmo system — read it beside the // GDScript, which really draws: // // fn draw_orbits(mut gizmos: Gizmos, time: Res<Time>) { // let elapsed = time.elapsed_seconds(); // for index in 0..5 { // let radius = 30.0 + index as f32 * 22.0; // let angle = elapsed * (1.0 + index as f32 * 0.4); // gizmos.circle_2d(Vec2::ZERO, radius, Color::srgb(0.2, 0.3, 0.4)); // gizmos.circle_2d( // Vec2::new(angle.cos(), angle.sin()) * radius, // 5.0, // Color::srgb(0.4, 0.95, 0.6), // ); // } // } fn main() { println!("the same drawing calls, in a different engine"); }
extends Control var elapsed := 0.0 func _ready() -> void: custom_minimum_size = get_viewport_rect().size func _process(delta: float) -> void: elapsed += delta queue_redraw() if elapsed > 6.0: set_process(false) # a code example, not a screen saver func _draw() -> void: var pane := get_viewport_rect().size var middle := pane * 0.5 var span: float = min(pane.x, pane.y) * 0.45 draw_rect(Rect2(Vector2.ZERO, pane), Color(0.06, 0.07, 0.10)) for index in 5: var radius := span * (0.25 + index * 0.18) var angle := elapsed * (1.0 + index * 0.4) draw_arc(middle, radius, 0.0, TAU, 64, Color(0.20, 0.28, 0.38), 1.5) var dot := middle + Vector2(cos(angle), sin(angle)) * radius draw_circle(dot, 5.0, Color(0.35, 0.95, 0.62)) draw_string(ThemeDB.fallback_font, Vector2(12, 22), "drawn by Godot, in your browser", 0, -1, 14, Color(0.75, 0.80, 0.88))
Three things this row is quietly demonstrating. The drawing calls correspond almost one to one, so the Bevy knowledge transfers. The layout asks the engine for its pane (get_viewport_rect().size) and computes from it, which is what makes a drawing fill the space rather than sit in a corner. And set_process(false) after six seconds is deliberate: an animation that never stops keeps the engine at sixty frames a second for as long as the page is open, and the reader never sees a finished result.
gdext: Keeping The Rust
gdext: a Rust class the editor can see
gdext builds a shared library that the editor loads, and a #[derive(GodotClass)] struct appears in the node list exactly as a built-in does — #[export] puts a field in the Inspector.
// godot-rust (gdext). The struct becomes a node type that appears // in the editor's node list and is instantiated like a built-in. // // use godot::prelude::*; // // #[derive(GodotClass)] // #[class(base = Node2D)] // struct Spinner { // base: Base<Node2D>, // #[export] speed: f32, // shows up in the Inspector // } // // #[godot_api] // impl INode2D for Spinner { // fn init(base: Base<Node2D>) -> Self { // Self { base, speed: 2.0 } // } // fn process(&mut self, delta: f64) { // let turn = self.speed * delta as f32; // self.base_mut().rotate(turn); // } // } fn main() { println!("a shared library the editor loads"); }
extends Node2D # The same class in GDScript, for comparison. The two are # interchangeable from the engine's point of view: either can be # attached to a node, exported to the Inspector, and called from # the other. @export var speed: float = 2.0 func _process(delta: float) -> void: rotate(speed * delta) # Which is the whole argument for gdext: you do not choose one # language for the project. You choose per script.
The two columns are interchangeable to the engine, which is the point: this is not a migration decision but a per-script one. The realistic split is engine, editor and glue in GDScript; simulation, procedural generation, pathfinding and anything with a tight loop in Rust. Two costs to plan for — a build step between you and pressing play (GDScript reloads instantly, Rust does not), and the borrow discipline showing up in ordinary engine calls, since self.base() and self.base_mut() cannot be held at once.
Iteration Speed
The compile step goes away
This is the difference a Rust game developer feels within an hour, and it is worth weighing honestly against everything the borrow checker buys.
// The Rust game-development complaint, and it is a real one: // // cargo build seconds to minutes, on every change // dynamic linking, bevy's fast-compiles feature, cranelift, // and a lot of effort spent getting it down // // For gameplay code — where you change a number, look, change it // again — that loop is the thing that hurts most. fn main() { let jump_height = 4.5_f32; println!("rebuild to try {} instead", jump_height + 0.5); }
extends SceneTree # GDScript is reloaded by the editor as you save. Change a number, # press play, see it — with no build at all. Godot will also let you # edit values on a RUNNING game through the remote inspector and # keep the changes. # # That loop is the single biggest day-to-day difference, and for # gameplay tuning it matters more than language features do. func _init(): var jump_height := 4.5 print("try %.1f instead, without rebuilding" % (jump_height + 0.5))
The reason it matters more in games than elsewhere: gameplay tuning is a loop of change-a-number-and-look, run hundreds of times a day, and a thirty-second rebuild in the middle of it changes what you are willing to try. Godot's remote inspector goes further, letting you edit a running game's values and keep them. The counter-argument is equally real — a large GDScript codebase gives back some of that time in run-time errors a compiler would have caught — which is once again the case for gdext on the parts that are worth compiling.

Thank you — anything else?