RivalityFX Docsrivalityfx.com ↗

Hooks & the snapshot

Stability: Stable.

Hooks are optional functions you add to your descriptor to extend or override engine behaviour. Data-only modes (like the shipped 1v1 Duel) use none. Add them only when you need custom logic.

How hooks run (cross-VM)

A mode is a separate resource, so a hook is a real function reference that runs in your mode's VM, not the engine's. Two consequences:

  • A hook receives a scene id (and, for tick, a plain snapshot) — never the engine's internal Scene object. It reads from the snapshot and acts back through the write-exports.
  • Your resource must stay started for hooks to fire. When your resource stops, the engine unregisters the mode, so a dead hook is never called.

Hooks are invoked defensively (pcall): an error in a hook is caught and printed as [engine] mode <id> hook <name>: <err> rather than crashing the engine.

The hooks

HookSignatureFires when
setup(sceneId)A new scene of your mode is created. Good for initialising mst via setVar.
onJoin(sceneId, src)A player has joined the scene (after they're placed in the bucket and spawned frozen).
tick(sceneId, snapshot)Every 500 ms. Replaces the driver entirely — you drive the whole match.
onKill(sceneId, killerSrc, victimSrc)A PvP kill. killerSrc is nil if the killer is unknown (e.g. self/world).
onDeath(sceneId, victimSrc)A non-kill death (suicide / environment).

onKill and onDeath are mutually exclusive for a single death: a player-kill fires onKill; a suicide or environmental death fires onDeath.

local MODE = {
    id = 'mygame', label = 'My Game',
    teamCount = 2, perTeam = 2,
 
    setup = function(sceneId)
        exports.engine:setVar(sceneId, 'objectives', 0)
    end,
 
    onKill = function(sceneId, killerSrc, victimSrc)
        if killerSrc then
            exports.engine:notify(sceneId, 'First blood!')
        end
    end,
}

tick replaces the driver

If your descriptor has a tick, the engine calls it every 500 ms and runs no driver for that scene. You are responsible for the whole state machine — spawning, state transitions, win detection — using the snapshot to read and the write-exports to act:

tick = function(sceneId, snap)
    if snap.state == 'waiting' and snap.bothPresent then
        exports.engine:spawnAll(sceneId, { alive = true })
        exports.engine:setState(sceneId, 'live', 300)
        exports.engine:announce(sceneId, 'engine:live')
    elseif snap.state == 'live' and snap.now >= snap.phaseEnd then
        exports.engine:setState(sceneId, 'ended', 6)
    end
end

If your custom rules fit a reusable shape, consider a shared registerDriver instead — it uses the exact same (sceneId, snapshot) convention.

The snapshot

tick (and external drivers) receive a read-only plain-data view of the scene. You never mutate it — write back through the write-exports.

FieldTypeMeaning
idnumberScene id (pass it to the write-exports).
modeIdstringYour mode's id.
statestring'waiting' | 'live' | 'ended'.
roundnumberCurrent round number (0 before the first).
phaseEndnumberGetGameTimer() ms at which the current phase ends (0 if untimed).
nownumberGetGameTimer() ms at snapshot time (compare with phaseEnd).
msttableThe mode state bag — whatever you've written via setVar.
map{ label, areas, spawns }The scene's map: label, plus the arena's per-team areas/spawns placement arrays (indexed by team number; a system reads them to derive the play area). areas/spawns are absent if the arena declares none.
countsarrayPlayers per team, indexed by team number (counts[1], counts[2]).
alivearrayAlive players per team, same indexing.
winsarrayEach team's score (round wins / continuous score), same indexing.
playerslist{ { src, name, team, kind, alive, kills, deaths }, ... }team is the index (nil for a spectator).
bothPresentbooleantrue when every team has ≥ minToStart players (or, for a teamless mode, when the scene has any combatants).
{
  id = 3, modeId = 'mygame', state = 'live', round = 2,
  phaseEnd = 184213, now = 152877, mst = {},
  map = { label = 'The Yard' },   -- + areas/spawns when the arena declares them
  counts = { 2, 2 }, alive = { 1, 2 }, wins = { 1, 0 },
  players = {
    { src = 5, name = 'Alice', team = 1, kind = 'player', alive = true, kills = 3, deaths = 1 },
    -- ...
  },
  bothPresent = true,
}

See also