The Host–Script Boundary
An embedded Lua VM can act only through the host functions and libraries the game exposes; typed contracts and error context keep that boundary clear.
Cheatsheet for Lesson 11.4
A one-page summary of “The Host–Script Boundary” you can keep beside your notes. Plain Markdown.
Lesson 11.4 — The Host–Script Boundary: cheatsheet
- Remember: Lua proposes data; the host checks and applies the proposal after script execution returns successfully.
- Also useful: The
gametable is a labelled service counter: grouping callbacks makes it clear which operations come from the host.
Key terms
String: Passing a table where aStringis required produces a Lua error instead of an unchecked cast.script_path:script_pathis the file the host chose to run.
Review
- Q: Why does the Rust host load table, string, and math libraries but omit Lua's I/O, OS, debug, and package libraries? A: The scripts get a smaller, documented set of capabilities
- Q: A Rust crate offers an option to compile its own bundled copy of a C library instead of finding one already installed. What is the benefit? A: The build works without having to locate or install a separate library
The host owns the interpreter
Section titled “The host owns the interpreter”mlua creates and controls a Lua virtual machine from the host. The project pins:
[dependencies]anyhow = "1.0"mlua = { version = "0.12", features = ["lua54", "vendored", "error-send"] }lua54 chooses Lua 5.4. vendored builds the matching Lua implementation with the crate, so a learner does not need to find a separate Lua DLL. error-send lets contextual host error-reporting carry a Lua failure through anyhow without discarding its details.
The interpreter is a subsystem with its own state, memory manager, call stack, and error reporting. Embedding does not merge Lua and the host into one language. mlua builds a checked bridge that converts values and controls when execution crosses between them.
Treat each crossing as an interface:
- host-to-Lua inputs need a documented table shape and units;
- Lua-to-host requests need type checks and validation against current game state;
- errors need enough context to identify the script and boundary;
- callbacks need time and resource limits appropriate to the host loop.
The safest design passes snapshots and requests, not borrowed pointers into live game objects.
The host remains in charge even while Lua decides what it would like to do:
Lua plans with owned data; the host decides whether each plan is still valid at commit time.
Load only the libraries you need
Section titled “Load only the libraries you need”The Rust host creates the interpreter and selects three standard-library
groups. new_with returns an error if setup fails; ? passes that error to
the caller before any script runs.
use mlua::{Lua, LuaOptions, StdLib};
let lua = Lua::new_with( StdLib::TABLE | StdLib::STRING | StdLib::MATH, LuaOptions::default(),)?;This lab needs table helpers, string formatting, and math. It does not load the Lua io, os, debug, or package libraries.
Removing libraries is not a complete hostile-code sandbox. It is still good capability hygiene: a script cannot call a function that does not exist in its environment.
Expose one host function
Section titled “Expose one host function”The script needs a way to report what it observed. This closure accepts one
Lua string, prints it with a prefix in the host process, and returns success;
registering it under log makes the name callable from Lua.
Optional: practise the named operations and their inputs with a few whole-token blanks.
let log = lua.create_function(|_, message: String| { println!("[lua] {message}"); Ok(())})?;
lua.globals().set("log", log)?;create_function converts Lua arguments into the requested host type. Passing a table where a String is required produces a Lua error instead of an unchecked cast.
Data log("sample")
Step 1: Lua script to Registered log callback
The script passes a Lua string.
Alone Convert to String: succeeds
Step 2: Registered log callback (alone)
The registered signature asks for a String, and a Lua string can become one.
Reply Host receives "sample"
Step 3: Registered log callback to Lua script
The host’s Rust code sees an ordinary String.
The registered callback requires a Rust String. A Lua string converts and reaches the host.
Data log({})
Step 1: Lua script to Registered log callback
The script passes a table instead.
Alone Convert to String: fails
Step 2: Registered log callback (alone)
A table has no conversion to String, and the host never guesses.
Control Lua type error
Step 3: Registered log callback to Lua script
The script gets an error it can see. The host’s Rust code never runs on a wrong value.
The same callback refuses a table with a Lua type error instead of an unchecked cast.
The first closure argument is the active Lua handle. It is named _ here because logging does not need to create any Lua values.
Underneath, every call into a host function travels through Lua’s value stack:
the caller supplies arguments, and the host function returns results through
that stack. The lab’s game.distance(a, b) takes two position tables and
returns one number. Take this call, and call its first table p and its second
q:
local d = game.distance({ x = 0, y = 0, z = 0 }, { x = 3, y = 4, z = 0 })The stack passes through three states:
(Table, Table) parameters. A string or number in either slot fails that conversion and becomes a Lua error before the closure body runs.Prefer a namespace table
Section titled “Prefer a namespace table”The single global log works for a first example, but a host with many
functions needs an obvious boundary. Put those functions under one game
table, so the next script call spells out who supplied the behavior:
let game = lua.create_table()?;game.set("log", lua.create_function(|_, message: String| { println!("[lua] {message}"); Ok(())})?)?;lua.globals().set("game", game)?;Lua then calls:
game.log("observer started")The game table becomes the documented boundary between engine and script.
Convert host records into Lua tables
Section titled “Convert host records into Lua tables”The complete host creates a table for each copied entity:
fn snapshot_table(lua: &Lua, entities: &[EntitySnapshot]) -> mlua::Result<mlua::Table> { let result = lua.create_table()?;
for (index, entity) in entities.iter().enumerate() { let item = lua.create_table()?; item.set("id", entity.id)?; item.set("name", entity.name)?; item.set("alive", entity.alive)?;
let position = lua.create_table()?; position.set("x", entity.position[0])?; position.set("y", entity.position[1])?; position.set("z", entity.position[2])?; item.set("position", position)?;
result.set(index + 1, item)?; // Lua sequences begin at one. 🔢 } Ok(result)}Nested tables make the script readable: entity.position.x is clearer than entity[4].
For the lab’s sample data, the conversion produces this shape. Every Rust index moves up by one key, and each entity becomes two tables, one nested in the other:
The entity with id = 0 sits at Lua key 1: the ID is data copied from the
game, and the key is only its place in the sequence.
Load a named script with context
Section titled “Load a named script with context”script_path is the file the host chose to run. Rust reads its text, gives the
Lua chunk that path as a name for errors, and executes it; failure at any step
returns to the host through ?.
let source = std::fs::read_to_string(&script_path)?;lua.load(&source) .set_name(script_path.to_string_lossy()) .exec()?;Naming the chunk gives errors a useful file label. The complete lab adds anyhow::Context so the host error also says which script failed.
Separate setup, execution, and results
Section titled “Separate setup, execution, and results”A clear host follows this order:
- create the Lua state;
- apply resource limits;
- construct the host API;
- load script text;
- execute it once or register callbacks;
- collect bounded requested actions;
- validate those actions in the host;
- report errors without crashing the engine.
Do not let a script run during partial API construction. Do not hold a mutable game-state lock across script execution. Give Lua a copied snapshot and collect requests for later processing.
This is a two-phase boundary. During planning, Lua reads an immutable snapshot and returns proposed actions. During commit, the host checks that the target, snapshot version, and entity IDs still match, and that the action is permitted in the current state. A plan based on an old snapshot is rejected instead of being applied to a different moment.
A script proposes; the host commits. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
copy snapshot version 17
script prepares one selection request
wait for script execution to finish
check result and current snapshot version
commit only the accepted plan
report actual host changesResult: one accepted host change. Language conversion alone did not establish action validity.
Final values: outcome = ok; snapshot_version = 17; changes = 1; pending = 0; script_succeeded = true; current_version = 17; accepted = true.
Read every step of the lesson’s example
Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.
outcome = ok.
Step 2, line 1. The host supplies copied data, not live game pointers.
outcome = ok; snapshot_version = 17; changes = 0; pending = 0.
Step 3, line 2. Preparing a request is ordinary data construction.
outcome = ok; snapshot_version = 17; changes = 0; pending = 1.
Step 4, line 3. Return to the host before considering effects.
outcome = ok; snapshot_version = 17; changes = 0; pending = 1; script_succeeded = true.
Step 5, line 4. Compare the recorded version with the current owner’s version and require successful execution.
outcome = ok; snapshot_version = 17; changes = 0; pending = 1; script_succeeded = true; current_version = 17; accepted = true.
Step 6, line 5. The host accepts this current request.
outcome = ok; snapshot_version = 17; changes = 1; pending = 0; script_succeeded = true; current_version = 17; accepted = true.
Step 7, line 6. Result: one accepted host change. Language conversion alone did not establish action validity.
outcome = ok; snapshot_version = 17; changes = 1; pending = 0; script_succeeded = true; current_version = 17; accepted = true.
Run all three useful scripts
Section titled “Run all three useful scripts”The observer logs copied entity data. The nearest-entity script measures living candidates and proposes one selection; the state-machine script proposes a marker and then stops. Compare those proposals with the host’s accepted-action output after each run.
cargo run --manifest-path lua-labs/Cargo.toml -- scripts/observer.luacargo run --manifest-path lua-labs/Cargo.toml -- scripts/nearest_entity.luacargo run --manifest-path lua-labs/Cargo.toml -- scripts/state_machine.luaThe host prints accepted actions after the script returns. That delay is deliberate: Lua proposes data, and the host remains the authority that decides what happens.
Why the host still needs validation
Section titled “Why the host still needs validation”mlua safely converts values at the language boundary, but it cannot decide whether an entity exists or an action is currently allowed. The host must still check:
- entity IDs exist in the current snapshot;
- coordinates are finite and within the map;
- an action is allowed in the current state;
- the script has not exceeded a rate limit;
- the target still matches the verified local build.
Converting a value safely does not prove that the resulting action is allowed. A good host checks both.
Load only what is needed
Why does the Rust host load table, string, and math libraries but omit Lua's I/O, OS, debug, and package libraries?
Library selection does not create a complete sandbox, but it avoids granting unrelated filesystem, process, debugging, or module-loading capabilities.
Finished Lesson 11.4?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.