RivalityFX Docsrivalityfx.com ↗

Mode descriptor

Stability: Stable.

A descriptor is the plain-data table you pass to registerMode. It fully describes a mode: its team structure, loadouts, timings, and win condition. Data-only modes need no functions; advanced modes may add hooks.

Two principles shape the contract:

  • Structure only, no identity. A mode declares how many teams (teamCount) of how many players (perTeam). Teams have no names, colors, or forced models — they are anonymous, and the engine renders every match egocentrically (you vs the enemy, battle-royale style). The runtime team objects are private to the engine and never appear in this contract.
  • Maps live in battlegrounds. A mode carries no spawn data. Battlegrounds register a modes.<id> block via exports.sdk:registerBattleground, and your mode automatically draws from every battleground that supports it. See the battleground SDK.
local MODE = {
    id         = 'duel',
    apiVersion = 1,
    label      = '1v1 Duel',
    teamCount  = 2,
    perTeam    = 1,
    maxRound   = 5,
    rules = {
        structure = 'rounds',
        phases    = { live = 90, post = 4 },
        roundEnd  = { 'elimination', 'timeLimit' },
        win       = { roundWins = 5 },
    },
    armor   = 100,
    loadout = { { 'WEAPON_PISTOL', 999 }, { 'WEAPON_COMBATPISTOL', 999 } },
}

You can pass a raw table straight to registerMode, but the SDK ships a builder that validates the descriptor at your mode's own load and gives your editor autocomplete. Opt in by including the SDK's descriptor library, then wrap your table:

-- fxmanifest.lua
shared_scripts {
    '@sdk/shared/kit.lua',         -- SDK helpers the contract uses (load first)
    '@sdk/shared/criteria.lua',    -- the criterion contract `defineMode` delegates to (required)
    '@sdk/shared/descriptor.lua',  -- defineMode + the RfxMode types
}

All three are required: defineMode validates criteria through the criterion contract whether or not your descriptor declares any. Omit one and the SDK refuses the descriptor with a message naming the missing file.

-- config.lua (or main.lua)
Config.mode = defineMode{
    id = 'duel', label = '1v1 Duel',
    teamCount = 2, perTeam = 1,
    -- ...
}
  • Validation runs here, tied to your resource — a malformed descriptor prints a precise diagnostic and aborts the load instead of failing silently at runtime.
  • Defaults are filled (perTeam, minToStart, maxRound, timings; teamCount = 2 when rules is present), so the table you get back is the resolved one.
  • Autocomplete + type-checking come from the RfxMode LuaCATS class — a LuaLS-aware editor suggests fields and flags type errors as you type.

registerMode validates too; defineMode just moves the check earlier, to where you're working. Without the include, exports.sdk:defineMode(spec) does the same from your registration thread.

Versioning

The descriptor shape is a contract with a version. The SDK implements one (exports.sdk:modeApiVersion(), currently 1), and your mode pins the version it was written against:

defineMode{ id = 'mygame', apiVersion = 1, ... }

Validation compares the two so the contract can evolve without breaking modes silently:

Your apiVersion vs the SDKResult
equalOK
omittedwarning — pin it
higher (mode targets a newer SDK)error — update the SDK
lower (older than the SDK)warning — update the descriptor

A breaking change to the descriptor shape bumps the SDK's version; pinning yours turns "my mode mysteriously misbehaves" into a clear, actionable line at startup.

Top-level fields

