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.

  1. 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.
  2. Extract the delivered 9labs_capsulerent folder into the server's resources directory.
  3. Start it after the framework and the key resource you use.
  4. Give an administrator access to the station creator.
  5. 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.

  1. /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.
  2. 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.
  3. 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.

KeyWhat it does
localeNotification and creator language: en, bs, de.
frameworkauto, qbx, qb, esx, standalone or custom. auto takes the first of qbx_core, qb-core or es_extended that is running, otherwise standalone.
keysauto (qbx_vehiclekeys or qb-vehiclekeys when running), qbx, qb, none or custom.
interactionExactly 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, interactDistanceControl id (38 = E), the label the prompt shows, and the 3 m reach to the tablet.
notify9labs_ui, ox_lib (default, falls back to 9labs_ui when ox_lib is not running), native or custom.
displayTitle, subtitle, ticker and accent on the world tablet and the sign. animation = false keeps the textures static.
tabletDUI texture resolution and the inspect camera: cameraDistance 1.20 m and cameraFov 40 fill about 91% of screen height.
npcThe clerk in the booth: model, seat offsets, seatHeight, heading and the seated animation. The chair is part of the model, not a spawned prop.
glowStrip colour and the creator's presets. DUI replacement is shared, so the nearest station's colour wins.
spawnDistance / despawnDistance85 m and 100 m. Stations are created around the player rather than all at once.
defaultSideCreator default, left or right; each station can differ.
placeAce, creatorPermission, commands, placement limits and ground offsets.
blipsMap icon defaults plus the sprite and colour lists offered per station in the creator.
playSounds, soundsVolume, distance, rolloff, and the rise/door/sink cues on a 30 fps, 0–600 frame timeline.
uiCreator and rental HUD theme: colours, currency, number format and screen anchors.
debugStep-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

KeyMeaning
reservationTimeoutSeconds a paid reservation holds its pickup station (600).
autoReleaseDistanceReserved vehicles are handed over automatically inside this radius (10 m).
returnDistanceHow close the rental must be to open a return capsule (18 m).
returnTimeoutSeconds to drive into an opened return bay before it may close (180).
warningSecondsHow long before the end the renter is warned (60).
requestCooldownMsRate 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
StatusWhat is true
reservedPayment succeeded and the pickup station is held for this character. No vehicle exists and the rental timer has not started.
handoverThe player reached the server-checked pickup radius and the capsule is opening. The vehicle exists but is frozen and cannot be driven.
activeThe capsule is fully open, the vehicle is unfrozen and keys are issued. Only now do handedOverAt and expiresAt exist.
returningThe rented vehicle passed the final return checks and the capsule is closing.
returnedStorage 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

  1. Walk up to the tablet — E prompt or target — and choose a car, a duration and an account.
  2. Confirm. The capsule rises, opens and releases the car with keys and a full tank.

Returning

  1. Bring the car within returnDistance of any station.
  2. Open the return capsule from the tablet, or from the bay option when you use a target.
  3. 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 suffixWhen it fires
reservationCreatedPayment and persistent reservation succeeded.
reservationCancelledReservation was cancelled and refunded.
reservationExpiredThe pickup timeout elapsed.
vehicleHandedOverCapsule opened, timer started, vehicle released and keys issued.
rentalReturnStartedFinal platform checks passed and the capsule started closing.
rentalReturnedReturn animation completed, state cleared and vehicle removed.
rentalExpiredActive 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.

  1. When the rental app opens, call GetCatalog and RefreshRental.
  2. With no current rental, render only stations whose available is true.
  3. Render catalog.vehicles, catalog.plans and each vehicle's exact prices[plan.id].
  4. Submit station, vehicle, plan and account IDs to ReserveVehicle.
  5. On success show reserved and route the player to result.station.coords.
  6. On vehicleHandedOver, switch the UI to the active countdown using expiresAt.
  7. 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.

CodeMeaning
invalidInvalid IDs, state or request data.
unavailableResource storage, framework or player state is not ready.
rate_limitThe player repeated the request too quickly.
busyStation, owner or capsule is already processing another action.
already_rentedThis character already owns a reservation or rental.
insufficientThe selected server-side account cannot cover the calculated price.
too_farServer coordinates are outside the required pickup or return radius.
model_failedThe configured vehicle cannot load, fit or spawn safely.
storage_failedPersistent JSON state could not be saved.
refund_failedThe payment bridge could not return reservation funds.
bay_blockedA ped or vehicle blocks the capsule bay.
bring_scooterThe rented vehicle is not close enough to the return station.
vehicle_movingThe returned vehicle has not stopped.
park_straightThe vehicle origin is outside the physical platform bounds.
clear_platformA 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.