
In part one of this series, we started developing a simple Flappy Bird clone game using the DragonRuby game development toolkit. We didn't come very far, though — we stopped after integrating player input to keep our plane afloat.
In this second and concluding part, we'll implement the remaining simple game mechanics. We'll also take a brief look at interfacing with an HTTP server and publishing our game on itch.io.
Scene Management
Let's continue developing the game by adding a condition for terminating it ("game over"). Whenever the plane collides with an obstacle or drops out of the bottom of the screen, the game should abort and give feedback to the player.
For simplicity, let's start with the latter. To keep responsibilities clear in our game code, we'll create a game_over? method that returns true or false (we will later extend it to include arbitrary collisions):
def game_over? args args.state.plane_pos.y <= 0 - args.state.plane_pos.h end
Here, we simply check the plane's y coordinate against the lower boundary of the screen. Additionally, we subtract the sprite's height, so that this check only returns true when the plane has fully disappeared.
When the game is over, we want to display a special screen that just reads "Game Over" (later, we'll add a score). To do this, we have to divide our game logic into two scenes: a :game scene and a :game_over scene. As you might have guessed, we can just make this an additional key to our game state.
In each tick, we will check whether the game is over. If it is, we'll just switch the args.state variable:
def tick args + args.state.scene ||= :game args.state.dy ||= 0 # etc. + if game_over?(args) + args.state.scene = :game_over + end end
We've now arrived at a point that warrants a closer look at code organization. Looking at our program right now, it is clear that we can draw a line between code that renders something to the screen and code that calculates changes to the game state. Let's extract both into their own methods (for good measure, I've also added an init method that encapsulates the state initialization code neatly):
GRAVITY = 0.5 FLAP_POWER = 10 def tick args init args calc args render args end def init args args.state.scene ||= :game args.state.dy ||= 0 args.state.plane_pos ||= { x: 640, y: 360, w: 88, h: 73, anchor_x: 0.5, anchor_y: 0.5 } end def render args args.outputs.sprites << { x: 0 - Kernel.tick_count % 1280, y: 0, w: 1280, h: 720, path: "sprites/background.png" } args.outputs.sprites << { x: 1280 - Kernel.tick_count % 1280, y: 0, w: 1280, h: 720, path: "sprites/background.png" } args.outputs.sprites << { **args.state.plane_pos, path: "sprites/Planes/planeRed1.png" } end def calc args args.state.dy -= GRAVITY if args.inputs.mouse.down || args.inputs.keyboard.key_down.space args.state.dy += FLAP_POWER end args.state.plane_pos.y += args.state.dy args.state.scene = :game_over if game_over?(args) end def game_over? args args.state.plane_pos.y <= 0 - args.state.plane_pos.h end
We can now complete our scene management code in the render method. If the current scene is :game, we'll continue painting our plane sprite. If it's :game_over, we'll print a message instead:
def render args # rolling background args.outputs.sprites << { x: 0 - Kernel.tick_count % 1280, y: 0, w: 1280, h: 720, path: "sprites/background.png" } args.outputs.sprites << { x: 1280 - Kernel.tick_count % 1280, y: 0, w: 1280, h: 720, path: "sprites/background.png" } if args.state.scene == :game args.outputs.sprites << { **args.state.plane_pos, path: "sprites/Planes/planeRed1.png" } elsif args.state.scene == :game_over args.outputs.labels << { x: 640, y: 360, text: "GAME OVER", size_px: 128, anchor_x: 0.5, anchor_y: 0.5 } end end
This completes our minimal scene management implementation. It's not fully complete yet, because the game should reset after a timeout when it's over, but we will leave that out for now. If we run it from our terminal with ./dragonruby, this is what we get:

Collision Detection
Let's now piece together the final component of our game mechanic: obstacles and detecting collisions. We will add a key called walls to our game state, which will hold an array of all currently visible walls. Each wall will have two sprites — one for a rock emerging from below, and one for a stalactite from above. Other than that, we only have to store an x position and update it, as walls move from right to left.
Let's start with preparing the state in init:
def init args args.state.scene ||= :game args.state.dy ||= 0 + args.state.walls ||= [] # etc. end
Next, we'll add some code to calc, to:
- update the
xposition of all visible walls so that they move to the left (in the code below, every wall'sxcoordinate is diminished by 8) - destroy walls that have moved out to the left. We remove all walls whose
xposition is smaller than -108 (the wall sprite's width) from the array usingreject!. - create new walls from the right. We do this at an interval of 120 frames (that's every two seconds) by adding a new hash to the array with an
xposition of 1280 (the screen's width).
def calc args args.state.dy -= GRAVITY if args.inputs.mouse.down || args.inputs.keyboard.key_down.space args.state.dy += FLAP_POWER end args.state.plane_pos.y += args.state.dy + # walls + args.state.walls.each { |w| w.x -= 8 } + args.state.walls.reject! { |w| w.x < -108 } + args.state.walls << { + x: 1280 + } if Kernel.tick_count % 120 == 0 args.state.scene = :game_over if game_over?(args) end
Note that we are using absolute numbers for positions here, just to avoid blowing up the scope of this article. For robust, relative positioning, take a look at the Grid class.
Next, we actually render the walls using more sprites from Kenney's "Tappy Plane" kit:
def render args # ... if args.state.scene == :game args.outputs.sprites << { **args.state.plane_pos, path: "sprites/Planes/planeRed1.png" } + args.state.walls.each do |wall| + wall.sprites = [ + { + x: wall.x, + y: 0, + w: 108, + h: 239, + path: "sprites/rock.png" + }, + { + x: wall.x, + y: 720, + w: 108, + h: 239, + anchor_y: 1, + path: "sprites/rockDown.png" + } + ] + args.outputs.sprites << args.state.walls.map(&:sprites) end elsif args.state.scene == :game_over # ... end end
Note that we actually mutate the wall entities stored in the game state by setting a sprites property on them that contains an array of sprite definitions. Thus, every object in args.state.walls contains an array of sprites, which we then use to actually draw the sprites to args.outputs. The reason for this detour will become clear presently.
We will handle the actual collision detection next. Since we have extracted the game_over? method, which is called every tick, this is the logical place to put the code that checks if the plane intersects with an obstacle.
As it turns out, this is pretty straightforward, since DragonRuby provides a couple of convenience methods for collision detection, such as intersect_rect? and any_intersect_rect?.
We can make use of the latter to test whether a collection of objects that respond to x, y, w, and h (such as our walls' sprites) overlaps with another object that also responds to those attributes (such as our plane_pos). All that's needed is a bit of data massaging using flat_map, and this expression returns true whenever our plane intersects with a wall sprite.
def game_over? args return true if args.state.plane_pos.y <= 0 - args.state.plane_pos.h args.state.walls .flat_map { |wall| wall.sprites || [] } .any_intersect_rect? args.state.plane_pos end
With these changes, our game finally behaves as you would expect it to:

Storing and Managing a Score
The final piece of functionality we want to add to the game is tracking a score for our player and displaying it. Again, let's start by adding it to our init method:
def init args args.state.scene ||= :game + args.state.score ||= 0 args.state.dy ||= 0 args.state.walls ||= [] args.state.plane_pos ||= { x: 640, y: 360, w: 88, h: 73, anchor_x: 0.5, anchor_y: 0.5 } end
Next up, we are going to display it in the top left corner. Let's add that to render:
def render args args.outputs.sprites << { x: 0 - Kernel.tick_count % 1280, y: 0, w: 1280, h: 720, path: "sprites/background.png" } args.outputs.sprites << { x: 1280 - Kernel.tick_count % 1280, y: 0, w: 1280, h: 720, path: "sprites/background.png" } + args.outputs.labels << { x: 10, y: 710, text: "SCORE: #{args.state.score}", size_px: 64 } # etc... end
To keep things as simple as possible, we are going to increase the score count by 1 every time a wall has moved out of the left screen boundary. The simplest way to do this is to calculate the difference of the wall count before and after their removal in calc. If it has decreased (and the player is still playing, i.e., it's not game over), we increase the score by 1:
def calc args # omitted # score + wall_count_before = args.state.walls.count args.state.walls.reject! { |w| w.x < -108 } + if wall_count_before > args.state.walls.count && args.state.scene == :game + args.state.score += 1 + end # omitted end
Now we can observe how the game score increases with every surpassed wall:

Note: The args.gtk.slowmo! helper method can be really useful when debugging time-critical game mechanics!
Playing Sounds
Before moving on to the final steps (integration and deployment), let's add an audible touch to the game. There are lots of different sounds you might want to add to your game, but in general, they fall into two categories: long-running looping sounds and one-shot sounds.
Let's start with the first category and play a looping engine sound for the plane. In DragonRuby, sound is just another output device and handled with the tick method. However, as sound is time-based, you'll want to only trigger it once, otherwise you'll get hundreds of overlapping samples. That's why we start the engine sound in the init method, like this:
def init args # omitted if args.state.tick_count == 1 args.audio[:engine] = { input: "sounds/engine.ogg", gain: 0.2, looping: true } end end
Notably, we only start playback at the first tick, and set looping to true. This will ensure that the engine sound keeps playing back, and not overlap.
Let's now turn to some sound effects. First, we are going to add a sound whenever the plane climbs. We already check for input in calc, so we just have to add a one-shot sound:
def calc args # omitted if args.inputs.mouse.down || args.inputs.keyboard.key_down.space args.state.dy += FLAP_POWER + args.outputs.sounds << "sounds/jump.ogg" end # omitted end
As you can see, we just append a sound file path to args.outputs.sounds, which is arguably a very Rubyesque DSL. We can apply the same technique when increasing the score count:
def calc args # omitted if wall_count_before > args.state.walls.count && args.state.scene == :game args.state.score += 1 + args.outputs.sounds << "sounds/score.ogg" end # omitted end
Finally, let's add a sound when the plane crashes. For this, we'll have to set a game_over_played boolean flag, because otherwise the sound would start playing at each tick:
def calc args # omitted if game_over?(args) args.state.scene = :game_over + unless args.state.game_over_played + args.audio[:engine].paused = true + args.outputs.sounds << "sounds/explosion.ogg" + args.state.game_over_played = true + end end end
When the game is over, the engine sound is paused and an explosion sound plays. However, to ensure that this happens only once, we set (and subsequently check at every tick) args.state.game_over_played.
Note: If you're wondering where to obtain sounds, it's the same procedure as with sprites. There are bazillions of sound packs available online!
Integration with an HTTP Server to Store and Retrieve High Scores
What would a video game be without a leaderboard? Obviously, the easiest choice would be to keep high scores in a local file. However, DragonRuby also contains a minimal HTTP client that can interact with remote servers.
We are going to use this functionality in the "game over" scene to:
- Persist the current score via a POST request.
- Retrieve the highest 3 scores via a GET request.
To achieve this, we first need a minimal local web application. We are going to use Roda here to fit the whole app into one file. Install it with gem install roda and then use this config.ru:
require "roda" $scores = [] class App < Roda plugin :json plugin :json_parser route do |r| r.post "score" do $scores << r.params["count"] $scores.sort.reverse[0..2] end end end run App.freeze.app
If you run this with rackup, it should give you a server running on http://localhost:9292 that you can use to persist the scores. You can POST new scores to /score, which returns the best 3 scores in the response. For demonstration purposes, we are just storing the leaderboard locally, in the global variable called $scores.
First, we need to again add a new variable to the game state, to keep the current state of the leaderboard locally:
def init args args.state.scene ||= :game args.state.score ||= 0 + args.state.hi_scores ||= [] args.state.dy ||= 0 args.state.walls ||= [] # etc. end
To send a POST request to the server, DragonRuby has a method called http_post_body, one of the rare cases of asynchronous code in the framework. We have to wait for this "promise" to complete, therefore, we first store it in the game state. Add this to the calc method:
def calc # omitted if game_over?(args) # omitted + payload = "{\"count\": #{args.state.score}}" + args.state.post_result ||= args.gtk.http_post_body "http://localhost:9292/score", + payload, + ["Content-Type: application/json", "Content-Length: #{payload.length}"] end end
This sends (you have guessed it) the payload as a JSON object ({ "count": YOUR_CURRENT_SCORE }) to the /score route.
Now all that's left is to retrieve the updated high score table once that "promise" has resolved. We do this in the render method and render the leaderboard to the screen if we are in the game_over scene:
def render # omitted elsif args.state.scene == :game_over args.outputs.labels << { x: 640, y: 520, text: "GAME OVER", size_px: 128, anchor_x: 0.5, anchor_y: 0.5 } + if args.state.post_result && args.state.post_result[:complete] + if args.state.post_result[:http_response_code] == 200 + args.state.hi_scores = args.gtk.parse_json args.state.post_result[:response_data] + + args.state.hi_scores.each_with_index do |score, index| + args.outputs.labels << { x: 560, y: 400 - index * 90, text: "#{index + 1}. #{score}", size_px: 64 } + end + end + end end end
After that, we can behold our high score table once the plane has crashed:

We are going to stop here. Just keep in mind that to restart the game, you'd now have to clear the post_result, along with all other relevant game parameters.
Publishing
Now that we've built our first game, we want to tell the world about it! Luckily, DragonRuby comes with built-in support to publish games as HTML (i.e., WASM-based) exports on itch.io, an online marketplace for indie games. Let's walk through the deployment process.
First, create an itch.io account if you don't have one already, and create a new game. Give your game a title, and be sure to select "HTML" as the project type:

Now it's time to fill out the game's metadata for the actual bundled package. Open the file located at mygame/metadata/game_metadata.txt and uncomment the first couple of lines. Then fill it out with your itch.io username, your game's id and title, and add an icon if you want:
devid=my_itch_io_username devtitle=My Name gameid=tappy-plane-demo gametitle=Tappy Plane Demo version=0.1 icon=metadata/icon.png
Note that devid must match your itch.io username, and gameid must match the project's URL path.
After you've completed this step, it's time to start the actual bundling. Run this command in the terminal:
$ ./dragonruby-publish --package mygame
This creates a builds folder into which DragonRuby compiles binaries of all target platforms:
$ ls builds tappy-plane-demo-html5-0.1/ tappy-plane-demo-linux-raspberrypi-0.1/ tappy-plane-demo-html5.zip tappy-plane-demo-linux-raspberrypi.bin* tappy-plane-demo-linux-amd64-0.1/ tappy-plane-demo-linux-raspberrypi.zip tappy-plane-demo-linux-amd64.bin* tappy-plane-demo-mac-0.1/ tappy-plane-demo-linux-amd64.zip tappy-plane-demo-macos.zip tappy-plane-demo-linux-arm64-0.1/ tappy-plane-demo-windows-amd64-0.1/ tappy-plane-demo-linux-arm64.bin* tappy-plane-demo-windows-amd64.exe* tappy-plane-demo-linux-arm64.zip tappy-plane-demo-windows-amd64.zip
Depending on your operating system, you can now even go ahead and try the game binary locally. However, we are going to use the HTML export and upload it to itch.io. For this, just edit your game settings and select the tappy-plane-demo-html5.zip file from the builds folder. Make sure to tick the "This file will be played in the browser" checkbox:

Finally, we have to make sure the viewport dimensions are configured properly, and SharedArrayBuffer support is enabled (this is required for DragonRuby's WASM engine to work).

After you have saved these changes, your game should be playable in the browser at the specified URL. Yay!
Deploying to itch.io is only one option, though. DragonRuby helps you deploy to Steam, mobile platforms, and even to Raspberry Pi or virtual reality headsets. For more information, check out the DragonRuby official documentation.
Wrapping Up
In this two-part series, we've taken DragonRuby from a fresh install to a complete, playable game — covering core game architecture, scene management, collisions, scoring, audio, and even online leaderboards and publishing. Along the way, we've seen how DragonRuby’s minimal, Ruby-flavored API makes it possible to build and ship games fast without sacrificing clarity or control.
While our "Tappy Plane" clone is simple by design, it demonstrates the full workflow from idea to release. With these building blocks in place, you can now focus on creative touches — refining gameplay, adding polish, or inventing something entirely new.
Happy coding!
Wondering what you can do next?
Finished this article? Here are a few more things you can do:
- Share this article on social media
Most popular Ruby articles

What's New in Ruby on Rails 8
Let's explore everything that Rails 8 has to offer.
See more
Measuring the Impact of Feature Flags in Ruby on Rails with AppSignal
We'll set up feature flags in a Solidus storefront using Flipper and AppSignal's custom metrics.
See more
Five Things to Avoid in Ruby
We'll dive into five common Ruby mistakes and see how we can combat them.
See more

Julian Rubisch
Our guest author Julian is a freelance Ruby on Rails consultant based in Vienna, specializing in Reactive Rails. Part of the StimulusReflex core team, he has been at the forefront of developing cutting-edge HTML-over-the-wire technology since 2020.
All articles by Julian RubischBecome our next author!
AppSignal monitors your apps
AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

