server.lua

Every function in this file ships empty on purpose. They are the places where the gym hands control over to your own resources.

FunctionWhat it is for
OpenStashOpening a stash interaction point in your inventory.
OnGymPlayerLoadedRe-applying your own effects for a player's Strength and Agility on join.
OnStatLevelUpThe same effects, the moment a stat levels.
CanStartExerciseBlocking a set before it starts.
OnExerciseStartedReacting to a set that is now running.
OnExerciseFinishedReacting to a set that has ended, however it ended.

To push something into the gym from the outside instead, for example handing a player a membership, see exports.md.

Open Stash

Lua
---@param source number  Player server id
---@param stashId string  Stable stash identifier
function OpenStash(source, stashId)
    -- Open your inventory stash for this player here, keyed by stashId. Until this is
    -- filled in, the stash and boss stash interaction points do nothing.
end
  • Description: Called on the server when a player uses a Stash or Boss Stash interaction point. Every inventory resource opens a stash differently, so this is left to you.
  • When it fires: only from those two interaction point modules, which belong to the business module. If you do not run businesses, you never need this.
  • stashId: a stable identifier generated when the interaction point is saved. It never changes for that point, so it is safe to use as the stash key.

Example with ox_inventory:

Lua
function OpenStash(source, stashId)
    exports.ox_inventory:RegisterStash(stashId, 'Gym Stash', 50, 100000)
    TriggerClientEvent('ox_inventory:openInventory', source, 'stash', stashId)
end

Example with qb-inventory:

Lua
function OpenStash(source, stashId)
    TriggerClientEvent('inventory:client:SetCurrentStash', source, stashId)
    TriggerEvent('qb-inventory:server:OpenInventory', source, 'stash', stashId, {
        maxweight = 100000,
        slots = 50,
    })
end

Leave it empty if you do not want stashes at the gym. Nothing else depends on it, and the interaction points simply do nothing when used.


Gym stat hooks

Two functions that let you tie your own effects to a player's gym progress, for example a bigger inventory at higher Strength. The gym already grants bonus max health and faster stamina recovery from these stats on its own, see gym-config.md; these hooks are for everything else.

The data both hooks receive

Both are handed the same stats table, so one function can serve them both:

Lua
stats.strength.level      -- current level, starts at 1
stats.strength.xp         -- XP inside that level
stats.strength.xpToNext   -- XP needed for the next level
stats.agility.level       -- the same three fields
stats.agility.xp
stats.agility.xpToNext

Strength levels on reps that hit the upper body, Agility on reps that hit the lower body and core. Which muscles an exercise works, and how much each level is worth, are both set in the Gym Creator.

OnGymPlayerLoaded

Lua
---@param source number  Player server id
---@param identifier string  Framework identifier
---@param stats table
function OnGymPlayerLoaded(source, identifier, stats)
end
  • When it fires: once per join, for every player, including one who has never set foot in a gym. In that case the levels are simply 1, which is what you want: your handler can be a plain "set the state from the level" with no special case for a fresh player.
  • Why you need it: a level is a state, not an event. Without this hook, a player who levelled up yesterday would join today with your effect missing until they happen to level again.

This runs the moment the player's gym data is ready, which may be before your inventory resource has finished loading them. If yours needs longer, add your own Wait inside the function.

OnStatLevelUp

Lua
---@param source number  Player server id
---@param identifier string  Framework identifier
---@param stats table  Already holding the new levels
function OnStatLevelUp(source, identifier, stats)
end
  • When it fires: whenever Strength or Agility gains a level, so usually in the middle of a workout. Both stats can level in the same set, and the hook fires once for that set with both new values already in stats.

Example

Because both hooks take the same table, write the logic once:

Lua
local function applyGymPerks(source, stats)
    local slots = 40 + math.floor(stats.strength.level / 5)
    exports.ox_inventory:SetPlayerInventorySlots(source, slots)
end
 
function OnGymPlayerLoaded(source, identifier, stats)
    applyGymPerks(source, stats)
end
 
function OnStatLevelUp(source, identifier, stats)
    applyGymPerks(source, stats)
end

Both hooks are called inside a pcall, so an error in your code is logged and the gym carries on. It will not break a player's join or interrupt their workout.


Exercise hooks

Three functions that wrap a workout set: one gate before it starts, one signal when it does, one when it ends. All three are optional. Delete any of them and the gym behaves exactly as it did before.

The data table all three receive

They share one data table, so a single helper can serve all three. The fields marked end only are filled in for OnExerciseFinished and are absent in the other two.

FieldMeaning
data.exerciseIdWhat is being performed, for example bench_press.
data.eqIdId of the placed machine, unique inside its zone.
data.zoneIdGym zone id, or the string global for a world prop.
data.gymIdThe same zone as a number, nil for global props.
data.gymNameGym name, nil for global props.
data.businessIdThe business the gym is linked to, nil when it is standalone.
data.bodyPartPrimary muscle worked, one of the body parts from gym-config.md.
data.maxRepsRep target of the exercise, nil when it has none.
data.globaltrue for props enabled on the creator's global-models.md page.
data.repsEnd only. Reps that counted, already validated server side.
data.freeRepsEnd only. How many of those a spotter granted.
data.xpEnd only. Skill tree XP granted for the set, 0 without devhub_skillTree.
data.statLevelsEnd only. { strength = n, agility = n }, the levels gained this set.
data.durationMsEnd only. How long the set lasted.
data.successEnd only. true only when the rep target was reached.
data.reasonEnd only. Why it ended, see the list below.

data.reps is the count the server accepted, not the count the client claimed. Reps are counted client side, so the server clamps the report against the exercise's rep cap and the time the set actually took. Use this number for rewards and never trust a higher one.

CanStartExercise

Lua
---@param source number  Player server id
---@param identifier string  Framework identifier
---@param data table
---@return boolean allowed, string|nil reason  The reason is shown to the player
function CanStartExercise(source, identifier, data)
    return true
end
  • When it fires: the last gate before a set starts, after the membership and fatigue checks the gym runs itself. A handler here only ever sees sets the gym would otherwise allow.
  • Blocking: return false to refuse, with an optional message that is shown to the player. Anything other than false lets the set through, so a function that forgets to return a value does not accidentally lock the gym.
Lua
function CanStartExercise(source, identifier, data)
    if IsPlayerCuffed(source) then
        return false, "Not with those on"
    end
    return true
end

OnExerciseStarted

Lua
---@param source number  Player server id
---@param identifier string  Framework identifier
---@param data table
function OnExerciseStarted(source, identifier, data)
end
  • When it fires: the player passed every gate and the set is running. Somewhere to start a sweat effect, tell a stamina script to back off, or log who trains where.

OnExerciseFinished

Lua
---@param source number  Player server id
---@param identifier string  Framework identifier, still valid for an offline player
---@param data table  End-only fields included
function OnExerciseFinished(source, identifier, data)
end
  • When it fires: exactly once per set, whatever ended it. Anything you started in OnExerciseStarted always gets its counterpart, including when the player disconnects mid rep.

data.reason is one of:

ReasonWhat happened
max_repsThe rep target was reached. This is the only reason where data.success is true.
esc_cancelThe player stopped the set themselves.
minigame_failA rep minigame was lost.
fatigue_overloadThe trained muscle hit its limit mid set.
config_stopThe per-rep hook in configs/client.lua returned false.
client_stopAny other stop reported by the client.
player_droppedThe player left while exercising.
resource_stopThe gym resource was stopped or restarted.

The last two arrive with data.reps at 0, because a set is only counted once the client reports it, and by then the player may already be gone. Write to the database by identifier in those cases rather than doing anything with source.

Example

Because all three take the same table, the interesting part is usually one helper:

Lua
function CanStartExercise(source, identifier, data)
    -- No training in the middle of a robbery.
    if exports['my_heists']:IsPlayerBusy(source) then
        return false, "Later. You have somewhere to be."
    end
    return true
end
 
function OnExerciseStarted(source, identifier, data)
    TriggerClientEvent('my_effects:sweat', source, true)
end
 
function OnExerciseFinished(source, identifier, data)
    TriggerClientEvent('my_effects:sweat', source, false)
 
    if data.reps > 0 then
        print(('%s did %d reps of %s at %s')
            :format(identifier, data.reps, data.exerciseId, data.gymName or 'a world prop'))
    end
end

All three run inside a pcall, so an error in your code is written to the console and the gym carries on. It cannot strand a player in a set that never ends.


Membership exports

Hooks let you react to what the gym does. To push something into it from your own resource, granting a pass, taking one away or checking one, use the three server exports on exports.md.