on_start()
Runs once, right after the chunk body. 5000 ms budget. An error here stops the script before it ever ticks.
<exe>/scriptsOpen the Lua tab, press Ctrl+N for a template, then Ctrl+Enter to run.
local LIMIT_KMH = 90
local warned = false
function on_start()
print("watching for " .. LIMIT_KMH .. " km/h")
end
function on_update(dt)
if not game.is_attached() then return end
local kmh = vehicle.get_speed_kmh()
hud.set("speed", string.format("%.0f km/h", kmh))
if kmh > LIMIT_KMH and not warned then
warned = true
notify.toast(string.format("Over %d km/h", LIMIT_KMH))
elseif kmh < LIMIT_KMH - 5 then
warned = false
end
end
function on_stop()
hud.clear()
end
on_start()Runs once, right after the chunk body. 5000 ms budget. An error here stops the script before it ever ticks.
on_update(dt)Runs ~30× per second while the script is running. dt is seconds since the last call (0.0333). 120 ms budget per call.
on_stop()Runs once on stop, on restart and on app shutdown. 5000 ms budget. Undo feature toggles and clear HUD rows here.
Run-to-completion is fine. A script with no on_update and no live timer or event handler runs its body, runs on_stop, releases its Lua state and returns to Idle. It never sits around as a fake "running" entry.
Timers and events keep you alive. A script whose whole body is timer.every(...) or events.on(...) has no on_update, but the runtime still drives it every tick.
An error kills the script. Message and traceback land in the console, the status flips to Error, and the editor drops a marker on the failing line. Wrap risky calls in pcall if you would rather keep running.
Attach state and pointer health. Check this before anything else — most reads raise if the game is gone.
game.is_attached()
True when the game process is attached. game.is_running() is an alias.
game.pointers_valid()
True when the pointer chain resolved.
game.get_state()
Table: attached, pointers_valid, pid, module_base, status.
game.pid()
Attached process id, 0 when detached.
game.module_base()
Base address of eurotrucks2.exe.
game.tmp_module_base()
Base address of core_ets2mp.dll. Returns 0 when TruckersMP is not loaded — a cheap singleplayer test.
game.status()
Human-readable pointer status string, the same text the app shows.
game.foreground()
True only while the game window is the foreground window. Gate every hotkey with this so keys typed into a browser do nothing.
truck, playerThe local truck. All three names are the same table — in ETS2 the player is the truck. Every getter raises no truck: the game is not attached, or you are not in a vehicle when the read fails.
vehicle.get_position()
Table: x, y, z (chunk-local), chunk_x, chunk_z, plus world_x, world_y, world_z. Use the world_* fields for distance maths — the game stores x/z inside a 4096 m chunk, so a naive difference jumps 4096 m at a boundary.
vehicle.get_rotation()
Table: heading, pitch, roll in degrees, plus the raw quaternion w, x, y, z.
vehicle.get_velocity()
{x, y, z} linear velocity in m/s, read straight off the rigid body.
vehicle.get_angular_velocity()
{x, y, z} angular velocity in rad/s.
vehicle.get_speed()
Physics speed in m/s.
vehicle.get_speed_kmh()
Physics speed in km/h.
vehicle.get_steering()
Steering wheel position, -1 full right to 1 full left.
vehicle.get_rpm()
Engine RPM.
vehicle.body()
Chassis physics-body address, or nil when unresolved. Pass it to entities.read_body() or to any physics write.
vehicle.cabin_body()
Cabin physics-body address.
vehicle.set_position(x, y, z)
Teleport the truck; cabin and trailers follow and the chunk bookkeeping is handled for you. Queued onto the render thread, so it lands next frame.
vehicle.set_velocity(x, y, z [, body])
Overwrite linear velocity, m/s. body defaults to the chassis. Clamped to ±500 m/s. Returns true on success.
vehicle.add_velocity(x, y, z [, body])
Add to the current linear velocity. The boost / grapple / launch primitive: vehicle.add_velocity(0, 18, 0).
vehicle.set_angular_velocity(x, y, z [, body])
Overwrite spin, rad/s. Clamped to ±100 rad/s.
vehicle.add_angular_velocity(x, y, z [, body])
Add spin. Air control, barrel rolls.
vehicle.set_orientation(w, x, y, z [, body])
Write the orientation quaternion. Normalised for you — an unnormalised quaternion scales the whole rotation matrix and turns the truck to soup.
vehicle.set_heading(deg [, body])
Point the truck at a compass heading, level.
vehicle.level([body])
Stand the body upright about world Y, keeping its heading. The unflip primitive — the suspension settles it back onto the slope within a frame or two.
vehicle.nudge(dx, dy, dz [, body])
Move the body relative to where it is now. Relative only: an absolute body write would skip the chunk bookkeeping. Absolute moves go through vehicle.set_position().
vehicle.set_suspension(front, rear)
Air suspension ride height, front and rear. Queued onto the render thread.
vehicle.repair()
Repair the truck. Fires the game's own remote call on a detached thread.
vehicle.refuel()
Fill the tank. Server-side call.
vehicle.fix_cargo()
Fix damaged cargo. Server-side call.
Attached trailers, 1-based. Every getter returns nil for an index that is not attached.
trailer.count()
Number of attached trailers.
trailer.get_position(i)
{x, y, z, chunk_x, chunk_z} of trailer i.
trailer.get_rotation(i)
{heading, pitch, roll, w, x, y, z} of trailer i.
trailer.get_velocity(i)
{x, y, z} linear velocity of trailer i.
trailer.body(i)
Physics-body address of trailer i. Your own trailers accept physics writes.
Streamed nearby players, from a snapshot refreshed at most 4× a second. See the entity table for every field.
entities.count()
Number of entries in the snapshot, trailers included.
entities.get(i)
One entity table, 1-based. nil if out of range.
entities.all()
Array of every entity in the snapshot.
entities.nearest()
Closest non-trailer entity, or nil.
entities.read_body(addr)
Read any rigid body live: x/y/z, chunk_x/chunk_z, heading/pitch/roll, qw..qz, vel_x/y/z. This is how you get another player's true position and orientation without waiting for the 4 Hz snapshot. Reads only — writes to someone else's body are refused.
entities.by_id(game_id)
Look one entity up by its game id.
entities.find(name_part)
First entity whose name contains name_part, case-insensitive.
entities.within(radius [, include_trailers])
Every entity inside radius metres, nearest first. Trailers excluded unless you ask for them.
entities.players()
Every non-trailer entity.
entities.admins()
Every non-trailer entity whose TMP role is not "Player".
Direct access to the attached game process only. There is no path from Lua into Ghosty's own address space, and none to allocate, re-protect or execute anything. Every call is capped at 4096 bytes.
memory.read_float(addr)
4-byte float, or nil if the read fails. Same shape for read_int (4-byte signed), read_byte, read_double.
memory.read_ptr(addr)
64-bit pointer as an integer.
memory.read_string(addr [, max])
NUL-terminated string. max defaults to 128, clamped to 1..4096.
memory.read_bytes(addr, len)
Block read as a binary string, 1..4096 bytes. Pulls a whole struct in one call instead of a dozen round trips.
memory.write_float(addr, v)
Returns true on success. Also write_int, write_double, and write_byte(addr, 0..255).
memory.write_bytes(addr, str)
Write a binary string, 1..4096 bytes.
memory.resolve(base, {off, ...})
Walk a pointer chain, up to 32 offsets. Returns nil if any hop fails.
memory.pointer(name)
A resolved game pointer by name; nil for anything unknown or unresolved.
memory.module_base()
Same value as game.module_base().
game_ctrlgame_actorvisual_interiortrailer_actorgame_physics_vehiclephysics_pointerphysics_powertrainphysics_enginevehiclemodule_basetmp_base
Drives the app's own feature toggles. Setting is queued onto the render thread; getting is immediate.
features.list()
Array of every toggleable name. Print it once rather than hard-coding the list.
features.get(name)
Current boolean, or nil for an unknown name.
features.set(name, on)
Turn a feature on or off.
two_wheeltwo_wheel_fliptwo_wheel_trailerno_damageinfinite_fuelinfinite_albedoghost_modejump_hacksuper_brakefake_lagspeed_unlockerspeed_lock_110speed_lock_150auto_park_suspensionflyfly_trailerssuspension_editor
Console lines go to the Lua tab. Notifications go to the app, or into the game itself.
print(...)
Goes to the Lua console, not to a stdout nobody can see. Arguments are tab-joined and honour __tostring.
console.log(...)
Info line. console.warn(...) and console.error(...) give the coloured levels.
console.clear()
Wipe the console.
console.dump(t)
Readable, cycle-safe table dump, capped at 60 keys per level.
notify.toast(text)
Toast inside Ghosty.
notify.in_game(text)
Notification inside ETS2 itself.
Live key/value rows drawn above the console, one row per key. Rewriting a key replaces its row instead of scrolling the console.
hud.set(key, text [, r, g, b])
Create or update one row. Colour components are 0..1 and default to the app's text colour. Text truncates at 256 characters; 24 rows total across all scripts.
hud.remove(key)
Drop one row.
hud.clear()
Drop every row this script owns. Stopping a script clears its rows automatically.
Per-script persistence, JSON on disk at configs/lua_store/<Script>.json. The one filesystem door a script gets, and a narrow one: you never name a path — the filename comes from the script's own name.
storage.get(key [, default])
Read a value back, next session included. Returns default when the key is missing.
storage.set(key, value)
Persist a number, string, boolean or table. Raises if the value is too large, too deeply nested, or holds something that cannot round-trip (functions, userdata, threads).
storage.has(key)
True when the key exists and is not null.
storage.delete(key)
Remove one key.
storage.keys()
Array of key names. storage.all() returns the whole table.
storage.clear()
Wipe the store. storage.save() forces a write now — otherwise dirty stores commit at most every 2 seconds, and always on stop.
512 keys64 KB per value1 MB per file8 levels deep128-char keys
Driven from the same tick as on_update, so a script with only timers still runs. A throwing callback logs an error and does not kill the script.
timer.every(seconds, fn)
Repeat fn forever. Returns an id. No accumulator boilerplate.
timer.after(seconds, fn)
Run fn once, later. Returns an id.
timer.cancel(id)
Stop one timer. timer.clear() stops all of them.
Detection for a family only runs while something is listening to it, so an empty script pays nothing.
events.on(name, fn)
Subscribe. Returns an id for events.off(id).
events.emit(name, ...)
Fire your own event. Handlers run under pcall.
events.on_key(vk, fn)
fn(true) on press, fn(false) on release. Also emits the generic key_down / key_up events with the virtual-key code.
events.off(id)
Unsubscribe one handler.
entity_joinedentityA player appears in the snapshot (scanned 4×/s)entity_leftentityA player leaves the snapshotadmin_nearentityA non-"Player" role appears nearbyadmin_leftgame_idThat admin leaves the snapshotcrashdrop_kmh, last_kmhSpeed drops more than 25 km/h in one tickflippedrotation|roll| > 65° or |pitch| > 60°, on the edgeairborneposition, velocityVertical velocity or height clears the ground bandlandedposition, rotationBack within 1 m of the tracked ground levelkey_down / key_upvkAny key registered through events.on_keyWindows virtual-key state. Level-triggered and edge-triggered variants, plus a named key table.
input.is_key_down(vk)
Level-triggered: true for as long as the key is held. Fires globally, whatever window has focus.
input.pressed(vk)
Edge-triggered: true exactly once per press. input.released(vk) is the mirror.
input.game_pressed(vk)
Edge-triggered and only while ETS2 has focus. Use this for hotkeys — a hotkey that fires while you type in a browser is a bug every time.
input.KEY
Named virtual keys: F1–F12, A–Z, D0–D9, NUM0–NUM9, LEFT/UP/RIGHT/DOWN, SHIFT, CTRL, ALT, SPACE, ENTER, TAB, ESC, CAPS, BACKSPACE, INSERT, DEL, HOME, END, PAGEUP, PAGEDOWN, LMB, RMB.
Economy calls, clocks and self-control.
profile.add_money(n)
Add money. Server-side call, queued onto the render thread.
profile.set_level(n)
Set profile level. Server-side call.
time.now()
Milliseconds since the app started, as a float. The right clock for throttling.
time.clock()
Unix timestamp in seconds.
time.sleep(ms)
Sleep, capped at 5000 ms, chunked so a stop request or a blown budget lands promptly. It holds up every other running script, so prefer timer.after.
script.name()
This script's name — the same string that owns its HUD rows and its storage file.
script.stop()
Stop this script from inside itself. Does not return.
Pure-Lua helpers loaded into every state. They widen nothing — they are sugar over calls that already exist.
vec.new(x, y, z)
Plus add, sub, mul(v, k), len, norm, dot, cross, dist(a, b), lerp(a, b, t). All take and return {x, y, z} tables.
world.of(entity_or_position)
Chunk-corrected {x, y, z} for anything carrying world_* or chunk_* fields.
world.dist(a, b)
Distance that survives a chunk boundary. Use this, never vec.dist on raw x/z.
world.bearing(from, heading_deg, target)
Signed degrees off the nose: negative is left, positive is right.
util.clamp(v, lo, hi)
Plus round(v, places), sign, lerp(a, b, t), approx(a, b, eps), map(v, a1, a2, b1, b2).
util.clock(seconds)
Formats "m:ss". util.comma(n) gives 1,250,000.
util.dump(t)
Readable table dump. util.keys(t) returns sorted keys, util.count(t) counts them.
No function matches that search.
What entities.get(), entities.all() and entities.nearest() hand back.
namestringPlayer nametagstringTMP tagrolestringTMP role; "Player" for a normal accountmodelstringTruck modelgame_idintGame-side id, stable while streamedtmp_idintTruckersMP id, -1 when unknownsteam_idintSteam idx y znumberChunk-local position — do not measure with thesechunk_x chunk_zint4096 m chunk indicesworld_x world_y world_znumberChunk-corrected position. Use these.distancenumberMetres from your truckis_trailerboolTrue for trailer entriesis_adminboolTrue when role is not "Player"bodyintPhysics-body address — pass to entities.read_body()net_playerintNetwork player object addressheading pitch rollnumberDegrees. Present only when the game published a rotationqw qx qy qznumberOrientation quaternion, same conditionhas_velocityboolWhether the velocity fields are meaningful yetvel_x vel_y vel_znumberDerived by differentiating snapshots — the game does not publish other players' velocitiesspeed_kmhnumberDerived speed, same sourceScript list on the left, editor and console on the right, shell along the bottom.
.lua fileShell. The input under the console evaluates one line in its own state with the same API surface. It tries your line as an expression first, so vehicle.get_speed() prints its result the way a REPL should. ↑ / ↓ walk the history.
API explorer. A filterable list of every function, grouped by table. Click one and it drops into the editor at the cursor.
Autorun. Right-click a script → Run on startup. Off by default, per script, remembered in the workspace file.
Error markers. A runtime or syntax error puts a marker on the failing line and prints the traceback to the console.
Patterns worth copying.
local nextTick = 0
function on_update(dt)
if time.now() < nextTick then return end
nextTick = time.now() + 1000
if not game.is_attached() then return end
print(string.format("%.0f km/h", vehicle.get_speed_kmh()))
end
function on_update(dt)
if input.game_pressed(input.KEY.F8) then
vehicle.level()
notify.in_game("unflipped")
end
end
function on_update(dt)
local me = vehicle.get_position()
local r = vehicle.get_rotation()
for _, e in ipairs(entities.within(300)) do
local d = world.dist(me, e)
local off = world.bearing(me, r.heading, e)
hud.set(e.name, string.format("%5.0f m %+4.0f deg", d, off))
end
end
-- No on_update at all: the runtime keeps this alive for the timer.
local crashes = storage.get("crashes", 0)
events.on("crash", function(drop, lastKmh)
crashes = crashes + 1
storage.set("crashes", crashes)
console.warn(string.format("crash #%d, lost %.0f km/h", crashes, drop))
end)
events.on("admin_near", function(e)
notify.toast("admin nearby: " .. e.name)
end)
timer.every(5, function()
hud.set("crashes", "crashes: " .. crashes)
end)
local FEATURE = "two_wheel"
function on_start()
for _, n in ipairs(features.list()) do print(n) end
end
function on_update(dt)
features.set(FEATURE, input.is_key_down(input.KEY.F8))
end
function on_stop()
features.set(FEATURE, false)
hud.clear()
end