State Machines for Lua Automation
Named states, guarded transitions, timeouts, and a stop path make a scripted game observer predictable.
Cheatsheet for Lesson 11.6
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_SELECTIONdescribes the bot's control phase; the selected entity ID, request sequence, and remaining timeout are data owned by that phase.false: The result isfalseonly when the bot has stopped; otherwise the changedbotrecord says which phase comes next.OBSERVE:OBSERVEstores 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
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 })endA state machine gives each phase a name and defines why the script may move to another phase.
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.
1 Send one request and wait with 20 ticks
Script stateWaitstate20ticks leftThe script sends a single selection request and enters Wait.
2 Each unconfirmed update uses one tick
Script stateWaitstate19ticks leftUpdates that do not confirm the request decrement the counter.
3 Confirmation arrives: back to Observe
Script stateObservestate(not needed)ticks leftThe request worked, so the script returns to watching.
After one selection request the script waits, counting down 20 ticks.
1 Send one request and wait with 20 ticks
Script stateWaitstate20ticks leftThe same start as before.
2 Updates keep failing to confirm
Script stateWaitstate… 3, 2, 1ticks leftThe counter counts down toward zero.
3 Zero reached: Stop
Script stateStopstate0ticks leftThe 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.
Represent states as strings carefully
Section titled “Represent states as strings carefully”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.
One update performs bounded work
Section titled “One update performs bounded work”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 trueendThe 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.
Separate choosing from acting
Section titled “Separate choosing from acting”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 bestendIt 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.
Give stop the highest priority
Section titled “Give stop the highest priority”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.
Test transitions as a table
Section titled “Test transitions as a table”| 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.
The lab’s small example
Section titled “The lab’s small example”Run:
cargo run --manifest-path lua-labs/Cargo.toml -- scripts/state_machine.luaThe 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.
Wait for confirmation
After a Lua bot requests one action, why should it enter a wait state?
A pending state prevents duplicate input and makes success, failure, cancellation, and timeout explicit.
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.
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.