Browse docs

Custom Medications

Declare inventory items that mix declarative effects (pain, blood, heal) with optional per-tick callbacks running on the client and server.

What it is

Config.CustomMedications is a third sibling table to Config.Medications and Config.HealingItems. Each entry registers a usable inventory item that, when used, plays an animation on the patient, applies declarative vitals effects, and (optionally) runs paired onUseClient / onUseServer callbacks (and onTick/onFinish/onCancel variants) for full control.

Declarative fields (pain, bleeding, heal, effect, requiresOpenWound, stopBleeding, pauseBleedingSeconds, duration, effectDurationSeconds) are forwarded to the same engine that powers Config.Medications, so vitals stay synchronised without duplicated logic.


The three type values

TypeBehaviour
"medication"One-shot consumable. Plays anim for time ms, runs onUseClient / onUseServer once, applies declarative effects on finish.
"infusion"Setup phase (time ms) then a tick loop running for injectingTime ms. onTickClient / onTickServer fire every tickIntervalMs (default 1000). Declarative effects apply once on finish.
"custom"No built-in effects. Pure callback hook. Use this for anything that doesn't fit the declarative model — defibrillators, vehicle interactions, custom revives.

Field reference

FieldTypeRequiredNotes
typestringyes"medication", "infusion", or "custom".
timenumber (ms)yesDuration of the progress phase. For infusions this is the setup phase before the tick loop starts.
injectingTimenumber (ms)infusionHow long the tick loop runs.
tickIntervalMsnumber (ms)noTick cadence. Default 1000.
removeItembooleannoRemove the item on finish. Default true. Set false for reusable items like a defibrillator.
anim{ dict, name }noPlayed on the user's ped while the run is active.
langstringnoProgress label shown while the run is active.
langUsedstringnoSuccess notification text shown on finish.
volumenumbernoInformational. Surfaced on self.volume for callback use.
painnumbernoNegative reduces pain, positive raises it. Routed through the existing pain-modifier system.
bleedingnumbernoClass delta (-2 reduces bleeding by 2 classes on the matched wound).
healnumbernoHP to restore on finish.
effectstringnoScreen effect name.
effectDurationSecondsnumbernoEffect duration in seconds.
requiresOpenWoundbooleannoIf true, run only starts when the target has an active open wound.
stopBleedingbooleannoPermanently stops bleeding on every open wound.
pauseBleedingSecondsnumbernoPauses bleeding on every open wound for N seconds.
durationnumbernoOptional pain-modifier expiry in seconds.
access.lockAccessbooleannotrue -> only on-duty medics can use the item, and self.target is auto-resolved to the closest player within _maxTargetDistance (refused if none).
onUseClient, onUseServerfunctionnoRun once at start, on each side.
onTickClient, onTickServerfunctionnoInfusions only. Run on each tick on each side.
onFinishClient, onFinishServerfunctionnoRun once on completion on each side.
onCancelClient, onCancelServerfunctionnoRun once on interruption on each side.

Legacy aliases (normalised at load):

  • painReduce = N -> pain = -N
  • lockAccess = true/false (top-level) -> access.lockAccess = ...

A single top-level knob applies to every entry:

config.lua
Config.CustomMedications._maxTargetDistance = 3.0  -- metres

This bounds the auto-resolved self.target for medic-only items and is also the range threshold that auto-cancels an in-progress cross-player infusion if the medic walks away.


The self helper

Each callback receives a self table sized to its environment. Client-side self cannot mutate vitals — only the server-side helper can. This makes the security model trivial: the client can't ask the server to mutate state through the helper.

Client self

Static state (read-only):

  • self.itemName, self.type, self.volume, self.time, self.injectingTime, self.elapsed
  • self.source — serverId of the user
  • self.target — serverId resolved at start (closest player for medic-only, otherwise self)

Vital mirrors (refreshed every tick from a piggybacked snapshot — no extra round-trip):

  • self.blood, self.pain, self.heartRate, self.bloodPressure, self.oxygenLevel

