dragonruby
This skill guides writing idiomatic, performant DragonRuby GTK (DRGTK) game code. DragonRuby uses a 60fps game loop, a bottom-left coordinate origin (1280×720), and flows everything through the args object passed to tick.
Sub-skills
For specialised topics, use these companion skills:
/dragonruby-rendering— render targets, cameras, HD/lowrez, pixel arrays, advanced sprites/dragonruby-audio— spatial audio, procedural synthesis, beat sync, crossfading/dragonruby-3d— 3D rendering, raycasting, Mode7, matrix math, VR/dragonruby-pathfinding— A*, BFS, flood fill, quadtrees, spatial queries/dragonruby-ui— UI controls, menus, scroll views, accessibility, input remapping/dragonruby-platformer— platformer physics, action states, cameras, level editors
Project Structure
Each game gets its own unzipped copy of DragonRuby — never share the engine binary across projects.
mygame/
app/
main.rb # entry point — defines tick(args)
player.rb # require from main.rb
enemies.rb
sprites/
sounds/
fonts/
metadata/
game_metadata.txt # title, version, itch/steam IDs, orientation
cvars.txt # dev server, rendering options
tmp/ # gitignore
logs/ # gitignore
.gitignore
The DragonRuby engine binary is large and licensed per-developer — never commit it. Builds and runtime-generated directories should also be excluded.
# DragonRuby engine (download separately — do not commit)
dragonruby
dragonruby.exe
DragonRuby\ GTK.app/
# Runtime-generated directories
tmp/
logs/
# Export builds (large platform-specific archives)
builds/
# OS artifacts
.DS_Store
Thumbs.db
# Solargraph LSP config (optional — commit if whole team uses it)
# .solargraph.yml
Place this file at the root of your project (the directory containing mygame/). The engine binary lives at the same level and must not be pushed to a public repo due to licensing.
Core Loop & Lifecycle
def boot(args)
args.state = {} # initialize state cleanly
end
def tick(args)
defaults(args)
input(args)
calc(args)
render(args)
end
def shutdown(args)
# runs before exit
end
Keep tick as a coordinator that delegates to focused methods. This is the idiomatic DRGTK structure.
State (args.state)
Persistent property bag — values survive between ticks. Use ||= to initialize once.
# Simple values
args.state.score ||= 0
args.state.enemies ||= []
args.state.game_over ||= false
# Entity with auto-generated id/timestamps
args.state.player ||= args.state.new_entity(:player) do |p|
p.x = 640; p.y = 360; p.w = 32; p.h = 32
p.hp = 3
end
# Spawning into a collection
args.state.enemies << args.state.new_entity(:enemy) do |e|
e.x = rand(1280); e.y = 720; e.hp = 1
end
State entities auto-get: entity_id, entity_type, created_at, created_at_elapsed, global_created_at.
GTK.reset wipes all state. Within tick, use GTK.reset_next_tick.
Serialisation (save/load)
GTK.serialize_state('save.txt', args.state)
loaded = GTK.deserialize_state('save.txt')
args.state = loaded if loaded
For more control use JSON:
GTK.write_file('save.json', { score: args.state.score }.to_json)
data = GTK.parse_json(GTK.read_file('save.json'))
Class-based approach (for larger games)
class Game
attr_gtk # injects args, state, inputs, outputs, audio
def tick
state.player ||= { x: 640, y: 360 }
calc_movement
render_player
end
end
$game ||= Game.new
def tick(args)
$game.args = args
$game.tick
end
Outputs / Rendering
Origin: bottom-left. Screen is 1280×720.
Render order (back → front): solids → sprites → primitives → labels → lines → borders → debug
Sprites
args.outputs.sprites << {
x: 100, y: 100, w: 32, h: 32,
path: 'sprites/hero.png',
angle: 45, # degrees counter-clockwise
anchor_x: 0.5, anchor_y: 0.5, # rotation pivot (0–1)
flip_horizontally: facing_left,
r: 255, g: 255, b: 255, a: 255, # tint + alpha
# Sprite sheet cropping (source_ uses bottom-left origin):
source_x: 0, source_y: 0, source_w: 16, source_h: 16
# OR tile_ (top-left origin):
# tile_x: 0, tile_y: 0, tile_w: 16, tile_h: 16
}
Triangle sprites (three-vertex form):
args.outputs.sprites << { x: 0, y: 0, x2: 50, y2: 100, x3: 100, y3: 0, path: 'sprites/tex.png' }
Convenience bang methods
rect.merge(r: 255, g: 0, b: 0).solid! # returns hash with primitive_marker: :solid
rect.merge(r: 255).border!
rect.center.merge(text: "hi").label!
Solids, borders, lines
args.outputs.solids << { x: 0, y: 0, w: 100, h: 100, r: 255, g: 0, b: 0, a: 200 }
args.outputs.borders << { x: 10, y: 10, w: 200, h: 50, r: 255, g: 255, b: 0 }
args.outputs.lines << { x: 0, y: 0, x2: 100, y2: 100, r: 255 }
Special paths:
{ path: :solid } # filled rect without an image file
{ path: :empty } # transparent (useful with blendmode: 0 for clipping)
Labels
args.outputs.labels << {
x: 640, y: 360,
text: "Score: #{args.state.score}",
size_px: 24, # OR size_enum: 2
alignment_enum: 1, # 0=left 1=center 2=right
vertical_alignment_enum: 1, # 0=bottom 1=center 2=top
font: 'fonts/myfont.ttf',
r: 255, g: 255, b: 255, a: 255
}
# Multi-line text helpers
lines = String.wrapped_lines(long_text, 40) # array of strings, max 40 chars each
Background color
args.outputs.background_color = [30, 30, 40]
Debug output
args.outputs.debug << "tick: #{Kernel.tick_count}"
args.outputs.debug.watch(args.state.player)
args.outputs.debug << args.gtk.framerate_diagnostics_primitives
Primitives (manual layering)
args.outputs.primitives << { primitive_marker: :sprite, x: 0, y: 0, w: 32, h: 32, path: 'img.png' }
Inputs
Keyboard
args.inputs.keyboard.key_down.space # true only the tick pressed
args.inputs.keyboard.key_held.left # true while held
args.inputs.keyboard.key_up.escape # true the tick released
# Available: .a–.z, .left, .right, .up, .down, .space, .enter,
# .backspace, .escape, .tab, .shift, .control, .alt,
# .f1–.f12, number keys, etc.
Mouse
args.inputs.mouse.x, args.inputs.mouse.y
args.inputs.mouse.click # click event object (nil or has .x/.y/.point)
args.inputs.mouse.click.point # {x:, y:} — useful for inside_rect?
args.inputs.mouse.button_left # held
args.inputs.mouse.button_right
args.inputs.mouse.held # any button held
args.inputs.mouse.up # released this tick
args.inputs.mouse.moved
args.inputs.mouse.wheel # { x:, y: } scroll delta
args.inputs.mouse.inside_rect?(rect)
args.inputs.mouse.position # {x:, y:} — always available (no click required)
# Previous click for drag detection:
args.inputs.mouse.previous_click
Drag and drop pattern:
if args.inputs.mouse.click && target_under_mouse
args.state.dragging = target_under_mouse.id
args.state.drag_offset = { x: args.inputs.mouse.x - target.x,
y: args.inputs.mouse.y - target.y }
elsif args.inputs.mouse.held && args.state.dragging
target.x = args.inputs.mouse.x - args.state.drag_offset.x
target.y = args.inputs.mouse.y - args.state.drag_offset.y
elsif args.inputs.mouse.up
args.state.dragging = nil
end
Controller
c = args.inputs.controller_one # also controller_two, three, four
c.key_down.a; c.key_held.b; c.key_up.x
c.left_analog_x_perc # -1.0 to 1.0
c.left_analog_y_perc
c.connected
# Buttons: .a .b .x .y .l1 .r1 .l2 .r2 .l3 .r3 .start .select
Unified input (keyboard + controller)
args.inputs.left_right # -1, 0, or 1
args.inputs.up_down # -1, 0, or 1
args.inputs.left_right_perc # -1.0 to 1.0
args.inputs.directional_vector # {x:, y:} or nil — safe-navigate with &.
args.inputs.last_active # :keyboard, :mouse, or :controller
Unified movement:
v = args.inputs.directional_vector
player.x += (v&.x || 0) * player_speed
player.y += (v&.y || 0) * player_speed
Touch
args.inputs.touch # hash of active touch points
args.inputs.finger_left.x
args.inputs.finger_right.x
Scene Management
def tick(args)
args.state.scene ||= :title
case args.state.scene
when :title then tick_title(args)
when :game then tick_game(args)
when :over then tick_over(args)
end
# Apply queued scene changes atomically at end of tick
if args.state.next_scene
args.state.scene = args.state.next_scene
args.state.next_scene = nil
end
end
Never set args.state.scene directly mid-tick; always use next_scene.
For fade transitions:
args.state.scene_at ||= Kernel.tick_count
a = 255 - (255 * args.state.scene_at.ease(30, :flip))
args.outputs.solids << { x: 0, y: 0, w: 1280, h: 720, r: 0, g: 0, b: 0, a: a }
Geometry
All methods via Geometry.* or args.geometry.*.
Collision
Geometry.intersect_rect?(a, b)
Geometry.intersect_rect?(a, b, tolerance)
Geometry.inside_rect?(inner, outer)
Geometry.find_intersect_rect(rect, array) # first hit
Geometry.find_all_intersect_rect(rect, array) # all hits
Geometry.find_collisions(rects) # full collision map
# On objects directly:
player.intersect_rect?(enemy)
array.any_intersect_rect?(player)
# Quad tree (many entities):
qt = Geometry.create_quad_tree(rects)
Geometry.find_all_intersect_rect_quad_tree(rect, qt)
# Circles:
Geometry.intersect_circle?(shape1, shape2)
Geometry.point_inside_circle?(point, center, radius)
Distance & Angles
Geometry.distance(a, b)
Geometry.distance_squared(a, b) # faster for comparisons
Geometry.angle(from, to) # degrees
Geometry.angle_from(to, from)
Geometry.angle_vec(degrees) # { x:, y: } unit vector
Transforms
Geometry.rotate_point(point, degrees, pivot)
Geometry.scale_rect(rect, ratio)
Geometry.anchor_rect(rect, anchor_x, anchor_y)
Geometry.center_inside_rect(target, reference)
Geometry.rect_center_point(rect)
Geometry.rect_to_lines(rect)
# UI menu navigation:
Geometry.rect_navigate(rect, rects, left_right, up_down, wrap_x: false, wrap_y: false)
Rect helpers on objects
player.shift_rect(dx, dy) # returns moved rect hash
player.center # { x:, y: }
Audio
# One-shot sound
args.outputs.sounds << 'sounds/coin.wav'
args.outputs.sounds << { path: 'sounds/hit.wav', gain: 0.5 }
# Persistent / looping
args.audio[:music] = {
input: 'sounds/theme.ogg',
looping: true,
gain: 0.8,
pitch: 1.0,
paused: false,
x: 0.0, y: 0.0, z: 0.0 # spatial: -1 to 1
}
args.audio.delete(:music) # stop
args.audio.volume = 0.5 # global volume
# Inspect after loading:
args.audio[:music].playtime # seconds elapsed
args.audio[:music].playlength # total duration
For advanced audio (spatial, synthesis, beat sync, crossfading) → use /dragonruby-audio.
Easing & Animation
Numeric helpers
# Frame animation
frame = start_tick.frame_index(frame_count: 4, hold_each_frame_for: 8, repeat: true)
path = "sprites/walk_#{frame}.png"
# Timing
start_tick.elapsed_time # ticks since start_tick
start_tick.elapsed?(120) # true if 120 ticks have passed
# Lerp & math
current_x = current_x.lerp(target_x, 0.1)
current_x = current_x.towards(target_x, 5) # move by up to 5 units/tick
val.clamp(0, 100)
val.clamp_wrap(0, 100)
val.remap(0, 100, 0.0, 1.0)
angle.vector_x; angle.vector_y # cos/sin components
angle.vector_x(distance); angle.vector_y(distance) # scaled
2.seconds # => 120 frames
# Simple ease on a tick:
perc = start_tick.ease(duration, :smooth_stop_quint) # 0.0 to 1.0
x = x_start + (x_end - x_start) * perc
Easing module
perc = Easing.ease(start_tick, args.tick_count, duration, :smooth_stop_quint)
# Definitions: :identity, :flip, :quad, :cube, :quart, :quint,
# :smooth_start_quad/cube/quart/quint, :smooth_stop_quad/cube/quart/quint
perc = Easing.smooth_stop(start_at: start_tick, end_at: start_tick + duration,
tick_count: args.tick_count, power: 3)
perc = Easing.spline(start_tick, args.tick_count, duration, [[0, 0.33, 0.66, 1.0]])
Particle / fade queue
args.state.particles ||= []
# Spawn:
args.state.particles << { x: x, y: y, w: 8, h: 8,
dx: angle.vector_x(3), dy: angle.vector_y(3),
a: 255, path: 'sprites/particle.png' }
# Update:
args.state.particles.each { |p| p.x += p.dx; p.y += p.dy; p.dx *= 0.95; p.a -= 8 }
args.state.particles.reject! { |p| p.a <= 0 }
Layout
12×24 grid in landscape. Returns pixel rects for responsive UI.
rect = Layout.rect(row: 0, col: 0, w: 6, h: 2)
# => { x:, y:, w:, h:, center: { x:, y: } }
group = Layout.rect_group(row: 10, dcol: 1, w: 1, h: 1, group: items)
args.outputs.debug << Layout.debug_primitives # visualise the grid
Layout.portrait?; Layout.landscape?
Runtime / GTK Utilities
Window & cursor
args.gtk.set_window_title("My Game")
args.gtk.toggle_window_fullscreen
args.gtk.set_window_scale(2)
args.gtk.hide_cursor
args.gtk.set_cursor('sprites/cursor.png', 0, 0)
args.gtk.set_mouse_grab(1) # 0=ungrab, 1=grab, 2=grab+relative
File I/O (sandboxed)
args.gtk.write_file('save.json', content)
args.gtk.read_file('save.json') # returns nil if missing
args.gtk.append_file('log.txt', "line\n")
args.gtk.delete_file('old.txt')
args.gtk.list_files('saves/')
Write path: macOS ~/Library/Application Support/[game], Windows AppData\Roaming\[dev]\[game], Linux ~/.local/share/[game].
Data parsing
args.gtk.parse_json('{"score":42}')
args.gtk.parse_json_file('data/config.json')
args.gtk.parse_xml_file('data/levels.xml')
Sprite utilities
args.gtk.calcstringbox("Hello", size_enum, font) # [w, h]
args.gtk.calcspritebox('sprites/hero.png') # [w, h]
args.gtk.get_string_rect("text", size_enum, font) # hash
args.gtk.reset_sprite('sprites/hero.png')
Async HTTP
$req ||= args.gtk.http_get('https://example.com/data.json')
if $req && $req[:complete]
data = args.gtk.parse_json($req[:response_data]) if $req[:http_response_code] == 200
$req = nil
end
Development helpers
GTK.reset # wipe state (use in console)
GTK.reset_next_tick # safe to call within tick
GTK.slowmo!(2) # half-speed debug
args.gtk.notify!("msg")
args.gtk.benchmark(seconds: 1, a: -> { fast }, b: -> { slow })
Kernel.tick_count # current tick
Kernel.global_tick_count # never resets
args.gtk.platform?(:macos) # :win :linux :web :ios :android :touch :desktop
args.gtk.production?
Console (dev tool)
# In boot or tick:
GTK.console.set_command "reset_with count: 100"
# Custom console buttons:
class GTK::Console::Menu
def custom_buttons
[button(id: :my_btn, row: 2, col: 18, text: "Reset", method: :my_reset)]
end
def my_reset
GTK.reset
end
end
Array & Numeric Extensions
Array
tiles.map_2d { |row, col, tile| ... }
items.include_any?([:sword, :shield])
rects.any_intersect_rect?(player, 0.1)
list.reject_nil # compact alias
list.reject_false # removes nil + false
[1,2].product([3,4]) # all combinations
array.push_back(item) # append
array.pop_front # shift
Numeric
2.seconds # 120 frames
val.lerp(10, 0.1)
val.towards(10, 2) # move toward 10 by max 2
val.clamp(0, 100)
val.clamp_wrap(0, 100)
val.remap(0, 100, 0.0, 1.0)
val.to_radians; val.to_degrees
val.idiv(32) # integer division
val.fdiv(3) # float division
val.zmod?(3) # val % 3 == 0
tick.elapsed_time
tick.elapsed?(duration)
tick.frame_index(frame_count: 4, hold_each_frame_for: 8, repeat: true)
val.randomize(:ratio) # random 0..val
val.randomize(:ratio, :sign) # random -val..val
(-1..1).rand # random float in range
Common Patterns
Spawn on interval
if Kernel.tick_count.zmod?(120) # every 2 seconds
args.state.enemies << { x: 1280, y: rand(720), w: 16, h: 16, hp: 3 }
end
Remove dead entities
args.state.enemies.reject! { |e| e[:hp] <= 0 || e[:x] < -32 }
Collision response
args.state.enemies.each do |e|
next unless Geometry.intersect_rect?(args.state.player, e)
e[:hp] -= 1
# push-back:
angle = Geometry.angle(e, args.state.player)
e[:x] += angle.vector_x * 3
e[:y] += angle.vector_y * 3
end
Snap to grid
grid_x = mouse_x.idiv(32) * 32
grid_y = mouse_y.idiv(32) * 32
Screen center
cx = args.grid.w / 2 # 640
cy = args.grid.h / 2 # 360
Smooth camera follow
args.state.cam_x = args.state.cam_x.lerp(player.x - 640, 0.08)
args.state.cam_y = args.state.cam_y.lerp(player.y - 360, 0.08)
Camera shake (trauma-based)
args.state.trauma = (args.state.trauma + 0.3).clamp(0, 1)
offset = 20.0 * args.state.trauma ** 2
cam.x_off = offset.randomize(:ratio, :sign)
cam.y_off = offset.randomize(:ratio, :sign)
args.state.trauma *= 0.92 # decay each tick
Timed flash effect
args.state.flash_at ||= -999
args.state.flash_at = Kernel.tick_count if hit
if args.state.flash_at.elapsed_time < 30
a = 255 * args.state.flash_at.ease(30, :flip)
args.outputs.solids << { x: 0, y: 0, w: 1280, h: 720, r: 255, g: 255, b: 255, a: a }
end
Floating damage numbers
args.state.floaters ||= []
args.state.floaters << { x: e.x, y: e.y, text: "-#{dmg}", a: 255, dy: 2 }
args.state.floaters.each { |f| f.y += f.dy; f.a -= 5 }
args.state.floaters.reject! { |f| f.a <= 0 }
args.outputs.labels << args.state.floaters.map { |f| f.merge(r: 255, g: 0, b: 0) }
Frame animation (sprite sheet)
col = args.state.anim_start.frame_index(frame_count: 6, hold_each_frame_for: 5, repeat: true)
args.outputs.sprites << { x: p.x, y: p.y, w: 32, h: 32,
path: 'sprites/run.png',
source_x: col * 32, source_y: 0, source_w: 32, source_h: 32 }
Directional sprite flipping
path: "sprites/hero.png",
flip_horizontally: player.dx < 0
Wave difficulty scaling
args.state.spawn_rate = (args.state.spawn_rate * 0.95).to_i.clamp(20, 300)
Seeded RNG for reproducibility
@rng = Random.new(seed)
val = @rng.rand(0...10)
Performance Tips
- Prefer Hash or class primitives over Array form in hot code paths
args.outputs.static_sprites— holds references instead of clearing each tick; update in place- Implement
draw_override(ffi_draw)+attr_spritefor maximum render throughput - Use
Geometry.create_quad_treefor collision with many entities - Use
Geometry.distance_squaredinstead ofdistancewhen comparing distances - Avoid allocating new arrays every tick — mutate in place (
reject!,map!,push) - Use render targets (
:symbol) to cache complex scenes; only redraw when content changes - Viewport culling: only transform/render entities inside the camera rect
- One-time initialization guard:
return if Kernel.tick_count != 0 args.gtk.warn_array_primitives!— audits array-form output usage
# Static sprites (fastest for large numbers of mostly-static objects)
if Kernel.tick_count == 0
args.state.stars = 500.map { Star.new(args.grid) }
args.outputs.static_sprites << args.state.stars
end
# Stars mutate in place; no need to re-add each tick
# draw_override (maximum speed):
class Star
attr_sprite
def draw_override(ffi_draw)
ffi_draw.draw_sprite @x, @y, @w, @h, @path
end
end