| name | machin-game-demo-sand |
| description | Build, run, and modify machin-game-demo-sand — a granular pile in a box. Use when working on this repo, or as the reference example of a Box AABB primitive + smallest-overlap-axis collision + altitude heat-map visualization in machin (MFL). Covers the math3d module, the raylib FFI block, the Box struct + phys_collide_box, and the altitude_color LUT. |
machin-game-demo-sand
Hundreds of granular particles in an open-top box — pure composition on machin-game-demo-physics.
Shared game-dev substrate (raylib FFI, FlyCam, math module, build/vendoring raylib) lives in the canonical machin-gamedev skill.
Build & run
./build.sh
./machin-game-demo-sand
Needs machin v0.48.0+, a C compiler, raylib, and a display.
Architecture
The shared substrate
Inherited verbatim from the base: math3d (Vec3 + 11 ops), raylib FFI block (DrawSphere, DrawCubeWires, DrawLine3D, DrawGrid, the Camera3D cstruct, and the input/fps helpers), the Verlet integrator, the ground + sphere-sphere collisions, the FlyCam, and the LCG pseudo-random.
The sand deltas
1. Box struct + singleton
type Box struct {
min Vec3
max Vec3
}
var sandbox = box_new(-7.0, 0.0, -7.0, 7.0, 14.0, 7.0)
opens_top = conferred in the function — the +Y face is the only one we don't enforce, so sand can spill out the top. The other four closed faces (left, right, bottom, back, front) are the only ones phys_collide_box clamps against.
2. phys_collide_box(w, box) — smallest-overlap axis
For each particle inside the box with a non-zero penetration depth on any closed face, find the smallest positive depth across the candidates (d_left, d_right, d_bottom, d_back, d_front) and snap the particle to that face.
Then bleed 70% of impact velocity: p.old = p.pos + (p.old - p.pos) * 0.3. (Approximates restitution < 1. Without this, particles bounce forever off the box bottom.)
3. altitude_color(y) (instead of speed_color)
t := clampf(y / 14.0, 0.0, 1.0)
if t < 0.5 {
s := t * 2.0
r = 30 + s*100; g = 20 + s*60; b = 160 + s*60 // dark blue → cyan
} else {
s := (t - 0.5) * 2.0
r = 130 + s*100; g = 80 + s*110; b = 220 - s*180 // cyan → yellow → red
}
The Y axis becomes a visible "depth scale" — bottom particles are deep blue, top particles are deep red. The pile stratigraphy is readable at a glance.
4. Emitter loop
frame_count = (frame_count + 1) % SPAWN_EVERY; on wrap, if len(particles) < MAX_N, spawn one particle at (rng_range(-2, 2), 14, rng_range(-2, 2)) with radius rng_range(0.18, 0.34) and mass rng_range(0.8, 1.6). The funnel mouth radius (~2 units) is much smaller than the box (14×14), so the particles concentrate on the center column — yielding a conical pile.
Patterns worth copying
- Smallest-overlap axis rule for AABB collision. Naive "clamp all axes" teleports particles sliding along an edge onto the wrong face. Pick the smallest positive penetration and snap to that face.
open_top flag on the Box. A rigid container primitive needs an "is this face closed?" query per axis. A single int + a one-line conditional in phys_collide_box covers 5 out of 6 sides.
- Damping on impact via
(p.old - p.pos) * restitution. Verlet's old stores the implicit velocity; flipping it toward pos after a collision simulates restitution. 0.3 (= 30% retention = 70% loss) is the right ballpark for sand.
- Module-level singleton box. For a single-physics-world application,
var sandbox = box_new(...) is one line and the solver reads it without the caller threading it through.
Modifying
- Spawn rate: tweak
SPAWN_EVERY (smaller = denser stream) and MAX_N (cap on total).
- Funnel position: change the
rng_range(-2.0, 2.0) ranges; smaller = tighter pile, larger = wider spread.
- Open more sides: extend
phys_collide_box with d_top + the box.open_top == 0 branch; today only the top is open.
- A second box: replace the singleton
sandbox with []box{} + a per-iteration box loop. ~30 extra LOC.
- Color theme: swap the LUT in
altitude_color; the key invariant is "low Y → cool hue, high Y → warm hue" so the stratigraphy reads.
- Spatial grid: once you exceed n≈400 you need it. The pattern is
map[Vec3i][]int keyed by floor(pos * cellSize). The [x]int builtin in machin already supports this idiom; the chain is add particle → bin.key = floor(p.pos / cell); rebuild bins per substep.
Future directions
- Friction — on the impact-damping step, scale tangential velocity separately from normal velocity, so particles slide along walls rather than just stop.
- Cohesion (wet sand) — add a desired-distance constraint between near neighbors, gated by a threshold; ~50 LOC over the existing distance-constraint pattern.
- Multiple funnels —
[]Vec3{} of funnel positions; the emitter chooses one per frame.
- Spatial hash — the right next-step when n exceeds ~400 (this is the demo that exposes the base's O(n²) ceiling).