Browse docs
Custom Medications
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
| Type | Behaviour |
|---|---|
"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
| Field | Type | Required | Notes |
|---|---|---|---|
type | string | yes | "medication", "infusion", or "custom". |
time | number (ms) | yes | Duration of the progress phase. For infusions this is the setup phase before the tick loop starts. |
injectingTime | number (ms) | infusion | How long the tick loop runs. |
tickIntervalMs | number (ms) | no | Tick cadence. Default 1000. |
removeItem | boolean | no | Remove the item on finish. Default true. Set false for reusable items like a defibrillator. |
anim | { dict, name } | no | Played on the user's ped while the run is active. |
lang | string | no | Progress label shown while the run is active. |
langUsed | string | no | Success notification text shown on finish. |
volume | number | no | Informational. Surfaced on self.volume for callback use. |
pain | number | no | Negative reduces pain, positive raises it. Routed through the existing pain-modifier system. |
bleeding | number | no | Class delta (-2 reduces bleeding by 2 classes on the matched wound). |
heal | number | no | HP to restore on finish. |
effect | string | no | Screen effect name. |
effectDurationSeconds | number | no | Effect duration in seconds. |
requiresOpenWound | boolean | no | If true, run only starts when the target has an active open wound. |
stopBleeding | boolean | no | Permanently stops bleeding on every open wound. |
pauseBleedingSeconds | number | no | Pauses bleeding on every open wound for N seconds. |
duration | number | no | Optional pain-modifier expiry in seconds. |
access.lockAccess | boolean | no | true -> only on-duty medics can use the item, and self.target is auto-resolved to the closest player within _maxTargetDistance (refused if none). |
onUseClient, onUseServer | function | no | Run once at start, on each side. |
onTickClient, onTickServer | function | no | Infusions only. Run on each tick on each side. |
onFinishClient, onFinishServer | function | no | Run once on completion on each side. |
onCancelClient, onCancelServer | function | no | Run once on interruption on each side. |
Legacy aliases (normalised at load):
painReduce = N->pain = -NlockAccess = true/false(top-level) ->access.lockAccess = ...
A single top-level knob applies to every entry:
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.elapsedself.source— serverId of the userself.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)— wrapsexports.xsound:PlayUrlPosat the user's coordsself:coords()— returns the user'svector3self:cancel(reason)— abort cleanly (noonFinish*, no item consumption)
Targeting helpers (return a serverId or nil):
self:nearbyPlayer(opts)— closest player ped withinopts.maxDistance(default3.0);opts.requireFacing = truefor 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 forself.sourceself: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 toConfig.BloodSystem.maxVolumeself:setBleeding(zone, classDelta, target?)self:heal(amount, target?)— fires the existing heal event on the targetself:revive(target?)— runs the standard revive flow
Helpers:
self:isMedic(serverId?)— usesSky_Jobs.PlayerCacheself:distance(target?)self:cancel(reason)— server-driven cancel (e.g. when the target walks out of range mid-infusion)
Worked examples
One-shot medication
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
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")
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:
- The source's job (via
Sky_Jobs.PlayerCache.GetJob) must appear inConfig.JobswithcountsAsMedic = true. Sky_Jobs.PlayerCache.GetDutymust return truthy.- A target player must exist within
_maxTargetDistanceof 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
| Situation | Outcome |
|---|---|
| User enters a vehicle | Auto-cancel. onCancel* fires. No declarative effects. Item is not consumed. |
| User dies | Auto-cancel. Same as above. |
| Source disconnects | Server clears the run on playerDropped. |
Cross-player infusion: medic walks > _maxTargetDistance + 0.5 from the target | Server cancels with reason = "out_of_range". |
| User tries to start a second custom medication while one is active | Refused. |
| User has a self-bandage in progress | Custom medication start is refused. |
itemName collides with Config.Medications or Config.HealingItems | Console 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 clientselfdeliberately has no mutators. Put state changes in the server-side callback. - Don't call
Wait(...)longer thantimeinonUseClientfor non-infusion types — the run finishes when the server timer expires regardless of the client callback's progress. requiresOpenWound = truevalidates 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 ofactiveRuns[src]— a player can't trigger the same item twice in parallel. self.targetis fixed at start. If you want a callback to retarget mid-run, pass an explicit target into the mutator:self:heal(20, self:nearbyPlayer()).