1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
|
# raylib-jamstack — Jolt Physics Ruby API Specification (`Jolt::`)
The optional **3D rigid-body physics** layer.
[Jolt Physics](https://github.com/jrouwe/JoltPhysics) is a fast, modern engine
(used in AAA titles). These bindings are hand-written over the
[joltc](https://github.com/amerkoleci/joltc) C API and follow the same design
rules as the other specs (snake_case, `?` predicates, `=` setters, keyword args).
Vectors accept `Array`s or `Rl::Vector3`/`Vector4` and are returned as
`Rl::Vector3`/`Vector4` (so results drop straight into raylib draw calls). Runs
**single-threaded** `step` only — identical on desktop and the wasm/web build.
## Mental model
- A `Jolt::World` owns the simulation (gravity, bodies). Step it each frame.
- A `Jolt::Shape` is a reusable collision volume (box, sphere, capsule, cylinder).
- A `Jolt::Body` is a rigid body (a body id bound to its world) with a shape,
a motion type (static / kinematic / dynamic), a transform, and velocities.
---
## 1. World
```ruby
world = Jolt::World.new(gravity: [0, -9.81, 0], max_bodies: 10240)
world.gravity = [0, -20, 0]
world.step(dt = 1.0/60.0, collision_steps: 1) # advance the simulation
world.update(dt) # alias of step
world.optimize_broad_phase # call once after bulk-adding bodies
```
Integrate with the raylib loop:
```ruby
Rl.while_window_open do
world.step(Rl.frame_time)
Rl.draw(clear_color: Rl::RAYWHITE) do
Rl.begin_mode3d(camera)
world.query ... # your own draw using body.position / body.rotation
end
end
```
> Single-threaded only (no `set_threads`); `step` runs on the calling thread.
> This is also the only mode valid on wasm, so behaviour matches across targets.
## 2. Shapes
```ruby
Jolt.box(width, height, depth) # full dimensions (not half-extents)
Jolt.sphere(radius)
Jolt.capsule(half_height, radius) # half-height of the cylindrical section
Jolt.cylinder(half_height, radius)
```
Shapes are reusable across many bodies.
## 3. Bodies
```ruby
body = world.body(
shape: Jolt.sphere(0.5),
position: [0, 10, 0], # Array or Rl::Vector3
rotation: [0, 0, 0, 1], # quaternion x,y,z,w (or Rl::Vector4)
motion: Jolt::DYNAMIC, # Jolt::STATIC | KINEMATIC | DYNAMIC
restitution: 0.0, # bounciness 0..1
friction: 0.2,
activate: true,
velocity: [0, 0, 0], # optional initial linear velocity
mass: nil, # kg; nil = derive from shape volume (density)
linear_damping: 0.05, # drag (slows linear motion)
angular_damping: 0.05, # drag (slows spin)
ccd: false, # continuous collision (fast bodies vs thin walls)
sensor: false) # trigger volume: detect overlap, no physical response
world.add_body(...) # alias of body
body.sensor = true ; body.ccd = true # also settable at runtime
```
Shapes also include `Jolt.convex_hull(points)` (an `Array` of `[x,y,z]`) and
`Jolt.mesh(vertices)` (triangle soup, 3 verts per triangle — **static bodies
only**, for level geometry).
`Jolt::Body`:
```ruby
body.id ; body.to_i
body.position # -> Rl::Vector3 ; body.position = [x,y,z]
body.center_of_mass # -> Rl::Vector3
body.rotation # -> Rl::Vector4 (quaternion x,y,z,w)
body.set_transform(position:, rotation: nil, activate: true)
body.linear_velocity ; body.linear_velocity = [x,y,z]
body.angular_velocity ; body.angular_velocity = [x,y,z]
body.apply_force(v) ; body.apply_impulse(v) ; body.apply_torque(v) # chainable
body.active? ; body.activate ; body.deactivate # sleeping bodies are inactive
body.remove # remove + destroy
# tunable properties (get + set)
body.user_data ; body.user_data = entity_id # 64-bit tag for collision lookup
body.motion_type ; body.motion_type = Jolt::KINEMATIC ; body.set_motion_type(mt, activate: true)
body.friction = 0.8 ; body.restitution = 0.9 ; body.gravity_factor = 0.0
```
> Motion types: `STATIC` (never moves — floors/walls), `KINEMATIC` (moved by you
> via velocity/transform, infinite mass), `DYNAMIC` (simulated). A body's
> collision layer is derived automatically from its motion type.
> `user_data` is the bridge to your game: store a flecs entity id (or any 64-bit
> tag) so collision events can be mapped back to game objects.
## 3a. Collision events
`world.contacts` returns the collisions that **began** during the last `step` as
`Jolt::Contact`s. Pair this with `body.user_data` to react in gameplay:
```ruby
world.step(dt)
world.contacts.each do |c|
c.body_a_id ; c.body_b_id # the two bodies' ids
c.body_a ; c.body_b # -> Jolt::Body
c.point # -> Rl::Vector3 (world-space) ; c.normal -> Rl::Vector3
c.involves?(player) # is a given body/id in this contact?
hit = c.other(player) # the *other* body in the contact
damage!(hit.user_data) if c.involves?(player)
end
```
> Only contacts that newly begin are reported (not every step they persist), so
> the list stays small. The buffer is capped (4096/step); excess is dropped.
**Contacts that ENDED** this step (stopped touching) come from `world.contacts_ended`
(`Jolt::ContactEnd`: `body_a_id`/`body_b_id`/`body_a`/`body_b`/`involves?`/`other`,
no point/normal). Combine with a **sensor** body (`sensor: true`) for trigger
volumes — `contacts` = "entered the zone", `contacts_ended` = "left the zone":
```ruby
zone = world.body(shape: Jolt.box(4,4,4), position: [0,2,0], motion: Jolt::STATIC, sensor: true)
world.step(dt)
world.contacts.each { |c| on_enter(c.other(zone)) if c.involves?(zone) }
world.contacts_ended.each { |c| on_leave(c.other(zone)) if c.involves?(zone) }
```
## 3c. Constraints / joints
Connect two bodies (one may be `STATIC` to anchor to the world). Each returns a
`Jolt::Constraint`; call `joint.remove` to detach (also done on GC).
```ruby
world.weld(a, b) # fixed: rigid weld at current relative pose
world.ball_joint(a, b, point) # point-to-point (free rotation about a world point)
world.distance_joint(a, b, pa, pb, min: 0, max: 2.0) # rope/rod between two world points
world.hinge(a, b, point, axis, min_deg: -90, max_deg: 90) # door (rotate about axis)
world.slider(a, b, point, axis, min: -2, max: 2) # piston (slide along axis)
world.cone(a, b, point, axis, half_angle_deg: 30) # swing/twist limit about axis
```
`point`/`axis` are world-space (`Array` or `Rl::Vector3`); hinge limits in degrees,
slider limits in metres.
> The **world retains** every constraint it creates, so a joint stays alive even
> if you don't keep the returned handle — call `joint.remove` to delete it (which
> also drops the world's reference). Likewise for ragdolls (§4b).
## 3b. Character controller
A `Jolt::Character` is a kinematic capsule (Jolt `CharacterVirtual`) for players:
precise control, **stair-stepping** (up to ~0.4 m), slope handling, and ground
detection. It is not a rigid body — you set its velocity each frame (applying
gravity/jump yourself) and call `update`, which moves and slides it along the world.
```ruby
char = world.character(shape: Jolt.capsule(0.6, 0.3), position: [0, 2, 0],
max_slope_deg: 45, mass: 70)
# canonical per-frame loop
dt = Rl.frame_time
v = char.velocity
vy = char.on_ground? ? (jump? ? 6.0 : 0.0) : v.y - 20.0 * dt # gravity / jump
char.velocity = [input_x * speed, vy, input_z * speed]
char.update(dt) # moves + collides + steps stairs + sticks to floor
world.step(dt)
char.position # -> Rl::Vector3 ; char.position = [x,y,z]
char.velocity # -> Rl::Vector3
char.on_ground? # standing on walkable ground?
char.ground_state # :on_ground | :on_steep | :not_supported | :in_air
char.ground_normal # -> Rl::Vector3
char.supported? # touching anything that supports it?
char.max_strength # max force (N) it exerts on dynamic bodies it walks into
char.max_strength = 6000 # raise above the 100 N default to push heavy props
char.mass = 200 # effective mass vs. dynamic bodies (still kinematic to gravity)
# --- riding moving platforms ---
char.ground_velocity # -> Rl::Vector3: velocity of the surface underfoot (0 if airborne)
char.ground_body # -> Jolt::Body the character stands on, or nil when airborne
char.ride(dt) # like update(dt), but first ADDS ground_velocity to your own
# velocity, so a KINEMATIC platform/elevator carries the player
```
> `ride` only inherits the velocity of a **STATIC/KINEMATIC** ground body. A
> DYNAMIC ground body (a ball you stand on, a constrained pendulum) reports its
> *reaction to your weight* (and its own bounce/swing) as `ground_velocity` —
> inheriting that would fling the character — so dynamic ground is ignored and you
> simply stand/collide on it. (`ground_velocity` itself still returns the raw value.)
A moving platform is just a `KINEMATIC` body you drive each frame; `ride` makes
the character inherit its motion instead of being left behind:
```ruby
plat = world.body(shape: Jolt.box(4, 0.5, 4), position: [0,0,0], motion: Jolt::KINEMATIC)
# each frame:
plat.linear_velocity = [vx, 0, 0] # feeds the character's ground_velocity
char.velocity = [input_x*5, vy, input_z*5] # your own movement + gravity/jump
char.ride(dt) # inherits the platform's velocity
world.step(dt)
plat.set_transform(position: next_xyz) # pin the kinematic body (anti-drift)
```
> By default the character can push the dynamic bodies it walks into, but the Jolt
> default `maxStrength` (100 N) is too weak to shove default-density props
> (density 1000 kg/m³ — a 0.5 m sphere is ~520 kg). Raise `max_strength` when you
> want the player to bowl props around; raise `mass` to make props shove the
> player less.
> The character keeps its `world` alive (GC) for its lifetime. `update` uses
> Jolt's `ExtendedUpdate` (stair-step height 0.4 m, stick-to-floor). It collides
> with static + dynamic bodies but is itself kinematic (infinite mass): it stops
> at obstacles rather than being pushed.
## 4. Queries
```ruby
hit = world.raycast(origin, direction) # direction is the full ray vector
if hit
hit.body_id # the hit body's id ; hit.body -> Jolt::Body
hit.fraction # 0..1 along the ray
hit.point # -> Rl::Vector3 (world-space hit point)
hit.normal # -> Rl::Vector3 (world-space surface normal at the hit)
end
# which bodies contain a point? (overlap test) -> Array<Jolt::Body>
world.overlap_point([x, y, z]).each { |b| ... }
```
## 4b. Ragdolls
A ragdoll is a tree of dynamic bodies (one per skeleton joint) wired with
**swing-twist** constraints (a cone swing limit + a twist range), so a humanoid
collapses believably. Build it from an Array of part Hashes, **parents before
children** (skeleton order). Each part's body is a normal `Jolt::Body`.
```ruby
rd = world.ragdoll(parts: [
# the single root has no :parent
{ name: :torso, shape: Jolt.capsule(0.22, 0.16), position: [0, 4.0, 0], mass: 20 },
{ name: :head, shape: Jolt.sphere(0.16), position: [0, 4.45, 0], parent: :torso,
joint: [0, 4.24, 0], # world-space pivot connecting to the parent
twist_axis: [0,1,0], plane_axis: [1,0,0], # bone axis + a perpendicular
cone_deg: 25, plane_deg: 25, # swing limits (normal/plane half-cone)
twist_min_deg: -25, twist_max_deg: 25 },
{ name: :lleg, shape: Jolt.capsule(0.20, 0.085), position: [-0.1, 3.4, 0], parent: :torso,
joint: [-0.1, 3.78, 0], cone_deg: 40, twist_min_deg: -10, twist_max_deg: 10, mass: 5 },
], user_data: 0)
rd.body_count # number of parts
rd.bodies # -> Array<Jolt::Body> in part order (read .position/.rotation to draw)
rd[0] # bodies[0] (the root)
rd.activate # wake all parts (e.g. before applying an impulse)
rd.bodies.each { |b| b.apply_impulse([fx, fy, fz]) } # fling it
rd.remove # take it out of the world (also done on GC)
```
Per part — required: `name:`, `shape:`, `position:`. Optional: `rotation:`
(quaternion, default identity), `mass:` (kg, else shape-derived), `motion:`
(default `DYNAMIC`), and for non-root parts the joint to the parent: `parent:`,
`joint:` (default = `position`), `twist_axis:` (default `[0,1,0]`), `plane_axis:`
(default `[1,0,0]`), `cone_deg:`/`plane_deg:` (default 45), `twist_min_deg:`/
`twist_max_deg:` (default ±45).
> Adjacent parts don't collide with each other (Jolt
> `DisableParentChildCollisions`); the ragdoll collides with the rest of the
> world on the `MOVING` layer. The ragdoll keeps its `world` alive (GC).
> Capsule parts: their local axis is **Y** — render the two endpoints with
> `Rl.vector3_rotate_by_quaternion([0, half_height, 0], body.rotation)`.
## 5. Worked example (bouncing balls over raylib)
```ruby
world = Jolt::World.new(gravity: [0, -9.81, 0])
ground = world.body(shape: Jolt.box(50, 1, 50), position: [0, -0.5, 0],
motion: Jolt::STATIC)
balls = (0...20).map do |i|
world.body(shape: Jolt.sphere(0.5), position: [i - 10, 8, 0], restitution: 0.6)
end
world.optimize_broad_phase
Rl.init_window(960, 540, "physics")
cam = Rl::Camera3D.new(Rl::Vector3.new(0, 8, 20), Rl::Vector3.new(0, 2, 0),
Rl::Vector3.new(0, 1, 0), 45, Rl::CAMERA_PERSPECTIVE)
Rl.while_window_open do
world.step(Rl.frame_time)
Rl.draw(clear_color: Rl::RAYWHITE) do
Rl.begin_mode3d(cam)
balls.each { |b| p = b.position; Rl.draw_sphere(p, 0.5, Rl::RED) }
Rl.draw_grid(20, 1)
Rl.end_mode3d
end
end
```
---
## 6. Determinism
Off by default. Jolt is deterministic given the same binary + inputs; for
*cross-platform* determinism (replays/netcode) rebuild the joltc/Jolt CMake with
`-DCROSS_PLATFORM_DETERMINISTIC=ON` (small perf cost). No API change.
## 7. Build / web
joltc + Jolt (v5.5.0) are vendored and compiled to a single merged
`libjoltphysics.a` (CMake; `-DINTERPROCEDURAL_OPTIMIZATION=OFF` because zig's lld
can't link GCC-LTO objects — see `.agents/knowledge/jolt-binding.md`). Adds
~1.14 MB to `game.wasm` (~300-400 KB gzipped). Single-threaded, so wasm works
unchanged.
## 8. Not yet exposed
Shape-cast / shape-overlap queries (sweep/overlap an arbitrary shape),
height-field & compound shapes, vehicles, soft bodies, ragdoll pose/motor driving
(animated → physical blending; the bodies + joints are exposed, but not
`DriveToPose`), custom collision layers, multithreading. The joltc C API supports
all of these — bind as needed. Also a first-cut leak: shapes and per-world
layer-filter tables aren't freed (bounded; both are long-lived).
|