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
Mods\Hello\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:

Mods\Hello\Scripts\main.lua
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.

WARNING: This is not a sandbox. A mod's Lua runs inside the game process with the game's rights, exactly as a DLL mod would, and 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)