Local helpers (run on the user's machine):

  • self:fadeOut(ms) / self:fadeIn(ms)
  • self:ragdoll(ms)
  • self:playSound(soundSet, soundName)
  • self:playSoundAt(url, volume) — wraps exports.xsound:PlayUrlPos at the user's coords
  • self:coords() — returns the user's vector3
  • self:cancel(reason) — abort cleanly (no onFinish*, no item consumption)

Targeting helpers (return a serverId or nil):

  • self:nearbyPlayer(opts) — closest player ped within opts.maxDistance (default 3.0); opts.requireFacing = true for forward-cone filter; opts.includeSelf = false.
  • self:aimingAt() — the ped the user is currently free-aiming at.

Server self

Same static state, plus:

  • self.patient — the patient record for self.source
  • self:patientFor(serverId) — patient record for any other player

Direct read of the patient record is fine: self.patient.conditions.bloodVolume, self.patient.conditions.pain, self.patient.vitals.heart_rate, etc.

State mutators (no event hop, direct call into the medication engine — target defaults to self.target when omitted):

  • self:reducePain(amount, target?) / self:addPain(amount, target?)
  • self:addBlood(amount, target?) — clamped to Config.BloodSystem.maxVolume
  • self:setBleeding(zone, classDelta, target?)
  • self:heal(amount, target?) — fires the existing heal event on the target
  • self:revive(target?) — runs the standard revive flow

Helpers:

  • self:isMedic(serverId?) — uses Sky_Jobs.PlayerCache
  • self:distance(target?)
  • self:cancel(reason) — server-driven cancel (e.g. when the target walks out of range mid-infusion)

Worked examples

One-shot medication

config.lua
vomex = {
    type        = "medication",
    time        = 2000,
    removeItem  = true,
    anim        = {
        dict = "anim@heists@narcotics@funding@gang_idle",
        name = "gang_chatting_idle01"
    },
    lang        = "Nehme Vomex...",
    langUsed    = "Vomex eingenommen",
    pain        = -2,
    access      = { lockAccess = false }
},

Player uses vomex from inventory -> 2-second progress -> item removed -> pain drops by 2. No callbacks needed.

Sustained infusion

config.lua
fentanyl = {
    type           = "infusion",
    time           = 3000,
    injectingTime  = 5 * 60 * 1000,
    tickIntervalMs = 1000,
    removeItem     = true,
    volume         = 20,
    anim           = { dict = "anim@heists@narcotics@funding@gang_idle",
                       name = "gang_chatting_idle01" },
    lang           = "Verabreiche Fentanyl...",
    langUsed       = "Fentanyl verabreicht",
    pain           = -8,
    duration       = 300,
    access         = { lockAccess = true },

    -- Visual: ragdoll + fade. Runs on the patient's machine.
    onUseClient    = function(self) self:fadeOut(15000) end,
    onTickClient   = function(self) self:ragdoll(5000) end,
    onFinishClient = function(self) self:fadeIn(15000) end,

    -- Authoritative: blood + pain mutations. Runs on the server.
    onTickServer   = function(self)
        if self.patient.conditions.bloodVolume < 60 then
            self:addBlood(0.1)
        end
        local pain = self.patient.conditions.pain or 0
        if pain > 0 then
            self:reducePain(pain / 5)
        end
    end
},

Medic uses fentanyl near a downed patient -> closest player is auto-targeted -> 3 s setup -> tick loop fires every second for 5 minutes, ragdolling the patient and topping up blood until conscious.

Pure callback (type = "custom")

config.lua
defibrillator = {
    type        = "custom",
    time        = 8000,
    removeItem  = false,
    anim        = { dict = "anim@heists@narcotics@funding@gang_idle",
                    name = "gang_chatting_idle01" },
    lang        = "Benutze Defibrillator...",
    langUsed    = "Defibrillator benutzt",
    access      = { lockAccess = true },

    onUseClient = function(self)
        self:playSoundAt("https://example.com/defib.mp3", 1.0)
    end,
    onUseServer = function(self)
        Wait(6000)
        self:revive(self.target)
    end
}

Medic uses the defibrillator -> sound plays at the user's coords -> after a 6 second charge, the targeted player is revived -> item is not removed.


lockAccess semantics

access.lockAccess = true means the use is gated server-side as medic job + on duty:

  1. The source's job (via Sky_Jobs.PlayerCache.GetJob) must appear in Config.Jobs with countsAsMedic = true.
  2. Sky_Jobs.PlayerCache.GetDuty must return truthy.
  3. A target player must exist within _maxTargetDistance of the medic. If none, the use is refused with a notification.

For self-use items (access.lockAccess = false, the default), self.target = self.source and the range check is skipped.


Cancellation, stacking, collisions

SituationOutcome
User enters a vehicleAuto-cancel. onCancel* fires. No declarative effects. Item is not consumed.
User diesAuto-cancel. Same as above.
Source disconnectsServer clears the run on playerDropped.
Cross-player infusion: medic walks > _maxTargetDistance + 0.5 from the targetServer cancels with reason = "out_of_range".
User tries to start a second custom medication while one is activeRefused.
User has a self-bandage in progressCustom medication start is refused.
itemName collides with Config.Medications or Config.HealingItemsConsole prints a collision warning at start. The custom medication is not registered. The original medication still works.

Callback runtime errors are caught by pcall on both sides — a typo in your config will log the offending item and side, and clean-cancel the run rather than crashing the player thread.


Common pitfalls

  • Don't mutate vitals from onUseClient / onTickClient. The client self deliberately has no mutators. Put state changes in the server-side callback.
  • Don't call Wait(...) longer than time in onUseClient for non-infusion types — the run finishes when the server timer expires regardless of the client callback's progress.
  • requiresOpenWound = true validates against the target, not the source. For self-use items this is the user; for medic-only items it's the auto-resolved nearby player.
  • Reusable items (removeItem = false) still go through the cooldown of activeRuns[src] — a player can't trigger the same item twice in parallel.
  • self.target is fixed at start. If you want a callback to retarget mid-run, pass an explicit target into the mutator: self:heal(20, self:nearbyPlayer()).