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].inspectvar 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 healthvar 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 timeRuby'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 nilvar 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 fallbackvar 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 divisorprint(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.5This 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 equivalentlerp, 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.lengthseed(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.inspectvar 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()) # radiansThis 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.reversevar 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 indexIndexing 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}"
endprint("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.lengthvar 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 workGDScript 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.sumvar 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_map — map, 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 }.inspectvar 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.inspectvar 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 arrayslice 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.inspectvar 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 }.firstvar 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.inspectvar 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"
endvar 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 healthvar 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, " " }
putsfor 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}"
endvar 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
putsfor 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.callvar 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) # 100Because 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 classesThat 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}"
endvar 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 thereThis 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
endextends 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 notOnce 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}"]
endextends 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 truthyvar 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.inspectvar 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).inspectvar 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 nodeCalling 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.