Platform Game in Ruby2D
A Ruby2D platformer core assembled from player state, time-scaled movement, input state, axis-specific collision resolution, and a camera transform.
A platformer is a world of static solids and a dynamic body. The player accumulates gravity, carries horizontal and vertical velocity, and is returned to a non-overlapping position whenever movement enters a solid. Walking, landing, jumping, and hitting a ceiling are different outcomes of the same update loop.
Install Ruby2D once:
gem install ruby2d
World Units and Constants
Positions and dimensions use pixels. Velocity uses pixels per second. Gravity uses pixels per second squared. Keeping these units distinct makes numerical values interpretable: RUN_SPEED = 240.0 means four pixels per 60 fps frame; GRAVITY = 1_500.0 increases downward velocity by 25 pixels per second on each 60 fps frame.
require 'ruby2d'
WINDOW_WIDTH = 800
WINDOW_HEIGHT = 480
WORLD_WIDTH = 1_600
RUN_SPEED = 240.0
JUMP_SPEED = 560.0
GRAVITY = 1_500.0
set title: 'Ruby2D Platformer',
width: WINDOW_WIDTH,
height: WINDOW_HEIGHT,
background: '#101418'
WORLD_WIDTH exceeds the window width so the camera has a world to follow. The y-axis grows downward on the screen; a jump therefore uses negative vertical velocity.
Static Solids and Dynamic State
A platform is an axis-aligned rectangle that does not move. The player has the same rectangle dimensions plus velocity and a contact flag. A hash keeps the state explicit: player[:x] is the world x-coordinate; player[:shape] is the Ruby2D object used only for drawing.
player = {
x: 80.0, y: 220.0,
w: 24, h: 32,
vx: 0.0, vy: 0.0,
grounded: false,
shape: Rectangle.new(x: 80, y: 220, width: 24, height: 32, color: '#58d68d')
}
platforms = [
{ x: 0, y: 420, w: 1_600, h: 60 },
{ x: 180, y: 330, w: 180, h: 24 },
{ x: 480, y: 250, w: 160, h: 24 },
{ x: 820, y: 350, w: 220, h: 24 }
]
platforms.each do |platform|
platform[:shape] = Rectangle.new(
x: platform[:x], y: platform[:y],
width: platform[:w], height: platform[:h],
color: '#4b6380'
)
end
The visual rectangles are not the physics model. Collision uses the four numeric edges in each hash. This distinction makes it possible to replace rectangles with sprites or load platforms from a Tiled collision layer without rewriting movement.
Input State
Keyboard events report changes, but horizontal movement must persist while a key remains held. The input hash records that state. A jump is a one-time request rather than a sustained force; holding the jump key does not add upward velocity on every frame.
input = { left: false, right: false }
jump_requested = false
on :key_down do |event|
input[:left] = true if event.key == 'left'
input[:right] = true if event.key == 'right'
jump_requested = true if event.key == 'space'
end
on :key_up do |event|
input[:left] = false if event.key == 'left'
input[:right] = false if event.key == 'right'
end
The update pass converts left and right into a horizontal axis: -1, 0, or 1. More advanced movement adds acceleration and friction, but a direct velocity is sufficient to establish the physics order.
Bounding-Box Overlap
An axis-aligned bounding box overlaps another when it overlaps on both the x and y axes. The test says that two bodies penetrate; it does not determine how to correct them.
def overlaps?(a, b)
a[:x] < b[:x] + b[:w] &&
a[:x] + a[:w] > b[:x] &&
a[:y] < b[:y] + b[:h] &&
a[:y] + a[:h] > b[:y]
end
Edge contact is not overlap because the comparisons are strict. A player exactly on a floor has no penetration; the next downward movement causes the contact that needs correction.
Horizontal Resolution
Horizontal movement occurs before vertical movement. A positive horizontal velocity means the player entered a solid from the left; a negative velocity means entry from the right. The correction places the player exactly against the relevant edge and clears the velocity that caused the penetration.
def resolve_horizontal!(player, platforms)
platforms.each do |platform|
next unless overlaps?(player, platform)
if player[:vx] > 0
player[:x] = platform[:x] - player[:w]
elsif player[:vx] < 0
player[:x] = platform[:x] + platform[:w]
end
player[:vx] = 0
end
end
The exclamation mark is Ruby convention for a method that mutates an argument. resolve_horizontal! changes the player hash in place.
Vertical Resolution and Ground Contact
Vertical contact distinguishes a floor from a ceiling through the sign of vy. A positive vy is falling. Landing sets grounded because the player is supported from below; a ceiling collision does not.
def resolve_vertical!(player, platforms)
player[:grounded] = false
platforms.each do |platform|
next unless overlaps?(player, platform)
if player[:vy] > 0
player[:y] = platform[:y] - player[:h]
player[:grounded] = true
elsif player[:vy] < 0
player[:y] = platform[:y] + platform[:h]
end
player[:vy] = 0
end
end
Clearing grounded at the beginning of the pass prevents stale state. A jump is valid only when a falling collision has re-established floor contact during the immediately preceding update.
Time-Scaled Update and Camera
dt is capped to prevent a long pause in execution from producing a large single movement step. The update applies input, gravity, jumping, horizontal movement and collision, vertical movement and collision, then projects world coordinates into the window through a camera offset.
last_time = Time.now
update do
now = Time.now
dt = [now - last_time, 0.05].min
last_time = now
axis = input[:right] ? 1 : input[:left] ? -1 : 0
player[:vx] = axis * RUN_SPEED
player[:vy] += GRAVITY * dt
if jump_requested && player[:grounded]
player[:vy] = -JUMP_SPEED
end
jump_requested = false
player[:x] += player[:vx] * dt
resolve_horizontal!(player, platforms)
player[:y] += player[:vy] * dt
resolve_vertical!(player, platforms)
camera_x = [[player[:x] - WINDOW_WIDTH / 2.0, 0].max,
WORLD_WIDTH - WINDOW_WIDTH].min
player[:shape].x = player[:x] - camera_x
player[:shape].y = player[:y]
platforms.each do |platform|
platform[:shape].x = platform[:x] - camera_x
platform[:shape].y = platform[:y]
end
end
show
The camera affects only shape.x and shape.y. player[:x], platform coordinates, and collision calculations stay in world space. A Tiled map follows the same rule: objects and collision rectangles remain in map coordinates while rendering subtracts the camera offset.
Movement Invariants
The update loop preserves several conditions: a player that overlaps a solid is corrected before rendering; floor contact is earned each frame; an upward jump has negative velocity; and the camera never changes world state. Coyote time, jump buffering, moving platforms, slopes, and animation add more state around this loop without changing its fundamental order.