Rhai is an embedded scripting language for Rust. 100% safe Rust, dynamically typed, designed for runtime flexibility in compiled applications.
Use cases: Plugin systems, config DSLs, game scripting, rule engines, hot-reloadable logic
Not for: Heavy computation, performance-critical paths, low-level system access
Core Concepts
Basic Usage
use rhai::{Engine, EvalAltResult};
fn main() -> Result<(), Box<EvalAltResult>> {
let engine = Engine::new();
let result = engine.eval::<i64>("40 + 2")?;
println!("{}", result); // 42
Ok(())
}Exposing Rust Functions
fn add(x: i64, y: i64) -> i64 { x + y }
let mut engine = Engine::new();
engine.register_fn("add", add);
engine.eval::<i64>("add(5, 3)")?; // 8Exposing Rust Types
#[derive(Clone)] // Required
struct Player {
name: String,
health: i64,
}
impl Player {
fn new(name: String) -> Self {
Player { name, health: 100 }
}
fn take_damage(&mut self, dmg: i64) {
self.health -= dmg;
}
}
let mut engine = Engine::new();
engine.register_type::<Player>();
engine.register_fn("Player", Player::new);
engine.register_fn("take_damage", Player::take_damage);
engine.register_get("health", |p: &mut Player| p.health);
engine.eval::<()>(r#"
let p = Player("Alice");
p.take_damage(30);
print(p.health); // 70
"#)?;Performance Model
Execution: Tree-Walking Interpreter
Source → AST → Recursive Evaluation (no JIT)
Every operation involves:
- Type check at runtime
- Dynamic dispatch
- Variable access = HashMap lookup
- Function call = name hash + registry lookup + type match
Performance vs Rust
| Operation | Overhead | Reason |
|---|---|---|
| Integer math | 10-20x | Type dispatch |
| Variable access | 50-100x | Hash lookup |
| Function call | 100-500x | Name lookup + dispatch |
| Loops | 10-20x | Interpreter overhead |
The Dynamic Type
pub struct Dynamic(Union); // Similar to Box<dyn Any>
// Every value is boxed, even primitives
let x = Dynamic::from(42);
let y = x.cast::<i64>();AST Caching
eval() parses and executes. If running the same script multiple times, parse once:
// This parses the script string every call
for _ in 0..1000 {
engine.eval::<i64>("calc(x)")?;
}
// Parse once, execute 1000 times
let ast = engine.compile("calc(x)")?;
for _ in 0..1000 {
engine.eval_ast::<i64>(&ast)?;
}Applies to any script run more than once, whether in a loop or called periodically. Parsing overhead: ~50-70% of execution time for small scripts.
For one-off scripts, just use eval().
General Performance Strategy
Keep compute-heavy operations in Rust, expose to scripts:
// Slow: Matrix multiplication in script
// Fast: Do it in Rust, expose the function
fn matrix_multiply(a: Vec<Vec<f64>>, b: Vec<Vec<f64>>) -> Vec<Vec<f64>> {
// Compiled code
}
engine.register_fn("matrix_multiply", matrix_multiply);Call from Rust into scripts for orchestration. Avoid calling from scripts into Rust in tight loops (FFI boundary overhead).
Type System Integration
Requirements for Custom Types
T: Clone + Send + Sync + 'static- Clone: Rhai passes by value, no borrowing
- Send + Sync: Thread safety with
syncfeature - ‘static: No lifetimes in scripts
Type Registration
engine.register_type::<Player>();This registers the type’s TypeId in the engine, enabling runtime type checking and casting.
Function Registration
engine.register_fn("func", my_func);Rhai generates a wrapper:
fn wrapper(args: &[&mut Dynamic]) -> Result<Dynamic, Error> {
// 1. Extract and type-check arguments
let arg1 = args[0].clone().cast::<ArgType>();
// 2. Call your function
let result = my_func(arg1);
// 3. Box return value
Ok(Dynamic::from(result))
}Stored in function registry, looked up by hash of name + argument types.
Built-in Type Mappings
| Rust | Rhai | Notes |
|---|---|---|
i64 | INT | Default integer |
f64 | FLOAT | Default float |
String | string | Immutable, Arc’d |
Vec<T> | array | T must be Variant |
HashMap<String, T> | map | Object-like |
No Lifetimes Allowed
// Won't compile
struct Bad<'a> {
data: &'a str,
}
// Use owned data instead
#[derive(Clone)]
struct Good {
data: String,
}Cloning Behavior
Scripts pass values by clone:
#[derive(Clone)]
struct Expensive {
data: Vec<u8>,
}
// Script clones on every function call
let script = r#"
let obj = create();
process(obj); // Clone
process(obj); // Another clone
"#;For expensive types, use Arc:
#[derive(Clone)]
struct Shared {
data: Arc<Vec<u8>>, // Only Arc is cloned
}No Type Coercion
// Error: type mismatch
let x = 5; // i64
let y = 2.5; // f64
x + y;
// Must convert explicitly
to_float(x) + y;Generics
Cannot register generic functions directly. Must monomorphize:
fn max<T: Ord>(a: T, b: T) -> T {
if a > b { a } else { b }
}
// Register for each concrete type
engine.register_fn("max", |a: i64, b: i64| max(a, b));
engine.register_fn("max", |a: f64, b: f64| max(a, b));Plugin Macros
Export Module
Use #[export_module] for cleaner API exposure:
use rhai::plugin::*;
#[export_module]
mod player_api {
pub type PlayerType = Player;
// Constructor
pub fn new_player(name: &str) -> Player {
Player::new(name.to_string())
}
// Methods
pub fn take_damage(player: &mut Player, dmg: i64) {
player.take_damage(dmg);
}
// Getters/setters
#[rhai_fn(get = "health")]
pub fn get_health(player: &mut Player) -> i64 {
player.health
}
#[rhai_fn(set = "health")]
pub fn set_health(player: &mut Player, val: i64) {
player.health = val;
}
// Index access
#[rhai_fn(index_get)]
pub fn get_item(player: &mut Player, index: i64) -> String {
player.inventory[index as usize].clone()
}
}
// Register entire module
engine.register_static_module("player", exported_module!(player_api).into());In script:
let p = player::new_player("Alice");
p.take_damage(30);
print(p.health);
p.health = 100;Custom Type with Derive
use rhai::CustomType;
#[derive(Clone, CustomType)]
#[rhai_type(extra = Self::build_extra)]
struct Config {
#[rhai_type(readonly)] // No setter
pub host: String,
pub port: i64,
#[rhai_type(skip)] // Not exposed
internal_id: u64,
}
impl Config {
fn build_extra(builder: &mut TypeBuilder<Self>) {
builder
.with_fn("new_config", Self::new)
.with_fn("to_url", Self::to_url);
}
fn new(host: String, port: i64) -> Self {
Config { host, port, internal_id: 0 }
}
fn to_url(&mut self) -> String {
format!("http://{}:{}", self.host, self.port)
}
}
engine.build_type::<Config>();Export Functions
#[export_fn]
pub fn calculate(x: i64, y: i64) -> i64 {
x * y + 10
}
// Auto-generates registration code
engine.register_fn("calculate", calculate);Language Syntax
Variables & Types
let x = 42; // i64
let y = 3.14; // f64
let name = "Alice"; // string
let items = [1, 2, 3]; // array
let map = #{a: 1, b: 2}; // object map
const PI = 3.14159; // constantFunctions & Closures
fn add(x, y) {
x + y // Last expression is return value
}
let multiply = |x, y| x * y;
// Closures capture by clone, not reference
let multiplier = 5;
let f = |x| x * multiplier;
multiplier = 10; // Doesn't affect fArrays & Functional Operations
let nums = [1, 2, 3, 4, 5];
nums.push(6);
nums.filter(|x| x % 2 == 0);
nums.map(|x| x * 2);
nums.reduce(|acc, x| acc + x, 0);Sandboxing
let mut engine = Engine::new();
engine.set_max_operations(10_000); // Prevent infinite loops
engine.set_max_string_size(1_000); // Limit memory
engine.set_max_array_size(1_000);
engine.set_max_call_levels(10); // Recursion limitStandard Library
Included: Basic math, strings, arrays, maps, type conversion, print()
NOT included: File I/O, network, regex, JSON, date/time, threading
Must expose explicitly:
engine.register_fn("read_file", |path: &str| {
std::fs::read_to_string(path).unwrap_or_default()
});Custom Operators
// Define operator with precedence level
engine.register_custom_operator("**", 160)?;
engine.register_fn("**", |a: i64, b: i64| a.pow(b as u32));
engine.eval::<i64>("2 ** 8")?; // 256Precedence scale: * is 180, + is 150, == is 90. Higher binds tighter.
Common Patterns
Script Cache
struct ScriptCache {
engine: Engine,
scripts: HashMap<String, AST>,
}
impl ScriptCache {
fn get_or_compile(&mut self, name: &str, code: &str) -> &AST {
self.scripts.entry(name.to_string())
.or_insert_with(|| self.engine.compile(code).unwrap())
}
fn run(&self, name: &str) {
let ast = self.scripts.get(name).unwrap();
self.engine.eval_ast::<()>(ast).unwrap();
}
}Property Access
engine.register_get("health", |p: &mut Player| p.health);
engine.register_set("health", |p: &mut Player, v: i64| p.health = v);
// In script: p.health = 50;Scopes for Shared State
let mut scope = Scope::new();
scope.push("global_counter", 0_i64);
// Multiple scripts share scope
engine.eval_with_scope::<()>(&mut scope, "global_counter += 1")?;
engine.eval_with_scope::<()>(&mut scope, "global_counter += 1")?;
let counter = scope.get_value::<i64>("global_counter").unwrap();
assert_eq!(counter, 2);Error Handling
match engine.eval::<i64>("1 + unknown") {
Ok(v) => println!("{}", v),
Err(e) => match *e {
EvalAltResult::ErrorVariableNotFound(var, _) => {
println!("Unknown variable: {}", var);
}
_ => println!("Error: {}", e),
}
}When to Use
Good for:
- Plugin systems where users extend behavior
- Configuration DSLs with logic
- Game scripting with hot reload
- Business rule engines
- Data transformation pipelines
Bad for:
- Heavy numerical computation
- Performance-critical code paths
- Direct hardware/system access
- Core application logic
Comparison to Alternatives
| Rhai | Lua | V8 (JS) | |
|---|---|---|---|
| Pure Rust | Yes | No | No |
| Safe | Yes | No | Partial |
| Easy FFI | Yes | Medium | Medium |
| Speed | Medium | Fast | Fastest |
| Memory | Low | Low | High |
| Sandbox | Built-in | Manual | Manual |
Dependencies
[dependencies]
rhai = { version = "1.17", features = [
"sync", # Thread-safe Engine
"serde", # Serialization support
"metadata", # Function metadata
]}