9LABS Capsule Rental · v1.2.0 A car rental station under the pavement.
The player picks a car, a duration and an account on a physical tablet — or reserves one from a phone app. The capsule rises out of the ground, opens its doors and hands the car over on the platform.
Introduction
A server-authoritative vehicle rental system with physical underground delivery capsules, a world-space tablet, persistent rentals and, since v1.2.0, a public integration API.
What v1.2.0 added
Client and server exports intended for phone applications and other resources. An integration
can list stations and prices, reserve a vehicle remotely, route the player to the pickup,
monitor the reservation, cancel it, start a return and receive every lifecycle event — without
editing a single protected file.
Server-side by design
Everything that costs money or hands out a vehicle is decided on the server: identity, distance,
price, account, cooldowns, one rental per character, station locks and bay occupancy. The phone
never calculates or submits an authoritative price.
RequirementsFXServer with OneSync, Lua 5.4 (enabled by the manifest). No ox_lib and no required phone resource.
FrameworksQbox, QBCore, ESX, standalone rental credits, or a custom bridge. Keys via qbx_vehiclekeys, qb-vehiclekeys or custom.
Two station shapesA rental booth with a clerk and a screen, or a compact freestanding tablet, each placed separately from the capsule.
Escrow-friendlyConfig, both bridges, locales and the JSON state files are in escrow_ignore. No require anywhere.
Installation
Start it after your framework and key resource, give yourself the creator, place a station, reconnect once.
- Upload the release ZIP through Cfx Asset Escrow before distributing it to customers. The ZIP shipped in the Tebex folder is the pre-escrow source package.
- Extract the delivered
9labs_capsulerent folder into the server's resources directory. - Start it after the framework and the key resource you use.
- Give an administrator access to the station creator.
- Restart the resource, reconnect once so the streamed models load, then run
/capsuleplace and create at least one station.
ensure qbx_core
ensure qbx_vehiclekeys
ensure 9labs_capsulerent
add_ace group.admin capsulerent.place allow
Keep your state files. Placed stations live in locations.json, reservations
and rentals in rentals.json. Preserve both across upgrades, and merge new keys into
your config.lua rather than overwriting it.
Placing a station
A station is two placements: the capsule that holds the car, and the terminal the player talks to.
-
/capsuleplace opens the creator. Aim at the ground, Enter locks the capsule, arrows
nudge it, Q and E rotate, Page Up and Page Down change height, Shift makes every step fine, and
the right mouse button looks around. Choose which side of the terminal the capsule sits on.
-
Choose Rental booth (booth with a clerk and a screen) or
Freestanding tablet (compact pedestal) and place it separately — at most 20 m
from the capsule and clear of the bay.
- Name the station, pick the strip colour and the map blip, then save.
The save refuses ground that is not flat: all four corners of each footprint must stand within
23 cm of each other. /capsuleremove edits the nearest placed station.
creator.minimumSpacing (12 m) keeps two stations apart and
creator.maxLocations (100) caps the total.
Ground offsets
creator.capsuleGroundOffset is 0 — the capsule sits at terrain level — while
creator.boothGroundOffset lifts the rental booth 3 cm so it does not z-fight with the
pavement. The freestanding tablet stays on the ground. Cursor snapping and saving use each
component's own offset.
Fixed stations
Stations written into the locations table in config.lua are fixed and
cannot be edited in game — handy for a map you ship with the server. Existing stations saved
before the booth/tablet split keep their original combined layout.
config.lua
Every operator-facing option lives here. Restart the resource after a change; a model or YTYP update also needs a client reconnect.
| Key | What it does |
locale | Notification and creator language: en, bs, de. |
framework | auto, qbx, qb, esx, standalone or custom. auto takes the first of qbx_core, qb-core or es_extended that is running, otherwise standalone. |
keys | auto (qbx_vehiclekeys or qb-vehiclekeys when running), qbx, qb, none or custom. |
interaction | Exactly one is used: 9labs_ui (built-in E prompt, the default), ox_lib TextUI, ox_target or qb-target (eye only, no E prompt), or custom. |
interactKey, interactKeyLabel, interactDistance | Control id (38 = E), the label the prompt shows, and the 3 m reach to the tablet. |
notify | 9labs_ui, ox_lib (default, falls back to 9labs_ui when ox_lib is not running), native or custom. |
display | Title, subtitle, ticker and accent on the world tablet and the sign. animation = false keeps the textures static. |
tablet | DUI texture resolution and the inspect camera: cameraDistance 1.20 m and cameraFov 40 fill about 91% of screen height. |
npc | The clerk in the booth: model, seat offsets, seatHeight, heading and the seated animation. The chair is part of the model, not a spawned prop. |
glow | Strip colour and the creator's presets. DUI replacement is shared, so the nearest station's colour wins. |
spawnDistance / despawnDistance | 85 m and 100 m. Stations are created around the player rather than all at once. |
defaultSide | Creator default, left or right; each station can differ. |
placeAce, creator | Permission, commands, placement limits and ground offsets. |
blips | Map icon defaults plus the sprite and colour lists offered per station in the creator. |
playSounds, sounds | Volume, distance, rolloff, and the rise/door/sink cues on a 30 fps, 0–600 frame timeline. |
ui | Creator and rental HUD theme: colours, currency, number format and screen anchors. |
debug | Step-by-step output in F8. |
Do not convert this resource to require. Every runtime script is loaded
directly by fxmanifest.lua, which is what keeps the escrowed build working.
Fleet and pricing
A plan sets the duration and the base price; each vehicle carries a multiplier. The tablet and the integration API use the same fleet, plans, accounts and stations.
rental = {
accounts = { 'cash', 'bank' },
plans = {
{ id = 'short', minutes = 15, price = 150 },
{ id = 'medium', minutes = 30, price = 250 },
{ id = 'long', minutes = 60, price = 400 },
},
vehicles = {
{ id = 'blista', model = 'blista', label = 'Blista',
category = 'City compact', image = 'images/blista.png', multiplier = 1.0 },
{ id = 'sultan', model = 'sultan', label = 'Sultan',
category = 'Sports sedan', image = 'images/sultan.png', multiplier = 1.5 },
},
returnDistance = 18.0,
returnTimeout = 180,
reservationTimeout = 600,
autoReleaseDistance = 10.0,
requestCooldownMs = 1000,
warningSeconds = 60,
}
How a price is reached
The server always calculates selected plan price × selected vehicle multiplier.
A medium plan at 250 with a sultan multiplier of 1.5 costs 375. Phone
apps read the ready-made prices table on each vehicle and never submit a price.
Timings
| Key | Meaning |
reservationTimeout | Seconds a paid reservation holds its pickup station (600). |
autoReleaseDistance | Reserved vehicles are handed over automatically inside this radius (10 m). |
returnDistance | How close the rental must be to open a return capsule (18 m). |
returnTimeout | Seconds to drive into an opened return bay before it may close (180). |
warningSeconds | How long before the end the renter is warned (60). |
requestCooldownMs | Rate limit on repeated requests from one player. |
The car has to fit
The capsule takes a model up to 2.3 m wide, 5.6 m long and 2.0 m high. Anything larger will not
close. Images are relative to web/; put the PNGs in web/images/.
Standalone credits
With framework = 'standalone' — or auto with no framework running — rentals are paid with credits kept per FiveM license in resource KVP storage.
These are not framework money and never touch a player's bank account.
standalone.startingCash (1000) and standalone.startingBank (0) are
granted once, the first time a license is seen. Refill from the console, or as a creator admin
in game:
capsulecredits <playerId> <amount> [cash|bank]
Free rentals: set every plan price to 0. The flow, cooldowns and the one-rental-per-character rule stay exactly the same.
Rental lifecycle
Since v1.2.0 a rental has states, and the timer does not start until the car is actually in the player's hands.
reserved -> handover -> active -> returning -> returned
|
+-> cancelled / reservation expired
active -> rental expired
| Status | What is true |
reserved | Payment succeeded and the pickup station is held for this character. No vehicle exists and the rental timer has not started. |
handover | The player reached the server-checked pickup radius and the capsule is opening. The vehicle exists but is frozen and cannot be driven. |
active | The capsule is fully open, the vehicle is unfrozen and keys are issued. Only now do handedOverAt and expiresAt exist. |
returning | The rented vehicle passed the final return checks and the capsule is closing. |
returned | Storage is cleared and the vehicle is removed after the return animation. |
Automatic handover
The client runs a low-frequency proximity watcher. When a reserved player enters
autoReleaseDistance, it requests handover on its own. The server independently
validates character identity, routing bucket, player health, actual coordinates, station
ownership, the station lock, capsule occupancy and duplicate requests before it spawns anything.
Do not add a second proximity loop in a phone app unless the UI needs one. The built-in watcher already handles it.
Renting and returning
The world tablet drives both halves for a walk-up rental. Returning is deliberately physical: the car has to be on the platform.
Renting at the station
- Walk up to the tablet — E prompt or target — and choose a car, a duration and an account.
- Confirm. The capsule rises, opens and releases the car with keys and a full tank.
Returning
- Bring the car within
returnDistance of any station. - Open the return capsule from the tablet, or from the bay option when you use a target.
- Drive onto the raised platform within
returnTimeout, step out, and confirm beside the bay.
The car must be stopped and completely inside the platform bounds, with no ped or unrelated
vehicle in the closing area. A rental survives reconnects and restarts; if the vehicle is lost,
the record is marked recoverable and the tablet offers to recover it for the
remaining time. Time keeps running while the player is offline, the renter is warned
warningSeconds before the end, and an expired rental removes the car without
refunding unused time.
Storage and refunds
Two plain JSON files, both in escrow_ignore, both written before money moves.
locations.jsonStations placed in the creator.
rentals.jsonReservations and rentals, including ones whose owner is offline.
The rules the server enforces
- One character can hold only one reservation or rental record.
- A pickup station can hold only one reservation and one active capsule cycle.
- A reservation is written before payment is requested, then finalized after payment succeeds.
- A handover that fails on model load or fit cancels and refunds the reservation automatically.
- Cancellation and online reservation expiry refund the original account.
- An active rental keeps counting down while disconnected, and expiry refunds nothing.
Offline expiry is not auto-refunded. If a paid reservation expires while its owner is
offline, the record moves to refund_pending and the console prints the identity,
amount and account for manual reconciliation — which avoids issuing the same refund twice on a
framework without an idempotent offline-money API.
Never delete or edit rentals.json while the resource is running. Back it up first.
Integration API: overview and data
Any phone can integrate by calling the documented Lua exports from its own client or server code. No protected file is edited, and no phone resource is required.
Return shape
Mutation and network-backed client exports return one of these two shapes. Use code for application logic and error for display — it is already translated for the player.
{ ok = true, ... }
{ ok = false, code = 'stable_code', error = 'Translated message for the player' }
Catalog
GetCatalog returns ready, the stations map,
vehicles, plans, accounts, reservationTimeout
and autoReleaseDistance. Check ready before showing any purchase action.
Station
{
id = 'placed:...',
name = 'Legion Square',
coords = { x = 0.0, y = 0.0, z = 0.0 },
heading = 90.0,
side = 'left',
version = 'booth',
available = true,
revision = 1,
fixed = false,
terminal = {
coords = { x = 0.0, y = 0.0, z = 0.0 },
heading = 180.0,
},
}
terminal is optional; use the main coords for pickup GPS routing.
available is false while the station holds a reservation or an active capsule cycle,
and the server repeats every availability and occupancy check when an action is actually requested.
Vehicle
{
id = 'sultan',
model = 'sultan',
label = 'Sultan',
category = 'Sports sedan',
image = 'images/sultan.png',
multiplier = 1.5,
prices = {
short = 225,
medium = 375,
long = 600,
},
}
prices is added to catalog and export copies, already calculated from server config.
Reservation and rental
{
status = 'reserved',
station = 'placed:...',
vehicle = 'sultan',
plan = 'medium',
model = 'sultan',
plate = 'CP123456',
price = 375,
account = 'bank',
durationSeconds = 1800,
reservedAt = 2000000000,
reservationExpiresAt = 2000000600,
reservationRemaining = 600,
handedOverAt = nil,
expiresAt = nil,
remaining = 1800,
netId = nil,
recoverable = false,
}
After handover, status becomes active, netId holds the
networked vehicle ID, handedOverAt and expiresAt are populated, and
remaining counts down. recoverable becomes true only when an active
persisted rental no longer has a live vehicle, such as after a resource restart.
Server exports
For the authoritative side of a phone resource. Every player-specific export takes the real FiveM server ID first.
local Capsule = exports['9labs_capsulerent']
Capsule:GetCatalog() -- whole catalog, check catalog.ready
Capsule:GetStations() -- station map keyed by station id
Capsule:GetAvailableVehicles(stationId) -- fleet with exact per-plan prices
Capsule:GetRental(source) -- public reservation/rental state, or nil
Capsule:ReserveVehicle(source, {
id = stationId,
vehicle = 'sultan',
plan = 'medium',
account = 'bank',
route = true,
})
Capsule:CancelReservation(source)
Capsule:RequestHandover(source, stationId)
Capsule:EndRental(source, stationId)
Capsule:ConfirmReturn(source, stationId)
Never accept a client-supplied source ID. Use the source of the server
event or callback the request arrived on.
ReserveVehicle
Performs the authoritative identity, duplicate ownership, station availability, account, balance,
price, durable storage and payment checks. It does not spawn the vehicle or start the
rental timer. On success it returns ok, the rental and the
station. GPS routing defaults to enabled; pass route = false when the
app routes the player itself. A positional form is also accepted:
local result = exports['9labs_capsulerent']:ReserveVehicle(
source,
stationId,
'sultan',
'medium',
'bank',
true
)
The rest
CancelReservation — only reserved can be cancelled. It refunds through the same account and bridge and unlocks the pickup station. A handover already in progress cannot be cancelled. RequestHandover — normally the built-in watcher calls this around 10 m. It is public for a manual retry or status action; server distance and ownership checks always apply. EndRental — starts the physical return at a nearby station. The rented vehicle must exist and be inside returnDistance. It opens the capsule; it does not bypass parking or safety checks. ConfirmReturn — call after the vehicle is stopped on the raised platform and the renter has exited. The server verifies the entity, ownership, routing bucket, exact platform bounds, speed and a clear closing area.
Client exports
Network exports yield while waiting for the protected server callback and return a result table, so call them from normal event, thread or callback context.
local Capsule = exports['9labs_capsulerent']
Capsule:GetCatalog() -- network call, returns { ok, catalog }
Capsule:GetStations() -- local synchronized map, no network
Capsule:GetAvailableVehicles(stationId)
Capsule:GetRental() -- immediate local state
Capsule:RefreshRental() -- authoritative refresh
Capsule:ReserveVehicle(stationId, 'sultan', 'medium', 'bank', true)
Capsule:SetPickupRoute(stationId) -- true when the waypoint was set
Capsule:CancelReservation()
Capsule:RequestHandover(stationId) -- defaults to the reservation's station
Capsule:EndRental(stationId) -- defaults to the nearest station
Capsule:ConfirmReturn(stationId)
Local versus authoritative
GetStations and GetRental answer immediately from local state kept
current by server pushes — no network request. RefreshRental is the authoritative
read; use it when opening a phone screen, where one request is worth the certainty.
Defaults
Routing defaults to enabled, and on success the waypoint is set to the capsule pickup
coordinates; SetPickupRoute does the same on its own and returns true
when the station exists locally. The station argument is optional on
RequestHandover (defaults to the reservation's pickup station),
EndRental and ConfirmReturn (nearest synchronized station). Every final
distance, vehicle and ownership check stays server-side.
Lifecycle events
Every lifecycle change fires a local server event and a targeted network client event.
| Event suffix | When it fires |
reservationCreated | Payment and persistent reservation succeeded. |
reservationCancelled | Reservation was cancelled and refunded. |
reservationExpired | The pickup timeout elapsed. |
vehicleHandedOver | Capsule opened, timer started, vehicle released and keys issued. |
rentalReturnStarted | Final platform checks passed and the capsule started closing. |
rentalReturned | Return animation completed, state cleared and vehicle removed. |
rentalExpired | Active rental timer elapsed and the vehicle and state were removed. |
Server listener
AddEventHandler('9labs_capsulerent:server:vehicleHandedOver', function(src, data)
print(('Capsule handover: source=%s owner=%s plate=%s'):format(
src,
data.owner,
data.plate
))
end)
Arguments are (src, data), where data.owner is the stable bridge
identity. src is 0 when an expiry happens while the player is offline.
Client listener
RegisterNetEvent('9labs_capsulerent:client:vehicleHandedOver', function(data)
if source ~= 65535 then return end
print(('Rental active until %s'):format(data.expiresAt))
end)
Keep the source == 65535 check so another client resource cannot spoof a server lifecycle notification.
Full names
9labs_capsulerent:server:reservationCreated
9labs_capsulerent:server:reservationCancelled
9labs_capsulerent:server:reservationExpired
9labs_capsulerent:server:vehicleHandedOver
9labs_capsulerent:server:rentalReturnStarted
9labs_capsulerent:server:rentalReturned
9labs_capsulerent:server:rentalExpired
9labs_capsulerent:client:reservationCreated
9labs_capsulerent:client:reservationCancelled
9labs_capsulerent:client:reservationExpired
9labs_capsulerent:client:vehicleHandedOver
9labs_capsulerent:client:rentalReturnStarted
9labs_capsulerent:client:rentalReturned
9labs_capsulerent:client:rentalExpired
Phone workflow
The recommended order, and two examples that already follow it.
- When the rental app opens, call
GetCatalog and RefreshRental. - With no current rental, render only stations whose
available is true. - Render
catalog.vehicles, catalog.plans and each vehicle's exact prices[plan.id]. - Submit station, vehicle, plan and account IDs to
ReserveVehicle. - On success show
reserved and route the player to result.station.coords. - On
vehicleHandedOver, switch the UI to the active countdown using expiresAt. - To return, call
EndRental near a station, guide the player onto the platform, then call ConfirmReturn after they exit — the built-in world interaction can also do the final confirmation.
Client-only integration
local Capsule = exports['9labs_capsulerent']
local function openRentalApp()
local catalogResult = Capsule:GetCatalog()
if not catalogResult.ok then
return { ok = false, code = catalogResult.code, error = catalogResult.error }
end
local rentalResult = Capsule:RefreshRental()
if not rentalResult.ok then
return rentalResult
end
return {
ok = true,
catalog = catalogResult.catalog,
rental = rentalResult.rental,
}
end
local function reserveFromPhone(data)
return Capsule:ReserveVehicle({
id = data.station,
vehicle = data.vehicle,
plan = data.plan,
account = data.account,
route = true,
})
end
Server-backed callback
local Capsule = exports['9labs_capsulerent']
RegisterNetEvent('my_phone:server:reserveCapsule', function(data)
local src = source
if type(data) ~= 'table' then return end
local result = Capsule:ReserveVehicle(src, {
id = data.station,
vehicle = data.vehicle,
plan = data.plan,
account = data.account,
route = true,
})
TriggerClientEvent('my_phone:client:capsuleResult', src, result)
end)
The example deliberately uses the real server event source. Capsule Rental still
validates every ID, the money, ownership, locks and distance itself, but the phone resource
should also apply its normal callback authentication and request throttling.
Error codes
Stable codes for your logic, translated messages for the player.
| Code | Meaning |
invalid | Invalid IDs, state or request data. |
unavailable | Resource storage, framework or player state is not ready. |
rate_limit | The player repeated the request too quickly. |
busy | Station, owner or capsule is already processing another action. |
already_rented | This character already owns a reservation or rental. |
insufficient | The selected server-side account cannot cover the calculated price. |
too_far | Server coordinates are outside the required pickup or return radius. |
model_failed | The configured vehicle cannot load, fit or spawn safely. |
storage_failed | Persistent JSON state could not be saved. |
refund_failed | The payment bridge could not return reservation funds. |
bay_blocked | A ped or vehicle blocks the capsule bay. |
bring_scooter | The rented vehicle is not close enough to the return station. |
vehicle_moving | The returned vehicle has not stopped. |
park_straight | The vehicle origin is outside the physical platform bounds. |
clear_platform | A ped or unrelated vehicle remains inside the closing area. |
Framework, money and keys
Both bridge files are open and escrow-exempt, and each starts with a CUSTOM block. Every built-in adapter lives below that block and needs no edits.
Server bridge
-- server/bridge.lua, CUSTOM block
Custom.identity = function(source) return 'char:' .. source end
Custom.getMoney = function(source, account) return 0 end
Custom.removeMoney = function(source, account, amount) return false end
Custom.addMoney = function(source, account, amount) end
Custom.isAdmin = function(source) return false end
-- used when keys = 'custom'
Custom.giveKeys = function(source, plate, vehicle) end
Custom.removeKeys = function(source, plate) end
Custom.identity, getMoney, removeMoney, addMoney
and isAdmin are used when framework = 'custom';
giveKeys and removeKeys when keys = 'custom'.
Client bridge
-- client/bridge.lua, CUSTOM block
Custom.showPrompt = function(text) end
Custom.hidePrompt = function() end
Custom.notify = function(message, type) end
Custom.addTarget = function(entity, options) end
Custom.removeTarget = function(entity) end
showPrompt and hidePrompt, notify, addTarget
and removeTarget are used when interaction = 'custom' or
notify = 'custom'.
Nothing in the bridges is a network endpoint. Prices, ownership, distance and cooldowns
stay in the protected code, so an edited bridge cannot be used to hand out free cars.
Legacy exports
Integrations written before v1.2.0 keep working unchanged.
-- server
exports['9labs_capsulerent']:getStations()
exports['9labs_capsulerent']:getRental(source)
-- client
exports['9labs_capsulerent']:getLocations()
exports['9labs_capsulerent']:getNearestStation()
exports['9labs_capsulerent']:openStation(stationId)
exports['9labs_capsulerent']:previewStation(stationId, delay, vehicle, hold)
exports['9labs_capsulerent']:clearPreview(stationId)
previewStation plays one open, hold and close cycle with a local car and no rental
behind it, for trailers and camera work. stationId may be 'near' (the
station closest to the rendered camera), delay is seconds before it starts,
vehicle a model name or 'none', and hold the seconds the
capsule stays open. The station must be loaded on that client, so stay within
spawnDistance of it. The same thing from chat:
/capsulepreview near sultan 0 8
Troubleshooting
Start here
/capsulediag prints the state of every loaded station and its vehicle. Set
debug = true for step-by-step output in F8.
The catalog is empty
- Confirm
9labs_capsulerent is actually started. - Confirm at least one station exists in
locations.json or config.lua. - Check
catalog.ready and the server console for invalid storage errors.
A reservation returns busy
- Refresh the catalog and inspect
station.available. - Make sure another reservation or capsule cycle does not already own the station.
- Clear vehicles and peds physically blocking the capsule footprint.
The reservation works but no capsule opens
- Confirm the rental state is
reserved. - Confirm the player is inside
autoReleaseDistance, in routing bucket 0 and alive. - Call
RequestHandover once and display its stable code. - Verify the configured vehicle model exists and fits the capsule dimensions.
Handover worked but the timer already lost time
Inspect handedOverAt and expiresAt. In v1.2.0, expiresAt
must be absent during reserved and handover, then equal
handedOverAt + durationSeconds once the status becomes active.
The return is rejected
EndRental needs the actual rental vehicle near the selected station. ConfirmReturn needs the player on foot, the vehicle stopped and completely inside the raised platform. - Show the returned
code to tell bring_scooter, vehicle_moving, park_straight and clear_platform apart.
The models are invisible after the first start
Reconnect once. Streamed assets added by a freshly started resource are not loaded for a client already in the session.
The creator refuses to save
The flatness check failed: all four corners of each footprint must stand within 23 cm of each
other. The terminal must also be within 20 m of the capsule and clear of the bay.