FieldTypeRequiredDefaultScopeNotes
idstringyesallUnique across all modes. Re-using an id overwrites the earlier mode.
apiVersionnumbernocurrentallThe descriptor contract version you target. Pin it (current: 1). See Versioning.
labelstringnoallShown in the menu, HUD, and banners. Strongly recommended.
rulestablenoallDeclarative behaviour, auto-run on the built-in rules runtime — elimination rounds, deathmatch, or zone control. The common way to give a mode behaviour. See Drivers.
driverstringnoallA native driver name registered via registerDriver — the imperative escape hatch for a shape rules can't express. Ignored if rules or a tick hook is present.
systemslistnoallCross-cutting modules to attach — a shrinking zone, a loot layer. Each entry is a system id string, or { id, config }. Behaviour lives in the system resource (registered with the engine via registerSystem); systems run alongside rules/driver/tick, including for a runtime-less mode. See Systems.
queuestringnoinferredallThe matchmaking principle: 'duel', 'teams', or 'royale'. Its live effect is the arena's start policy'royale' makes an arena gather (a minimum, then a countdown, N teams), while 'duel'/'teams' start the instant the arena is full. Omit and the engine infers it from the team shape (a 2-seat mode ⇒ duel, else teams).
royaletablenoqueue='royale'Gather tuning for a royale arena: { min, countdown }min = players that arm the drop countdown, countdown = seconds to launch (it fires at once if the arena fills first). Optional; the engine falls back to safe defaults. (There is no longer a custom/minRange/countdownRange opt-in — a royale arena is created through the normal Create-arena form.)
teamCountnumberfor team modes2 when rules is setteam modesNumber of anonymous teams in a match. 2 for a head-to-head mode; N for a royale (rules.structure = 'royale', last-team-standing) where it is the lobby size in teams. The default two-team rules runtime/HUD reject 3+ teams unless the structure is 'royale'.
perTeamnumberno1allPer-team player cap (1 = 1v1, 2 = 2v2, 7 = 7v7). Max players in a match = teamCount × perTeam (derived — never declared).
minToStartnumberno1team modesPlayers per team required before a round/match starts.
maxRoundnumberno-1rules roundsRound wins to take the match. -1 = infinite (rounds cycle forever). Also the default best-of for the Create-arena form (the creator overrides within roundsRange).
respawnsbooleannonil (false)rules continuoustrue ⇒ the dead are not sent to spectate (the runtime respawns them). Set true for continuous/deathmatch-style rules.
timingstableno{}team modesPhase durations in seconds (a fallback for rules.phases). See timings below.
armornumberno0team modesBody armour on spawn — the same for every combatant (the kit is symmetric).
loadoutlist of { 'WEAPON_HASH', ammo }no{}team modesDefault weapons on spawn, the same for every team. The first entry is the primary drawn on spawn. An arena's loadout pick replaces it for that scene.
loadoutstablenocreate formMap of key → { label, weapons = {{'WEAPON_HASH', ammo}, …} }. The selectable loadout catalog the arena creator picks from; the chosen set replaces loadout for everyone in that scene. Forwarded to the Create form.
randomLoadoutbooleannonil (false)create formAdds a Random choice to the Create form: the engine picks one of loadouts at match start — the same set for both sides (symmetric), a fresh pick each match. Needs ≥ 2 real loadouts.
roundsRangetablenocreate form{ min, max, step } — the best-of range the arena creator may pick (default = maxRound). The engine clamps the value to [min, max]. Omit it and the mode simply has no Rounds knob.
criteriaarraynocreate formEXTRA match criteria this mode adds to the platform set (map / weapons / rounds / stamina / spectators — do not redeclare those; reuse an id to override one). Each entry generates a create-form control, a summary row, a spec column and server-side validation.
menutablenopresentationNUI lobby-card presentation. Plain data the engine forwards into the lobby snapshot; not used by gameplay. See menu.
presenttablenopresentationOptional showcase block (hero art + info/buy links), shared across all descriptors. Forwarded in the lobby snapshot; the mode cover may render present.hero. See Presentation block.
setupfunctionnohook(sceneId) — scene created. See Hooks.
onJoinfunctionnohook(sceneId, src) — a player joined.
tickfunctionnohook(sceneId, snapshot) — every 500 ms, replaces the driver.
onKillfunctionnohook(sceneId, killerSrc, victimSrc) — a PvP kill.
onDeathfunctionnohook(sceneId, victimSrc) — a non-kill death (suicide / environment).

Teams are indices. Everywhere a team appears across the platform — arena spawn arrays, the hook snapshot's counts/alive/wins, a player's team field — it is the team number (1..teamCount). There are no role names to keep in sync.

timings

Phase durations in seconds. The rules runtime prefers rules.phases (live / post / respawn); this top-level timings table is the fallback block, and its ['end'] value also sets the post-round / post-match pause. Each key has a built-in fallback if both are omitted.

KeyStructureMeaningFallback
roundrounds · continuousLive duration (rounds: round safety timer; continuous: match time limit).120 · 300
['end']bothPost-round / post-match pause.5
respawncontinuousSeconds before a dead player respawns.3

end is a Lua reserved word — always write it as ['end'].

