jolt-ruby
Ruby bindings for Jolt Physics, built on
the joltc C API, ffi, and larb.
jolt-ruby uses SI units in a right-handed, Y-up coordinate system: metres, kilograms, seconds, and radians. Passing centimetres or degrees without conversion will produce incorrect simulation results.
Installation
gem "jolt-ruby"
bundle install
Release builds use platform gems for x86-64/aarch64 Linux, arm64/x86-64 macOS, and x64-mingw-ucrt. On another platform RubyGems falls back to the source gem, which requires CMake 3.16 or newer and a C++17 compiler.
The 1.x public API follows semantic versioning. Native declarations mirror the
vendored joltc header; higher-level Ruby APIs are added deliberately rather
than exposing unsafe ownership or callback behavior.
First simulation
require "jolt"
system = Jolt::System.new(gravity: [0, -9.81, 0])
floor = system.bodies.create(
shape: Jolt::Shape.box([50, 0.5, 50]),
position: [0, -0.5, 0],
motion: :static,
layer: :non_moving
)
ball = system.bodies.create(
shape: Jolt::Shape.sphere(0.5),
position: [0, 5, 0],
mass: 2,
restitution: 0.6,
ccd: true
)
system.update(1.0 / 60)
puts ball.position
system.destroy
Jolt.shutdown
Jolt.init is called automatically. A System owns its bodies, constraints,
characters, and retained shapes. Destroying the system invalidates their Ruby
handles; subsequent access raises Jolt::UseAfterDestroyError. Explicit
destroy/release is supported, and shutdown is also registered with
at_exit.
Layers
Layers.default provides :non_moving and :moving. Custom collision matrices
remain entirely native during simulation:
layers = Jolt::Layers.define do |layer|
layer.broad_phase :static_bp, :dynamic_bp
layer.object :ground, broad_phase: :static_bp
layer.object :player, :enemy, broad_phase: :dynamic_bp
layer.collide :player, with: %i[ground enemy]
layer.collide :enemy, with: %i[ground player enemy]
end
system = Jolt::System.new(layers:)
Shapes and bodies
Primitive shapes:
Jolt::Shape.box([1, 0.5, 1])
Jolt::Shape.sphere(0.5)
Jolt::Shape.capsule(half_height: 0.8, radius: 0.3)
Jolt::Shape.cylinder(half_height: 1, radius: 0.5)
The advanced builders are convex_hull, mesh, heightfield, compound,
scaled, and offset. Hull vertices and mesh vertices accept nested arrays or
packed little-endian f32 strings; mesh indices also accept packed u32 strings.
Mesh and heightfield shapes are static-only.
Bodies support :static, :kinematic, and :dynamic motion. Common operations
include:
body.linear_velocity = [1, 0, 0]
body.angular_velocity = [0, 1, 0]
body.apply_impulse([0, 10, 0])
body.add_force([5, 0, 0], point: [0, 1, 0])
body.friction = 0.4
body.sensor = true
body.kinematic_move_to([2, 0, 0], [0, 0, 0, 1], 1.0 / 60)
Arbitrary Ruby objects can be stored in body.user_data. They never cross the
native boundary.
Contacts and queries
Jolt invokes contact listeners from worker threads. jolt-ruby records those
calls into a bounded native lock-free queue, then creates immutable Ruby
snapshots after System#update:
system.update(1.0 / 60)
system.contact_events.added.each do |contact|
puts [contact.body_a, contact.body_b, contact.point, contact.normal].inspect
end
warn "contact queue overflow" if system.contact_events.dropped_count.positive?
Synchronous queries return registered Jolt::Body handles:
hit = system.raycast(
origin: [0, 10, 0],
direction: [0, -20, 0],
layer_mask: %i[non_moving moving]
)
nearby = system.overlap_sphere([0, 0, 0], 2, layer_mask: :moving)
system.overlap_point([0, 0, 0]) { |body| puts body.user_data }
Overlap queries use Jolt's broad phase and therefore report bodies whose bounds overlap the query.
Constraints
The collection supports fixed, point, distance, hinge, and slider constraints:
hinge = system.constraints.hinge(
body_a: door,
body_b: frame,
anchor: [0, 1, 0],
axis: [0, 1, 0],
limits: (-Math::PI / 2)..(Math::PI / 2)
)
hinge.motor_speed = 1.5
hinge.disable_motor
hinge.destroy
Destroying either body destroys its attached constraints first.
Virtual characters
CharacterVirtual#update uses Jolt's extended update with stair walking and
stick-to-floor enabled:
character = Jolt::CharacterVirtual.new(
system,
shape: Jolt::Shape.capsule(half_height: 0.8, radius: 0.3),
position: [0, 2, 0],
max_slope: Math::PI / 4,
mass: 70
)
character.velocity = [3, character.velocity.y - 9.81 / 60, 0]
character.update(1.0 / 60)
puts character.ground_state # :on_ground, :sliding, or :in_air
Fixed stepping and rendering
System#update releases Ruby's GVL while Jolt runs. Use FixedStepper to keep
simulation stable and interpolate rendering transforms:
stepper = Jolt::FixedStepper.new(hz: 60, max_substeps: 5)
alpha = stepper.advance(frame_delta) { |step| system.update(step) }
render_transform = ball.interpolated(alpha)
Each system permits one native operation at a time. Do not read, mutate, or
destroy a system or one of its handles from another thread while
System#update or CharacterVirtual#update is running. Such access raises
Jolt::ConcurrentAccessError.
The returned transform exposes position and rotation as Larb::Vec3 and
Larb::Quat, matching the stagecraft binding contract. See
examples/stagecraft_binding.rb.
Development
Initialize the submodule and run the complete build:
git submodule update --init
bundle install
bundle exec rake
Useful tasks:
bundle exec rake native:compile # build joltc and the helper library
bundle exec rake generator:generate # regenerate all joltc FFI declarations
bundle exec rake native:verify_layout # compare Clang, C++, and FFI layouts
bundle exec rake native:gem # build a precompiled platform gem
bundle exec rake native:smoke_gem # install and run the platform gem
bundle exec rake package:smoke_source # compile, install, and run the source gem
JOLT_STRESS=1 bundle exec rspec spec/stress
The generator reads joltc.h through ffi-clang. CI rejects stale output and
compares all generated struct sizes and field offsets against a C++ probe.
License
The gem is available under the MIT License. Jolt Physics and
joltc retain their respective upstream licenses.