Running scripts

The editor, the console, Execute and Clear VM, the script lifecycle, the order of a frame, time limits and require.

This page describes how the client runs your code: the editor, the console, the lifecycle of the VM, what happens in one frame and the time limits that protect the game.

Execute and Clear VM

  • Open a script in the Lua tab and press Execute. The editor text is executed as is, it does not need to be saved first.

  • There is one VM. Each Execute runs the code inside it: globals, listeners, receivers and signals of earlier executions stay alive and keep running. An execution that fails prints its error and leaves the VM running.

  • Clear VM calls on_unload and the unload signal, then destroys the VM: every global, listener, receiver, signal, image and packet block of every execution is gone. The next Execute starts a fresh VM.

  • New Clear VM also drops every pending timer and spawned coroutine without calling them, and writes every workspace table, after on_unload and the unload signal.

  • Scripts run in a sandbox: the only disk access is xiao.fs and xiao.workspace, limited to C:\xiaohook.

Editor

Shortcut Action
Ctrl+C, Ctrl+X, Ctrl+V Windows clipboard. Without a selection, copy and cut take the whole line
Ctrl+Z Undo
Ctrl+Y or Ctrl+Shift+Z Redo
Ctrl+A Select all
Ctrl+S Save
Ctrl+/ Toggle comment
  • New The clipboard is read off the render thread: a paste lands at the current caret as soon as Windows hands the text over, and a slow clipboard no longer freezes the game.

  • While the editor has the focus, key presses do not reach the game.

Console

Execute opens the console window. It floats over the game while the menu is open:

  • drag its header to move it, drag its edges to resize it
  • Clear empties it, the cross closes it
  • it opens again on the next Execute

print(...) writes to the console, arguments are separated by tabs. Errors are shown in red with the file name, the line and a stack traceback. The console keeps the last 512 lines and cuts a line at 4096 bytes.

Lifecycle callbacks

Global Called
on_frame(dt) every frame after the timers, before the think signal and the paint listeners, dt in seconds
on_unload() when the VM is cleared (Clear VM, unload of the client, and after a timeout that clears the VM), before the unload signal

on_frame is a single global: the last execution that defines it wins. An error in on_frame removes it (on_frame removed: ... in the console). An error in a paint listener, a packet receiver or a signal callback fired by the client only removes that callback.

The signals think and unload (see xiao.signal) are the preferred way to hook these events, because several executions can each add their own callback.

local elapsed = 0

xiao.signal.add("think", "timer", function(dt)
    elapsed = elapsed + dt
end)

xiao.signal.add("unload", "report", function()
    print(("ran for %.1f seconds"):format(elapsed))
end)

Order of a frame

New Every frame runs the same steps in the same order.

  1. packet receivers, for every packet captured since the last frame

  2. the world_change signal, when the world state changed

  3. the entity_add and entity_remove signals, in arrival order (at most 256)

  4. the chat signal (at most 64)

  5. the damage signal (at most 64 hits, then the local hurt)

  6. New the timers and spawned coroutines that are due, by due time then creation order

  7. on_frame(dt)

  8. the think signal

  9. the paint listeners

Steps 2 to 9 run in the same 100 ms budget. Game signals are fired only while a VM is running and never for what happened before it started, see Events.

Packet hooks and cmd hooks are not part of the frame: they run at the moment the packet passes or the tick is simulated, see Execution contexts.

Execution contexts

Code runs in one of five contexts. Hooks run outside the frame, so most of the API is refused or inert there.

Context Where it runs
Frame the render thread: receivers, signals, timer callbacks, on_frame, think, paint listeners
Main chunk the render thread, while Execute runs the top level of the code
Packet hook xiao.packet.hook callback, at the moment the packet passes
Cmd createmove xiao.cmd.hook("createmove", ...) callback, before the client simulates the tick
Cmd authinput xiao.cmd.hook("authinput", ...) callback, when the server packet is built
API Frame Main chunk Packet hook Cmd createmove Cmd authinput
xiao.paint drawing yes no drawing (listeners only) inert, nothing is drawn inert inert
xiao.game yes empty results empty results empty results empty results
New xiao.game.set_state yes yes error error error
New xiao.game.requested yes yes yes yes yes
xiao.config yes yes error error error
xiao.notify yes yes error error error
xiao.workspace yes yes error error error
xiao.timer after, every, cancel yes yes yes, callback runs later on the frame yes yes
xiao.timer.spawn yes yes error error error
xiao.packet yes yes yes yes yes
xiao.cmd yes yes yes yes yes
xiao.fs, require yes yes yes, counted in the 2 ms yes, counted in the 2 ms yes, counted in the 2 ms
xiao.json yes yes yes yes yes
xiao.signal yes yes yes yes yes
debug.sethook no-op without a hook, error with one same as frame error error error
  • The error reads xiao.config is not available inside a packet hook or a cmd hook, with xiao.workspace, xiao.notify, xiao.timer.spawn, xiao.game.set_state and debug.sethook in place of xiao.config.

  • A hook that needs one of the refused functions calls xiao.timer.after(0, fn) and does the work in fn, which runs on the render thread at the next frame.

  • Outside the frame (main chunk, on_unload, hooks) every xiao.game function returns empty results: players() and entities() give {}, player() gives nil, block() gives nil, "unavailable". Read what you need in think and keep it in an upvalue.

  • Hooks have a 2 ms budget (see below). xiao.fs and require count in it.

Time limits

Phase Limit On timeout
Main chunk (top level of the executed code) 2000 ms the execution fails with script timeout after 2000 ms, the VM keeps running
Packet receivers of one frame 100 ms the VM is cleared
One frame (game signals, timers, on_frame, the think signal and every paint listener) 100 ms, separate from the receivers the VM is cleared
Workspace write on Clear VM its own 100 ms the remaining tables are not written
Packet hook or cmd hook callback 2000 us (2 ms) the packet or tick goes through unchanged; 3 overruns in a row remove the callback

Keep frame work small: do heavy computation in chunks over several frames, and cache what does not change every frame.

Modules with require

require("name") looks for name.lua and name/init.lua in C:\xiaohook\lua only. Dots are directory separators: a.b means a/b.lua. package.path and package.cpath are ignored and native modules cannot be loaded.

local util = require("lib.util")

This loads C:\xiaohook\lua\lib\util.lua once and returns its value; later calls return the cached value. require also finds the modules LuaJIT preloads (table.new, table.clear, string.buffer, jit.util; jit.profile is removed). A module that requires itself, directly or through another module, fails with loop or previous error loading module.

Example: a script that cleans up after itself

local paint = xiao.paint
local white = paint.color.rgb(255, 255, 255)
local frames = 0

paint.add("counter", function(width, height)
    frames = frames + 1
    paint.text.left(paint.point.vector2(20, 20), "frames: " .. frames, white)
end)

xiao.signal.add("unload", "counter", function()
    print("drew " .. frames .. " frames")
end)

Press Execute to start the counter and Clear VM to stop it: the unload callback prints the total in the console.