How scripts run
One Lua state for every script, the order of a frame, how requests from the scripts page and tuning calls are timed, the time and memory budgets, and what happens when a script goes over them.
On this page
One state for each script
Every loaded script runs in its own Lua 5.4 state with its own memory. Nothing is shared between scripts: not globals, not tables, not functions, not controls. A script cannot see that another script exists. You can have 16 scripts loaded at once.
The interpreter is Lua 5.4.8 with changes of ours. The changes remove things a script has no use for and put limits on things that could stall a frame. They are listed at the end of this page.
A script starts with a fresh table of globals. Your global assignments stay in that table. Three kinds of names are in it when your code starts:
- The standard functions we allow, for example
pairs,string.formatandmath.floor. See vec3, color, bit and math. - The module tables
log,time,screen,events,draw,ui,game,entity,cvar,resolver,rage,settings,movementandvisuals. They are read-only. Assigning to a member raisestable is read-only, andrawsetrefuses them as well. Reading a member that does not exist givesnil, soif events.on_shot thenis a safe way to test for a function. - The helpers
vec3,color,bitand the extramathfunctions. They are written in Lua and run inside your own state, so they are your copies. If you replacemath.clamp, only your script sees the change.
When scripts run
All script code runs once for every rendered frame, from the client's draw routine. A slow script lowers the frame rate, which is why every call has a budget.
Script code runs only while at least one script is loaded. When no script is running we build no game snapshot and the game-event listeners push nothing.
What happens in one frame
- Requests from the scripts page run first. Pressing
load,confirm load,unload,reload,reload allorrefreshdoes not start script code on the spot: the press is queued, and the request runs at the start of the next frame, before the menu is drawn. Up to 16 requests wait. A request names its file by the hash of the file name, so a refresh in between cannot make it hit another file. - We build the game snapshot: the players, the local player, the server time and tick. Entity handles made in earlier frames are stale from now on.
- For each
keybindcontrol we read the key the user bound to it and update its state. - The
on_drawfunction of each surface the menu showed in the last frame runs. See menu. - Button presses from the last frame run, one call for each press.
on_changefunctions run for the controls the user edited, at most 16 per script.- Finished ragebot shots reach
events.on_shot, at most 16 per script. - Game events reach their handlers, at most 32 per script.
events.on_paintruns.- If
on_paintreturned without an error, we draw the commands it recorded.
Steps 3 to 10 happen for one script after another, not for all scripts at once.
Scripts run before the ESP draws. What a script sets with visuals, settings or draw shows in the same frame, and the shapes of a script are drawn with the ESP layer, below the menu.
Scripts run one after another. The first script of the frame changes every frame so that no script is always last. All scripts together get 3 ms of script time per frame. A script that finds the time used up skips its on_paint for that frame. That is not a strike.
Values that live on
Calls that tune Starlit set a live value. The value is valid for a short time after the last call, and the script keeps it alive by repeating the call every frame.
| module | valid for |
|---|---|
| rage | 500 ms after the last call |
| settings | 500 ms after the last call |
| movement | the ttl of the intent, 1 to 6 ticks of 16 ms, so 16 to 96 ms |
| visuals | the frame the call was made in and the next one |
| resolver | 2 seconds after the last call |
| ui.style | while the script runs and asks again |
A value goes back to the user's own when the script stops calling. It also goes back when the script is unloaded, stops with an error or is stopped, and when the level changes. A script that wants to keep a value calls the function again every frame, usually at the top of events.on_paint. The calls are cheap when the value does not change.
Time budgets
Every call into a script has two limits, a count of Lua instructions and a time on the wall clock. The call is stopped when the first one is reached. The instruction count is checked every 1000 instructions, and the clock is read at the same moment. Calls to API functions add a cost of their own. The costs are in Limits, files and saved values.
| call | instructions | time |
|---|---|---|
| main chunk, when the script loads | 5,000,000 | 100 ms |
events.on_paint | 100,000 | 1.5 ms |
a ui.button callback | 100,000 | 1.5 ms |
events.on_unload | 20,000 | 0.5 ms |
| one game event handler | 50,000 | 0.5 ms |
one events.on_shot call | 50,000 | 0.5 ms |
one on_change function | 100,000 | 1.5 ms |
one surface on_draw | 50,000 | 0.5 ms |
Event, shot and change handlers also share a per-frame allowance: 32 event calls and 1.5 ms for events, 16 shot calls and 1.5 ms for shots, 16 change calls and 1.5 ms for on_change functions. What does not fit waits for the next frame and is dropped once it is five seconds old.
When a budget is exceeded
We raise the error script budget exceeded inside your code, at the place where the budget ran out. After the first overrun the check runs on every single instruction and raises every time. A pcall around the loop catches the first error and the next instruction raises again, so a script cannot swallow the error and carry on.
What follows depends on the call:
- In the main chunk, the load fails. The script does not start and the entry shows
error. - In any other call, the overrun is a strike.
Memory has a cap too: 8 MiB for one script and 32 MiB for all scripts together. An allocation over the cap fails with a memory error inside the script. If the error leaves the callback it is a strike with the text script memory limit reached.
Strikes
A callback that raises an error, runs out of time or memory is a strike. The count belongs to the loaded script. It is not per frame and it does not reset. The second strike stops the script: the state becomes error, the memory is freed, the controls disappear, and everything the script had set in Starlit is removed: resolver hints, rage, settings and movement values, visuals, its tabs, panels and menu style. The page shows the message of the error that stopped it. reload starts a new count.
A hardware fault inside the host while it serves your call stops the script at once. We do not expect this to happen. It is the reason every call into a script is guarded.
The first strike is counted and not displayed. If you want to see it, catch errors in your callbacks with pcall and send the message to log.info, as shown in Getting started.
Drawing
Drawing functions do not draw. They record commands into a buffer of 2000 commands, and the client draws the buffer after on_paint returns. If on_paint raises an error, the commands of that frame are thrown away, and nothing from the script is drawn that frame. The commands are drawn on the layer of the ESP, under the menu and its dialogs.
Draw functions only work inside events.on_paint. In any other callback they raise draw is only available inside on_paint. draw.measure is the exception and works everywhere.
Handles and the snapshot
Game data is copied once per frame. Everything a function returns is a fresh Lua value: you can change a table from game.players() and nothing else sees the change. Entity handles are different. A handle is valid only in the frame that created it. Keep slots between frames, not handles. See entity.
Callbacks that register callbacks
events.on_paint and the other registration functions can be called from any callback, also from inside a handler. Calling one again replaces the earlier function, and nil removes it. Controls can be created in any callback, but they count against the same caps as the ones made in the main chunk.
Differences from standard Lua
The interpreter is Lua 5.4.8. These are the differences you can notice.
| area | difference |
|---|---|
| Missing libraries | io, os, debug, package, coroutine and require do not exist. |
| Missing base functions | print, load, loadfile, dofile, collectgarbage, warn and _VERSION do not exist. Use log.info instead of print. |
| Missing string functions | string.dump, string.pack, string.unpack and string.packsize do not exist, and string.format has no %p. |
| Chunks | Only text is loaded. Precompiled chunks are refused. |
getmetatable | Returns nil for everything that is not a table, strings included. |
tostring | A table, function or userdata converts to its type name only. |
| Finalizers and weak tables | __gc never runs and __mode is ignored. Objects are freed when the script unloads. |
| Closing | To-be-closed variables (local x <close>) and __close are refused. |
xpcall | The message handler runs after the error has unwound, inside its own protected call and under the same budget. |
math.random | Seeded from a source of ours at load. math.randomseed() reseeds from the same source. |
| Pattern matching | A pattern is at most 256 bytes and a subject at most 256 KiB. A match call may take 200,000 steps. Going over the step limit raises pattern too complex. |
| Size caps | string.rep and table.concat results are at most 64 KiB, table.move at most 2^18 elements, table.sort at most 65,536 elements. |
| Recursion | The Lua stack holds 60,000 slots and C calls nest 200 deep. Deeper recursion is a stack overflow error. |
The standard names that remain are listed in vec3, color, bit and math.