box2d-ruby
box2d-ruby is an idiomatic Ruby binding for the Box2D 3 C API. It ships a
complete generated FFI layer and a memory-safe Ruby API for worlds, bodies,
shapes, events, queries, joints, and debug drawing.
The gem currently vendors Box2D 3.1.0.
API stability
Version 1.0 freezes the documented Ruby API. Releases in the 1.x series follow
semantic versioning: existing public methods and their documented behavior will
remain compatible, and breaking changes require a new major version. The
generated Box2D::Native layer mirrors the vendored Box2D headers exactly and
is regenerated when that native dependency is intentionally upgraded.
Units and coordinates
Box2D uses SI units: meters, kilograms, seconds, and radians. It has no built-in pixel scale and this binding uses a y-up coordinate system.
Do not pass screen pixels directly to physics APIs. A common game scale is 32–64 pixels per meter:
scale = Box2D::PixelScale.new(64)
physics_position = scale.vector_to_meters([320, 128])
screen_position = scale.vector_to_pixels(physics_position)
Installation
Add the gem to your bundle:
bundle add box2d-ruby
Or install it directly:
gem install box2d-ruby
Platform gems contain a precompiled Box2D library. The source gem falls back to
building the vendored C17 source with CMake and requires CMake 3.16 or newer, a
C compiler, make (or another CMake build backend), and Ruby development
headers.
The runtime dependency is ffi. larb is optional: if it is already loaded,
vector-returning APIs produce Larb::Vec2; otherwise they return two-element
arrays.
Quick start
require "box2d"
world = Box2D::World.new(gravity: [0, -9.8]) do |configuration|
configuration.substeps = 4
end
world.create_body(type: :static, position: [0, -10]) do |body|
body.box(50, 10)
end
ball = world.create_body(
type: :dynamic,
position: [0, 4],
user_data: {kind: :ball}
) do |body|
body.circle(
radius: 0.5,
density: 1.0,
friction: 0.3,
restitution: 0.6
)
body.bullet = true
end
60.times { world.step(1.0 / 60) }
p ball.position
ball.linear_velocity = [3, 0]
ball.apply_impulse([0, 5])
world.destroy
World#step releases Ruby's GVL while Box2D is solving. Accessing the same
world from another Ruby thread during a step is rejected with
Box2D::ReentrantStepError.
Fixed timestep
FixedStepper implements an accumulator with a substep cap:
stepper = Box2D::FixedStepper.new(hz: 60, max_substeps: 5)
app.run do |delta_time|
alpha = stepper.advance(delta_time) { |step| world.step(step) }
render(alpha)
end
advance returns interpolation alpha in [0, 1). Excess accumulated time is
discarded at max_substeps to prevent a spiral of death.
Shapes
A body block is a shape builder:
body = world.create_body(type: :dynamic, position: [0, 3]) do |builder|
builder.box(half_width, half_height, center: [0, 0], angle: 0)
builder.circle(radius: 0.5, center: [0, 0])
builder.capsule([-1, 0], [1, 0], radius: 0.25)
builder.polygon([[0, 0], [1, 0], [0, 1]])
builder.segment([-1, 0], [1, 0])
end
Every shape builder accepts:
density,friction,restitution, androlling_resistancesensor: truefilter: {category:, mask:, group:}sensor_events,contact_events, andhit_events- Ruby-owned
user_data
Chains are separate native objects:
chain = ground.chain(
[[-4, 0], [-2, 1], [2, 1], [4, 0]],
loop: false,
friction: 0.8
)
Box2D chains require at least four vertices.
Events
Contact and sensor event buffers are transient in Box2D. World#step copies
them into immutable Ruby values before returning:
world.step(1.0 / 60)
world.events.begin_contacts.each do |contact|
a = contact.shape_a.body.user_data
b = contact.shape_b.body.user_data
p [a, b, contact.manifold]
end
world.events.end_contacts.each { |contact| p contact }
world.events.hits.each { |hit| p hit.approach_speed }
world.events.sensor_begins.each { |event| p [event.sensor, event.visitor] }
world.events.sensor_ends.each { |event| p [event.sensor, event.visitor] }
Queries
hit = world.raycast([0, 10], [0, -10])
if hit
p [hit.shape, hit.point, hit.normal, hit.fraction]
end
world.overlap_aabb([-2, -2], [2, 2]) do |shape|
p shape
end
world.overlap_circle([0, 1], radius: 0.5) { |shape| p shape }
world.overlap_capsule([-1, 0], [1, 0], radius: 0.25) { |shape| p shape }
world.overlap_polygon(
[[-1, -1], [1, -1], [1, 1], [-1, 1]],
position: [4, 0],
angle: Math::PI / 4
) { |shape| p shape }
All query methods accept filter: {category:, mask:}. Without a block, overlap
queries return an Enumerator. Returning false from the block stops the
query.
Joints
The high-level API supports revolute, prismatic, distance, mouse, and weld joints:
joint = world.create_revolute_joint(
body_a: arm,
body_b: hand,
anchor: [1, 2],
limits: (-0.5..0.5),
motor: {speed: 2.0, max_torque: 10.0}
)
joint.motor_speed = -2.0
joint.destroy
Equivalent constructors are:
create_prismatic_jointcreate_distance_jointcreate_mouse_jointcreate_weld_joint
All anchors supplied to constructors are world coordinates and are converted to body-local coordinates internally.
Debug drawing
Debug drawing converts native callbacks into arrays suitable for Ruby pattern matching:
world.debug_draw(flags: [:shapes, :joints, :aabbs]) do |command|
case command
in [:polygon, points, color]
draw_outline(points, color)
in [:solid_circle, transform, radius, color]
draw_circle(transform[:position], radius, color)
in [:segment, point1, point2, color]
draw_line(point1, point2, color)
else
# Other commands include solid_polygon, solid_capsule, transform,
# point, circle, and string.
end
end
The owning world retains every FFI::Function used by the draw session, so GC
cannot collect a callback while native code is using it.
Runnable line-renderer examples are included for both supported rendering styles:
gem install rugl glfw
ruby examples/rugl_debug_draw.rb
gem install stagecraft
ruby examples/stagecraft_debug_draw.rb
Both adapters consume the same Box2DDebugLines converter in
examples/support, which turns debug callbacks into colored line-list
vertices.
Verification
bundle exec rake builds the CMake extension, checks all generated FFI struct
layouts, and runs the complete spec suite. Deterministic 100-body fixtures live
under spec/snapshots/100_boxes for every supported native-gem target. Update
the fixture for the current platform after an intentional solver change with:
bundle exec rake snapshots:update
bundle exec rake native:verify_package additionally builds a platform gem,
extracts its packaged library, and reruns the full suite against that exact
artifact. Release CI performs this check for all five supported targets before
uploading any gem.
Ownership and destruction
World owns every native body, shape, chain, and joint. Ruby wrappers are
non-owning ID values and do not use GC finalizers.
Destroy resources explicitly:
shape.destroy
body.destroy
joint.destroy
world.destroy # destroys all remaining children
Access through a destroyed handle raises Box2D::UseAfterDestroyError. Ruby
objects assigned as user_data are held in world-side registries; Ruby object
pointers are never written to Box2D's native void* fields.
Native API
All 409 exported Box2D 3.1.0 functions are available under Box2D::Native.
Structs, enums, and function signatures are generated from the vendored headers:
version = Box2D::Native.b2GetVersion
p [version[:major], version[:minor], version[:revision]]
Set BOX2D_LIBRARY_PATH to load a compatible external Box2D shared library
instead of the bundled extension.
Development
Install dependencies and run the complete quality gate:
bundle install
bundle exec rake
The default task:
- builds the vendored native library;
- checks that generated FFI declarations match the headers;
- compiles a C layout probe and checks all 71 struct sizes, alignments, and field offsets;
- runs the RSpec suite, including 1000 steps under
GC.stress.
Useful tasks:
bundle exec rake bindings:generate
bundle exec rake bindings:check
bundle exec rake native:compile
bundle exec rake native:package
bundle exec rake build
See examples/ for complete runnable scripts.
License
The Ruby binding is available under the MIT License. Vendored Box2D source is
also MIT licensed; its license is included at
ext/box2d/vendor/box2d/LICENSE.