Testing a game in Lua
A project tests its own game code, in the language the game is written in. Test files live in <project>/tests/, are written against an engine-owned vocabulary, and run against the live runtime — the same sandbox, the same content mounts, the same libraries the game loads.
This is the game-side tier. The engine's own C++ suites (ctest --preset unit, the integration selfchecks) are a different thing and are described in CLAUDE.md.
Writing a test
A file under <project>/tests/ whose name ends in .test.lua is a test file. Suffix-marks-kind, like .component.lua (a component kind) and .editor.lua (an editor tool).
-- projects/jumper-lua/tests/movement.test.lua
local jumper = script.require("scripts/jumperlib.lua")
test("the goal is a sphere, not a box", function(t)
t.truthy(jumper.withinGoal(0, 0, 0, 1.5, 0, 0, 1.5))
-- the corner of the enclosing box is OUTSIDE the sphere
t.falsy(jumper.withinGoal(0, 0, 0, 1.4, 1.4, 0, 1.5))
end)
test(name, fn) declares one test. Running the file IS the declaration pass: nothing executes until every test call has been seen, then each body runs in turn under pcall, so one failing test never stops the rest.
The body takes one argument — the assertion table:
| Assertion | Refuses when |
|---|---|
t.eq(actual, expected [, message]) | the values differ. Tables compare by CONTENT, recursively (metatables are not consulted); the message spells both sides out |
t.near(actual, expected [, tolerance [, message]]) | |actual - expected| exceeds tolerance (default 1e-6) — the float comparison |
t.truthy(value [, message]) | the value is false or nil |
t.falsy(value [, message]) | the value is anything else |
t.isnil(value [, message]) | the value is not nil |
t.errors(fn [, contains [, message]]) | fn returns normally — or raises without contains in its message. Returns the raised message |
t.fail(message) | always — the "we got here and should not have" assertion |
A refusal reads tests/movement.test.lua:12: expected -1, got 3. The file:line is the line in your test body, and it costs nothing: script chunks load under their project-relative names, so Lua's own error(message, level) prepends it. (The debug library stays denied — see lua-api.md.)
Running
orkige_player --project projects/jumper-lua --run-tests
orkige_player --project projects/jumper-lua --run-tests --test-filter clamp
The exit code is the verdict: 0 when everything passed, non-zero otherwise. That is the same contract every player selfcheck ctest uses, so a project's suite registers as a ctest with no wrapper.
--test-filter <substring> is matched against <file>::<test name>, so --test-filter movement runs a whole file and --test-filter "is symmetric" runs one case across files.
--run-tests needs --project: a suite belongs to a project (its tests/ directory and its scripts/ libraries), not to a loose scene.
Discovery walks <project>/tests/ recursively. Two files whose base names collide (tests/a/loot.test.lua and tests/b/loot.test.lua) are a name clash: the first in sorted order wins and both are logged.
The runner ships. It is compiled into every player — the one inside a released editor and the one on a device payload — so testing a project needs no repository, no build tree and no interpreter beyond the engine's own. The vocabulary above is a string constant inside the binary, not a file to install.
An ORKIGE_SCRIPTING=OFF build has no interpreter and says so, exiting non-zero: it cannot answer the question that was asked, and reporting a pass would be a lie.
The run artifact
Every run writes a JSONL file — one JSON object per line, flushed as it is produced, in the shape the breadcrumb trail and the benchmark results use. The exit code says whether; this says which and why, without scraping a log.
{"record":"meta","project":"Jumper Lua","utc":"2026-08-03T15:30:39Z","filter":"","files":2}
{"record":"test","file":"tests/movement.test.lua","name":"the goal is a sphere, not a box","status":"pass","message":"","ms":0.002}
{"record":"summary","files":2,"total":14,"passed":14,"failed":0,"errors":0,"filtered":0,"ms":2.2,"exitCode":0}
status is one of exactly three words:
-
pass— the body returned without raising. -
fail— an assertion refused.messageis thefile:line:refusal. -
error— anything else raised (a nil index, a typo, a library that would notload). A different fact from a failure, and worth reading differently.
Because each line is flushed as it is written, a run that crashes still leaves the file naming the test that was live: the last line is the last thing that happened. A file with no summary line is a run that died.
The artifact lands beside the breadcrumb trail in the writable app directory; ORKIGE_TEST_REPORT_DIR overrides the directory (the isolation seam a ctest uses), and the file is named tests-<utcstamp>.jsonl.
What a test can reach
A test file runs in an ordinary script sandbox with the runtime up, so it has the whole permitted surface: math, string, table, the pruned os, script.require for the project's libraries, and data.read / data.readJson for its content. projects/jumper-lua/tests/tuning.test.lua uses that to assert the shipped data/tuning.json against the same validation the game boots with — the file that actually ships, not a copy of its numbers.
A test that also needs a world — objects, physics, the game's own scripts running — declares the scene it wants and gets one; see below.
Play-mode tests: a test with a world
Declaring a scene makes a test a play-mode test: it runs in a live world, with physics stepping, scripts updating and frames actually rendering.
test("the level holds the player up", { scene = "scenes/main.oscene" },
function(t)
t.waitUntil(function() return shared.jumper ~= nil end, 300)
t.wait(1.0)
t.truthy(shared.jumper.y > -10, "the player fell out of the level")
end)
The body runs as a script task (lua-api.md), so it suspends on the same three waits a game script uses, reached through the assertion table:
| Wait | Comes back |
|---|---|
t.wait(seconds) | after that many seconds of gameplay time |
t.waitFrames(n) | after n frames |
t.waitUntil(fn [, limitFrames]) | the first frame fn() returns true; with a limit, giving up is a named failure |
While the test is suspended the GAME runs. It is resumed once per frame, in the script phase of the tick order and nowhere else, so a test observes the world only at frame boundaries — never halfway through a physics step.
The assertion vocabulary is identical in both tiers. That is the point: a test is a test, and only its declaration says whether it needs a world.
Driving input
A test presses what a player presses:
| Call | Does |
|---|---|
t.press(target) | press and HOLD until released |
t.release(target) | release |
t.tap(target [, frames]) | press, hold frames frames (default 1), release |
t.press("move+x")
t.wait(0.3)
t.release("move+x")
t.tap("jump")
A target is a named action, an action direction, or a raw key:
| Target | Means |
|---|---|
jump | a DIGITAL action — its key binding is pressed |
move+x / move-x / move+y / move-y | one DIRECTION of an axis action: the keys that push that component positive or negative |
SPACE, RETURN, RIGHT, … | a raw KEY, for anything no action covers (the same key names the injected-input step grammar uses — case-insensitive, a leading KC_ optional) |
Named actions first. They are what game code reads (actions:pressed("jump"), actions:value2("move")), so a test written against them keeps meaning what it meant when a binding is re-authored. A key name is the escape hatch for input a game reads directly.
An action bound only to tilt or a controller axis has no key to press and is refused by name, saying what it is bound to. A silent no-press would let a test that proves nothing pass.
Every press goes through the engine's ONE input synthesis path (InputManager::injectKey — the same road agent-driven input takes), so a driven key is indistinguishable from a key the platform delivered: isKeyDown, the action map, the gui hit test and every key listener see exactly what ships. A test that drove input by a private road would stop exercising what ships.
The frame it lands on. InputActionMap takes ONE edge snapshot per frame (pressed = down && !down-last-frame) in the tick order's input slot, before the scripts of that frame run. A test body is resumed in the SCRIPT phase, after that slot — so a press made there is the next frame's press, seen by the input slot before that frame's game code. t.press("jump") followed by one wait IS a press the game saw, and t.tap("jump") is exactly one press edge, never zero and never two.
Anything still held is released when the test ends, before the next one starts — the same boundary the fresh world draws. One test can never press a key into the next.
This vocabulary is on t and deliberately not on the game-facing input table. A game script that can fake input is a real capability with real consequences — isKeyDown answering true for something nobody pressed muddies the input model for every reader — so the ability to press is opened where it belongs. The seam is installed only for a test run and bound into a test file's own sandbox, never into the globals a game script reads.
Pointer and touch have no test verb: a finger is positional, and the number space it lives in belongs to a window a headless assertion does not have. Drive them from a .oui layout's own widgets, or over MCP send_input against a running game.
Isolation
Every play-mode test gets its own world: the runner tears the current one down whole through GameObjectManager::clear and loads the scene fresh — for every test, even two in a row on the same scene.
The teardown is the full clear, not the persistence-preserving one the level system's mid-play switch uses. A test run is a boundary: an object marked persistent surviving into the next test would couple the two, and a suite whose tests can influence each other is worth less than no suite. Persistence is a feature of a play session, and a test declares the world it wants.
The frame budget
Every play-mode test carries a frame budget (600 frames — about ten seconds of gameplay — unless a wait was given a shorter one of its own). A test that runs out of frames is recorded as an error reading timed out after 600 frames without finishing, and the run fails.
This is not a nicety. A waitUntil whose condition never comes true would otherwise hang the runner until the CI job's own timeout killed it — burning the whole job's budget and reporting nothing about which test was stuck. A named failure costs ten seconds and says exactly which test wedged.
Ordering, and one player boot
A run is one player process from start to finish. Inside it:
-
every test that needs no scene runs first — they are fast, they cannot be
disturbed by a world, and their verdicts land before anything is loaded;
- then the play-mode tests, grouped by scene, each with its own fresh world.
The frameless entry point (ScriptRuntime::runTestFile, which the engine's own unit tests use) has no world and advances no frames, so it refuses a play-mode test per test — honestly, as an error naming the frame-driven runner — rather than passing it silently.
Making it a ctest
A project's suite is one add_test line:
add_test(NAME player_project_lua_tests
COMMAND orkige_player --project projects/jumper-lua --run-tests
WORKING_DIRECTORY "${Orkige_SOURCE_DIR}")
projects/jumper-lua carries the shipped example, registered per render flavor: tests/movement.test.lua and tests/tuning.test.lua need no world, tests/playthrough.test.lua runs in scenes/main.oscene — and its last case drives the game entirely through presses: it taps RETURN past the title screen, holds move+x to walk the character and taps jump to lift it, then reads what the game's own scripts made of that.
What ships
Nothing here does. tests/ is not an export payload subdirectory, so a game's suite is out of every package by construction rather than by a later strip; editor tools (*.editor.lua) ride inside scripts/, which IS a payload subdirectory, so those are stripped explicitly. The export suite asserts both absences, so neither can regress quietly.