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)")?; // 8

Exposing 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:

  1. Type check at runtime
  2. Dynamic dispatch
  3. Variable access = HashMap lookup
  4. Function call = name hash + registry lookup + type match

Performance vs Rust

OperationOverheadReason
Integer math10-20xType dispatch
Variable access50-100xHash lookup
Function call100-500xName lookup + dispatch
Loops10-20xInterpreter 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 sync feature
  • ‘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

RustRhaiNotes
i64INTDefault integer
f64FLOATDefault float
StringstringImmutable, Arc’d
Vec<T>arrayT must be Variant
HashMap<String, T>mapObject-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;      // constant

Functions & 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 f

Arrays & 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 limit

Standard 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")?;  // 256

Precedence 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

RhaiLuaV8 (JS)
Pure RustYesNoNo
SafeYesNoPartial
Easy FFIYesMediumMedium
SpeedMediumFastFastest
MemoryLowLowHigh
SandboxBuilt-inManualManual

Dependencies

[dependencies]
rhai = { version = "1.17", features = [
    "sync",       # Thread-safe Engine
    "serde",      # Serialization support
    "metadata",   # Function metadata
]}