Skip to content

Instantly share code, notes, and snippets.

@skinnyjames
Last active July 27, 2025 17:25
Show Gist options
  • Select an option

  • Save skinnyjames/e4c4aa5e1d405d9ff1e68c87db46bb95 to your computer and use it in GitHub Desktop.

Select an option

Save skinnyjames/e4c4aa5e1d405d9ff1e68c87db46bb95 to your computer and use it in GitHub Desktop.
Squares w/ Hokusai
require "hokusai"
require "hokusai/backends/raylib"
# Just a wrapper for the game state
# We will provide this to descendant blocks
# using Hokusai::Block::provide
class Game
attr_accessor :score, :active, :best, :pristine
def initialize
@score = 0
@active = false
@best = 0
@pristine = true
end
end
# Just a wrapper for the Square state
class Square
attr_accessor :x, :y, :target_x, :target_y,
:started, :started_at, :type, :size
def self.random(size, type, canvas)
x = canvas.x + rand(canvas.x + canvas.width)
y = canvas.y - size
target_x = canvas.x + rand(canvas.x + canvas.width)
target_y = canvas.y + canvas.height + size
new(size, type, [x, y], [target_x, target_y])
end
def initialize(size, type, origin, target)
@x = origin[0]
@y = origin[1]
@target_x = target[0]
@target_y = target[1]
@size = size
@type = type
@started = false
@started_at = nil
@rotation = 0
end
def start
self.started = true
self.started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
end
def duration
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - started_at
end
# moves the position toward target position
def move(amount)
self.x += target_x / amount
self.y += target_y / amount
if x >= target_x
self.x = target_x
end
end
def finished(canvas)
y > target_y
end
def collided(player)
(x >= player[0] && x <= player[0] + player[2] && y >= player[1] && y <= player[1] + player[3]) ||
(x + size >= player[0] && x + size <= player[0] + player[2] && y + size > player[1] && y + size <= player[1] + player[3])
end
end
# Helper block to center slotted content
class CenterLayout < Hokusai::Block
template <<~EOF
[template]
dynamic { @size_updated="set_size }
slot
EOF
uses(dynamic: Hokusai::Blocks::Dynamic)
attr_accessor :content_width, :content_height
def set_size(width, height)
self.content_width = width
self.content_height = height
end
def render(canvas)
canvas.x = (canvas.width - content_width) / 2.0
canvas.y = (canvas.height - content_height) / 2.0
canvas.width = content_width
canvas.height = content_height
yield canvas
end
end
# The actual Game UI
# Utilizes the drawing API
class Squares < Hokusai::Block
template <<~EOF
[template]
empty {
cursor="pointer"
@click="bounce"
}
EOF
FPS = 60
computed :speed, default: 0.3, convert: proc(&:to_f)
computed :player_size, default: 40.0, convert: proc(&:to_f)
inject :game
uses(
empty: Hokusai::Blocks::Empty
)
attr_accessor :current_x, :direction, :squares, :counter
def initialize(**args)
super
@counter = 0
@squares = []
@direction = :right
@current_x = nil
end
def right_boundary(canvas)
canvas.x + canvas.width - player_size
end
def find_player_x(canvas)
self.current_x = canvas.x if current_x.nil?
if current_x <= canvas.x
self.direction = :right
elsif current_x >= right_boundary(canvas)
self.current_x = right_boundary(canvas)
self.direction = :left
end
case direction
when :right
self.current_x += (30.0 * speed)
when :left
self.current_x -= (30.0 * speed)
end
current_x
end
# click handler for game
# switches the direction of our player
def bounce(event)
case direction
when :left
self.direction = :right
when :right
self.direction = :left
end
end
def compute_squares(canvas, player)
squares.reject! { |square| square.finished(canvas) }
if squares.size < 6 && (squares.size.zero? || squares.last.duration > 1000)
type = (counter % 5).zero? ? :good : :bad
square = Square.random(player_size, type, canvas)
square.start
squares << square
self.counter += 1
end
squares.each do |square|
if square.collided(player)
case square.type
when :good
# points!
game.score += 1
squares.delete(square)
else
# end game
game.active = false
end
end
square.move(200.0)
end
end
def render(canvas)
# carve out space for the moving square
draw do
player_y = canvas.y + canvas.height - player_size
player_x = find_player_x(canvas)
compute_squares(canvas, [player_x, player_y, player_size, player_size])
# draw score in the center of screen
fsize = 60
centerx = (canvas.width - fsize) / 2 + canvas.x
centery = (canvas.height - fsize) / 2 + canvas.y
text(game.score.to_s, centerx, centery) do |command|
command.size = fsize
command.color = Hokusai::Color.new(100, 100, 100)
end
# draw player rect
rect(canvas.x, player_y, canvas.width, player_size) do |command|
command.color = Hokusai::Color.new(222, 222, 222)
end
# draw player
rect(player_x, player_y, player_size, player_size) do |command|
command.color = Hokusai::Color.new(222, 32, 84)
end
# draw falling squares
squares.each do |square|
rect(square.x, square.y, square.size, square.size) do |command|
command.color = (square.type == :good) ? Hokusai::Color.new(222, 32, 84) : Hokusai::Color.new(33, 33, 33)
end
end
end
yield canvas
end
end
# Our load/reload screen
class Load < Hokusai::Block
style <<~EOF
[style]
loadTextStyle {
size: 40;
color: rgb(33, 33, 33);
content: "Squares!";
}
buttonStyle {
cursor: "pointer";
}
EOF
template <<~EOF
[template]
vblock
[if="pristine"]
vblock
text {
...loadTextStyle
}
[else]
vblock
text {
...loadTextStyle
:content="game_over_text"
}
vblock
button {
...buttonStyle
:content="button_text"
@clicked="start"
}
EOF
inject :game
uses(
vblock: Hokusai::Blocks::Vblock,
text: Hokusai::Blocks::Text,
button: Hokusai::Blocks::Button
)
def start(_event)
game.score = 0
game.active = true
game.pristine = false
end
def pristine
game.pristine
end
def game_over_text
"Game Over!\nScore #{game.score}"
end
def button_text
game.pristine ? "Start" : "Replay"
end
end
# The block that will be rendered to the window
class GameApp < Hokusai::Block
template <<~EOF
[template]
vblock
[if="game_active"]
squares
[else]
center
load { :width="300.0", :height="300.0" }
EOF
provide :game, :game
uses(
vblock: Hokusai::Blocks::Vblock,
center: CenterLayout,
squares: Squares,
load: Load,
)
def game_active
game.active
end
def game
@game ||= Game.new
end
end
Hokusai::Backends::RaylibBackend.run(GameApp) do |config|
config.fps = 60
config.width = 700
config.height = 500
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment