Lobby contract
Stability: Stable.
The lobby contract is how a menu (the built-in one, or your own NUI) lets players browse Arenas and join, create, spectate, or leave them. It has two directions:
- Client → server: net events the player triggers.
- Server → client: a live lobby snapshot the engine pushes.
You only need this if you're building a custom menu. Modes themselves don't use it — the engine drives joining.
Client → server
TriggerServerEvent('engine:lobbyOpen') -- first contact + an immediate snapshot push
TriggerServerEvent('engine:requestLeave') -- leave to the lobby (an arena wait OR a live match)
TriggerServerEvent('engine:lobbyClose') -- menu closed (informational)The push list is presence-based: everyone whose presence is
lobbyreceives the snapshot ~1/s, menu open or not.engine:lobbyClosedoes not unsubscribe — it only reminds an arena-committed player they're still waiting.
Entering a match is always through an Arena (below) — there is no direct
"quickjoin". You browse the arena list and join one (or your party joins
together); the arena auto-starts on its fill policy — full for a duel/squad, a
minimum plus a gather countdown for a royale. There is no ready-check and no
host-start. A player is in at most one Arena OR one scene — that single
invariant is what the old commitment split collapses to. engine:requestLeave
returns the player to the lobby (bucket 0), fires engine:left +
engine:team(nil), and clears their scoreboard.
Arena vs battleground. In this contract an Arena is a match container — an instance players join, which owns a live scene while in-game. The SDK battleground registry (
registerBattleground,battleground_pack_*,Engine.battlegroundsForMode, therfx_battlegroundsconvar) is a different concept: the pool of battleground maps a mode draws its spawns from. A match Arena's map is picked from that registry — those are thecreate.mapsoptions below. Where it matters we write "match Arena" vs "battleground".
Arenas
Every mode is arena-creatable. A player with the create tier opens an arena
from the Create form (create == join — the creator drops straight in);
everyone else browses and joins a listed arena. (The built-in menu shows
casual arenas in that browser and surfaces ranked slots on each mode's
cover — both go through the same engine:arena:join.) There is no host role and no
ready-check — the arena auto-starts on its start policy. A player is in at
most one arena OR one scene:
-- client -> server
TriggerServerEvent('engine:arena:create', { modeId, name, access = 'public'|'private',
password, spectators, rounds, map, loadout, stamina })
TriggerServerEvent('engine:arena:join', { arenaId, password })
TriggerServerEvent('engine:arena:leave') -- drop MY slot (the arena persists)
TriggerServerEvent('engine:arena:spectate', { arenaId }) -- watch a live arena's match
TriggerServerEvent('engine:arena:delete', { arenaId }) -- manager only (creator's license, or admin)
rounds is clamped to the mode's roundsRange; map/loadout are keys into
the shared battleground pool / the mode's loadouts (the Create-form options are
forwarded as modes[].create, below). ranked is not a client field — a
ranked arena is opened by the system/console only (see below). The server
pushes back:
| Event | Payload | Meaning |
|---|---|---|
engine:arena:closed | { reason } | Create/join refused, or the arena was deleted / dissolved — return to the arena list + show reason. |
engine:arena:toast | { msg, tone } | Non-fatal notice (delete acknowledged, server arena limit reached) — toast without leaving the screen. |
engine:arena:launch | — | The match is starting — close the menu (the engine takes over with engine:spawn / team / HUD). |
engine:arena:reopen | — | Pop the menu back to the arena list (after a match ends). |
There is no engine:arena:state — the old per-member ready view is gone (there
is no ready-check). The "waiting in an arena (3/4, starts when full)" state is
rendered by the always-on status widget straight off the snapshot's lobbyHud
(below).
At the end of a best-of, the scene freezes on the final scoreboard (no
rematch prompt): players leave on their own with Esc — a clean exit — or are
returned to the lobby once the post-match timeout elapses. The exit is offered,
not guessed at: the locked scoreboard's foot reads ESC — Return to the lobby,
and once the result banner has played the leave confirm opens by itself.
Dismissing it (Esc / Back to scoreboard) hands the board back and does not ask
again — the timeout remains the backstop.
Membership ≠ management. Leaving or disconnecting drops only your slot —
the arena persists. The manager (the creator, keyed by license, or any admin)
is the only role that may delete it, and that role survives a reconnect. A
regular player's arena is ephemeral (garbage-collected once it sits empty); a
staff/console arena is persistent (recycles for the next match, never GC'd).
Ranked
Ranked is an arena flag, not a client action. A ranked arena records stats and
is opened by the system/console only (from boot.cfg, or an admin ACE); the
server rejects ranked = true from any other source, and there is no menu
affordance to open one — so there are no ranked-specific client events. A
casual arena records nothing. See Server configuration for the admin knobs.
Server → client: the lobby snapshot
The snapshot is delivered via exports.ui:setLobby, which fires the client event
ui:lobby with the payload:
RegisterNetEvent('ui:lobby', function(data)
-- data is the snapshot below
end)Snapshot shape
{
modes = {
{
-- always present
id = 'duel', label = '1v1 Duel', perTeam = 1, teamCount = 2,
-- NUI card fields, from the mode's `menu` + shared `present` blocks (safe defaults)
name = '1v1 Duel', short = '1V1', sub = 'Solo · Best of',
rgb = '34 211 230', tagline = 'One arena. One opponent. Settle it.',
desc = '...', flow = '1v1', category = 'Duels', flag = nil,
present = { ... }, -- the full RfxPresent block (for the hero band)
pop = { open = 1, live = 2, playing = 4 }, -- live per-mode activity for the cover badges
status = 'available', access = 'player',
-- the Create-arena form options — present for EVERY mode (all are arena-creatable)
create = { maps = {{key='lot', label='Parking Lot'}, …},
loadouts = {{key='pistols', label='Pistols'}, …},
rounds = { min = 1, max = 9, step = 2, default = 5 },
stamina = { min = 0, max = 100, step = 10, default = 100 } },
canCreate = true, -- may THIS player create an arena of this mode
createReq = 'staff', -- the tier required to create (for the gated hint)
},
-- ... sorted by flow then id
},
arenas = { -- EVERY match Arena (all modes). The built-in NUI partitions this client-side:
-- CASUAL fills the browser; RANKED (ranked=true) surface on the mode cover.
{
id = 7, modeId = 'duel', mode = '1v1 Duel', name = "Ryu's arena",
players = 1, max = 2,
state = 'waiting', -- 'waiting' | 'gathering' | 'in-game' | 'concluded'
ranked = false, private = false,
loadout = 'pistols', -- weapon-config KEY (label via modes[].create.loadouts); nil = mode default
rounds = 5, -- best-of for this arena (nil if the mode has no rounds)
joinable = true, -- open + not full (a browser [Join])
spectatable = false, -- a public in-game arena that allows watchers ([Spectate])
manage = false, -- may THIS viewer delete it (manager/admin) -> [Delete]
},
-- ... sorted by id
},
scenes = {
{
id = 1, modeId = 'duel', mode = '1v1 Duel', map = 'Warehouse',
state = 'live', round = 3,
teams = { -- one slot per team, by index
{ team = 1, count = 1, max = 1, wins = 2 },
{ team = 2, count = 1, max = 1, wins = 1 },
},
},
-- ... sorted by id
},
population = 12, -- total connected players (lobby + in-match); the top-right badge
myArena = false, -- the subscriber's current arena id, or false (always present)
myScene = 1, -- the subscriber's current scene id, or nil
myTeam = 1, -- the subscriber's team index, or nil
status = 'idle', -- lobby activity: 'idle' (browsing) | 'joining' (waiting in an arena)
maxScene = 8, -- global concurrent-scene cap (engine config)
ping = 28, -- the subscriber's ping in ms (for the menu's connection pill)
profile = { -- the subscriber's profile (ACE tier live; stats from the stats store)
name = 'VOID_Ryu', initials = 'VR', tier = 'staff', rank = 'STAFF',
stats = { duel = { kills, deaths, wins, losses, draws } }, -- CAREER (omitted when rfx_career_stats=false)
season = { duel = { ... } }, seasonId = 'alpha', -- current season layer
canPrivate = true, privateReq = 'all', -- may create a PRIVATE arena + the required tier
},
lobby = { count = 3, players = { { name = 'VOID_Ryu', initials = 'VR' }, … } },
-- the always-on status widget: the ONE pre-match context, or false when idle
lobbyHud = { kind = 'arena', name = "Ryu's arena", mode = '1v1 Duel',
size = 1, max = 2, free = 1, members = { … } },
-- the single authoritative pre-match commitment (drives the rail badges), or false.
-- ARENA WINS over group: in an arena (even as a party) -> kind='arena' with the fields.
commitment = { kind = 'arena', modeId = 'duel', arenaId = 7, label = "Ryu's arena" },
}| Path | Type | Meaning |
|---|---|---|
modes[] | list | All registered modes, sorted by flow then id. A mode disabled in server.cfg never registers, so it's naturally absent. |
modes[].create | table | Create-arena form options — present for every mode (all are arena-creatable): maps / loadouts (each {key,label}; maps resolved live from the battleground pool), rounds ({min,max,step,default}), and stamina. |
modes[].canCreate / createReq | boolean / string | May this player create an arena of this mode, and the tier required (createReq, for the gated "+ Create" hint). UX only — the server re-checks on engine:arena:create; joining an arena is never gated by this. |
modes[].pop | table | Live per-mode activity for the mode-cover badges: open (joinable casual arenas), live (matches in progress), playing (players in them). Concluded matches (frozen scoreboard) are excluded, so it never over-reports. |
arenas[] | list | Every match Arena (all modes), sorted by id. The built-in NUI partitions it: casual arenas fill the browser (reached via a mode cover's Play casual); ranked (ranked=true) surface on that mode's cover as a per-loadout slot list. Each row: id, modeId/mode, name, players/max, state (waiting | gathering | in-game | concluded), ranked, private, loadout (weapon-config key → label via modes[].create.loadouts), rounds (best-of), joinable (open + not full), spectatable (public in-game + watchers allowed), and manage (per-viewer: may you delete it — manager or admin). |
myArena | number | false | The subscriber's current match-Arena id, or false. Authoritative — sent as false (not nil) so it's always present. |
scenes[].teams | list | One entry per team, indexed by team number: team, count/max (fill) and wins (score). Teams are anonymous — there is no name or color to display; a browser view shows "Team 1 / Team 2". Omitted for teamless scenes. |
myScene / myTeam | number | The subscriber's current scene / team index, or nil. |
status | string | The subscriber's lobby activity: idle (browsing) or joining (waiting in an arena). There is no ready / queued state anymore — no ready-check, no queue. |
profile | table | Name + ACE-derived tier (live, no persistence) plus the persistent per-mode stats bags (career/season) and the private-arena rights (canPrivate/privateReq). The server re-checks every gated action, so these are display hints, not security. (No canRanked: ranked arenas are console/admin-only, with no menu affordance to gate.) |
lobby | table | Who is in the hub right now: count + a light players list (name, initials). |
lobbyHud | table | false | The always-on status widget data — the ONE pre-match context, mutually exclusive: a group (kind='group'), an arena wait (kind='arena': name/mode/size/max/free/members), or an in-flight invite (kind='inviting') — or false when idle. |
commitment | table | false | The single authoritative pre-match commitment the menu reads for its rail badges: { kind='arena', modeId, arenaId, label }, { kind='group' }, or false when idle. Arena wins over group — in an arena (even grouped) → kind='arena', so commitment, myArena and the cover strip can't disagree. One arena membership = one commitment. |
population | number | Total connected players (lobby + in-match), for the menu's top-right badge. |
The menu also receives a leaderboard push (ui:leaderboard, slow cadence)
with { season, supported, byMode = { [modeId] = rows[] } } — supported is
false under the KVP stats backend (no cross-player reads).
Minimal custom-menu sketch
local snap
RegisterNetEvent('ui:lobby', function(d) snap = d end)
AddEventHandler('onClientResourceStart', function(res)
if GetCurrentResourceName() ~= res then return end
TriggerServerEvent('engine:lobbyOpen')
end)
-- create an arena of any mode (create == join: you drop straight in):
TriggerServerEvent('engine:arena:create', { modeId = 'mymode', name = 'My arena' })
-- or join a listed arena (pass `password` to clear a private gate):
TriggerServerEvent('engine:arena:join', { arenaId = snap.arenas[1].id, password = '' })
-- to leave (your pre-match arena slot, or a live scene):
TriggerServerEvent('engine:requestLeave')See also
- Client events — what the engine sends back after a join.
- Getting started — testing a mode end-to-end.
- Internal: buckets — why each scene is isolated.