Spawns: battlegrounds, indexed by team

This section is about battlegrounds, not the match container. It covers the SDK battleground registry (registerBattleground / battleground_pack_*) — the pool of maps + spawn data a mode draws from. The match Arena players join and fight in (the lobby contract) is a different entity; it picks one of these maps for its scene.

A mode carries no spawn data. A battleground (map) registers a modes.<id> block whose areas (drop-in circles) and spawns (static points) are arrays indexed by team number — entry 1 is where team 1 enters, entry 2 team 2:

-- in a battleground pack (resources/[packs]/battleground_pack_*), not the mode:
defineBattleground{
    id = 'warehouse', apiVersion = 1, label = 'Warehouse',
    modes = {
        duel = {
            areas = {
                { x = 1209.0, y = -3115.0, z = 5.5, r = 8.0, face = 'center' },  -- team 1
                { x = 1234.0, y = -3115.0, z = 5.5, r = 8.0, face = 'center' },  -- team 2
            },
        },
    },
}

See the battleground SDK for the full contract. The engine round-robins compatible battlegrounds as it creates scenes; the arena creator picks one in the Create form. If no loaded battleground supports the mode, scenes fall back to a placeholder labelled Arena with no spawn points — so always ship at least one compatible battleground. Spawn z need not be exact either way — a ground spawn re-probes the ground under the point, and a drop comes from high above and lands the player on whatever ground is there.

Optional presentation data for the NUI lobby menu's mode card. It is plain design data — gameplay never reads it. The engine copies it into each mode's entry in the lobby snapshot (with safe defaults for any omitted field), and the menu builds the card from there.

The card identitytitle, sub, rgb, desc, hero — is not in menu: it lives in the shared present block (the same block a cosmetic pack uses). The engine flattens present onto the snapshot's top-level name/sub/rgb/desc for the NUI. menu carries only the rail/routing fields that are mode-specific:

FieldTypeDefault (in snapshot)Notes
shortstringid upper-casedShort tag/badge (e.g. 1V1).
taglinestring''One-line hook. Rendered large in the mode cover's hero band (no pack analogue, so it stays in menu).
categorystring— (nil)Family heading the menu's mode rail groups this mode under (e.g. 'Duels', 'Battle Royale'). Pure design data; omit and the mode falls under a generic heading. Modes sharing a value read as one labelled section.
flowstring'1v1'Rail family / order the menu groups this mode under: '1v1' (duels — sorted first) or 'royale' (battle-royale — after). It sets rail ordering only; each mode opens its own cover.
flagstringOptional ribbon (e.g. 'BETA').
accessstring'player'Min tier the menu suggests to enter: player | vip | staff. The menu gates the card off the player's tier (ACE-derived); the engine does not re-check this access hint on engine:arena:join — gating is menu-side only. (Arena creation is a separate, server-enforced tier — see canCreate/createReq in the snapshot.)
-- rail/routing (mode-specific)
menu = {
    short    = '1V1',
    flow     = '1v1',
    category = 'Duels',
    tagline  = 'One arena. One opponent. Settle it.',
},
-- card identity + showcase (shared RfxPresent — see presentation.mdx)
present = {
    title = '1v1 Duel',
    sub   = 'Solo · Best of',
    rgb   = '34 211 230',
    desc  = 'Pick a map and a loadout, then create an arena or join an open duel.',
    -- hero/info/buy optional
},

Notes

  • Appearance never carries team identity. Every combatant keeps the skin they equipped in the lobby (the customization resource); the engine never forces a model. PvP damage is enabled by friendly-fire settings (NetworkSetFriendlyFireOption / SetCanAttackFriendly), not by GTA teams — see the internal buckets notes.
  • Behaviour precedence: tick hook > rules runtime > named driver. A tick hook makes rules/driver irrelevant; a mode with none of them does not tick. Systems are orthogonal: they tick alongside whichever runtime the mode uses — including none — and never replace it. See Systems.
  • Only perTeam, minToStart, maxRound, timings (and teamCount when rules is present) are defaulted at registration.
  • The runtime supplies its own phase fallback values (round/end/respawn), used only when your descriptor omits the key. The shipped example mode also resolves some descriptor values from server.cfg convars in its own config.lua — that's a project convention, not part of this contract. A descriptor is just a table; how you build it is up to you.

See also