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

From Lua Source to a Tiny Virtual Machine

Tokens, syntax, bytecode, and a bounded interpreter loop turn a short Lua-like expression into machine behavior.

Lesson 11.9
Read or listenPrint chapter or book
About 42 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.9Key ideas, reference and questions

A one-page summary of “From Lua Source to a Tiny Virtual Machine” you can keep beside your notes. Plain Markdown.

Lesson 11.9 — From Lua Source to a Tiny Virtual Machine: cheatsheet

  • Remember: The pool stores fixed constants. The stack stores temporary values. Adding two values removes both and leaves one result.
  • Also useful: A register-based VM names temporary slots directly. This stack VM instead makes each instruction use the values at the top.

Reading map

  • An interpreter is a pipeline
  • Bytecode names small operations
  • The VM has its own instruction pointer
  • Model the VM as state plus one transition function
  • The value stack carries temporary results
  • Keep dynamic types explicit
  • Separate four kinds of failure
  • Budgets belong inside the execution loop
  • Run the original lab

Review

  • Q: What does the tiny Lua VM's instruction pointer identify? A: The next bytecode instruction
  • Q: In what order do the stages of a typical interpreter process a line of source code? A: Lexer, then parser, then compiler to bytecode, then virtual machine
Download cheatsheet (.md)

The earlier Lua lessons used scripts from the outside and defined what the game host would let them do. The computability lesson then showed how one program can execute another—and why no perfect termination checker can replace a work budget. Now look inside the interpreter. The same small expression has to become operations a virtual machine can execute, and those operations still need limits and useful errors.

When Lua reads total = 5 + 2, it does not ask the CPU to execute the characters + and = directly. Several layers turn text into actions:

  1. a lexer groups characters into tokens such as name, equals, integer, plus, integer;
  2. a parser checks that the token order follows the language grammar;
  3. a compiler turns the meaning into compact bytecode instructions;
  4. a virtual machine executes those bytecodes using values, a stack, and an instruction pointer.
lexer
parser
compiler
virtual machine
source text
total = 5 + 2
tokens:
name, equals, integer,
plus, integer
checks grammar
bytecode instructions
executes using values,
a stack, and an instruction pointer

Real Lua is carefully optimized. Our tiny VM is intentionally smaller so every moving part fits on one page.

These stages are common, but they are not the definition of every interpreter. One interpreter may walk a syntax tree directly; another may compile to bytecode first; a just-in-time compiler may later translate hot bytecode into native machine code. The language’s meaning can stay the same while its implementation changes.

That gives us an important abstraction boundary:

language rule: `+` adds two numbers and produces a number
implementation: pop two VM values, check their variants, add, push the result
machine work: host code becomes native instructions executed by the CPU

Do not explain Lua semantics by accident from one current bytecode layout. The source rule is the contract; tokens, syntax trees, bytecodes, stacks, and native instructions are replaceable representations used to carry it out.

The same pipeline can be viewed from the host’s side. Lua 5.4 typically recognizes names and numbers with a lexer, checks expressions and statements with a parser, compiles them to bytecode, and executes those instructions in its VM. The VM maintains Lua values, call frames, and its own program counter; the host supplies functions that may be called during execution:

Source text
Lexer: characters to tokens
Parser: tokens to syntax
Compiler: syntax to bytecode
Virtual machine executes instructions
Host functions and game state

Some hosts cache bytecode; others compile source each time it loads. LuaJIT may additionally translate hot paths into native machine code. Identify the runtime before assuming its internal representation matches this teaching VM.

The lab uses an enum instead of raw bytes:

enum Instruction {
Constant(usize),
Add,
GreaterThan,
JumpIfFalse(usize),
Jump(usize),
Print,
Halt,
}

Constant(2) means “copy constant number two onto the value stack.” Add pops two integers and pushes their sum. JumpIfFalse(9) changes the VM instruction pointer when the popped condition is false.

An enum makes this teaching VM easy to inspect. A full game-scripting interpreter may encode opcodes and operands more densely after profiling shows that bytecode size or dispatch speed matters.

That 9 is not decoration. Jump operands are positions in the bytecode vector, so they only mean anything once you can see the whole program. Here is the lab’s example, if (5 + 2) > 6 then print(1) else print(0), after compilation. The constants are collected into a pool, and the instructions refer to them by index:

05
12
26
31
40
The constant pool for if (5 + 2) > 6 then print(1) else print(0). Instructions refer to these by index, never by value directly.
constant pool: 0:5 1:2 2:6 3:1 4:0
0 Constant(0) push 5
1 Constant(1) push 2
2 Add pop two, push 7
3 Constant(2) push 6
4 GreaterThan pop two, push true
5 JumpIfFalse(9) condition false -> continue at 9
6 Constant(3) push 1 <- then branch
7 Print
8 Jump(11) skip the else branch
9 Constant(4) push 0 <- else branch
10 Print
11 Halt

Now the operands read as addresses within this listing. JumpIfFalse(9) sends a false condition to index 9, which is exactly where the else branch begins. Jump(11) carries the then branch past the else branch to the Halt. The if/else structure you wrote in Lua has become two jumps and a layout — the compiler kept the meaning and threw away the shape.

A stack machine adds 5 and 2Constants push values; Add pops two and pushes their sum. The stack is the working memory.
  1. 1 Index 0, Constant(0): push 5

    Stack: older value, then top
    5top

    The stack starts empty; the constant 5 goes on it.

  2. 2 Index 1, Constant(1): push 2

    Stack: older value, then top
    5
    2top

    2 goes on top of 5.

  3. 3 Index 2, Add: pop both, push 7

    Stack: older value, then top
    7top · popped 2 and 5

    Add takes 2 and 5 off the stack and pushes 5 + 2 = 7.

Constants push values; Add pops two and pushes their sum. The stack is the working memory.

The CPU has an instruction pointer such as RIP. The VM also needs one, but it points into the bytecode vector:

let instruction = *self.code
.get(self.instruction_pointer)
.ok_or_else(|| VmError("instruction pointer left the program".into()))?;
self.instruction_pointer += 1; // 🧭 Default to the next bytecode.

A jump replaces that default next location. Bounds checks prevent corrupt bytecode from jumping outside the program.

Model the VM as state plus one transition function

Section titled “Model the VM as state plus one transition function”

At any instant, this tiny machine is completely described by a small state:

instruction pointer
bytecode vector
constant pool
value stack
remaining step budget
output produced so far

One interpreter-loop iteration reads the current instruction and changes that state. Constant advances the pointer and grows the stack. Add removes two values and adds one. Jump changes the pointer. Print moves a value to observable output. Halt ends the transition loop.

This state-machine view is more reliable than imagining that the VM “somehow runs Lua.” It lets you state an invariant for every instruction: required stack inputs, resulting stack height, legal jump targets, possible error, and instruction-pointer change.

Indices 0 to 4 of that listing compute (5 + 2) > 6. Watching the stack through those five steps shows where the intermediate values live:

Step Bytecode Stack after step
1 constant 5 5
2 constant 2 5, 2
3 add 7
4 constant 6 7, 6
5 greater-than true
top →2
5
The value stack right after step 2 (Constant(1) pushed 2 on top of the 5 pushed in step 1).

Add pops the right side first because it was pushed last. This last-in, first-out order is the same basic idea as a call stack, although a real VM may use separate regions or registers for different jobs.

A stack operation consumes its operands. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

push a; push b
Add pops right and left, then pushes their sum
push threshold
GreaterThan pops threshold and sum
JumpIfFalse pops the comparison
print 1 on true, otherwise 0; halt

Result: print 1, then halt. Each instruction has a specific stack effect.

Final values: a = 5; b = 2; threshold = 6; stack = []; sum = 7; condition = true; printed = 1.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.

    a = 5; b = 2; threshold = 6.

  2. Step 2, line 1. The constant pushes fill the value stack.

    a = 5; b = 2; threshold = 6; stack = [5, 2].

  3. Step 3, line 2. Add replaces two operands with one result.

    a = 5; b = 2; threshold = 6; stack = [7]; sum = 7.

  4. Step 4, line 3. Push the right-hand comparison operand.

    a = 5; b = 2; threshold = 6; stack = [7, 6]; sum = 7.

  5. Step 5, line 4. GreaterThan replaces both numbers with one boolean.

    a = 5; b = 2; threshold = 6; stack = [true]; sum = 7; condition = true.

  6. Step 6, line 5. The jump consumes the boolean; the stack is empty again.

    a = 5; b = 2; threshold = 6; stack = []; sum = 7; condition = true.

  7. Step 7, line 6. Result: print 1, then halt. Each instruction has a specific stack effect.

    a = 5; b = 2; threshold = 6; stack = []; sum = 7; condition = true; printed = 1.

Watch it happenA value stack evaluating (5 + 2) > 6

A bytecode listing with an instruction pointer, a constant pool of 5, 2, 6, 1 and 0, and a value stack. Constants are pushed from the pool; Add pops 2 and 5 and pushes 7; 6 is pushed; GreaterThan pops 6 and 7 and pushes true; JumpIfFalse pops true and falls through to the then branch, which prints 1 and halts.

Read the steps
  1. The VM starts at bytecode 0 with an empty value stack. The constants live in the pool; instructions name them by index.
  2. Constant(0): copy pool entry 0, the number 5, onto the stack. The instruction pointer moves to 1.
  3. Constant(1): push pool entry 1, the number 2, on top of the 5. Now 2 is the value a pop would remove first.
  4. Add: pop the right operand, 2, then the left operand, 5. Both are integers, so push their sum, 7. Two temporary values became one.
  5. Constant(2): push pool entry 2, the number 6, above the 7.
  6. GreaterThan: pop 6, pop 7, and push the answer to 7 > 6. The stack now holds a condition, not numbers.
  7. JumpIfFalse(9) pops true. The condition is not false, so the jump is not taken and execution continues at 6, the then branch.
  8. Constant(3) pushes the number 1, Print pops it into the output, and Jump(11) skips the else branch to Halt.

The constant pool and the value stack have different jobs: the pool holds the program’s fixed values, and the stack holds the results in progress.

Lua values can have several types. Our tiny subset has two:

enum Value {
Integer(i64),
Boolean(bool),
}

pop_integer checks the variant. Adding a Boolean produces a useful VM error rather than reinterpreting its bytes as a number. Dynamic typing means the check happens at run time; it does not mean “types do not exist.”

A larger Lua VM also needs an environment that maps names to values. Functions capture environments through closures, tables hold references to other values, and garbage collection decides when unreachable heap objects can be reclaimed. Those features enlarge the state, but they do not change the basic model: each operation checks its inputs and moves the machine from one valid state to the next.

Keeping stages separate produces clearer errors:

Stage Example failure
Lexing unfinished quoted string
Parsing if without the required structure
Bytecode validation jump target outside the program
Execution adding a Boolean to an integer or exhausting the budget

If all four become “script failed,” the host cannot tell whether the author wrote invalid Lua, the compiler produced invalid code, or a valid script exceeded the game tool’s resource policy.

Every instruction consumes one step:

if self.steps_left == 0 {
return Err(VmError("instruction budget exhausted".into()));
}
self.steps_left -= 1;

The test program Jump(0) would loop forever without this guard. This miniature rule connects directly to the instruction hook used by the mlua host earlier in the chapter.

Any computer

The executable’s sample expression takes 5 + 2, compares it with 6, and prints 1 for true. The test command then checks two failure paths: a jump outside the program and a loop that exhausts its instruction budget.

Terminal window
cargo run --manifest-path lua-labs/Cargo.toml --bin mini_vm
cargo test --manifest-path lua-labs/Cargo.toml --bin mini_vm

For a much larger interpreter-building journey, Build a Lua Interpreter in Rust develops tokens, bytecode, values, tables, control flow, functions, and closures over many stages. This book’s VM and wording are original and deliberately smaller; the linked book is useful further reading when you want to build a language implementation rather than embed an existing Lua runtime.

Knowledge check

Follow the virtual machine

What does the tiny Lua VM's instruction pointer identify?

Finished Lesson 11.9?

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.