ruby · 5 min read
A Ruby Coin Collector: Gosu Rendering, Testable Game Rules
Build a Gosu coin collector with normalized movement and testable collisions: eight Ruby model tests and a short native window smoke run.
Hold right and the player moves at 200 pixels per second. Hold right and down together, and a naive implementation moves each axis at that speed. The diagonal is now faster. A tiny coin-collecting game is enough to expose that bug without a physics engine, sprites or a level editor.
This example splits a Gosu window from a plain Ruby game model. The window reads input and draws rectangles. The model owns movement, boundaries, collisions, score and coin placement. That gives the visible game a set of rules we can test without pretending that a unit test has inspected the screen.
Make movement a vector
The window converts arrow-key state into dx and dy, each -1, 0 or 1. Opposite keys cancel. The model normalizes that direction before multiplying by speed and elapsed time:
length=Math.hypot(dx,dy) if length>0 @x=(@x+dx/length*SPEED*dt).clamp(0,WIDTH-SIZE) @y=(@y+dy/length*SPEED*dt).clamp(0,HEIGHT-SIZE) endA horizontal input has length one; a diagonal has length square root of two. Normalization makes their total travel distances equal. The zero-length check handles a player standing still without dividing by zero.
The game measures elapsed time with Gosu.milliseconds. Speed is pixels per second, so the window converts milliseconds to seconds. Gosu's default update interval is roughly 16.67 ms, but drawing and updating are callbacks with different responsibilities. Its Window reference also notes that the operating system can request repainting. Moving the player inside draw would therefore tie game state to presentation events.
Elapsed time is capped at 0.05 seconds in this model. After a long pause, the player advances by at most ten pixels in one step. This deliberately drops excess elapsed time; it does not maintain real-time simulation through arbitrary stalls. A game needing precise physics should consider a fixed-step accumulator and a deliberate catch-up policy.
Define what counts as a collision
Both player and coins are axis-aligned rectangles. Overlap means their interiors intersect:
def overlap?(cx,cy) @x<cx+COIN && @x+SIZE>cx && @y<cy+COIN && @y+SIZE>cy endThe strict comparisons make edge contact alone insufficient. That is a design choice, and the test names make it visible: a coin at the player's right edge does not count, while one pixel of overlap does.
On each update, collected coins are removed and score increases once for each removed coin. When none remain, the next batch is sampled from distinct grid cells outside the player's current rectangle. That avoids spawning a new coin directly under a stationary player and awarding another point on the next update.
The model uses a seeded Random instance. The same seed produces the same initial layout in the tested Ruby environment, which makes debugging repeatable. Coin placement does not depend on a global random sequence that some unrelated subsystem might advance.
These are endpoint collision checks. They do not sweep the whole movement path for contacts. At different speeds, object sizes or time-step limits, a fast object can pass through a small target between updates. The cap limits this game's step size; it is not a general tunneling solution.
Keep the window small
The Gosu adapter creates a 640 by 480 window, renders the cyan player and yellow coins with Gosu.draw_rect, and draws the score with Gosu::Font. Everything needed to render it is in code; there are no missing sprite or sound downloads.
The update callback calculates direction and elapsed time, then calls Collector#step. The draw callback only reads state. Pressing Escape calls close!. The Gosu tutorial demonstrates the same basic window/input/rendering split; this example adds a model boundary so the movement rules can be checked independently.
That boundary becomes useful before the game is large. A failing score test should not require opening a window and steering to a randomly placed coin. Conversely, a green score test says nothing about whether text is visible or keyboard input reaches the application.
Run the checks and play the game
Download the example files and locked bundle. Install the dependencies, run bundle exec ruby test_game.rb, then start the window with bundle exec ruby window.rb. Arrow keys move; Escape exits.
Eight model tests passed on Ruby 3.3.2. They check equal horizontal/diagonal speed, equivalent short time steps before a collision, stall clamping, window bounds, collision edges, scoring and respawn, seeded layouts, and invalid elapsed-time values.
A separate native smoke run loaded Gosu 1.4.6, opened the window, ran twenty update callbacks and exited automatically. It completed on the local macOS machine after running outside the filesystem sandbox. That confirms a short native window lifecycle, not a manual playthrough or a visual-quality review. The initial sandbox attempt failed because SDL could not find a display; both outcomes are recorded.
The attachment's --smoke mode is for that bounded lifecycle check. It does not synthesize arrow keys or verify that the player reached a coin. Before sharing a packaged game, manually check input, focus changes, collision feedback, readability and exit behavior on each target platform.
Choose one next rule
A timer is a useful extension: decide whether time pauses when the window loses focus, and test that decision. A sound effect is another, but keep scoring in the model and let the window respond to a collection event. Loading an audio file should not determine whether a point exists.
The value of the example is that a visual toy has explicit behavior. “Movement feels wrong” can become a distance test. “Coins sometimes score twice” can become a stationary-player test. The window still makes the game enjoyable to explore; the model makes its mistakes easier to isolate.
Found a mistake or tried a different approach?
Send Alex a note ↗