Skip to content

Instantly share code, notes, and snippets.

@amirrajan
Last active October 24, 2025 13:11
Show Gist options
  • Select an option

  • Save amirrajan/a40c587da128ce4ca9d213d1f89ae937 to your computer and use it in GitHub Desktop.

Select an option

Save amirrajan/a40c587da128ce4ca9d213d1f89ae937 to your computer and use it in GitHub Desktop.
DragonRuby Game Toolkit - Definitely not Wordle
class Game
attr_gtk
GREEN = { r: 98, g: 140, b: 84 }
YELLOW = { r: 177, g: 159, b: 54 }
GRAY = { r: 64, g: 64, b: 64 }
def initialize
# get the list of words that can be inputed
@valid_words = GTK.read_file("data/valid.txt")
.each_line
.map { |l| l.strip }
.reject { |l| l.length == 0 }
# get the list of words that will be picked from
@play_words = GTK.read_file("data/play.txt")
.each_line
.map { |l| l.strip }
.reject { |l| l.length == 0 }
@player_progress = (GTK.read_file("user-data/progress.txt") || "")
.each_line
.map { |l| l.strip }
.reject { |l| l.length == 0 }
.map do |l|
word, result = l.split ","
{ word: word, result: result.to_sym }
end
# animation spline for when a letter is typed
@enter_char_spline_duration = 15
@enter_char_spline = [
[0.0, 0.0, 0.66, 1.0],
[1.0, 1.0, 1.0, 1.0],
[1.0, 0.33, 0.0, 0.0]
]
# animation spline for when a letter is flipped
@flip_spline_duration = 15
@flip_spline = [
[1.0, 0.66, 0.33, 0.0],
[0.0, 0.33, 0.66, 1.0],
]
# animation spline for an invalid word
@invalid_spline_duration = 15
@invalid_spline = [
[0.0, -0.5, 0.0, 0.5],
[0.0, -0.5, 0.0, 0.5],
]
# start a new game
new_game!
end
def save_progress!
content = @player_progress.map do |h|
"#{h.word},#{h.result}"
end.join "\n"
GTK.write_file "user-data/progress.txt", content
end
def new_game!
# from the list of playable words, choose a word
@target_word = @play_words.reject do |w|
@player_progress.any? { |h| h.word == w }
end.sample
# this is a look up table for coloring the keys
@key_colors = { }
# the current row the player is on
@current_guess_index = 0
# the current char the player is on
@current_guess_char_index = 0
# point at which the game has ended
@game_over_at = nil
# flag for when the game has endend
@game_over = false
# flag denoting whether the player won or lost when the game has ended
@winner = false
@new_game_at = Kernel.tick_count
# data structure for where the guesses will be stored,
# { rect:, action:, action_at:, char: }
# Layout api is used to create the board
@guesses = [
[
{ rect: Layout.rect(row: 4, col: 1, w: 2, h: 2) },
{ rect: Layout.rect(row: 4, col: 3, w: 2, h: 2) },
{ rect: Layout.rect(row: 4, col: 5, w: 2, h: 2) },
{ rect: Layout.rect(row: 4, col: 7, w: 2, h: 2) },
{ rect: Layout.rect(row: 4, col: 9, w: 2, h: 2) },
],
[
{ rect: Layout.rect(row: 6, col: 1, w: 2, h: 2) },
{ rect: Layout.rect(row: 6, col: 3, w: 2, h: 2) },
{ rect: Layout.rect(row: 6, col: 5, w: 2, h: 2) },
{ rect: Layout.rect(row: 6, col: 7, w: 2, h: 2) },
{ rect: Layout.rect(row: 6, col: 9, w: 2, h: 2) },
],
[
{ rect: Layout.rect(row: 8, col: 1, w: 2, h: 2) },
{ rect: Layout.rect(row: 8, col: 3, w: 2, h: 2) },
{ rect: Layout.rect(row: 8, col: 5, w: 2, h: 2) },
{ rect: Layout.rect(row: 8, col: 7, w: 2, h: 2) },
{ rect: Layout.rect(row: 8, col: 9, w: 2, h: 2) },
],
[
{ rect: Layout.rect(row: 10, col: 1, w: 2, h: 2) },
{ rect: Layout.rect(row: 10, col: 3, w: 2, h: 2) },
{ rect: Layout.rect(row: 10, col: 5, w: 2, h: 2) },
{ rect: Layout.rect(row: 10, col: 7, w: 2, h: 2) },
{ rect: Layout.rect(row: 10, col: 9, w: 2, h: 2) },
],
[
{ rect: Layout.rect(row: 12, col: 1, w: 2, h: 2) },
{ rect: Layout.rect(row: 12, col: 3, w: 2, h: 2) },
{ rect: Layout.rect(row: 12, col: 5, w: 2, h: 2) },
{ rect: Layout.rect(row: 12, col: 7, w: 2, h: 2) },
{ rect: Layout.rect(row: 12, col: 9, w: 2, h: 2) },
],
[
{ rect: Layout.rect(row: 14, col: 1, w: 2, h: 2) },
{ rect: Layout.rect(row: 14, col: 3, w: 2, h: 2) },
{ rect: Layout.rect(row: 14, col: 5, w: 2, h: 2) },
{ rect: Layout.rect(row: 14, col: 7, w: 2, h: 2) },
{ rect: Layout.rect(row: 14, col: 9, w: 2, h: 2) },
],
]
# generate the keyboard layout and wire up the button callbacks
@keyboard = [
*keyboard_buttons(17.25 + 1.5 * 0, 0, ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"]),
*keyboard_buttons(17.25 + 1.5 * 1, 0.6, ["A", "S", "D", "F", "G", "H", "J", "K", "L"]),
*keyboard_buttons(17.25 + 1.5 * 2, 0, ["ENT", "Z", "X", "C", "V", "B", "N", "M", "BKSP"],
{
"ENT" => lambda { guess_word! },
"BKSP" => lambda { unset_char! }
})
]
end
def guess_word!
# when the player presses enter, or clicks the "ENT" button
# get the full word for the current row
full_word = @guesses[@current_guess_index].map { |guess| guess.char }.join
# the word is valid if its length is 5 and it's in the valid word dictionary
is_valid = full_word.length == 5 && @valid_words.include?(full_word)
# if it's valid, then enumerate each one of the guess entries and queue up
# their animations
if is_valid
@guesses[@current_guess_index].each_with_index do |guess, i|
if @target_word[i] == guess.char
# if the index of the word matches exactly, then flip to green
guess.action = :flip_green
guess.action_at = Kernel.tick_count + i * @flip_spline_duration
# update the keyboard color lookup and queue it to be rendered
# after all animations have completed
if !@key_colors[guess.char] || @key_colors[guess.char].color_id == :yellow || @key_colors[guess.char].color_id == :gray
@key_colors[guess.char] ||= { **GREEN, at: Kernel.tick_count + 5 * @flip_spline_duration, color_id: :green }
end
elsif @target_word.include? guess.char
# if the target word contains the character, then flip to yellow
guess.action = :flip_yellow
guess.action_at = Kernel.tick_count + i * @flip_spline_duration
# update the keyboard color lookup and queue it to be rendered
# after all animations have completed
if !@key_colors[guess.char] || @key_colors[guess.char].color_id == :gray
@key_colors[guess.char] ||= { **YELLOW, at: Kernel.tick_count + 5 * @flip_spline_duration, color_id: :yellow }
end
else
# otherwise flip to gray
guess.action = :flip_gray
guess.action_at = Kernel.tick_count + i * @flip_spline_duration
# update the keyboard color lookup and queue it to be rendered
# after all animations have completed
if !@key_colors[guess.char]
@key_colors[guess.char] ||= { **GRAY, at: Kernel.tick_count + 5 * @flip_spline_duration, color_id: :gray }
end
end
end
if full_word == @target_word
# the player has won if their guess matches the target word
@game_over = true
@game_over_at = Kernel.tick_count + 5 * @flip_spline_duration
@winner = true
@player_progress << { word: @target_word, result: :win }
elsif @current_guess_index == 5
# the player has lost if they've run out of rows
@game_over = true
@game_over_at = Kernel.tick_count + 5 * @flip_spline_duration
@winner = false
@player_progress << { word: @target_word, result: :loss }
else
# increment to the next row after the guess
@current_guess_index += 1
@current_guess_char_index = 0
end
else
# if the word they selected isn't in the valid word dictionary,
# then queue the invalid animation
@guesses[@current_guess_index].each_with_index do |guess, i|
guess.action = :invalid
guess.action_at = Kernel.tick_count
end
end
end
def generate_letter_prefabs!
# on frame zero, generate textures/glpyhs for all the letters
return if Kernel.tick_count != 0
r = Layout.rect(row: 0, col: 0, w: 2, h: 2)
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P",
"A", "S", "D", "F", "G", "H", "J", "K", "L",
"Z", "X", "C", "V", "B", "N", "M"].each do |c|
outputs[c.downcase].w = r.w
outputs[c.downcase].h = r.h
outputs[c.downcase].background_color = [0, 0, 0, 0]
outputs[c.downcase].primitives << { x: r.w / 2, y: r.h / 2, text: c, anchor_x: 0.5, anchor_y: 0.5, size_px: r.h / 2, r: 255, g: 255, b: 255 }
end
end
def calc
return if @game_over_at && @game_over_at.elapsed_time < 30
return if @new_game_at && @new_game_at.elapsed_time < 30
if @game_over
# if they clicked or pressed enter, then start a new game
if inputs.mouse.click || inputs.keyboard.key_up.char == "\r" || inputs.keyboard.key_down == "\r"
save_progress!
new_game!
end
else
if inputs.mouse.click
# if they are using the mouse and they click, find the key that the mouse intersects with
keyboard_key = Geometry.find_intersect_rect(inputs.mouse, @keyboard, using: :rect)
# if the key is found, then call the on_click callback on the key
if keyboard_key
keyboard_key.on_click.call
end
elsif inputs.keyboard.key_up.char
# if they used the keyboard and it's backspace or enter,
# then delete or guess word
if inputs.keyboard.key_up.char == "\b"
unset_char!
elsif inputs.keyboard.key_up.char == "\r"
guess_word!
else
# if it's any other key, then check to see if the keyboard buttons has
# the key that was pressed
key = @keyboard.find do |k|
k.char == inputs.keyboard.key_up.char.upcase
end
# if so, then invoke the on_click callback (as if they clicked it with the mouse)
key.on_click.call if key
end
end
end
end
def tick
generate_letter_prefabs!
calc
render
# outputs.primitives << Layout.debug_primitives(invert_colors: true)
end
def unset_char!
# unsetting a char/deleting a char logic
if @current_guess_char_index == 4 && @guesses[@current_guess_index][@current_guess_char_index].char
# if it's the last spot and there is a char to be deleted, then clear out the char
@guesses[@current_guess_index][@current_guess_char_index].char = nil
elsif @current_guess_char_index == 0 && @guesses[@current_guess_index][@current_guess_char_index].char
# if it's the first spot and there is a char to be deleted, then clear out the char
@guesses[@current_guess_index][@current_guess_char_index].char = nil
elsif @current_guess_char_index != 0
# otherwise move back a spot, and clear out the char in that spot
@current_guess_char_index -= 1
@guesses[@current_guess_index][@current_guess_char_index].char = nil
end
end
def set_char! c
# set the current spot's char and increment to the next spot if they aren't already on the las
# spot
if !@guesses[@current_guess_index][@current_guess_char_index].char
@guesses[@current_guess_index][@current_guess_char_index].char = c
@guesses[@current_guess_index][@current_guess_char_index].action_at = Kernel.tick_count
@guesses[@current_guess_index][@current_guess_char_index].action = :set_char
@current_guess_char_index += 1 if @current_guess_char_index != 4
end
end
def keyboard_buttons(start_row, start_col, chars, callback_overrides = {})
# button construction
# layout api is used to create the rectangle, and the call back is set to
# set_char!(char) by default unless there is a callback override (eg for ENT and BKSP)
running_col = 0
chars.map_with_index do |c, i|
w = if c.length > 1
1.8
else
1.2
end
r = if c.length > 1
Layout.rect(row: start_row, col: start_col + running_col, w: w, h: 1.5)
else
Layout.rect(row: start_row, col: start_col + running_col, w: w, h: 1.5)
end
running_col += w
on_click = callback_overrides[c] || ->() { set_char! c }
{ rect: r, char: c, on_click: on_click }
end
end
def guess_char_prefab guess_char
# this is the prefab for rendering a tile
# get the location of the spot and a char (if one is there)
r = guess_char.rect
c = guess_char.char
# the default color for the tile is a grayish border
# with a dark background, and the character texture (if the spot has a character set)
border_color = if c
{ r: 90, g: 90, b: 90 }
else
{ r: 45, g: 45, b: 45 }
end
outer_tile = r.center.merge(path: :solid, **border_color, w: r.w, h: r.h, anchor_x: 0.5, anchor_y: 0.5)
inner_tile = r.center.merge(path: :solid, r: 18, g: 18, b: 18, w: r.w - 4, h: r.h - 4, anchor_x: 0.5, anchor_y: 0.5)
label_prefab = r.center.merge(path: c.downcase, w: r.w, h: r.h, anchor_x: 0.5, anchor_y: 0.5) if c
# sorting for rendering so that animations aren't behind other tiles (default is 0)
sort_order = 0
if guess_char.action_at && guess_char.action == :set_char
# if an action as been queued, and the action is :set_char
# use the enter_char_spline animation to compute the percentage
perc = if guess_char.action_at && guess_char.action_at.elapsed_time < @enter_char_spline_duration
Easing.spline(guess_char.action_at, Kernel.tick_count, @enter_char_spline_duration, @enter_char_spline)
else
0
end
# the percentage is used to scale the rect up, and back down
label_prefab = r.center.merge(path: c.downcase, w: r.w + 32 * perc, h: r.h + 32 * perc, anchor_x: 0.5, anchor_y: 0.5) if c
outer_tile = r.center.merge(path: :solid, **border_color, w: r.w + 32 * perc, h: r.h + 32 * perc, anchor_x: 0.5, anchor_y: 0.5)
inner_tile = r.center.merge(path: :solid, r: 18, g: 18, b: 18, w: r.w - 4 + 32 * perc, h: r.h - 4 + 32 * perc, anchor_x: 0.5, anchor_y: 0.5)
# set the sort order to 1 so that it renders at the top
sort_order = 1
elsif guess_char.action_at && guess_char.action == :flip_green
# if the animation is flip to green, then use the flip animation spline to compute the percentage
perc = if guess_char.action_at && guess_char.action_at.elapsed_time < @flip_spline_duration && guess_char.action_at.elapsed_time > 0
Easing.spline(guess_char.action_at, Kernel.tick_count, @flip_spline_duration, @flip_spline)
else
1
end
# default colors before the flip/reveal occurs
outer_tile_color = { r: 90, g: 90, b: 90 }
inner_tile_color = { r: 18, g: 18, b: 18 }
# half way through the animation, flip to green
if guess_char.action_at.elapsed_time > @flip_spline_duration.idiv(2)
outer_tile_color = GREEN
inner_tile_color = GREEN
end
# the perc value is used to control the height of the prefab
label_prefab = r.center.merge(path: c.downcase, w: r.w, h: r.h * perc, anchor_x: 0.5, anchor_y: 0.5) if c
outer_tile = r.center.merge(path: :solid, w: r.w, h: r.h * perc, anchor_x: 0.5, anchor_y: 0.5, **outer_tile_color)
inner_tile = r.center.merge(path: :solid, w: r.w - 4, h: (r.h - 4) * perc, anchor_x: 0.5, anchor_y: 0.5, **inner_tile_color)
sort_order = 1
elsif guess_char.action_at && guess_char.action == :flip_yellow
# same as flip_green, but yellow color
perc = if guess_char.action_at && guess_char.action_at.elapsed_time < @flip_spline_duration && guess_char.action_at.elapsed_time > 0
Easing.spline(guess_char.action_at, Kernel.tick_count, @flip_spline_duration, @flip_spline)
else
1
end
outer_tile_color = { r: 90, g: 90, b: 90 }
inner_tile_color = { r: 18, g: 18, b: 18 }
if guess_char.action_at.elapsed_time > @flip_spline_duration.idiv(2)
outer_tile_color = YELLOW
inner_tile_color = YELLOW
end
label_prefab = r.center.merge(path: c.downcase, w: r.w, h: r.h * perc, anchor_x: 0.5, anchor_y: 0.5) if c
outer_tile = r.center.merge(path: :solid, w: r.w, h: r.h * perc, anchor_x: 0.5, anchor_y: 0.5, **outer_tile_color)
inner_tile = r.center.merge(path: :solid, w: r.w - 4, h: (r.h - 4) * perc, anchor_x: 0.5, anchor_y: 0.5, **inner_tile_color)
sort_order = 1
elsif guess_char.action_at && guess_char.action == :flip_gray
# same logic as flip_green, but gray color
perc = if guess_char.action_at && guess_char.action_at.elapsed_time < @flip_spline_duration && guess_char.action_at.elapsed_time > 0
Easing.spline(guess_char.action_at, Kernel.tick_count, @flip_spline_duration, @flip_spline)
else
1
end
outer_tile_color = { r: 90, g: 90, b: 90 }
inner_tile_color = { r: 18, g: 18, b: 18 }
if guess_char.action_at.elapsed_time > @flip_spline_duration.idiv(2)
outer_tile_color = GRAY
inner_tile_color = GRAY
end
label_prefab = r.center.merge(path: c.downcase, w: r.w, h: r.h * perc, anchor_x: 0.5, anchor_y: 0.5) if c
outer_tile = r.center.merge(path: :solid, w: r.w, h: r.h * perc, anchor_x: 0.5, anchor_y: 0.5, **outer_tile_color)
inner_tile = r.center.merge(path: :solid, w: r.w - 4, h: (r.h - 4) * perc, anchor_x: 0.5, anchor_y: 0.5, **inner_tile_color)
sort_order = 1
elsif guess_char.action_at && guess_char.action == :invalid
# if the animation that's queued is an invalid word,
# compute the prec using the @invalid_spline
perc = if guess_char.action_at && guess_char.action_at.elapsed_time < @invalid_spline_duration && guess_char.action_at.elapsed_time > 0
Easing.spline(guess_char.action_at, Kernel.tick_count, @invalid_spline_duration, @invalid_spline)
else
0
end
# the perc value is used to shift the x value (shake animation)
label_prefab = { x: r.center.x + 64 * perc, y: r.center.y, w: r.w, h: r.h, anchor_x: 0.5, anchor_y: 0.5, path: c.downcase } if c
outer_tile = { x: r.center.x + 64 * perc, y: r.center.y, path: :solid, **border_color, w: r.w, h: r.h, anchor_x: 0.5, anchor_y: 0.5 }
inner_tile = { x: r.center.x + 64 * perc, y: r.center.y, path: :solid, r: 18, g: 18, b: 18, w: r.w - 4, h: r.h - 4, anchor_x: 0.5, anchor_y: 0.5 }
sort_order = 1
end
# return a structure that contains the sort order for rendering, and the prefab/primitives to render
{
sort_order: sort_order,
prefab: [
outer_tile,
inner_tile,
label_prefab
]
}
end
def render
# set the back ground color
outputs.background_color = [18, 18, 18]
# enumerate all the guesses, flat map the prefabs
# then sort each prefab by the sort order
# and shovel the prefab data into output.primitives
outputs.primitives << @guesses.flat_map do |guess|
guess.map do |guess_char|
guess_char_prefab(guess_char)
end
end.sort_by { |pf_container| pf_container.sort_order }
.map { |pf| pf.prefab }
# render the keyboard
outputs.primitives << @keyboard.map do |keyboard_key|
c = keyboard_key.char
r = keyboard_key.rect
# the color of the key is set to a defualt gray, unless there is a color override
# in the key_colors lookup (along with a time stamp of when to show the color -> we don't want to change the
# color during the reveal of a guess)
color = if @key_colors[keyboard_key.char] && @key_colors[keyboard_key.char].at < Kernel.tick_count
@key_colors[keyboard_key.char]
else
{ r: 131, g: 131, b: 131 }
end
# return the prefab for the key which is a combination of the color, plus a label representing the key
[
r.merge(path: :solid, **color),
r.center.merge(text: c, anchor_x: 0.5, anchor_y: 0.5, r: 255, g: 255, b: 255, size_px: 40)
]
end
end
end
# boot up of game
def boot args
args.state = {}
end
# top level tick function
def tick args
$game ||= Game.new
$game.args = args
$game.tick
end
# reset logic used when hotloading
def reset args
$game = nil
end
GTK.reset
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment