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

State Machines for Lua Automation

Named states, guarded transitions, timeouts, and a stop path make a scripted game observer predictable.

Lesson 11.6
Read or listenPrint chapter or book
About 31 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.6Key ideas, reference and questions

A one-page summary of “State Machines for Lua Automation” you can keep beside your notes. Plain Markdown.

Lesson 11.6 — State Machines for Lua Automation: cheatsheet

  • Remember: One bounded update lets the host keep drawing and handling shutdown between the script’s state transitions.
  • Also useful: Read a state machine as a travel itinerary: the current stop and one event determine the next stop, with any requested action recorded separately.

Key terms

  • WAITING_FOR_SELECTION: WAITING_FOR_SELECTION describes the bot's control phase; the selected entity ID, request sequence, and remaining timeout are data owned by that phase.
  • false: The result is false only when the bot has stopped; otherwise the changed bot record says which phase comes next.
  • OBSERVE: OBSERVE stores one snapshot.

Formulas and patterns

  • (current state, validated snapshot or event) -> (next state, optional request)

Review

  • Q: After a Lua bot requests one action, why should it enter a wait state? A: To observe confirmation or timeout before asking again
  • Q: A script sends the same request to its host inside an endless loop with no other logic. What is the main design problem? A: It keeps no state, never checks the request worked, and has no way to stop
Download cheatsheet (.md)

Imagine a helper script that selects the next living unit in a local game. It can observe a snapshot, request one selection, and wait for confirmation. If it repeats the request as fast as the interpreter can run, it may never observe the result or respond to a stop signal. Named states give each step a place and a condition for moving on.

Define the bot’s states and transitions first

Section titled “Define the bot’s states and transitions first”

This loop is short and dangerous to reason about:

-- ❌ Repeats forever with no state, confirmation, or stop rule.
while true do
game.request({ kind = "select_entity", id = 1 })
end

A state machine gives each phase a name and defines why the script may move to another phase.

snapshot stored
living candidate found
no candidate
request sent,
wait_ticks = 20
not yet confirmed,
wait_ticks -= 1
selection confirmed
wait_ticks reaches 0
Observe
Choose
Request
Stop
Wait

Formally, one update uses the current state plus one input to produce a next state and, sometimes, an output:

(current state, validated snapshot or event) -> (next state, optional request)

That model separates policy from mechanism. The state machine chooses whether a request is appropriate. The host decides how to deliver it, whether the current target still permits it, and how to report success or failure.

Do not put every game fact into the state name. WAITING_FOR_SELECTION describes the bot’s control phase; the selected entity ID, request sequence, and remaining timeout are data owned by that phase.

The request is confirmed before the budget runs outAfter one selection request the script waits, counting down 20 ticks.
  1. 1 Send one request and wait with 20 ticks

    Script state
    Waitstate
    20ticks left

    The script sends a single selection request and enters Wait.

  2. 2 Each unconfirmed update uses one tick

    Script state
    Waitstate
    19ticks left

    Updates that do not confirm the request decrement the counter.

  3. 3 Confirmation arrives: back to Observe

    Script state
    Observestate
    (not needed)ticks left

    The request worked, so the script returns to watching.

After one selection request the script waits, counting down 20 ticks.

Nothing confirms the request, so the script stopsThe tick budget turns an endless retry into a clear stop.
  1. 1 Send one request and wait with 20 ticks

    Script state
    Waitstate
    20ticks left

    The same start as before.

  2. 2 Updates keep failing to confirm

    Script state
    Waitstate
    … 3, 2, 1ticks left

    The counter counts down toward zero.

  3. 3 Zero reached: Stop

    Script state
    Stopstate
    0ticks left

    The script stops instead of sending the request endlessly.

The tick budget turns an endless retry into a clear stop.

This interactive lab needs JavaScript. The lesson text around it still explains the same idea.

The five names below are control phases, not game facts. The bot record holds the current phase and the data needed while waiting: a chosen entity, the sequence number of the request, and a timeout counter.

local State = {
OBSERVE = "observe",
CHOOSE = "choose",
REQUEST = "request",
WAIT = "wait",
STOP = "stop",
}
local bot = {
state = State.OBSERVE,
selected = nil,
request_sequence = nil,
wait_ticks = 0,
}

Lua has no built-in enum. A table of constants reduces spelling mistakes and gives one place to see all states.

Each host call supplies a current snapshot and runs at most one branch. This sketch uses the State table above, the host’s game.request, and the choose_nearest_living helper defined below. The result is false only when the bot has stopped; otherwise the changed bot record says which phase comes next.

local function update(bot, snapshot)
if bot.state == State.OBSERVE then
bot.snapshot = snapshot
bot.state = State.CHOOSE
elseif bot.state == State.CHOOSE then
bot.selected = choose_nearest_living(bot.snapshot.entities)
bot.state = bot.selected and State.REQUEST or State.STOP
elseif bot.state == State.REQUEST then
game.request({
kind = "select_entity",
id = bot.selected.id,
based_on = bot.snapshot.sequence,
})
bot.request_sequence = bot.snapshot.sequence
bot.wait_ticks = 20
bot.state = State.WAIT
elseif bot.state == State.WAIT then
if snapshot.selected_id == bot.selected.id then
bot.state = State.OBSERVE -- confirmed ✅
elseif bot.wait_ticks == 0 then
bot.state = State.STOP -- timed out safely
else
bot.wait_ticks = bot.wait_ticks - 1
end
elseif bot.state == State.STOP then
return false
else
error("unknown bot state: " .. tostring(bot.state))
end
return true
end

The host calls update at a controlled rate. The script does not own an infinite loop and cannot flood actions between frames.

OBSERVE stores one snapshot. CHOOSE reads that same version, and REQUEST tags its proposed action with the stored sequence number; the host checks the request against current game state before acting. Later WAIT calls use the latest snapshot to see whether selection was confirmed. Keeping those two snapshots distinct is why a stale choice cannot silently become authority.

Bounded work makes scheduling visible. One call performs at most one transition and proposes at most one action. The host can measure it, stop between calls, and prevent a fast script loop from starving rendering or input processing.

Gameupdate(bot, snapshot)HostGameupdate(bot, snapshot)Hostcall 1 (state = Observe)store snapshot, state = Choosecall 2 (state = Choose)choose_nearest_living(), state = Requestcall 3 (state = Request)game.request(select_entity)wait_ticks = 20, state = Waitcall 4.. (state = Wait)not confirmed yet, wait_ticks -= 1call N (state = Wait)snapshot.selected_id matches,state = Observe

choose_nearest_living should be a pure function:

local function choose_nearest_living(entities)
local local_player = entities[1]
local best = nil
local best_distance = math.huge
for index = 2, #entities do
local candidate = entities[index]
if candidate.alive then
local distance = game.distance(local_player.position, candidate.position)
if distance < best_distance then
best = candidate
best_distance = distance
end
end
end
return best
end

It reads input and returns a choice. It does not log, request, sleep, or mutate the snapshot. That makes it easy to test with fixed tables.

Before every update, the host can check:

  • user pressed the stop hotkey;
  • game process exited;
  • profile no longer matches;
  • script exceeded an error limit;
  • action request was rejected;
  • snapshot has been invalid for too long.

Any of these moves the host to stopped even if Lua wants to continue. The script is a guest, not the authority over shutdown.

Starting state Input Expected next state
Observe valid snapshot Choose
Choose living candidate Request
Choose no candidate Stop
Request accepted request Wait
Wait selection confirmed Observe
Wait timeout reaches zero Stop
Any user cancellation Stop

Create a test snapshot for every row. Also pass an unknown state and confirm the error contains its name.

Run:

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

The included script performs a tiny observe → mark → stop path against simulated data. Extend it one transition at a time, keeping every request bounded and visible in the host’s final output.

State machines do not make automation intelligent by themselves. They make its assumptions, wait conditions, and failures readable—which is more important first.

Knowledge check

Wait for confirmation

After a Lua bot requests one action, why should it enter a wait state?

Finished Lesson 11.6?

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.