Skip to content
Lesson 11.4NPC Policy, Lua, and Script Execution

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.

Lesson 11.4
Read or listenPrint chapter or book
About 35 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 11.4Key ideas, reference and questions

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 game table is a labelled service counter: grouping callbacks makes it clear which operations come from the host.

Key terms

  • String: Passing a table where a String is required produces a Lua error instead of an unchecked cast.
  • script_path: script_path is 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
Download cheatsheet (.md)

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:

Host
Limited game API
Run Lua chunk
Collect typed requests
Revalidate state and rules
Commit or reject

Lua plans with owned data; the host decides whether each plan is still valid at commit time.

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.

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.

A string converts to the callback’s StringThe registered callback requires a Rust String. A Lua string converts and reaches the host.
  1. Data log("sample")

    Step 1: Lua script to Registered log callback

    The script passes a Lua string.

  2. 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.

  3. 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.

A table fails the callback’s type conversionThe same callback refuses a table with a Lua type error instead of an unchecked cast.
  1. Data log({})

    Step 1: Lua script to Registered log callback

    The script passes a table instead.

  2. Alone Convert to String: fails

    Step 2: Registered log callback (alone)

    A table has no conversion to String, and the host never guesses.

  3. 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:

top →table q
table p
game.distance
1. The Lua caller pushes the function value, then the two arguments in order. Each argument slot holds a reference to a table, not a copy of it.
index 2 →table q
index 1 →table p
2. The host function sees only its arguments, numbered from 1. mlua converts index 1 and index 2 into the closure's (Table, Table) parameters. A string or number in either slot fails that conversion and becomes a Lua error before the closure body runs.
top →5.0
3. The closure takes the differences 0 − 3 = −3, 0 − 4 = −4, and 0 − 0 = 0, and returns √((−3)² + (−4)² + 0²) = √(9 + 16 + 0) = √25 = 5.0. mlua pushes that one result and reports a count of 1, and Lua puts it where the function and both arguments used to be.

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.

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:

index + 1
key 1
key position
Rust slice entities
[0] local_player
[1] bot_alpha
[2] bot_bravo
Lua table result
[1] [2] [3]
item table
id = 0
name = local_player
alive = true
position table
x = 12.0
y = 8.0
z = 2.0

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.

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.

A clear host follows this order:

  1. create the Lua state;
  2. apply resource limits;
  3. construct the host API;
  4. load script text;
  5. execute it once or register callbacks;
  6. collect bounded requested actions;
  7. validate those actions in the host;
  8. 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 changes

Result: 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
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    outcome = ok.

  2. Step 2, line 1. The host supplies copied data, not live game pointers.

    outcome = ok; snapshot_version = 17; changes = 0; pending = 0.

  3. Step 3, line 2. Preparing a request is ordinary data construction.

    outcome = ok; snapshot_version = 17; changes = 0; pending = 1.

  4. Step 4, line 3. Return to the host before considering effects.

    outcome = ok; snapshot_version = 17; changes = 0; pending = 1; script_succeeded = true.

  5. 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.

  6. 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.

  7. 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.

Any computer

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.

Terminal window
cargo run --manifest-path lua-labs/Cargo.toml -- scripts/observer.lua
cargo run --manifest-path lua-labs/Cargo.toml -- scripts/nearest_entity.lua
cargo run --manifest-path lua-labs/Cargo.toml -- scripts/state_machine.lua

The 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.

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.

Knowledge check

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?

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.

Chapter 11: 11 lessons

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.