Scripting with Rhai
Behaviour rules handle the common cases — greet on arrival, abort on trespass, respond to keywords. But some behaviours need logic that declarative rules cannot express. For these, the archipelago provides Rhai — a sandboxed scripting language that runs inside the engine.
What Rhai is
Rhai is a small, safe scripting language designed for embedding. It looks like Rust, runs fast, and cannot access the filesystem, network, or anything outside the sandbox. Scripts execute with strict resource limits — a budget of operations, a maximum stack depth, a memory ceiling. If a script exceeds its budget, it is terminated. The player sees fiction (“The spell unravels before completion”), not an error.
Entering script mode
> /script
Mode: script
rig>
The prompt changes to rig>. You are now wiring behaviour — attaching scripts to entities that fire when signals arrive.
A simple script
Attach a script to an NPC that responds when given an item:
rig> edit grak react:give
// Grak examines anything given to him
let item = world.signal().args["item"];
let origin = world.property(item, "origin");
if origin == "ironholt" {
emit(effect::say("Good steel. Ironholt mark. I'll take it."));
} else {
emit(effect::say("Foreign make. Interesting."));
emit(effect::emote("turns the item over in his hands."));
}
This script fires whenever someone gives Grak an item. It checks the item’s origin property and responds differently based on where it was made.
The world API
Scripts can read the world but not mutate it directly. All changes go through effects.
Read-only queries:
| Function | Returns |
|---|---|
world.self() | The entity this script is attached to |
world.room() | The room the entity is in |
world.occupants() | Who else is in the room |
world.inventory(entity) | What an entity is carrying |
world.property(entity, key) | A specific property value |
world.time() | The in-world time |
world.random(min, max) | A random number in range |
world.signal() | The signal that triggered this script |
Effect emission:
| Function | What it does |
|---|---|
emit(effect::say("...")) | Speak aloud |
emit(effect::emote("...")) | Perform an action |
emit(effect::move(direction)) | Move to another room |
emit(effect::abort(reason)) | Prevent the triggering action |
emit(effect::give(item, target)) | Give an item |
log(message) | Write to the operator log (not visible to players) |
Capability scoping
Scripts execute with the capability bag of the entity they are attached to. A script on an NPC can do what that NPC is authorised to do — no more. If a script tries to emit an effect that exceeds its entity’s capabilities, the Effect Interpreter rejects it.
This means a script cannot escalate privileges. A shopkeeper’s script cannot open locked doors. A guard’s script cannot spend the realm’s treasury. Authority is bounded by the entity’s role.
Resource limits
Every script invocation has a budget:
- Max operations: A ceiling on how many instructions can execute (prevents infinite loops)
- Max stack depth: Prevents deep recursion
- Max memory: Prevents allocation bombs
If a script hits any limit, it is terminated immediately. The player sees a fiction-appropriate message. The operator sees a structured log entry identifying the script, the entity, and the limit that was hit.
Write scripts that do one thing and finish. Long-running computation does not belong in a signal handler.
When to use Rhai vs. rules
| Use case | Approach |
|---|---|
| Greet on arrival | Rule |
| Respond to a keyword | Rule |
| Random chance of action | Rule |
| Abort if actor lacks a tag | Rule |
| Check item provenance and respond conditionally | Rhai |
| Count items in inventory and change behaviour | Rhai |
| Complex multi-step logic with branching | Rhai |
| Anything that needs arithmetic or string manipulation | Rhai |
Rules are cheaper, faster, and easier to read. Use them first. Reach for Rhai when the logic genuinely requires it.
Attaching scripts
Scripts attach to entities at specific signal hooks:
rig> edit grak react:enter # fires when someone enters Grak's room
rig> edit grak react:say # fires when someone speaks in Grak's room
rig> edit grak react:give # fires when someone gives Grak something
rig> edit grak act:tick # fires on Grak's periodic tick
The hook name follows the signal dispatch pattern: act, react, react_using, react_in, witness. Most NPC scripts use react — they respond to what happens around them.
Testing scripts
Switch to play mode and trigger the behaviour:
rig> /play
> give sword to grak
Grak turns the sword over in his hands. "Foreign make. Interesting."
If the script fails, you see nothing in play mode (silence is the fallback). Switch to admin mode to see the error:
> /admin
watch> errors last
[error] Script react:give on grak — operation budget exceeded at line 12
Fix the script, try again.