RivalityFX Docsrivalityfx.com ↗

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 lobby receives the snapshot ~1/s, menu open or not. engine:lobbyClose does 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, the rfx_battlegrounds convar) 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 the create.maps options 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)
The casual arena browser — a filterable list of open casual arenas for a mode, with Join, Spectate and Create
The built-in menu's casual browser, reached via a mode cover's Play casual: open casual arenas, filterable by mode. Ranked never appears here — it lives on the mode cover.

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:

EventPayloadMeaning
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:launchThe match is starting — close the menu (the engine takes over with engine:spawn / team / HUD).
engine:arena:reopenPop 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" },
}
PathTypeMeaning
modes[]listAll registered modes, sorted by flow then id. A mode disabled in server.cfg never registers, so it's naturally absent.
modes[].createtableCreate-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 / createReqboolean / stringMay 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[].poptableLive 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[]listEvery 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).
myArenanumber | falseThe subscriber's current match-Arena id, or false. Authoritative — sent as false (not nil) so it's always present.
scenes[].teamslistOne 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 / myTeamnumberThe subscriber's current scene / team index, or nil.
statusstringThe subscriber's lobby activity: idle (browsing) or joining (waiting in an arena). There is no ready / queued state anymore — no ready-check, no queue.
profiletableName + 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.)
lobbytableWho is in the hub right now: count + a light players list (name, initials).
lobbyHudtable | falseThe 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.
commitmenttable | falseThe 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.
populationnumberTotal 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.