Getting started
How to: Your first mod
This page gives a brief overview for beginners over how to write a first scripted mod: a
main.lua that logs, listens to the race and runs a timer. It also covers the rules every script
runs under.
Required knowledge. Please familiarize yourself with these pages before reading further.
Step 1: Create the mod folder#
Create a folder for the mod under Mods next to the game, with a Scripts folder inside
it and a ModInfo.ini describing the mod. For the next steps, we'll use the following folder name:
<game>\Mods\Hello\
├── Scripts\
│ └── main.lua
└── ModInfo.ini[Mod]
Name=Hello
Version=1.0
Author=You
Description=A first scripted mod.Step 2: Write main.lua#
A mod is a main.lua that registers handlers and then waits. The hook calls the handlers when the
game does something, from the frame tick or from the game function that raised the event. The following script
does a little of everything:
ss.log("hello from %s", ss.mod.name) -- string.format when given arguments
print("this goes to the same log")
ss.on("state", function(ev) -- ev.from, ev.to: "boot", "frontend", "loading", "race"
ss.log("%s -> %s", ev.from, ev.to)
end)
local ticker -- a timer that only runs during a race
ss.on("race_start", function(ev) -- the green light; ev.mode is the mode's name
ss.log("race started at %.1f s, mode %s", ss.game.time, tostring(ev.mode))
ticker = ss.every(1.0, function()
local car = ss.vehicle -- the local player's car, nil outside a race
if car then ss.log("%.0f km/h, lap %d", car.speed_kmh, car.race.lap) end
end)
ss.after(5.0, function() ss.log("five seconds in") end)
end)
ss.on("race_end", function()
if ticker then ss.off(ticker); ticker = nil end
end)
ss.log writes a line to the log. If the first argument is a string and more arguments follow, the
line is formatted with string.format. print is redirected to the same log, so library
code that prints is not lost. Every line carries the mod's name, which is how the output of two mods is told
apart.
ss.on(event, fn) registers fn for an event and returns
an id. The state event runs when the game moves between its coarse states, and
race_start runs at the green light.
ss.every(seconds, fn) and ss.after(seconds, fn) are timers, a repeating one and a
one-shot one. They return ids as well, and ss.off(id) removes a handler or a timer alike. The
example keeps its repeating timer in a local so the race_end handler can stop it, because a timer
is not stopped by the race ending.
ss.vehicle is the local player's car as a live object:
car.speed_kmh reads the game at that moment, and car.race.lap is the same player's lap
counter. It is nil outside a race, which is why the timer checks it.
Step 3: Run the game and read the log#
Start the game. The line sshook: lua: [Hello] hello from Hello appears in the debug output before
the window is up, and the state lines follow as the game boots into the front end. Start a race to
see the rest. How to: Testing a mod explains where the log goes
and how to run a race without sitting at the keyboard.
Handlers#
Every handler receives one table, ev, holding the event's fields. Handlers run on the game's main
thread, from the frame tick or from the hooked game function that raised the event, so a slow handler stalls the
game for exactly that long.
Each handler runs under pcall. An error is logged with a traceback (file and line) and the other
handlers for the event still run. A handler that fails fifty times in a row is removed, so a broken
tick handler does not flood the log for the whole session.
One state per mod#
Each mod runs in its own Lua state: LuaJIT 2.1, which is Lua 5.1 with LuaJIT's extensions, jit and
ffi included. Nothing is shared between mods. Globals, require results and errors stay
inside the mod that made them, and a main.lua that fails is reported and leaves the other mods
untouched. require("util") finds Scripts\util.lua in the mod's own folder, so a mod can
be split into files in the usual way.
What is not there#
os.exit, os.execute, os.remove, os.rename and
package.cpath (native modules) are removed. Everything else of the standard library stays,
io included, as does ffi.
ffi.cast reaches any address. A Lua mod deserves the same trust as a DLL.Bonus: Timers versus routines#
Timers run off the frame tick with the frame's real elapsed time, in every app state. They are not tied to the
race clock: a timer started in a race keeps counting through the menus and after the race ends, which is why the
example stops its timer on race_end. A sequence of several waits reads better as a
routine, a coroutine that waits on the game in the middle of its own flow:
ss.on("race_start", function()
ss.routine(function()
ss.wait(5) -- five seconds into the race
ss.log("%.0f km/h", ss.vehicle.speed_kmh)
ss.wait_event("lap") -- the next completed lap
ss.log("a lap is done")
end)
end)