BeamMP has no game modes. A BeamMP server relays vehicle state and runs Lua, and everything that makes a server more than a car park - races, whitelists, vehicle rules, chat commands, a Discord bridge - is a Lua plugin in Resources/Server. The server-side API is small enough to learn in an evening: you register handlers for a dozen events, call a couple of dozen MP.* functions, and use timers for anything that happens over time. That is also enough to run real races, because the server can read every vehicle's last reported position with MP.GetPositionRaw, which is all a checkpoint system needs. This guide covers the plugin model, the events and functions you will actually use, and a complete race plugin with a countdown, gates, laps and finishing times.
The BeamMP server guide covers installation, ServerConfig.toml, maps and client mods; read it first if your server is not running yet. Everything here targets the 3.x server, which uses Lua 5.3. Older plugins written for the 2.x API or for the long-retired community frameworks often do not load, and that is the first thing to check when a downloaded script does nothing.
How server plugins are loaded#
A plugin is a folder under Resources/Server. The server loads every .lua file in the root of that folder, in alphabetical order, and runs the top-level code once. Files in subfolders are ignored unless you require them, which is the right place for helpers and data.
Resources/ Client/ mod zips sent to players Server/ RaceControl/ main.lua loaded at start lib/gates.lua only if main.lua requires it Whitelist/ main.luaEach plugin runs in its own Lua state. Two plugins cannot read each other's variables; they talk through events, which is covered below. Within a plugin, the pattern is always the same: define global functions, then register them against event names with MP.RegisterEvent(eventName, functionName). The second argument is the name of a global function as a string, not the function itself, which is why a handler declared local silently never fires.
The server also watches the plugin folder. Saving a file in the plugin's root fires onFileChanged and reloads that plugin's state, so you can iterate without restarting the server. Reloading wipes the plugin's variables, so a race in progress is gone when you save main.lua; edit between races, not during them.
Output goes to the server console. print writes with a [LUA] prefix, and Util.LogInfo, Util.LogWarn, Util.LogError and Util.LogDebug write at the matching level. A syntax error is reported at load with the file and line, and the plugin is skipped. If a plugin "does nothing", the console from the moment of loading is where the answer is - reading the console covers doing that efficiently.
The events you will use#
| Event | Arguments | Cancellable |
|---|---|---|
onInit | none | No |
onPlayerAuth | name, role, is_guest, identifiers | Yes - return 1 or a reason string |
onPlayerConnecting | player_id | No |
onPlayerJoining | player_id | No |
onPlayerJoin | player_id | No |
onPlayerDisconnect | player_id | No |
onChatMessage | player_id, player_name, message | Yes - return 1 to hide the message |
onVehicleSpawn | player_id, vehicle_id, data | Yes - return 1 to refuse the spawn |
onVehicleEdited | player_id, vehicle_id, data | Yes - return 1 to refuse the edit |
onVehicleReset | player_id, vehicle_id, data | No |
onVehicleDeleted | player_id, vehicle_id | No |
onConsoleInput | input | No |
onShutdown | none | No |
Three of these do most of the work. onPlayerAuth runs before a player has an ID, so it is where whitelists and bans live; it receives an identifiers table with the player's beammp ID, IP and, where linked, Discord ID. onChatMessage is how every chat command is implemented - there is no command registry, you parse the text yourself. onVehicleSpawn and onVehicleEdited are where vehicle rules go: the data argument contains the vehicle configuration as JSON, so you can refuse a model, a part or a spawn outside the pits.
Cancelling is done by return value, and only the return value. Returning 1 from onChatMessage hides the message from everyone, which is what you want for commands - nobody needs to see /start in chat. Returning nothing lets it through.
The functions worth knowing#
| Function | What it does |
|---|---|
MP.SendChatMessage(id, text) | Chat to one player, or everyone with -1 |
MP.GetPlayers() | Table of player ID to name |
MP.GetPlayerName(id) | Name for an ID |
MP.GetPlayerIdentifiers(id) | beammp, ip and discord where known |
MP.IsPlayerGuest(id) | Whether the player has no BeamMP account |
MP.GetPlayerVehicles(id) | Table of vehicle ID to vehicle data |
MP.GetPositionRaw(id, vid) | Last reported pos, rot, vel, rvel, ping and tim |
MP.RemoveVehicle(id, vid) | Deletes a vehicle |
MP.DropPlayer(id, reason) | Kicks with a message |
MP.CreateEventTimer(name, ms) | Fires the event name every ms milliseconds |
MP.CancelEventTimer(name) | Stops it |
MP.TriggerLocalEvent(name, ...) | Calls handlers in this plugin, synchronously |
MP.TriggerGlobalEvent(name, ...) | Calls handlers in all plugins |
MP.TriggerClientEvent(id, name, data) | Sends a string to a client-side mod |
MP.Set(MP.Settings.MaxCars, n) | Changes a config value until restart |
MP.CreateTimer() | A stopwatch with :Start() and :GetCurrent() in seconds |
Positions are vectors in Lua arrays, so raw.pos[1], raw.pos[2] and raw.pos[3] are x, y and z. BeamNG uses z as up, so the ground plane is x and y. Util.JsonEncode and Util.JsonDecode turn tables into strings and back, which you need for both saving data and talking to client mods. FS.Exists, FS.CreateDirectory and FS.ConcatPaths handle files and paths portably.
Signatures do move between server versions, and the official scripting reference for the version you run beats any table on a blog, including this one.
Chat commands and admins#
There is no built-in admin list, so a plugin decides who is an admin. Key it on the beammp identifier, never on the display name, which players can change:
local ADMINS = { ["1234567"] = true }local function isAdmin(pid) local ids = MP.GetPlayerIdentifiers(pid) return ids ~= nil and ADMINS[tostring(ids.beammp)] == trueendfunction AdminChat(pid, name, msg) local cmd, arg = msg:match("^/(%S+)%s*(.*)$") if not cmd then return end if not isAdmin(pid) then return end if cmd == "kick" then for id, pname in pairs(MP.GetPlayers()) do if pname == arg then MP.DropPlayer(id, "Kicked by " .. name) end end elseif cmd == "cars" then MP.Set(MP.Settings.MaxCars, tonumber(arg) or 1) MP.SendChatMessage(-1, "Car limit is now " .. arg) else return end return 1endMP.RegisterEvent("onChatMessage", "AdminChat")The pattern ^/(%S+)%s*(.*)$ splits /kick SomeName into a command and the rest of the line. Unknown commands fall through and appear in chat, which is the friendlier failure. MP.Set changes a setting for the running server only; the value in ServerConfig.toml returns on the next restart, which makes it ideal for event nights.
Vehicle rules on spawn#
The data argument of onVehicleSpawn is a string with a prefix before the JSON, so cut from the first brace before decoding. Log one spawn before writing rules against it - the field names come from the game, and the safest way to know them is to look:
local ALLOWED = { covet = true, sunburst = true }function CarSpawn(pid, vid, data) local start = data:find("{") if not start then return end local cfg = Util.JsonDecode(data:sub(start)) print("spawn by " .. MP.GetPlayerName(pid) .. ": " .. tostring(cfg.jbm)) if cfg.jbm and not ALLOWED[cfg.jbm] then MP.SendChatMessage(pid, "Only the race cars are allowed tonight.") return 1 endendMP.RegisterEvent("onVehicleSpawn", "CarSpawn")MP.RegisterEvent("onVehicleEdited", "CarSpawn")Register the same handler on onVehicleEdited as well, or players spawn an allowed car and then swap it in the vehicle configurator. A refused spawn leaves nothing on other players' screens; a car removed afterwards with MP.RemoveVehicle flickers into existence first, so refuse at spawn whenever you can.
A checkpoint race plugin#
Races need four things: a list of entrants, a countdown, a way to tell when a car passes a point, and a clock. The server has all four. Gates are points on the map with a radius; each racer must pass them in order, and passing the last gate completes a lap. Positions are polled on a timer.
local ADMINS = { ["1234567"] = true }local GATES = { -- fill in with /pos, finish line last { x = 0.0, y = 0.0, r = 15 }, { x = 0.0, y = 0.0, r = 15 },}local LAPS = 3local race = { state = "idle", count = 0, racers = {}, finished = 0 }local clock = MP.CreateTimer()local function say(pid, text) MP.SendChatMessage(pid, "[Race] " .. text) endlocal function isAdmin(pid) local ids = MP.GetPlayerIdentifiers(pid) return ids ~= nil and ADMINS[tostring(ids.beammp)] == trueendlocal function firstVehicle(pid) local vehicles = MP.GetPlayerVehicles(pid) if vehicles == nil then return nil end for vid, _ in pairs(vehicles) do return vid endendlocal function reset() MP.CancelEventTimer("RaceCountdown") MP.CancelEventTimer("RaceTick") race = { state = "idle", count = 0, racers = {}, finished = 0 }endfunction RaceChat(pid, name, msg) local cmd = msg:match("^/(%S+)") if cmd == "join" and race.state == "idle" then race.racers[pid] = { gate = 1, lap = 1, done = false } say(-1, name .. " is on the grid") elseif cmd == "pos" then local vid = firstVehicle(pid) local raw = vid and MP.GetPositionRaw(pid, vid) if raw and raw.pos then say(pid, string.format("x=%.1f y=%.1f", raw.pos[1], raw.pos[2])) end elseif cmd == "start" and isAdmin(pid) and race.state == "idle" then race.state = "countdown" race.count = 5 MP.CreateEventTimer("RaceCountdown", 1000) elseif cmd == "stop" and isAdmin(pid) then reset() say(-1, "Race cancelled") else return end return 1endfunction RaceCountdown() if race.count > 0 then say(-1, tostring(race.count)) race.count = race.count - 1 return end MP.CancelEventTimer("RaceCountdown") race.state = "running" clock:Start() say(-1, "GO") MP.CreateEventTimer("RaceTick", 200)endfunction RaceTick() for pid, r in pairs(race.racers) do local vid = firstVehicle(pid) local raw = vid and MP.GetPositionRaw(pid, vid) if raw and raw.pos and not r.done then local g = GATES[r.gate] local dx, dy = raw.pos[1] - g.x, raw.pos[2] - g.y if dx * dx + dy * dy <= g.r * g.r then r.gate = r.gate + 1 if r.gate > #GATES then r.gate = 1 if r.lap >= LAPS then r.done = true race.finished = race.finished + 1 say(-1, string.format("P%d %s %.2fs", race.finished, MP.GetPlayerName(pid), clock:GetCurrent())) else r.lap = r.lap + 1 end end end end endendfunction RaceLeave(pid) race.racers[pid] = nil endMP.RegisterEvent("onChatMessage", "RaceChat")MP.RegisterEvent("RaceCountdown", "RaceCountdown")MP.RegisterEvent("RaceTick", "RaceTick")MP.RegisterEvent("onPlayerDisconnect", "RaceLeave")To set it up, drive to each point on the track - a few hundred metres apart on corners nobody can cut - and type /pos. Copy the x and y into GATES in driving order, with the start-finish line as the last entry so that cars leaving the grid head for gate one first. Save the file, the plugin reloads, and racers type /join before an admin types /start.
How accurate this is, honestly
The server only knows where a car was in its last update, so the precision depends on two things. Polling every 200 ms means a car at 200 km/h moves about 11 metres between checks, which is why the gate radius is 15 metres rather than three. And each position is as old as that player's network delay, so a driver on 150 ms ping is timed slightly late. In practice that is a few tenths of a second, which is fine for a fun Sunday race and not fine for a championship decided by hundredths.
Two ways to tighten it. Lowering the tick to 100 ms halves the distance problem and doubles the work; with a handful of racers that cost is trivial, with forty it is not. For real precision, detection has to happen on each player's own machine: a client-side mod in Resources/Client watches for gate crossings locally and reports them with TriggerServerEvent, and the server plugin receives them through MP.RegisterEvent with a handler taking the player ID and a data string. That is more work and needs a client mod you maintain, which is why most community race servers use the simpler server-side approach for casual events and a dedicated timing mod for leagues.
Making races feel like races
A few additions that cost little:
- Freeze the field before a race by setting
MaxCarsto1withMP.Setso nobody spawns a second car mid-race, and restoring it afterwards. - Refuse resets during a race.
onVehicleResetis not cancellable, but you can disqualify: if a racer resets whilerace.stateisrunning, setdoneand announce it. - Announce results to Discord by writing them to a file a separate bot reads, or by using a plugin built for webhooks - Discord webhooks for server status covers the receiving side.
- Keep layouts as data. Put each track's gates in its own file under
lib/andrequirethe right one, so changing venue does not mean editing the plugin.
Saving data between restarts#
Plugin variables die with the server. Anything that should survive - lap records, a ban list, race history - has to be written to disk. Lua's io library and Util.JsonEncode are enough:
local DIR = "Resources/Server/RaceControl/data"local FILE = FS.ConcatPaths(DIR, "records.json")local function loadRecords() if not FS.Exists(FILE) then return {} end local f = io.open(FILE, "r") local text = f:read("*a") f:close() return Util.JsonDecode(text) or {}endlocal function saveRecords(records) if not FS.Exists(DIR) then FS.CreateDirectory(DIR) end local f = io.open(FILE, "w") f:write(Util.JsonEncode(records)) f:close()endPaths are relative to the server's working directory. Writing a file inside the plugin's own root folder can trigger a hot reload of that plugin, which will wipe the race that just wrote the record - so put data files in a subfolder such as data/, as above, which the server does not load as plugin code. That is the single most confusing bug in BeamMP plugin development, and it looks like the server restarting the plugin at random.
Those data files are the part of your server you cannot download again. Back up Resources/Server before every edit session and before every server update; backups that actually restore is the argument for actually testing one.
Talking between plugins and to client mods#
Plugins are isolated, so a whitelist plugin cannot call a function in the race plugin. Events bridge them: MP.TriggerGlobalEvent("RaceFinished", pid, time) in one plugin calls every handler registered for RaceFinished in every plugin. MP.TriggerLocalEvent does the same within one plugin and returns synchronously, which is handy for splitting a large plugin into files.
Client mods are the other direction. A zip in Resources/Client can contain Lua that runs in each player's game, register handlers with AddEventHandler, and send data with TriggerServerEvent. The server sends with MP.TriggerClientEvent(pid, name, data) or MP.TriggerClientEventJson for a table. Everything travels as strings, so encode tables as JSON at both ends. Use this for user interface - a countdown on screen instead of in chat, a lap timer, a leaderboard - and keep the authority on the server: a client mod that reports its own lap times can be edited by the player running it.
Security, performance and keeping plugins working#
Plugins run with the server's full permissions and see every chat line and connection. Read anything you download before dropping it into a folder that loads automatically, and prefer plugins with a public source repository. Keeping a modded server clean is the general hygiene, and malicious plugins and mods covers what a hostile one can do.
Performance problems in BeamMP are almost always a handler doing too much too often. A timer at 50 ms that loops over every player and decodes JSON will show up as CPU you did not expect. Util.DebugExecutionTime() returns per-handler timing statistics, which turns a guess into a number. Never call MP.Sleep for long inside a handler; it blocks that plugin.
On RE:NODE the BeamMP plan gives you the console with history for reading plugin output, the file manager's editor with syntax highlighting for main.lua, and SFTP if you prefer working locally. Saving a plugin file hot-reloads it, so the edit-and-test loop does not need a restart, and a backup slot taken before an edit session is the undo button for the whole Resources folder.
After a BeamMP server update, read the changelog for scripting changes before restarting a server full of plugins. What to do when a mod update breaks is the recovery order if you find out the hard way.
Troubleshooting#
A handler never fires. The function is local, misspelt in MP.RegisterEvent, or the event name has the wrong capitalisation. Event names are case-sensitive.
The plugin resets itself at random. It writes a file into its own root folder, triggering a hot reload. Move data into a subfolder.
`attempt to index a nil value` on positions. The player has no vehicle, or MP.GetPositionRaw returned an empty result. Check for raw and raw.pos before using them.
Commands appear in chat. The handler does not return 1 for handled commands.
Gates are never triggered. The coordinates were copied as x and z instead of x and y, or the radius is smaller than the distance a car travels between polls.
An old plugin does nothing. It targets the 2.x API. Look for MP.RegisterEvent with string handler names; anything else needs porting.
FAQ#
Does BeamMP have built-in race modes?
No. The server has no game modes at all. Races, timing and leaderboards are Lua plugins, either written yourself or downloaded, sometimes with a client-side mod for on-screen timing.
Which Lua version does the BeamMP server use?
Lua 5.3 on the 3.x server. Plugins written for the old 2.x API need porting, mainly to the string-based MP.RegisterEvent handlers and the current event names.
Do players need to install anything for server plugins?
Not for server-side plugins in Resources/Server. Only client mods in Resources/Client reach players, and those are downloaded automatically when they join.
How accurate is server-side lap timing?
Within a few tenths of a second, limited by the polling interval and each player's ping. That is fine for casual races. Competitive timing needs detection on the client, reported to the server.
Can I add a password with a plugin?
There is no password field, but you can deny anyone at onPlayerAuth whose beammp identifier is not on your list, which is stricter than a password because it cannot be shared.
Do I need to restart the server after editing a plugin?
No. Saving a file in the plugin's root folder reloads that plugin. Its variables are reset, so do it between events rather than during one.




Comments
Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.