Puzzles
Usage guide of puzzle.beams and puzzle.circuits: the Rust puzzle engines, configuration, trace, step, sync and resolve commands, limits, composition, engines.
Puzzles: beams and circuits (puzzle.* kits) #
Two reusable puzzle engines written in Rust: light beams that bounce between mirrors, and signal graphs that open doors. They hold the rules only; your game owns the meshes, the colliders, the animation and the sounds. Readable offline: gripforge docs puzzles, or the MCP tool gripforge_docs (topic puzzles).
What it is for #
| Kit | What it adds |
|---|---|
puzzle.beams | sources, finite mirrors, colour splitters (prisms), absorbing receivers and blockers; one trace answers every beam segment and which receivers are lit |
puzzle.circuits | switches, pressure plates, boolean gates (and, or, xor, not), on-delays, pulses and latches in a bounded graph whose outputs open or close actuators |
They compose: feed the active value of a receiver into an input node of a circuit, and let the circuit drive a door. There is no second physics world and no hidden tick: the game decides when to trace and when to step.
Turn it on #
gripforge_gamekit_install { project_id: "gkp_…", id: "puzzle.circuits" }
gripforge_gamekit_install { project_id: "gkp_…", id: "puzzle.beams" }
Both need no other kit. Each one is a service with up to 128 independent instances (one per room or mechanism): create(id, config), request(id, command), remove(id), ids(). A command is a small JSON object with an op. Rust is the only implementation: the same core runs as a native C library, as a WebAssembly module and inside the kit host, and JavaScript only carries the JSON.
puzzle.beams #
Configuration (all optional, unknown fields and out-of-range values are rejected):
| Field | Default | Bounds | Meaning |
|---|---|---|---|
maxDistance | 100 | 0.01 to 10000 | total path length in metres, shared across reflections along each branch |
maxBounces | 16 | 0 to 64 | mirror and prism interactions a child ray follows |
maxSegments | 256 | 1 to 2048 | segment budget of one complete trace |
minPower | 0.001 | rays whose brightest channel is below this are pruned |
Send the complete current optical scene every time something moves:
{ "op": "trace",
"sources": [{ "id": "laser", "position": [-4,1,0], "direction": [1,0,0], "power": [1,1,1] }],
"surfaces": [
{ "id": "mirror", "kind": "mirror", "position": [0,1,0], "normal": [1,0,1], "radius": 1 },
{ "id": "sensor", "kind": "receiver", "position": [0,1,-4], "normal": [0,0,1], "radius": 0.5, "threshold": [0.5,0.5,0.5] } ] }
- 0 to 16 sources and 0 to 128 surfaces; ids are unique per category. Units are metres in one right-handed frame.
poweris an RGB triplet of a unitless gameplay quantity. - Surface kinds:
mirror(tint),prism(tintandreflect: a configurable splitter, for instancereflect: [1,0.5,0], not a physical refraction),receiver(thresholdper channel; active when every positive threshold is met; it absorbs, it does not pass the beam on),blocker(absorbs; may be asphere). Surfaces are finite disks by default (radius,normal,twoSided). Tint and splitting never amplify power. - The answer holds
segments(source,from,to,power,hit,depth),receivers(id,power,threshold,active),truncatedandlimits. If a budget is exhausted,truncatedis true and EVERY receiver is reported inactive, so an incomplete trace never powers a door. - Walls that must stop a beam have to be supplied as blockers: the kit does not query engine meshes. Use threshold margins, never equality of floating-point values.
- Other commands:
status,config,reset,preview(runs on a clone),save(gripforge.beams/1) andload(the derived results are recomputed, never trusted). Damage from a beam is not applied by the kit: it is a game decision.
puzzle.circuits #
{} creates one input named switch and one output named door sourced from it. A configuration:
{ "nodes": [
{ "id": "left", "kind": "plate", "threshold": 20 },
{ "id": "right", "kind": "plate", "threshold": 20 },
{ "id": "both", "kind": "and", "inputs": ["left", "right"] },
{ "id": "held", "kind": "delay", "inputs": ["both"], "seconds": 1 } ],
"outputs": [{ "id": "door", "source": "held" }] }
1 to 128 nodes, 0 to 64 outputs, at most 16 inputs per gate, no cycles (a latch supplies memory). Node kinds: input (initial), plate (threshold in kg, true when the observed load reaches it), and / or / xor (2 to 16 inputs, xor is odd parity), not, delay (true after the input has been high for seconds, 1 by default), pulse (a rising edge starts a pulse of seconds), latch (inputs: [set, reset], reset wins). Durations are 0.001 to 3600 s.
The loop of the game, once per fixed tick:
{ "op": "step", "dt": 0.016, "inputs": [{ "id": "left", "value": 25 }] }.dtis 0.000001 to 0.1 s; observations are absolute (a plate with nobody on it must be sent as 0) andinputnodes take booleans.{ "op": "sync" }answers{ state, action }.actionis null or{ token, effect }where the effect lists absolute boolean commands, for instance{ "kind": "outputs", "changes": [{ "id": "door", "value": true }] }.- Apply the whole batch to the world once, then
{ "op": "resolve", "token": 1, "success": true }. If a door is blocked or an actuator is missing, undo any partial work and resolve withsuccess: false; the signals stay committed and a latersyncretries.syncwithforce: truesends every output, false ones included (use it right after creation).
While an action is pending, other changes and save/load are refused. Use the instance lifetime plus the token as your receipt key so a duplicate resolve cannot apply an effect twice. Other commands: status, config, reset, preview, save (gripforge.actions/1), load. Replay is exact with the same fixed tick and the same observations.
Hooks and modding #
These two kits are pure drivers: they declare no hooks, collections or moddable keys. The decisions are yours, in the code that calls them: when to trace, which engine objects become sources or blockers, how a receiver or an output is shown or sounded. Everything else of the game (rewards, doors, interactions) reacts to the answers through the ordinary events and scripts of the game.
Engines #
The Rust core is delivered as a native C library, a WebAssembly module and a kit-host adapter, so the same puzzle runs in the web player and the native player, and any engine can call the C library. The engine adapter converts positions and normals, owns the colliders and actuators, and renders the beams (lines, particles, sounds) from the returned segments.
When it does not do what you expect #
| Symptom | Cause and fix |
|---|---|
| A receiver is never active | a budget was exhausted (truncated), a blocker is missing or in the way, or the threshold needs more power than arrives |
| A beam passes through a wall | the wall was not sent as a blocker |
| A plate stays lit | the engine did not send 0 when it emptied, or one body was counted twice |
| The door never moves | no sync after step, or the effect was not resolved (resolve with the token) |
| The graph is refused | a cycle, an unknown reference, a duplicate edge or an id over 128 bytes |
| Timing differs between runs | the tick size changed; keep one fixed dt |