PONYλM2Modula-2

Ruby.CodeCompared.To/GDScript

An interactive executable cheatsheet for Rubyists learning GDScript

Ruby 4.0 GDScript 4.5 (Godot 4.5.2)
Output & Running It
Hello, World
GDScript is the scripting language built into the Godot engine, and this page is written for a Rubyist coming from DragonRuby Game Toolkit — which runs mruby, making Ruby a real shipping game language. Start with the part that is identical: printing a line.
puts "Hello, World!"
print("Hello, World!")
GDScript's print appends a newline like puts, and both languages let you call it at the top of a file with no ceremony. The similarity is not a coincidence — GDScript's syntax is deliberately Python-flavored, and both languages optimize for reading like pseudocode.
String interpolation vs. format strings
GDScript has no #{} interpolation. The everyday replacement is the % operator, which takes a single value or an array of them and uses C-style conversion specifiers; String.format covers the named-placeholder case.
player_name = "Ada" score = 1200 puts "#{player_name} scored #{score}" puts format("%.2f seconds", 1.5)
var player_name := "Ada" var score := 1200 print("%s scored %d" % [player_name, score]) print("%.2f seconds" % 1.5) print("{name} scored {points}".format({"name": player_name, "points": score}))
The specifiers are the ones format/sprintf use in Ruby, so %s, %d, and %.2f behave as expected. A single non-array value works directly, but an array is required for more than one — and passing the wrong count is a runtime error, not a silent truncation.
p vs. printing a structure
Ruby distinguishes puts (human output, via to_s) from p/inspect (debug output). GDScript's print already shows containers structurally, and var_to_str is the closer counterpart of inspect — it produces text str_to_var can read back.
settings = { fullscreen: true, volume: 0.8 } p settings puts settings.inspect puts [1, 2, 3].inspect
var settings := {"fullscreen": true, "volume": 0.8} print(settings) # prints the structure directly print(var_to_str(settings)) # the round-trippable form print([1, 2, 3])
There is also print_debug, which adds the file and line, and printt/prints, which join their arguments with tabs or spaces. None of them return the value the way p does, so p-in-the-middle-of-an-expression has no GDScript equivalent.
Comments
Line comments are identical. GDScript has no block-comment syntax at all — a run of # lines is the only form — but it does give ## a meaning Ruby has no built-in equivalent for.
# A line comment =begin A block comment =end puts "commented"
# A line comment ## A documentation comment (shown in the editor's help) print("commented")
A ## comment above a class member becomes its documentation in Godot's built-in help, the way RDoc or YARD comments feed a doc generator — except the editor reads them directly, with no build step.
Dynamic Ruby vs. Optional Static Types
Every variable is declared with var
Ruby creates a local by assigning to it. GDScript requires var, and that gives it a place to hang an optional type: plain = leaves the variable a Variant that accepts anything, while := infers a type from the initial value and locks it in.
health = 100 health = "wounded" # perfectly legal puts health
var health = 100 # untyped: holds anything (a Variant) health = "wounded" # legal, because nothing declared a type print(health) var speed := 200.0 # := infers the type from the value, permanently print(speed)
This is the single biggest day-to-day difference in feel. Rubyists usually reach for plain var at first, then move to := once they notice that typed variables catch mistakes when the script loads instead of mid-game — and that Godot compiles typed code to faster bytecode.
Explicit type annotations
Ruby's type systems (RBS, Sorbet) are separate artifacts checked by separate tools. GDScript's annotations are part of the language and enforced by the engine, so a wrong assignment stops the script from loading rather than surfacing as a NoMethodError three frames later.
# Ruby has no runtime-enforced type declarations. # The closest thing is a check you write yourself: health = 100 raise TypeError, "health must be an Integer" unless health.is_a?(Integer) puts health # RBS/Sorbet types live outside the code, in .rbs files or sigs.
var health: int = 100 var player_name: String = "Ada" var velocity: Vector2 = Vector2(0, 0) var scores: Array[int] = [10, 20] print(health, " ", player_name, " ", velocity, " ", scores) # Assigning the wrong type is an error, caught when the script loads: # health = "wounded" ← "Cannot assign a value of type String to int"
The types available are the engine's own — int, float, String, bool, every built-in like Vector2 and Color, any Node subclass, and typed collections such as Array[int]. In a game loop running 60 times a second, this is worth real frame time, which is the practical argument for annotating even when you dislike the noise.
Constants are enforced, not a convention
A Ruby constant is a naming convention with a warning attached: reassignment works. const in GDScript is a real guarantee, checked when the script loads, and its value must be knowable then — so a constant cannot be computed from anything at runtime.
MAX_HEALTH = 100 SPEED = 200.0 # Reassigning warns but works; Ruby only enforces frozen VALUES: NAMES = ["Ada", "Grace"].freeze puts MAX_HEALTH, SPEED, NAMES.frozen?
const MAX_HEALTH := 100 const SPEED := 200.0 const NAMES := ["Ada", "Grace"] print(MAX_HEALTH, " ", SPEED, " ", NAMES) # MAX_HEALTH = 50 ← "Cannot assign to constant", refused at load time
Ruby's freeze protects the object, not the name; GDScript's const protects the name, and a constant array's contents stay mutable. The two languages have the guarantee in opposite places, which is worth remembering before assuming const NAMES is deeply immutable.
🚨 Zero and the empty string are FALSY
This is the rule to unlearn first. Ruby has exactly two falsy values, nil and false, so if count is true even at zero. GDScript follows Python's convention instead: 0, 0.0, "", an empty array, an empty dictionary, and null are all falsy.
count = 0 if count puts "Ruby: 0 is truthy" end text = "" puts "Ruby: an empty string is truthy" if text # Only nil and false are falsy in Ruby. puts "nil is falsy" unless nil
var count := 0 if count: print("this never prints") else: print("GDScript: 0 is FALSY") var text := "" if not text: print("GDScript: an empty string is FALSY too") var items := [] if not items: print("and so is an empty array")
Ported code does not crash on this — it silently takes the other branch. A Ruby habit like if player.ammo means "does ammo exist?" but reads in GDScript as "does the player have ammo left?", which is a different question with the same spelling. Write if ammo != null when presence is what you meant.
nil vs. null (and no NilClass methods)
Ruby's nil is an object with methods (nil.to_s, nil.to_a, nil?), which is why value.to_s is safe on anything. GDScript's null is a bare absence: calling a method on it is a runtime error in the style of a null-pointer dereference.
value = nil puts value.nil? puts value.inspect puts value.to_s.empty? # nil answers messages puts (value || "default") # || supplies a fallback
var value = null print(value == null) print(typeof(value) == TYPE_NIL) print(str(value)) # "<null>" # There are no methods on null — calling one is a runtime error. # The fallback idiom needs an explicit check: print(value if value != null else "default")
There is no safe-navigation operator either — no &. — so a nullable value gets an if. GDScript does have is_instance_valid() for the specific case of a freed object, which is a real hazard once nodes come into it: a variable can hold a reference to something the engine has already destroyed.
No symbols — StringName instead
Ruby's symbols have no GDScript counterpart, but the underlying idea does: StringName is an interned string that compares by pointer, written with an & prefix. The engine uses it for the things symbols name in Ruby — node names, animation names, input actions, method names.
# Symbols are interned, cheap to compare, and everywhere in Ruby: action = :jump settings = { fullscreen: true } puts action puts action.class puts settings[:fullscreen]
# GDScript has no symbols. Interned strings are StringName, written &"...": var action := &"jump" print(action) print(typeof(action) == TYPE_STRING_NAME) # Dictionary keys are ordinary strings (or anything else): var settings := {"fullscreen": true} print(settings["fullscreen"])
A StringName and a String with the same characters compare equal, so mixing them is harmless; the difference is only cost. Use &"..." for a name compared every frame, and a plain string everywhere else. Note that dictionary keys are strings here, so the { fullscreen: true } shorthand becomes {"fullscreen": true}.
Numbers & Math
Integer division agrees — until the operands go negative
Both languages truncate integer division, so the common case matches. Negative operands do not: Ruby floors (-7 / 2 == -4) while GDScript truncates toward zero (-3), and their % results take opposite signs.
puts 7 / 2 # 3 integer division puts 7.0 / 2 # 3.5 puts 7 % 2 # 1 puts(-7 / 2) # -4 Ruby FLOORS puts(-7 % 2) # 1 sign follows the divisor
print(7 / 2) # 3 integer division, same as Ruby print(7.0 / 2) # 3.5 print(7 % 2) # 1 print(-7 / 2) # -3 GDScript TRUNCATES toward zero print(-7 % 2) # -1 sign follows the dividend print(-7.0 / 2) # -3.5
This bites in exactly one place and it is a game-development place: wrapping coordinates or indices around a grid. Ruby's index % width is always in range; GDScript's can be negative, so reach for posmod(index, width) when you want Ruby's behavior.
Math functions are global, not on Math
Ruby splits arithmetic between the Math module and methods on numbers. GDScript puts everything in the global scope, so sqrt, abs, and clamp are called as plain functions — and adds the interpolation and easing helpers a game needs.
puts Math.sqrt(2).round(4) puts(-3.abs) puts 2 ** 10 puts 7.5.floor puts 7.4.round puts 15.clamp(0, 10)
print(snappedf(sqrt(2.0), 0.0001)) print(abs(-3)) print(pow(2, 10)) print(floor(7.5)) print(round(7.4)) print(clamp(15, 0, 10)) print(lerp(0.0, 100.0, 0.25)) # no Ruby equivalent
lerp, inverse_lerp, move_toward, smoothstep, and deg_to_rad have no Ruby counterparts and are used constantly in engine code. Note that ** exists in GDScript too, so pow(2, 10) and 2 ** 10 are both fine.
Random numbers
The pieces map closely: rand splits into randi and randf, srand becomes seed, and sample becomes pick_random. The one gap worth naming is randi_range(low, high), which is inclusive at both ends and says what it means.
srand(1) # deterministic for this example puts rand(6) + 1 # 1..6 puts rand.round(3) >= 0 # a float in 0.0...1.0 puts [1, 2, 3].sample.between?(1, 3) puts (1..6).to_a.shuffle.length
seed(1) # deterministic for this example print(randi() % 6 + 1) # 1..6 print(randi_range(1, 6)) # clearer: inclusive on both ends print(randf() >= 0.0) # a float in 0.0..1.0 print([1, 2, 3].pick_random() in [1, 2, 3]) var numbers := [1, 2, 3, 4, 5, 6] numbers.shuffle() # in place, unlike Ruby's shuffle print(numbers.size())
The big behavioral difference is mutation: Ruby's shuffle returns a new array (shuffle! mutates), while GDScript's shuffle() always mutates in place and returns nothing. Godot also has randomize() to seed from the clock — worth calling once at startup, since the generator is otherwise seeded identically every run.
Vector2 is built in
Godot ships vector, transform, rectangle, and color types as first-class values with operators, and they are pervasive: a node's position is a Vector2. DragonRuby leaves that to you — positions are numbers or hashes, and the math is code you write.
# DragonRuby keeps positions as plain numbers or hashes, # and vector math is something you write: position = { x: 3.0, y: 4.0 } length = Math.sqrt(position[:x]**2 + position[:y]**2) puts length moved = { x: position[:x] + 1, y: position[:y] + 1 } puts moved.inspect
var position := Vector2(3, 4) print(position.length()) # 5.0 print(position + Vector2(1, 1)) # operator overloading, built in print(position.normalized()) print(position.distance_to(Vector2(0, 0))) print(position.angle()) # radians
This is one of the clearest wins of a typed engine language. Vector2, Vector3, Rect2, Transform2D, and Color are value types with overloaded operators, so position += velocity * delta means exactly what it looks like. There is no way to define such a type yourself in GDScript, though — operator overloading is reserved for the engine's own types.
Strings
Length, case, and the method-name shift
Every operation you know has a counterpart; only the names move. The pattern to internalize: GDScript spells transformations to_*, predicates begins_with/ends_with/contains rather than with a trailing ?, and length() is a method call with parentheses.
text = "Hello, World" puts text.length puts text.upcase puts text.downcase puts text.include?("World") puts text.start_with?("Hello") puts text.reverse
var text := "Hello, World" print(text.length()) # a method call, not a property print(text.to_upper()) print(text.to_lower()) print(text.contains("World")) print(text.begins_with("Hello")) print(text.reverse())
GDScript has no ? or ! in identifiers, so the Ruby convention that tells you a method asks a question or mutates its receiver is simply absent — you check the documentation instead. Strings here are value types: to_upper() returns a new string and never modifies the original, so there is no upcase! equivalent.
Slicing: ranges vs. substr
Ruby indexes strings with ranges, which GDScript does not support at all. Instead there is substr(offset, length), plus left and right for the from-either-end cases that ranges usually cover in Ruby.
text = "Hello, World" puts text[0, 5] # "Hello" — offset and length puts text[0..4] # "Hello" — inclusive range puts text[-5..] # "World" — from the end puts text[7..] # "World"
var text := "Hello, World" print(text.substr(0, 5)) # "Hello" — offset and length print(text.substr(7)) # "World" — to the end print(text.right(5)) # "World" — the last five print(text.left(5)) # "Hello" — the first five print(text[0]) # a single character by index
Indexing with a single integer works and returns a one-character string, and negative indices count from the end (text[-1] is the last character). What does not exist is a range index — text[0..4] is a syntax error, because .. is not an operator in GDScript.
Splitting, joining, and stripping
The one that trips everybody: join is a method on the separator string, not on the array — " & ".join(names), the Python arrangement. strip becomes strip_edges(), and "-" * 20 becomes "-".repeat(20).
line = " ada,grace,alan " names = line.strip.split(",") puts names.inspect puts names.join(" & ") puts "-" * 20 puts "ada".center(9, "*")
var line := " ada,grace,alan " var names := line.strip_edges().split(",") print(names) print(" & ".join(names)) # join is on the SEPARATOR, not the array print("-".repeat(20)) print("ada".lpad(6, "*").rpad(9, "*"))
split returns a PackedStringArray rather than an ordinary Array — a compact, typed array the engine uses for string results. It indexes and iterates exactly like an array, but it will not accept non-string elements, which is usually what you wanted anyway.
Converting to and from numbers safely
Both languages have a lenient converter that returns 0 for nonsense. Ruby also offers Integer(), which raises — GDScript has no exceptions at all, so the defensive form is to ask is_valid_int() (or is_valid_float()) before converting.
puts "42".to_i puts "not a number".to_i # 0 — silently puts Integer("42") # raises on bad input puts 42.to_s + " points" begin Integer("nope") rescue ArgumentError => error puts "rejected: #{error.class}" end
print("42".to_int()) print("not a number".to_int()) # 0 — silently, like Ruby's to_i print("42".is_valid_int()) # check FIRST: there is no exception print("nope".is_valid_int()) print(str(42) + " points") var text := "nope" print(text.to_int() if text.is_valid_int() else "rejected")
This is the first place the missing exception system shows up, and the pattern generalizes across the whole language: check before acting, because nothing will be raised for you. str() is the global stringify function, standing in for to_s.
Multi-line strings and heredocs
GDScript's triple-quoted string is the multi-line form. There is no heredoc, and crucially no squiggly-heredoc equivalent: the literal keeps every space you type, so indenting it to match your code puts that indentation in the string.
intro_text = <<~TEXT Hello, traveler. The road is long. TEXT puts intro_text puts intro_text.lines.length
var intro_text := """Hello, traveler. The road is long.""" print(intro_text) print(intro_text.split("\n").size())
That is why the GDScript example above starts each line at column zero even though the surrounding code is indented. If you need indented source and unindented text, build the string with "\n".join([...]) or strip the lines afterward.
Arrays
Creating and growing an array
Arrays are the closest thing to a straight port on this page. The names change — size(), append(), has(), front()/back() — and << does not exist, but the semantics, including negative indices, match Ruby.
scores = [10, 20, 30] scores << 40 scores.push(50) puts scores.length puts scores.first, scores.last puts scores.include?(20) puts scores[-1]
var scores := [10, 20, 30] scores.append(40) # no << operator scores.push_back(50) # the same thing, engine-style name print(scores.size()) print(scores.front(), " ", scores.back()) print(scores.has(20)) print(scores[-1]) # negative indices work
GDScript also has push_front/pop_front (Ruby's unshift/shift) and pop_back (Ruby's pop). Like Ruby, an array is a reference: assigning it to another variable shares it, and duplicate() is the copy — Ruby's dup.
Typed arrays
An Array[int] is checked at runtime on every insertion, so a wrong element is rejected where it is added rather than discovered later by whatever consumes the array. Ruby has no equivalent — homogeneity is a property you assert in a test or a guard clause.
# Ruby arrays hold anything, and there is no way to # declare otherwise: mixed = [1, "two", :three, 4.0] puts mixed.inspect # Enforcing homogeneity means checking it yourself: scores = [10, 20] raise TypeError unless scores.all?(Integer) puts scores.sum
var scores: Array[int] = [10, 20] scores.append(30) # scores.append("forty") ← refused: the array is typed var total := 0 for score in scores: total += score print(total) var mixed := [1, "two", 4.0] # untyped arrays still hold anything print(mixed)
Typed arrays also let the engine store elements more efficiently and let the editor autocomplete their members. The type may be any built-in or class name, including your own — Array[Enemy] — and Dictionary[String, int] works the same way since Godot 4.4.
map and select take Callables, not blocks
The higher-order methods exist and behave the same, but a block is not a language construct in GDScript — you pass a Callable, written as an inline func(...) lambda whose body needs an explicit return.
numbers = [1, 2, 3, 4, 5, 6] evens = numbers.select { |number| number.even? } doubled = evens.map { |number| number * 10 } puts doubled.inspect puts numbers.sum puts numbers.reduce(1) { |product, number| product * number }
var numbers := [1, 2, 3, 4, 5, 6] var evens := numbers.filter(func(number): return number % 2 == 0) var doubled := evens.map(func(number): return number * 10) print(doubled) print(numbers.reduce(func(accumulator, number): return accumulator + number, 0)) print(numbers.reduce(func(product, number): return product * number, 1))
select is spelled filter, and there is no sum, min_by, group_by, each_with_object, or flat_mapmap, filter, reduce, any, and all are the whole set, so richer pipelines become explicit loops. Nothing chains as far as Enumerable does.
Sorting in place, with a comparison
Two differences at once. GDScript sorts in place and returns nothing, so var sorted = names.sort() assigns null — a genuine porting bug. And a custom order is a less-than predicate passed to sort_custom, not a <=> comparison returning -1/0/1.
names = ["grace", "ada", "alan"] puts names.sort.inspect # returns a NEW array puts names.inspect # the original is untouched players = [{ name: "ada", score: 30 }, { name: "alan", score: 10 }] puts players.sort_by { |player| player[:score] }.first[:name] puts names.sort { |left, right| right <=> left }.inspect
var names := ["grace", "ada", "alan"] names.sort() # sorts IN PLACE, returns nothing print(names) var players := [{"name": "ada", "score": 30}, {"name": "alan", "score": 10}] players.sort_custom(func(left, right): return left["score"] < right["score"]) print(players[0]["name"]) names.sort_custom(func(left, right): return left > right) print(names)
There is no sort_by, so sorting by a key means writing the comparison over that key, as above. Godot's sort is not stable, and a predicate that returns true for equal elements produces an undefined order rather than an error, so keep it a strict <.
Slices and ranges
GDScript has no range type and no .. operator; range() is a function that returns an array, and slicing is slice(begin, end) with an exclusive end — so Ruby's numbers[1..3] becomes slice(1, 4).
numbers = [10, 20, 30, 40, 50] puts numbers[1..3].inspect # 20, 30, 40 — inclusive puts numbers[1...3].inspect # 20, 30 — exclusive puts numbers.first(2).inspect puts numbers.each_slice(2).to_a.inspect puts (1..5).to_a.inspect
var numbers := [10, 20, 30, 40, 50] print(numbers.slice(1, 4)) # 20, 30, 40 — end is EXCLUSIVE print(numbers.slice(1, 3)) # 20, 30 print(numbers.slice(0, 2)) print(numbers.slice(1, 5, 2)) # every second element: 20, 40 print(range(1, 6)) # range() builds an array
slice takes an optional step, which covers Ruby's each_slice-adjacent cases, and negative indices work throughout. Because range() materializes an array, for index in range(1000000) allocates a million elements — the loop is idiomatic, but the huge-range case is one to avoid, unlike Ruby's lazy Range.
Hashes vs. Dictionaries
Hash literals become Dictionary literals
A Dictionary is Ruby's Hash with different method names and no symbol keys. One pleasant surprise: a string key that looks like an identifier can be read with dot syntax, so player.name and player["name"] are the same lookup.
player = { name: "Ada", score: 1200, alive: true } puts player[:name] player[:level] = 3 puts player.length puts player.key?(:score) puts player.keys.inspect
var player := {"name": "Ada", "score": 1200, "alive": true} print(player["name"]) print(player.name) # dot access also works for string keys player["level"] = 3 print(player.size()) print(player.has("score")) print(player.keys())
Dictionaries preserve insertion order, as Ruby hashes do. The method names to learn are size(), has(), keys(), values(), erase() (Ruby's delete), and merge(). Note that dot access is a convenience on the lookup only — it cannot create a new key.
Missing keys and defaults
get(key, default) is fetch with a fallback. What GDScript lacks is a default-valued dictionary — no Hash.new(0) and no default block — so counters and accumulators read counts.get(key, 0) + 1.
settings = { volume: 0.8 } puts settings[:missing].inspect # nil puts settings.fetch(:missing, 1.0) # a default counts = Hash.new(0) # a default for every key counts[:hits] += 1 puts counts[:hits] puts settings.fetch(:volume)
var settings := {"volume": 0.8} print(settings.get("missing")) # null print(settings.get("missing", 1.0)) # a default # There is no Hash.new(0), so seed the key yourself: var counts := {} counts["hits"] = counts.get("hits", 0) + 1 print(counts["hits"]) print(settings["volume"])
Reading a missing key with [] is worth care: on a Dictionary it returns null, but indexing past the end of an array is a runtime error rather than nil. There is also get_or_add(key, default), which inserts the default as it returns it.
Iterating keys and values
A for loop over a dictionary yields its keys, not key-value pairs — there is no two-variable form and no each with a block, so the value comes from a lookup inside the body.
scores = { ada: 30, grace: 25, alan: 10 } scores.each do |name, score| puts "#{name}: #{score}" end puts scores.values.sum puts scores.max_by { |_name, score| score }.first
var scores := {"ada": 30, "grace": 25, "alan": 10} for name in scores: # iterating gives KEYS, not pairs print("%s: %d" % [name, scores[name]]) var total := 0 for score in scores.values(): total += score print(total)
Because the higher-order helpers are thin, aggregations that are one Enumerable call in Ruby (values.sum, max_by) are explicit loops here. scores.values() and scores.keys() both return real arrays, so they can be sorted or filtered like any other.
Merging and copying
merge mutates the receiver here rather than returning a new dictionary, and its second argument decides whether existing keys are overwritten — the default is false, which keeps them. Duplicating first is how you get Ruby's non-destructive merge.
defaults = { volume: 0.8, fullscreen: false } overrides = { fullscreen: true } puts defaults.merge(overrides).inspect # a NEW hash puts defaults.inspect # unchanged shallow = defaults.dup deep = Marshal.load(Marshal.dump({ audio: { volume: 1 } })) puts shallow.inspect, deep.inspect
var defaults := {"volume": 0.8, "fullscreen": false} var overrides := {"fullscreen": true} var combined := defaults.duplicate() combined.merge(overrides, true) # merges IN PLACE; true = overwrite print(combined) print(defaults) # unchanged, because we duplicated first var deep := {"audio": {"volume": 1}}.duplicate(true) # true = deep print(deep)
duplicate(true) is a deep copy, which Ruby has no built-in for at all (hence the Marshal round-trip). Watch the default on merge: calling it with one argument silently keeps the original values, the opposite of what a Rubyist expects from merge.
Control Flow
if / elif / else — and indentation as syntax
Structurally identical; the syntax is Python's. A colon opens the block, indentation delimits it, elsif is spelled elif, and there is no end — the dedent ends the block.
score = 72 if score >= 90 puts "excellent" elsif score >= 60 puts "passing" else puts "failing" end
var score := 72 if score >= 90: print("excellent") elif score >= 60: print("passing") else: print("failing")
Indentation being syntax is the adjustment. Tabs and spaces cannot be mixed within a file, and a stray indent is a parse error rather than a style complaint — which is stricter than anything in Ruby, where whitespace never changes meaning.
No unless, no statement modifiers
Ruby's statement modifiers and unless have no GDScript equivalent — every condition opens an indented block. Negation is the word not (though ! also works), and the ternary is written value-first: a if condition else b.
alive = false puts "game over" unless alive puts "still here" if !alive == false health = 0 health = 100 if health.zero? puts health
var alive := false if not alive: print("game over") # No trailing-if modifier and no 'unless' — every condition is a block. var health := 0 if health == 0: health = 100 print(health) # The ternary IS available, and reads Python-style: print("dead" if not alive else "alive")
Losing unless and the trailing if makes guard-heavy Ruby noticeably longer in GDScript. The compensation is that the ternary composes into expressions, which is how default values are written here, since there is no ||= either.
Loops: times and each vs. for ... in
Ruby has a loop method for every shape (times, upto, step, each); GDScript has one for ... in that walks anything iterable. A bare integer counts from zero, and range() gives start/end/step — note the end is exclusive, unlike Ruby's ...
3.times { |index| print index, " " } puts (1..3).each { |index| print index, " " } puts ["a", "b"].each { |letter| print letter, " " } puts 1.step(9, 3) { |index| print index, " " } puts
for index in 3: # an integer is a range 0..<3 print(index) for index in range(1, 4): # start, exclusive end print(index) for letter in ["a", "b"]: print(letter) for index in range(1, 10, 3): # with a step print(index)
Because for is a statement rather than a method taking a block, there is no each_with_index — pair it with range(array.size()) when you need the index. while exists; until and loop do do not, so an infinite loop is while true: with a break.
case/in vs. match — real pattern matching on both sides
This is the closest correspondence on the page, and it will feel familiar immediately: GDScript's match destructures arrays and dictionaries and binds with var name, exactly as Ruby 4's case/in binds with => name. The wildcard is _ rather than else.
event = { type: "click", x: 3, y: 9 } case event in { type: "key", code: Integer => code } puts "key #{code}" in { type: "click", x: Integer => x, y: Integer => y } puts "click at #{x},#{y}" else puts "unknown" end point = [1, 2] case point in [0, 0] then puts "origin" in [Integer => x, Integer => y] then puts "at #{x},#{y}" end
var event := {"type": "click", "x": 3, "y": 9} match event: {"type": "key", "code": var code}: print("key %d" % code) {"type": "click", "x": var x, "y": var y}: print("click at %d,%d" % [x, y]) _: print("unknown") var point := [1, 2] match point: [0, 0]: print("origin") [var x, var y]: print("at %d,%d" % [x, y])
Two differences worth knowing: GDScript patterns cannot express a type constraint (there is no Integer => equivalent — use a guard inside the branch), and a dictionary pattern matches only if the keys listed are the only keys, unless you add .. to allow extras. Multiple alternatives per branch are comma-separated, and there is no fallthrough.
break and continue
Same two escapes, one renamed: Ruby's next is continue. Both apply to the innermost loop only, and neither language has a labeled break.
(1..6).each do |index| next if index.even? break if index > 5 print index, " " end puts
for index in range(1, 7): if index % 2 == 0: continue # Ruby's 'next' if index > 5: break print(index)
Ruby's next value — returning a value from a block iteration — has no counterpart, because for is not a method call and produces no value. Ruby's redo and retry have no equivalents either.
Blocks & Procs vs. Callables
There are no blocks — only Callable values
A block in Ruby is a piece of syntax attached to a call and invoked with yield. GDScript has no such construct: a function that takes behavior takes an ordinary parameter of type Callable, and invokes it with .call(...).
def each_twice(items) items.each do |item| yield item yield item end end each_twice([1, 2]) { |value| print value, " " } puts
# 'func(...)' creates a Callable; there is no block and no yield. var each_twice := func(items: Array, action: Callable): for item in items: action.call(item) action.call(item) each_twice.call([1, 2], func(value): print(value))
This is the largest single loss of expressiveness coming from Ruby, and it shows up everywhere — no each, no tap, no each_with_object, no block-based DSLs, no yield. In exchange, everything is explicit: there is only one way to pass behavior, and it has a type you can annotate.
Procs and lambdas vs. func literals
A func literal is Ruby's lambda: it can be typed, stored, and passed. The syntax difference that costs the most is invocation — there is no .() or [] shorthand, so every call is spelled .call(...).
doubled = ->(value) { value * 2 } puts doubled.call(5) puts doubled.(5) puts doubled[5] adder = proc { |left, right| left + right } puts adder.call(2, 3) puts adder.arity, doubled.lambda?
var doubled := func(value): return value * 2 print(doubled.call(5)) var adder := func(left: int, right: int) -> int: return left + right print(adder.call(2, 3)) print(doubled.get_argument_count()) print(doubled.is_valid())
GDScript has no proc/lambda distinction: arity is checked, and a return exits only the lambda, so the proc-returns-from-the-enclosing-method behavior has no counterpart. A multi-line lambda body must be indented under the func(...) line, which is why the assignment form above breaks across lines.
Closures capture surrounding locals
Lambdas do capture their enclosing locals — but by value, not by reference. A captured integer is a snapshot the lambda cannot write back to, so accumulating across calls means capturing something mutable, such as a one-element array or a dictionary.
def make_counter total = 0 ->(amount) { total += amount } end counter = make_counter counter.call(10) puts counter.call(5) separate = make_counter puts separate.call(100)
var make_counter := func(): var total := [0] # boxed: see the note below return func(amount): total[0] += amount return total[0] var counter: Callable = make_counter.call() counter.call(10) print(counter.call(5)) var separate: Callable = make_counter.call() print(separate.call(100))
This is a real semantic difference, not a syntax one: Ruby's closure shares the variable, GDScript's copies it. The boxing idiom above (var total := [0]) is the standard workaround, and it is worth knowing before writing a callback that is supposed to remember something.
No &:symbol shorthand
Ruby's &:symbol shorthand has no counterpart, so every mapped method call is written out as a lambda. Object#method does carry over — Callable(object, "method_name") builds a bound reference you can pass around — but only for real objects.
names = ["ada", "grace"] puts names.map(&:upcase).inspect puts names.map(&:length).inspect method_reference = "hello".method(:upcase) puts method_reference.call
var names := ["ada", "grace"] print(names.map(func(name): return name.to_upper())) print(names.map(func(name): return name.length())) # A method of an OBJECT can be captured as a bound Callable: var counted := RefCounted.new() var reference := Callable(counted, "get_class") print(reference.call()) # But String, int, Array and the other built-ins are NOT Objects, so # Callable("hello", "to_upper") does not even compile.
That last restriction is worth noticing: built-in values are not Objects in Godot, so there is no way to take a Callable to String.to_upper the way Ruby takes "hello".method(:upcase). A bound Callable is what you connect to a signal, hand to a Timer, or store as a callback, so the concept still matters well beyond map — and Godot also writes it object.method_name without parentheses when the type is known.
curry vs. Callable.bind
Ruby curries from the left: greet.curry["Hello"] fixes the first parameter. Callable.bind works from the right — bound arguments are appended after whatever the caller passes — so the two languages fix opposite ends of the parameter list.
greet = ->(greeting, name) { "#{greeting}, #{name}!" } hello = greet.curry["Hello"] puts hello["World"] # Or with a partial application helper: polite = greet.curry.call("Good day") puts polite.call("Ada")
var greet := func(greeting, name): return "%s, %s!" % [greeting, name] # bind() APPENDS its arguments, so it fixes the LAST parameter: var greet_world := greet.bind("World") print(greet_world.call("Hello")) # Fixing the FIRST parameter takes a lambda that closes over it: var say_hello := func(name): return greet.call("Hello", name) print(say_hello.call("Ada"))
Getting this backward silently scrambles arguments rather than failing, so it is worth reading bind as "supply the trailing arguments." There is also unbind(n) to drop arguments the caller supplies — genuinely useful when connecting to a signal that passes more values than your handler wants — and bindv to bind an array of them at once.
Methods vs. Functions
def vs. func
Three differences in one row: the keyword is func, the return type is declared after ->, and there is no implicit return — a function without return yields null, which is the single most common Ruby-to-GDScript bug.
def doubled(value) value * 2 # the last expression is the return value end def tripled(value) = value * 3 # Ruby 4 one-liner syntax puts doubled(21) puts tripled(21)
func doubled(value: int) -> int: return value * 2 # 'return' is required: nothing is implicit func tripled(value: int) -> int: return value * 3 print(doubled(21)) print(tripled(21))
Neither column runs here, and the reason is worth knowing if you are pasting from this page: both runners execute a snippet at method scope, and a func declaration needs class-body scope. In a real script this is the ordinary way to define a method; to define behavior inside a method, use a func(...) lambda instead (see the Blocks section). GDScript also has no one-line function form to match Ruby 4's def tripled(value) = value * 3.
Default arguments
Default arguments work the same way and are written with := (or = with an explicit type: greeting: String = "Hello"). As in Ruby, defaults must come after all required parameters.
greeting = ->(name, greeting = "Hello") { "#{greeting}, #{name}!" } puts greeting.call("World") puts greeting.call("World", "Howdy")
var greeting := func(name, greeting := "Hello"): return "%s, %s!" % [greeting, name] print(greeting.call("World")) print(greeting.call("World", "Howdy"))
A default expression is evaluated on every call, as Ruby's is — so func log(entries := []) gets a fresh array each time rather than sharing one, avoiding Python's mutable-default trap. Note that arity is strict: calling with too few or too many arguments is an error, not nil-filling.
No keyword arguments — pass a dictionary
Ruby's keyword arguments — required, optional, order-independent, and self-documenting at the call site — have no GDScript equivalent. When a call has enough parameters for order to become a hazard, the workaround is a Dictionary parameter, which trades the compiler's checking for readability.
spawn = ->(x:, y:, health: 100) { "enemy at #{x},#{y} with #{health} hp" } puts spawn.call(x: 10, y: 20) puts spawn.call(y: 5, x: 1, health: 50) # order does not matter
# GDScript has no keyword arguments at all. var spawn := func(options: Dictionary): var health = options.get("health", 100) return "enemy at %d,%d with %d hp" % [options["x"], options["y"], health] print(spawn.call({"x": 10, "y": 20})) print(spawn.call({"y": 5, "x": 1, "health": 50}))
Nothing verifies the keys of that dictionary, so a typo becomes a null rather than an ArgumentError — the opposite of the guarantee Ruby keywords give. In engine code the more idiomatic answer is often a small Resource or a class with @exported fields, which the editor can then check and even edit visually.
No splat: variable arity
GDScript functions have fixed arity: there is no *args, no **kwargs, and no way to accept "however many." A variadic Ruby method becomes a function taking an explicit Array, so the brackets move from the definition to the call site.
sum_all = ->(*numbers) { numbers.sum } puts sum_all.call(1, 2, 3, 4) log = ->(message, *details) { "#{message} (#{details.join(', ')})" } puts log.call("saved", "level 3", "no errors")
# No * splat: take an Array instead. var sum_all := func(numbers: Array): var total := 0 for number in numbers: total += number return total print(sum_all.call([1, 2, 3, 4])) var log := func(message: String, details: Array): return "%s (%s)" % [message, ", ".join(details)] print(log.call("saved", ["level 3", "no errors"]))
The one place variable arity does appear is Callable.callv(array), which calls a function with an array spread across its parameters — the closest thing to Ruby's * unsplatting at a call site. Engine functions like print accept many arguments, but that is a privilege of the built-ins, not something a script can declare.
Returning more than one value
Both languages return several values by returning an array. What GDScript lacks is the destructuring assignment that makes it comfortable in Ruby — no a, b = ..., so the caller indexes the array.
divide = ->(numerator, denominator) { [numerator / denominator, numerator % denominator] } quotient, remainder = divide.call(17, 5) # destructured puts "#{quotient} remainder #{remainder}"
var divide := func(numerator: int, denominator: int) -> Array: return [numerator / denominator, numerator % denominator] var result: Array = divide.call(17, 5) # no destructuring assignment var quotient: int = result[0] # := cannot infer: elements are Variants var remainder: int = result[1] print("%d remainder %d" % [quotient, remainder])
Note that := cannot be used on those elements: an untyped Array holds Variants, so there is nothing to infer and the parser says so. Returning a Dictionary is often better here anyway — result["quotient"] survives someone reordering the return values, where result[0] does not. Destructuring does exist in one place: match patterns, which bind with var.
Classes & Objects
A class per file, named with class_name
The shape is familiar — a constructor, instance state, methods — but the packaging is not. A GDScript file is a class: there is no class ... end wrapper, the optional class_name line registers it globally so other scripts can say Player.new(), and extends names the base type (RefCounted for a plain object, or a Node subclass for something that lives in a scene).
class Player attr_accessor :health def initialize(health = 100) @health = health end def damage(amount) @health -= amount end end player = Player.new player.damage(30) puts player.health
# In player.gd — one script file IS one class: class_name Player extends RefCounted var health: int = 100 func _init(starting_health: int = 100) -> void: health = starting_health func damage(amount: int) -> void: health -= amount # Elsewhere: # var player := Player.new() # player.damage(30) # print(player.health)
The constructor is _init, and instance variables are just var declarations at class scope — no @ sigil, and no attr_accessor needed, since all members are public and directly accessible. This example is display-only because the runner executes snippets at method scope, where class_name and func cannot appear.
attr_accessor vs. set/get on a variable
Ruby needs attr_reader plus a hand-written current= to validate a write. GDScript attaches set and get blocks directly to the variable, so the property keeps its plain-assignment syntax while gaining a hook.
class Health attr_reader :current def initialize @current = 100 end def current=(value) @current = value.clamp(0, 100) # validate on write end end health = Health.new health.current = 150 puts health.current
# In health.gd: class_name Health extends RefCounted var current: int = 100: set(value): current = clamp(value, 0, 100) # validate on write get: return current # Elsewhere: # var health := Health.new() # health.current = 150 # print(health.current) # 100
Because every member is already readable and writable, these blocks are only for behavior, not for access control — GDScript has no private, and a leading underscore is a convention exactly as it is in Ruby-adjacent Python. The engine equivalent of attr_accessor plus editor support is @export var speed := 200.0, which puts the field in Godot's inspector for a designer to tune without touching code.
Inheritance, and no mixins
extends is < and super() is super. The loss is Module: GDScript has single inheritance and no mixins, so there is nothing to include and no way to share behavior sideways across a hierarchy.
module Describable def describe "a #{self.class.name.downcase}" end end class Entity include Describable def initialize(name) @name = name end end class Enemy < Entity def describe super + " called #{@name}" end end puts Enemy.new("slime").describe
# In entity.gd: class_name Entity extends RefCounted var name: String = "" func _init(entity_name: String) -> void: name = entity_name func describe() -> String: return "an entity" # In enemy.gd: class_name Enemy extends Entity # single inheritance only; no modules, no mixins func describe() -> String: return super() + " called " + name # print(Enemy.new("slime").describe())
The engine's answer is composition through the scene tree: instead of mixing Damageable into ten classes, you add a Health child node to ten scenes. That is a genuinely different design habit, and it is the one most worth acquiring — Godot rewards small single-purpose nodes the way Ruby rewards small modules.
to_s vs. _to_string
GDScript's _to_string is to_s: print, str(), and %s all route through it. The leading underscore marks the whole family of engine callbacks — _init, _ready, _process, _to_string — that the runtime calls for you rather than you calling directly.
Point = Struct.new(:x, :y) do def to_s "(#{x}, #{y})" end end where = Point.new(3, 4) puts where puts "at #{where}"
# In point.gd: class_name Point extends RefCounted var x: int var y: int func _init(new_x: int, new_y: int) -> void: x = new_x y = new_y func _to_string() -> String: return "(%d, %d)" % [x, y] # print(Point.new(3, 4)) # calls _to_string # print("at %s" % Point.new(3, 4))
There is no separate inspect hook, so a class has one string form rather than Ruby's two. For a lightweight value type, Godot's own Vector2-style structs usually beat writing a class at all; when you do need a data holder that the editor can serialize, extend Resource instead of RefCounted.
Duck typing and reflection
Reflection exists and maps closely, with one split to remember: has_method, get_class, and call live on Object and its descendants, while built-in values like int and String are not objects and answer typeof instead.
text = "hello" puts text.respond_to?(:upcase) puts text.is_a?(String) puts text.class puts text.send(:upcase) puts 3.instance_of?(Integer)
var reference := RefCounted.new() print(reference.has_method("get_class")) # respond_to? print(reference is RefCounted) # is_a? print(reference.get_class()) # class print(reference.call("get_class")) # send print(typeof(3) == TYPE_INT) # for built-ins, not classes
That is why this example builds a RefCounted rather than asking a string whether it responds to a method — "hello".has_method(...) does not even parse. Duck typing works in the small (if thing.has_method("damage"): thing.damage(10)), but the engine idiom is a type check or a group membership test, both of which the editor can reason about.
Exceptions vs. No Exceptions
🚨 GDScript has no exceptions at all
There is no raise, no rescue, no ensure, and no exception hierarchy — nothing in GDScript unwinds the stack. Failure is reported the way C reports it: a sentinel return value, an error code, or null, and every caller checks.
def parse_level(text) raise ArgumentError, "not a number: #{text}" unless text.match?(/\A\d+\z/) Integer(text) end puts parse_level("3") begin parse_level("boss") rescue ArgumentError => error puts "rescued: #{error.message}" end
var parse_level := func(text: String): if not text.is_valid_int(): push_error("not a number: " + text) # logs; does NOT unwind return -1 # a sentinel the caller checks return text.to_int() print(parse_level.call("3")) var level: int = parse_level.call("boss") if level < 0: print("handled: the caller checks the return value")
This is the deepest structural difference on the page. Ruby lets a failure travel up to whoever is equipped to handle it; GDScript makes every layer decide immediately. push_error writes to the debugger and the console — it is logging, not control flow — and a genuinely unrecoverable state is usually handled by assert() during development or by defaulting to something safe and continuing, because crashing a running game is rarely the better option.
Error codes where Ruby raises
Every fallible engine call returns a value from the global Error enum — OK is 0, and the rest are named constants like ERR_FILE_NOT_FOUND. error_string() turns one into a readable message, filling the role Ruby's exception classes play.
# Ruby's stdlib raises, so the happy path stays clean: begin data = File.read("save.json") puts data.length rescue Errno::ENOENT => error puts "no save file: #{error.class}" end
# Engine calls return an Error enum value instead of raising. # (Shown with a value you can inspect rather than real file I/O.) var status := ERR_FILE_NOT_FOUND print(status) # 7 print(error_string(status)) # "File not found" print(status == OK) if status != OK: print("no save file: %s" % error_string(status))
Some APIs use the other convention instead, returning null on failure with the detail available from a separate call (FileAccess.open plus FileAccess.get_open_error()). Knowing which is which is part of learning the engine, and it is the main thing a Rubyist has to keep in their head where rescue used to catch everything uniformly.
assert, and what it does in a shipped game
assert(condition, message) halts the game and breaks into the debugger when it fails — but only in a debug build. Godot strips assertions from an exported release, so an assertion is a note to the developer, never a check the shipped game performs.
health = 100 raise "health must be positive" unless health.positive? puts "health is #{health}" # Ruby's assertions are just raises; there is no compile-out switch. def checked(value) raise ArgumentError, "negative" if value.negative? value end puts checked(5)
var health := 100 assert(health > 0, "health must be positive") print("health is %d" % health) # assert() is REMOVED from release builds — it is a development check, # not a runtime guarantee, so never rely on it for real validation: var checked := func(value: int): assert(value >= 0, "negative") return max(value, 0) # the fallback that survives export print(checked.call(5))
That is the opposite of Ruby, where a raise is a raise in every environment. The practical rule: assert what you believe to be impossible, and handle what you know can happen with an ordinary if. Note also that the arguments to assert are not evaluated in a release build, so any side effect inside one disappears too.
warn vs. push_warning and push_error
push_warning and push_error are Godot's structured logging: both write to the console and to the editor's Debugger panel with the script, function, and line attached, so a warning is clickable rather than just text on a stream.
warn "deprecated: use spawn_enemy instead" $stderr.puts "something looks wrong" puts "carrying on"
push_warning("deprecated: use spawn_enemy instead") push_error("something looks wrong") print("carrying on")
Neither one stops execution — the line after them runs, as the output shows. This is the whole reason they exist: with no exceptions in the language, the engine still needs a way for a script to say "this is wrong" and be noticed, and the Debugger panel is where a developer looks.
Immediate Mode vs. the Scene Tree
One tick(args) vs. _process(delta) on every node
This is the central difference between the two engines, and everything else in this section follows from it. DragonRuby gives you a single tick(args) that runs the whole game at a fixed 60 Hz; Godot walks the scene tree every frame and calls _process(delta) on each node that defines it, at whatever rate the frame took.
# DragonRuby: app/main.rb — ONE function, called 60 times a second. def tick args args.state.player_x ||= 100 args.state.player_x += 1 args.outputs.labels << [40, 700, "x = #{args.state.player_x}"] end
# Godot: player.gd — one script per node, each with its own callback. extends Node2D var player_x: float = 100.0 func _process(delta: float) -> void: player_x += 60.0 * delta # delta-scaled, not per-tick print("x = %.1f" % player_x)
The consequences are immediate: your Godot code is distributed across nodes rather than centralized in one function, and it must scale movement by delta because the frame rate is not fixed. Godot also offers _physics_process(delta), which does run at a fixed rate (60 Hz by default) — that is the callback closest to DragonRuby's tick, and the right home for movement and collision.
🚨 Re-emitting a frame vs. mutating a node that persists
In DragonRuby the display is a consequence of what this tick emitted: args.outputs.sprites << … declares a sprite for one frame, and forgetting to emit it next frame removes it. In Godot the sprite is a Sprite2D node that lives in the scene until something frees it, and your code mutates its position.
# IMMEDIATE MODE: nothing on screen persists. Every tick re-declares # the entire frame; anything you do not emit this tick disappears. def tick args args.state.x ||= 100 args.state.x += 1 args.outputs.sprites << { x: args.state.x, y: 300, w: 64, h: 64, path: "sprites/hero.png" } end
# RETAINED MODE: the Sprite2D node exists in the scene and keeps # existing. You change its properties; nothing is re-declared. extends Sprite2D func _ready() -> void: texture = load("res://sprites/hero.png") position = Vector2(100, 300) func _process(delta: float) -> void: position.x += 60.0 * delta # mutate what is already there
This inverts where bugs come from. In immediate mode a missing draw is a disappearing object; in retained mode a stale node is an object that will not go away, and the fix is queue_free(). It also changes how you think about state: DragonRuby's render list is derived from state every frame, so state is the single source of truth, while in Godot the node is the state and keeping a parallel copy in a script is how the two drift apart.
args.state vs. member variables on a node
DragonRuby's args.state is a magic open structure: any field you assign persists across ticks and survives a hot reload, so prototyping means inventing state as you go. Godot has no such object — state is a declared member variable on the node that owns it.
# args.state persists across ticks automatically, and survives a hot # reload. It is an open structure: assign any field, at any depth. def tick args args.state.score ||= 0 args.state.player ||= { x: 100, y: 100, health: 3 } args.state.score += 1 args.outputs.labels << [40, 700, "score #{args.state.score}"] args.outputs.labels << [40, 660, "hp #{args.state.player[:health]}"] end
# State is declared: it lives on the node, typed, and visible in the # editor's inspector when exported. extends Node2D var score: int = 0 @export var max_health: int = 3 # designers can tune this without code var health: int = max_health func _process(_delta: float) -> void: score += 1 print("score %d, hp %d" % [score, health])
The trade is speed of exploration against structure. args.state.whatever ||= 0 needs no declaration and no thought about ownership; var health: int forces you to decide which node owns the value, but the editor can then show it, type-check it, and — with @export — let a designer tune it without touching the script. Godot's equivalent of "state that outlives everything" is a singleton (an autoload) or a saved Resource.
Polling args.inputs vs. Input plus _input events
Godot supports both styles. Input.get_axis and Input.is_action_pressed poll exactly as DragonRuby does, while _input(event) is called only when input arrives — the option DragonRuby has no counterpart for.
# DragonRuby polls: every tick, ask what is currently true. def tick args args.state.x ||= 640 args.state.x += 5 * args.inputs.left_right # -1, 0, or 1 if args.inputs.keyboard.key_down.space args.outputs.labels << [40, 700, "jump!"] end if args.inputs.mouse.click args.outputs.labels << [40, 660, "clicked"] end end
extends Node2D func _process(delta: float) -> void: # Polling, via named actions from the Input Map (not raw keys): var direction := Input.get_axis("move_left", "move_right") position.x += 300.0 * direction * delta func _input(event: InputEvent) -> void: # Event-driven: called only when something actually happens. if event.is_action_pressed("jump"): print("jump!") elif event is InputEventMouseButton and event.pressed: print("clicked")
The other shift is indirection: Godot input is addressed through named actions defined in the project's Input Map, so "move_right" can be bound to a key, a gamepad stick, and a touch control at once, and rebinding is a project setting rather than a code change. DragonRuby names the hardware directly (keyboard.key_down.space), which is more immediate and less remappable.
Signals vs. checking a flag every tick
Signals are Godot's built-in observer pattern, and they have no DragonRuby equivalent: a node declares signal died, emits it at the moment something happens, and any number of listeners connect a Callable. The immediate-mode alternative is what the Ruby column shows — poll a flag every tick and remember whether you already reacted.
# There are no signals: the tick loop asks, every frame, whether # anything has changed. def tick args args.state.health ||= 3 args.state.was_alive = true if args.state.was_alive.nil? if args.state.health <= 0 && args.state.was_alive args.state.was_alive = false args.outputs.labels << [40, 700, "player died"] # handled inline end end
# Godot: declare a signal, emit it once, and let anyone subscribe. extends Node2D signal died(final_score: int) var health: int = 3 var score: int = 0 func take_damage(amount: int) -> void: health -= amount if health <= 0: died.emit(score) # fire once, at the moment it happens func _ready() -> void: died.connect(func(final_score): print("player died with %d" % final_score))
This is the piece of Godot most worth learning early, because the engine itself is built on it: body_entered on an Area2D, timeout on a Timer, pressed on a Button. A Rubyist will recognize it as blocks-as-callbacks with a registration step, and connect takes exactly the Callable from this page's Blocks section.
One Ruby file vs. scenes saved as files
DragonRuby has no scene format — the game is Ruby files, and an enemy is whatever hash you keep in args.state and emit each tick. In Godot an enemy is a saved .tscn file: a tree of nodes with scripts and properties, instanced with instantiate() and added to the tree with add_child().
# A DragonRuby game is Ruby files. Spawning an enemy means appending # to your own collection and re-emitting it every tick. def tick args args.state.enemies ||= [] if args.state.tick_count % 60 == 0 args.state.enemies << { x: rand(1200), y: 600, w: 32, h: 32, path: "sprites/enemy.png" } end args.outputs.sprites << args.state.enemies end
# A Godot game is scenes (.tscn): a saved tree of nodes with scripts # attached, instanced at runtime like a prefab. extends Node2D const EnemyScene := preload("res://enemy.tscn") func spawn_enemy() -> void: var enemy := EnemyScene.instantiate() enemy.position = Vector2(randf() * 1200.0, 600.0) add_child(enemy) # now it exists, and updates itself func remove_enemy(enemy: Node) -> void: enemy.queue_free() # and now it does not
Once instanced, that enemy runs its own _process and manages itself, which is why Godot code has no central update loop iterating over enemies. The cost is a second place where behavior lives: some of the game is in scripts and some is in scene files edited visually, and a Rubyist used to grepping a codebase has to get comfortable with an editor holding part of the design.
Rectangle checks vs. physics bodies
DragonRuby gives you helpers (args.geometry.intersect_rect?) and expects you to call them on the pairs you care about, every tick. Godot has a physics engine: you declare collision shapes on nodes, call move_and_slide(), and receive collisions as signals.
# DragonRuby: collision is arithmetic you invoke yourself, in the tick. def tick args args.state.hero ||= { x: 100, y: 100, w: 64, h: 64 } args.state.coin ||= { x: 120, y: 110, w: 32, h: 32 } args.state.hero[:x] += args.inputs.left_right * 5 if args.geometry.intersect_rect?(args.state.hero, args.state.coin) args.outputs.labels << [40, 700, "collected!"] end end
# Godot: the physics engine reports collisions to you. extends CharacterBody2D func _physics_process(delta: float) -> void: velocity.x = Input.get_axis("move_left", "move_right") * 300.0 move_and_slide() # movement AND collision resolution func _on_coin_area_entered(area: Area2D) -> void: print("collected!") # connected from the coin's signal area.queue_free()
The trade is control against machinery. Rectangle checks are trivially predictable and scale badly by hand; the physics engine handles slopes, sweeping to prevent tunneling, layers and masks, and areas versus bodies — but you configure it in the editor and it makes decisions on your behalf. Note the callback here is _physics_process, which runs at a fixed rate; doing physics in _process is a classic beginner bug.
Node lifecycle callbacks vs. tick zero
Godot gives a node named lifecycle hooks — _enter_tree, _ready, _exit_tree, plus _init from the Classes section — each with a defined moment. DragonRuby has one entry point, so setup is a branch on tick_count == 0 or a lazy ||=.
# There is no setup phase: the first tick is the setup, and every # initialization is an ||= or a tick_count check. def tick args if args.state.tick_count == 0 args.state.level = 1 puts "starting up" end args.outputs.labels << [40, 700, "level #{args.state.level}"] end
extends Node2D var level: int = 1 func _ready() -> void: # Called once, after this node AND its children are in the tree. print("starting up") func _enter_tree() -> void: pass # earlier: this node is in, children are not yet func _exit_tree() -> void: pass # cleanup, when the node leaves the tree func _process(_delta: float) -> void: print("level %d" % level)
_ready is the one to reach for: it fires after children exist, which is why get_node() is safe there and unreliable in _init. The Ruby column's idiom has a compensating virtue, though — because setup is just another tick, a hot reload re-runs it, which is what makes DragonRuby's edit-and-see-it loop as fast as it is.
Drawing a User Interface
A button, actually on the page
Both columns put a working button below the code and both buttons count their own clicks — go and click them. The Ruby column renders HTML into the preview pane; the GDScript column is a real Godot engine, compiled to WebAssembly, drawing a real Button node into a canvas.
require "erb" label = "Click me" template = <<~ERB <button style="padding:10px 22px;font:15px system-ui;border-radius:4px; border:1px solid #999;background:#eee;cursor:pointer" onclick="this.dataset.tally = (+this.dataset.tally || 0) + 1; this.textContent = 'Clicked ' + this.dataset.tally + ' time(s)'" ><%= label %></button> ERB print ERB.new(template).result(binding)
var button := Button.new() button.text = "Click me" button.position = Vector2(24, 24) button.size = Vector2(200, 44) var tally := 0 button.pressed.connect(func(): tally += 1 button.text = "Clicked %d time(s)" % tally) add_child(button)
The shapes are closer than they look. Button.new() is document.createElement, add_child(button) is appendChild, and button.pressed.connect(...) is addEventListener — a retained object you keep a reference to and mutate, in both cases. Where they part company is that Godot's event is a declared, typed signal belonging to the class (pressed exists because Button says it does, and connecting to a name it does not have is an error), while an HTML event name is a string the browser looks up at run time. The other difference is what the two are drawing on: HTML reflows into a document and its text is selectable and readable by a screen reader, while Godot paints a fixed pixel grid that knows nothing about either.
Stacking widgets: containers vs. flexbox
Neither column positions anything by hand: each hands its children to a layout and lets it decide. Godot calls that layout a container node, CSS calls it a flex formatting context, and the two agree on more than they disagree on.
require "erb" captions = ["Continue", "New game", "Options", "Quit"] template = <<~ERB <div style="display:flex;flex-direction:column;gap:10px;width:240px; padding:20px 24px;font:15px system-ui"> <% captions.each do |caption| %> <button style="padding:8px;border:1px solid #999;background:#eee"> <%= caption %> </button> <% end %> </div> ERB print ERB.new(template).result(binding)
var column := VBoxContainer.new() column.position = Vector2(24, 20) column.size = Vector2(240, 220) column.add_theme_constant_override("separation", 10) for caption in ["Continue", "New game", "Options", "Quit"]: var entry := Button.new() entry.text = caption column.add_child(entry) add_child(column)
A VBoxContainer IS flex-direction: column — it lays its children out in order along one axis, and add_theme_constant_override("separation", 10) is gap: 10px. HBoxContainer is row, GridContainer is display: grid, and a child's size_flags_horizontal = SIZE_EXPAND_FILL is flex: 1. The mapping is close enough that a web developer can usually guess the Godot name. What has no CSS counterpart is that a Godot container is a node in the tree that a script can reach and reconfigure, rather than a property of the parent box; and what has no Godot counterpart is the cascade — Godot styles come from StyleBox resources hung on a Theme, which are looked up by node type, never matched by selector.
Turning data into an interface
The same scoreboard, built from the same three records, by a template on the left and by a loop of constructor calls on the right.
require "erb" players = [ { name: "Ada", score: 1200 }, { name: "Grace", score: 980 }, { name: "Alan", score: 640 }, ] template = <<~ERB <div style="padding:20px 24px;font:15px system-ui"> <% players.each do |player| %> <div style="display:flex"> <span style="width:120px"><%= player[:name] %></span> <span><%= player[:score] %></span> </div> <% end %> </div> ERB print ERB.new(template).result(binding)
var players := [ { "name": "Ada", "score": 1200 }, { "name": "Grace", "score": 980 }, { "name": "Alan", "score": 640 }, ] var table := VBoxContainer.new() table.position = Vector2(24, 20) table.size = Vector2(280, 160) for player in players: var row := HBoxContainer.new() var who := Label.new() who.text = player["name"] who.custom_minimum_size = Vector2(120, 0) var points := Label.new() points.text = str(player["score"]) row.add_child(who) row.add_child(points) table.add_child(row) add_child(table)
This is where a Rubyist feels the absence most sharply: Godot has no template language. There is no ERB, no Slim, no <%= %> — the only way to turn data into an interface is to write the loop and call the constructors, which is why the GDScript column is longer for the same result. The compensation is that everything the loop produces is a live object: who.text = "Ada" can be assigned again next frame and the label changes, whereas re-rendering an ERB template throws the old string away and produces a new one. When a Godot interface needs to be defined declaratively, that happens in the editor and is saved as a .tscn scene file rather than expressed in code at all.
Dropping below the widgets to draw
Below the widget layer, both sides can put marks on a surface directly. Note what the GDScript column does not contain: no var, no statement at the outer level — it is a whole script file, with extends and a lifecycle method, exactly as it would be saved in a project.
require "erb" template = <<~ERB <svg width="300" height="200" style="display:block"> <rect x="20" y="20" width="260" height="120" fill="#2b2f3a" /> <circle cx="90" cy="80" r="40" fill="#cc342d" /> <line x1="150" y1="110" x2="260" y2="40" stroke="#478cbf" stroke-width="4" /> <text x="24" y="175" fill="#478cbf" font-family="system-ui" font-size="16">drawn as SVG</text> </svg> ERB print ERB.new(template).result(binding)
extends Control func _draw() -> void: draw_rect(Rect2(20, 20, 260, 120), Color("#2b2f3a")) draw_circle(Vector2(90, 80), 40, Color("#cc342d")) draw_line(Vector2(150, 110), Vector2(260, 40), Color("#87c8f5"), 4.0) draw_string(ThemeDB.fallback_font, Vector2(24, 170), "drawn by _draw()", HORIZONTAL_ALIGNMENT_LEFT, -1, 16, Color("#87c8f5"))
The two are not the same kind of thing, and that is the lesson. SVG is retained: each <circle> is an element that stays in the document, can be styled by CSS and can be given its own event handler. _draw() is immediate: it is called when Godot decides the node needs repainting, it emits commands into a frame, and nothing it drew exists afterwards — to change the circle you change a variable and call queue_redraw(), and the whole method runs again. A Rubyist coming from DragonRuby will recognize immediate mode here; it is the one place Godot works the way tick does, and it is opt-in rather than the whole engine.
Moving something, a frame at a time
Both squares below are moving, and they are moving for completely different reasons. Watch them for a moment before reading the note underneath.
require "erb" template = <<~ERB <div style="height:200px;position:relative;overflow:hidden"> <div style="position:absolute;top:80px;width:40px;height:40px; background:#cc342d; animation:slide 3.14s ease-in-out infinite alternate"></div> <style> @keyframes slide { from { left: 30px } to { right: 30px } } </style> </div> ERB print ERB.new(template).result(binding)
extends Control var marker: ColorRect var elapsed := 0.0 func _ready() -> void: marker = ColorRect.new() marker.color = Color("#cc342d") marker.size = Vector2(40, 40) add_child(marker) func _process(delta: float) -> void: elapsed += delta marker.position = Vector2(120.0 + sin(elapsed * 2.0) * 90.0, 90.0)
The CSS column describes the motion — two endpoints and a timing curve — and hands it to the browser's compositor, which runs it without ever calling back into Ruby. The GDScript column computes the motion: _process(delta) is called once per frame with the seconds elapsed since the last one, and the script decides where the square is now. That per-frame callback is the heart of a game engine and it is why delta is in the signature — a frame that took longer must move things further, or the game runs at a different speed on a different machine. Godot does also offer the declarative form, as a Tween or an AnimationPlayer, for exactly the cases where CSS would be reached for.
Tooling & Shipping
Running the game
Both tools are a single self-contained binary with no dependency manager to fight, and both hot-reload a saved script into the running game. The difference in daily feel is that DragonRuby's workflow is a text editor plus a terminal, while Godot's centers on its own editor, where scenes are assembled visually.
# DragonRuby ships as a binary you run against your game folder: # # ./dragonruby ./mygame # # app/main.rb is the entry point; saving any file hot-reloads it # immediately, with args.state preserved.
# Godot is an editor first, and a command-line tool second: # # godot --path . --main-scene res://main.tscn # run the project # godot --headless --script tools/build.gd # run a script, no window # # The editor's play button is the normal loop; saving a script while the # game runs reloads it, though node state is not preserved the way # args.state is.
Neither column runs here — these are shell commands, not code. Worth knowing for a Rubyist: godot --headless --script makes GDScript usable as a scripting language for build and asset tasks, which is the closest thing to running a plain Ruby script, and it is what the test suite for this page uses.
Gems vs. the Asset Library and GDExtension
This is a place where a Rubyist's expectations need resetting on both sides. DragonRuby is mruby, so RubyGems does not exist there — dependencies are plain-Ruby files you vendor. Godot has no package manager either: addons are folders you commit, and native extensions are compiled libraries.
# DragonRuby runs mruby, so RubyGems is NOT available: no Gemfile, # no bundler, no native gems. You vendor plain-Ruby source into your # project folder and require it: # # require 'app/lib/vector_math.rb' # # Pure-Ruby libraries often work; anything with a C extension or a # heavy stdlib dependency does not.
# Godot has no package manager either. Two routes: # # 1. The Asset Library (in-editor) drops a folder into your project; # GDScript addons are just scripts you commit alongside your game. # 2. GDExtension binds native code (C++, Rust, Swift) as engine # classes, which is how performance-critical work gets done. # # Reusing GDScript within a project is 'preload'/'load' by path: # # const VectorMath := preload("res://lib/vector_math.gd")
The upshot is that both ecosystems are vendoring cultures, which is a genuine adjustment from bundle add. Godot's compensation is GDExtension: when GDScript is too slow for a subsystem, you write that subsystem in C++ or Rust and it appears as an ordinary class to your scripts — closer to writing a C extension for CRuby than to anything available in DragonRuby.
What the tools can tell you before you run
Typed GDScript is checked when the script loads, so a misspelled method on a typed variable stops the game from starting instead of surfacing when that line finally executes. Ruby has nothing equivalent in the language — RuboCop, RBS, and Sorbet are separate tools over the same code.
# Ruby's checking is opt-in and external: RuboCop for style, # RBS/Sorbet for types, and a test suite for everything else. value = 100 puts value.upcase rescue puts "NoMethodError, at runtime"
var value := 100 # print(value.to_upper()) # ← "Invalid call. Nonexistent function 'to_upper' in base 'int'", # reported when the script LOADS, because value is typed as int. print(value) var untyped = 100 # a Variant: the same mistake is now runtime-only print(untyped)
The catch is that the guarantee is only as good as your annotations: an untyped var is a Variant, and Variants are checked at runtime exactly as Ruby is. That is the practical argument for := everywhere — and Godot can be configured to warn on untyped declarations, which is the closest thing to running a type checker in CI.
Gotchas for Rubyists
if value means "is it nonzero", not "does it exist"
The falsy-zero rule from the Variables section is repeated here because it is the mistake that survives a port: the code compiles, runs, and quietly takes the wrong branch. Every Ruby presence check of the form if value needs re-reading when it moves.
ammo = 0 if ammo puts "Ruby: the key exists, so this runs" end puts (ammo || 10) # 0, because 0 is truthy
var ammo := 0 if ammo: print("never runs") else: print("GDScript: 0 is falsy, so the presence check inverted") # Ask the question you actually meant: if ammo != null: print("ammo exists (and may be zero)")
There is no ||= or &&= either, so the "default it if absent" idiom becomes an explicit if x == null:. Watch for this especially around args.state.thing ||= 0, which is DragonRuby's most common line and has no direct translation.
Indentation is syntax, and there is no end
Ruby's end keywords make whitespace decorative; GDScript's blocks are their indentation. Godot's own convention is tabs, the editor inserts them, and mixing them with spaces inside one file is a parse error.
# Ruby does not care about whitespace: [1, 2].each do |number| if number > 1 puts "ugly but legal: #{number}" end end
# GDScript rejects inconsistent indentation outright. for number in [1, 2]: if number > 1: print("indentation is the block: %d" % number) # Mixing tabs and spaces in one file is an error, not a style opinion, # and a stray indent stops the script from loading.
The knock-on effect worth naming: a multi-line lambda has to be indented under its func(...) line, so the compact Ruby habit of a one-line block with a do…end fallback has no equivalent. Deeply nested engine code drifts right quickly, which is part of why Godot pushes behavior out into separate small nodes.
Methods that mutate do not warn you
Ruby marks destructive methods with !. GDScript has no such convention and no ?/! in identifiers at all, so sort(), reverse(), and shuffle() mutate the receiver and return nothing — while map(), filter(), and slice() return new arrays.
names = ["grace", "ada"] sorted = names.sort # returns a NEW array puts sorted.inspect puts names.inspect # unchanged — no ! in the name names.sort! # the ! is the warning puts names.inspect
var names := ["grace", "ada"] names.sort() # sorts IN PLACE and returns nothing print(names) # ["ada", "grace"] — the original moved # var sorted = names.sort() # ← refused when the script loads: "Cannot get return value of call # to sort() because it returns void" # The non-destructive form is an explicit copy first: var copy := names.duplicate() copy.reverse() print(names, copy)
The habit that breaks is sorted = names.sort. GDScript catches that particular one for you — assigning the result of a void call is refused when the script loads, which is one of the nicer consequences of the type system. What it cannot catch is the mutation itself, so when the original matters, duplicate() first — and remember duplicate() is shallow unless you pass true.
No Range type: .. is not an operator
Ruby's Range is a first-class lazy object usable as a value, a condition, and an index. GDScript has only the range() function, which builds a real array — so ranges cannot be stored as bounds, cannot cover characters, and are never lazy.
numbers = (1..5).to_a puts numbers.inspect puts numbers[1..3].inspect puts (1..5).include?(3) puts ("a".."e").to_a.inspect puts (1..Float::INFINITY).lazy.map { |n| n * 2 }.first(3).inspect
var numbers := range(1, 6) # a function returning an ARRAY print(numbers) print(numbers.slice(1, 4)) # slicing takes indices, not a range print(3 in numbers) # 'in' works on arrays and dictionaries # There is no character range, and nothing lazy: range() allocates # every element up front. print(range(0, 5).size())
Two practical consequences: for index in range(1_000_000) allocates a million-element array (write a while loop for very large counts), and a "between" test is value >= low and value <= high rather than (low..high).include?(value). The in operator does exist, but it tests membership in an array, dictionary, or string.
A variable can outlive the object it points to
Godot has two ownership models. RefCounted objects (and anything class_name … extends RefCounted) are reference-counted and behave like Ruby objects. Nodes are not: they are freed explicitly with free() or queue_free(), and a variable still pointing at one afterward is dangling.
# Ruby's garbage collector makes this impossible: as long as you hold # a reference, the object is alive. enemy = { name: "slime", health: 3 } reference = enemy enemy = nil puts reference[:name] # still perfectly valid
# RefCounted objects behave like Ruby's: held means alive. var counted := RefCounted.new() var reference := counted print(is_instance_valid(reference)) # But a Node is freed explicitly, and a stale reference to a freed one # is a real hazard — hence the check: var node := Node2D.new() node.free() print(is_instance_valid(node)) # false: the variable outlived the node
Calling a method on a freed node is a runtime error, so is_instance_valid(node) exists as the guard — a check with no Ruby counterpart at all, since Ruby has no way to destroy a live object. This matters most for anything that stores a node between frames: an enemy list, a target, a cached child.