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 viaexports.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 } },
}Authoring with defineMode (recommended)
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 = 2whenrulesis present), so the table you get back is the resolved one. - Autocomplete + type-checking come from the
RfxModeLuaCATS 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 SDK | Result |
|---|---|
| equal | OK |
| omitted | warning — 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
| Field | Type | Required | Default | Scope | Notes |
|---|---|---|---|---|---|
id | string | yes | — | all | Unique across all modes. Re-using an id overwrites the earlier mode. |
apiVersion | number | no | current | all | The descriptor contract version you target. Pin it (current: 1). See Versioning. |
label | string | no | — | all | Shown in the menu, HUD, and banners. Strongly recommended. |
rules | table | no | — | all | Declarative 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. |
driver | string | no | — | all | A 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. |
systems | list | no | — | all | Cross-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. |
queue | string | no | inferred | all | The 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). |
royale | table | no | — | queue='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.) |
teamCount | number | for team modes | 2 when rules is set | team modes | Number 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'. |
perTeam | number | no | 1 | all | Per-team player cap (1 = 1v1, 2 = 2v2, 7 = 7v7). Max players in a match = teamCount × perTeam (derived — never declared). |
minToStart | number | no | 1 | team modes | Players per team required before a round/match starts. |
maxRound | number | no | -1 | rules rounds | Round 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). |
respawns | boolean | no | nil (false) | rules continuous | true ⇒ the dead are not sent to spectate (the runtime respawns them). Set true for continuous/deathmatch-style rules. |
timings | table | no | {} | team modes | Phase durations in seconds (a fallback for rules.phases). See timings below. |
armor | number | no | 0 | team modes | Body armour on spawn — the same for every combatant (the kit is symmetric). |
loadout | list of { 'WEAPON_HASH', ammo } | no | {} | team modes | Default 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. |
loadouts | table | no | — | create form | Map 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. |
randomLoadout | boolean | no | nil (false) | create form | Adds 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. |
roundsRange | table | no | — | create 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. |
criteria | array | no | — | create form | EXTRA 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. |
menu | table | no | — | presentation | NUI lobby-card presentation. Plain data the engine forwards into the lobby snapshot; not used by gameplay. See menu. |
present | table | no | — | presentation | Optional 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. |
setup | function | no | — | hook | (sceneId) — scene created. See Hooks. |
onJoin | function | no | — | hook | (sceneId, src) — a player joined. |
tick | function | no | — | hook | (sceneId, snapshot) — every 500 ms, replaces the driver. |
onKill | function | no | — | hook | (sceneId, killerSrc, victimSrc) — a PvP kill. |
onDeath | function | no | — | hook | (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'steamfield — 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.
| Key | Structure | Meaning | Fallback |
|---|---|---|---|
round | rounds · continuous | Live duration (rounds: round safety timer; continuous: match time limit). | 120 · 300 |
['end'] | both | Post-round / post-match pause. | 5 |
respawn | continuous | Seconds before a dead player respawns. | 3 |
endis 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.
menu (NUI card)
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 identity — title, 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:
| Field | Type | Default (in snapshot) | Notes |
|---|---|---|---|
short | string | id upper-cased | Short tag/badge (e.g. 1V1). |
tagline | string | '' | One-line hook. Rendered large in the mode cover's hero band (no pack analogue, so it stays in menu). |
category | string | — (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. |
flow | string | '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. |
flag | string | — | Optional ribbon (e.g. 'BETA'). |
access | string | '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
customizationresource); 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:
tickhook >rulesruntime > nameddriver. Atickhook makesrules/driverirrelevant; 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(andteamCountwhenrulesis 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.cfgconvars in its ownconfig.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.