9LABS Scoot Rental · v4.1.0

A scooter kiosk under the pavement.

Place stations in game, set the plans, and let the bunker rise, open its hatch and push a scooter out on a fork. Everything that costs money is decided on the server.

Introduction

A server-authoritative scooter rental. The player picks a duration and a payment account at a station, the underground bunker rises, opens and physically dispenses the configured scooter. It can be returned at any SCOOT station.

What is in the resource

  • Animated underground rental bunkers, and optional independently placed world-space DUI tablets.
  • An in-game station creator, with placed stations and active rentals persisted to disk.
  • Qbox, QBCore, ESX, standalone and custom framework support, plus optional vehicle keys.
  • Built-in prompt, ox_lib, ox_target, qb-target and custom interaction modes.
  • Configurable plans, accounts, colours, logos, blips, sounds and UI placement.
  • Server and client exports for phone applications, and lifecycle events for start, return and expiry.

The server decides everything that costs money. A client or phone application cannot choose the price, bypass the station distance, create a second rental or grant itself ownership by calling an export.

Requirements

One framework choice, one streamed scooter model, OneSync. Everything else is optional.

ServerFiveM/FXServer with OneSync enabled.
Scooter modelThe 9LABS labscoot scooter is included and streamed by the resource. Any other model named in ScootConfig.scooterModel must be streamed by you.
FrameworkQbox (qbx_core), QBCore (qb-core), ESX (es_extended), standalone rental credits, or a custom server bridge.
Vehicle keysOptional: qbx_vehiclekeys, qb-vehiclekeys, or a custom bridge.

Optional resources

ox_target, qb-target and ox_lib are only needed when the configuration selects them.

ox_lib is not a dependency. The built-in 9labs_ui prompt and notifications work without it. Do not remove ox_lib from your server merely because SCOOT does not need it — other resources or your framework may still depend on it.

Installation

Start it after the framework, target and key resources, then place a station.

  1. Extract the delivered 9labs_scootbunker folder into your server's resources directory.
  2. Make sure the configured scooter model is streamed, and that rental.vehicleType matches the type declared in its vehicles.meta.
  3. Start the framework, target and key resources before SCOOT.
  4. Give administrators access to the station creator.
  5. Configure config.lua, start the resource, and reconnect once after a model or YTYP update so the streamed assets refresh.
  6. Run /scootplace in game to place stations.

Example for Qbox with ox_target and Qbox vehicle keys:

ensure qbx_core
ensure qbx_vehiclekeys
ensure ox_target
ensure 9labs_scootbunker
add_ace group.admin scootbunker.place allow

Keep the folder name. It must stay 9labs_scootbunker for the export examples in this manual to work.

Updating

Replace the resource files together, then put your own files back.

Back these up before you start:

config.lua
locations.json
rentals.json
server/bridge.lua
client/bridge.lua
logo/logo.png
locales/

Merge new configuration keys into your config and preserve locations.json and rentals.json. Stations saved by an older version keep working — they do not need converting to the newer tablet station format.

Asset Escrow and editable files

The release is built for Cfx Asset Escrow. These files are explicitly excluded from encryption by fxmanifest.lua:

config.lua
client/bridge.lua
server/bridge.lua
locales/*.lua
logo/*
locations.json
rentals.json

Frameworks, money, keys, targets, prompts, notifications, translations, logos and storage are all configurable without touching a protected implementation file.

Bunker and tablet stations

Every station has a bunker plus a separate static rounded frame. The frame does not animate with the machine underneath it.

Bunker interaction

The player interacts at the bunker service point. Depending on the configuration, the resource opens its built-in rental menu, or fires the external UI event for your own interface.

DUI tablet

A tablet station adds a physical world-space tablet that is positioned and rotated independently of the bunker. It has to stay outside the bunker and the scooter exit path, and within tablet.maxDistance of its bunker.

tablet = {
    maxDistance    = 12.0,
    cameraDistance = 1.6,
    cameraFov      = 40.0,
    autoFit        = true,
}

The prompt or target moves to the tablet, but the scooter is still released by the bunker.

Tablet stations always use their physical DUI. rental.externalUi replaces the menu for bunker-only stations, never for a tablet.

In-game creator

Aim at the ground, lock the position, adjust it and save. The creator shows its current keyboard controls on screen.

/scootplace             -- open the creator, place or edit stations
/scootremove            -- station list and removal
/scootdiag              -- animation and bone diagnostics for the nearest bunker
/scootglow <texture>    -- test the runtime glow texture
/scootsound ...         -- sound diagnostics

The creator places bunker-only and DUI-tablet stations. Placed stations are stored in locations.json and can be edited or deleted in game. Stations defined in config.lua are fixed and cannot be edited through the creator.

Static bunker station

locations = {
    {
        coords  = vec3(-1037.5, -2737.2, 19.10),
        heading = 150.0,
        name    = 'Airport SCOOT',
        color   = '#00e5b4',
    },
}

Static tablet station

locations = {
    {
        coords  = vec3(-1037.5, -2737.2, 19.10),
        heading = 150.0,
        name    = 'Airport SCOOT',
        terminal = {
            coords  = { x = -1034.8, y = -2736.2, z = 19.10 },
            heading = 240.0,
        },
    },
}

heading is the direction the bunker door faces, which is also the direction the scooter is dispensed.

Ground and streaming

Bunkers follow mild slopes and are created around each player rather than all at once.

groundAlign     = true
maxSlope        = 12.0
spawnDistance   = 50.0
despawnDistance = 55.0

With groundAlign enabled, the bunker and its frame follow mild ground slopes; anything steeper than maxSlope is clamped. The gap between spawnDistance and despawnDistance is deliberate — it stops a kiosk flickering while a player stands on the edge of the radius.

Logo, glow and blips

One PNG brands every bunker, and each station can carry its own strip colour and map icon.

Your logo

Place a transparent 1024×1024 PNG at logo/logo.png. It is composited at runtime over the supplied door and roof base textures, so give it a real alpha channel for clean edges.

Glow

Colours accept #rrggbb, rrggbb, 0xRRGGBB, #rgb or aarrggbb. With glow.perStation = true a placed station stores its own colour. The emissive strip uses one shared runtime texture, so the nearest station's colour is the one displayed as the player moves between stations.

Blips

Every station gets a map blip unless it is disabled globally or individually. Placed stations may override sprite, colour, scale, short-range mode, flashing and label from the creator.

Framework and keys

Normal setup happens in config.lua through the global ScootConfig table.

Framework

ValueBehaviour
autoFirst running resource, in this order: Qbox, QBCore, ESX; otherwise standalone.
qbxUses qbx_core.
qbUses qb-core.
esxUses es_extended.
standalonePersistent SCOOT rental credits stored per FiveM license.
customThe functions at the top of server/bridge.lua.

Pin it on a production server. With auto, a change in resource start order can change which adapter is chosen.

Vehicle keys

ValueBehaviour
autoqbx_vehiclekeys, then qb-vehiclekeys, when one is running.
qbxQbox vehicle keys.
qbQBCore vehicle keys.
noneNo keys are issued or removed.
customCustom.giveKeys and Custom.removeKeys in server/bridge.lua.

Interaction and notifications

Exactly one interaction mode is active at a time.

interaction      = '9labs_ui'
interactKey      = 38
interactKeyLabel = 'E'
interactDistance = 4.5

notify = '9labs_ui'
ValueBehaviour
9labs_uiBuilt-in E prompt. No external target or UI library required.
ox_libox_lib TextUI prompt, falling back to the built-in prompt when ox_lib is unavailable.
ox_targetox_target eye interaction only, no E prompt.
qb-targetqb-target eye interaction only, no E prompt.
customThe prompt and target functions in client/bridge.lua.

The Tebex package ships with interaction = '9labs_ui'; change it to whatever your server uses. notify accepts 9labs_ui, ox_lib, native and custom.

Plans and payment

A plan is an id, a duration and a price. The server picks the plan and its authoritative price by id.

rental = {
    plans = {
        { id = 'short',  minutes = 15, price = 75 },
        { id = 'medium', minutes = 30, price = 125 },
        { id = 'long',   minutes = 60, price = 200 },
    },
    accounts          = { 'cash', 'bank' },
    vehicleType       = 'bike',
    warningSeconds    = 60,
    returnDistance    = 8.0,
    requestCooldownMs = 1000,
    externalUi        = false,
}
  • Plan ids must be unique strings.
  • Minutes and prices must be whole numbers; a duration is at least one minute and a price may be zero.
  • Payment accounts may contain cash, bank or both.
  • One active rental per character. Time continues while the player is disconnected, and returning early refunds nothing.

Never accept a price from a client application. Send the plan id and let the server resolve the price.

The scooter model

The resource rents whichever vehicle you name, as long as the type matches.

scooterModel = 'labscoot'

rental = {
    vehicleType = 'bike',
}

The 9LABS scooter — labscoot, the default — ships with the resource and is streamed by it, so a fresh install rents a scooter without any extra download. What the package does not supply is an arbitrary third-party vehicle: point scooterModel at another model and streaming it becomes your job, from this or another resource. Either way, scooterModel must exist on the server, and vehicleType must match that model's vehicles.meta type, such as bike or automobile.

A type mismatch is the usual cause of a scooter that spawns and then behaves strangely, or does not appear at all.

Standalone credits

Standalone mode never touches framework money. It keeps a separate cash and bank rental wallet per FiveM license in resource KVP storage.

standalone = {
    startingCash = 1000,
    startingBank = 0,
}

Add credits from the server console, or in game as an authorized creator admin:

scootcredits <playerId> <positiveAmount> [cash|bank]

Free rentals: set every plan price to 0. Every other rule stays in place.

Rental lifecycle

v4.1.0 has a direct flow: a successful rent call is the handover, not a booking.

select station + plan + account
              |
              v
server validates player, distance, ownership, station, balance and storage
              |
              v
payment succeeds -> bunker begins dispensing -> rental is active
              |
              +--------------------+
              |                    |
              v                    v
      returned at a station     timer expires
  • The server creates and charges the rental only while the player is alive, on foot, in routing bucket 0 and within interactDistance of the selected station service point.
  • A successful rent immediately begins the bunker release cycle.
  • The duration begins at the release point, and the calculated expiry includes the release-animation offset.
  • The server enforces one rental per character, per-player and per-station locks, station busy state, a request cooldown, the account balance, persistent storage and model spawning.
  • A rental may be returned at any valid station once its scooter is within rental.returnDistance of it.
  • Unused time is not refunded, and the timer continues while the player is offline.

What the phone API cannot do

There is no remote reserved state in v4.1.0. The phone API lists stations and plans from anywhere, shows the active rental and routes the player to a station — but it cannot buy a rental remotely and hold it for later. Call rent after the player reaches the station; that distance check is intentional and is repeated by the protected server code.

There are also no public reserve, cancel or proximity auto-release exports and events, so do not build a phone UI that promises those states.

Persistence and recovery

Two plain JSON files, both Asset Escrow-exempt, both loaded when the resource starts.

locations.jsonStations placed in the creator.
rentals.jsonActive rentals, including ones whose owner is offline.

A rental that outlived its scooter

An active timer continues across disconnects and restarts. If the rental is still valid but its scooter entity is gone, getRental returns recoverable = true, and the built-in station menu lets the player collect a replacement scooter for the remaining time. No new payment is taken.

Payment safety

The server writes a provisional rental before charging. If it stops inside that narrow window, startup removes the stale provisional entry and prints the owner and the amount in the console so an administrator can decide whether a manual refund is needed. If storage is invalid or cannot be written, rental requests fail rather than continuing on unsafe in-memory payment state.

Server exports

Call these from server-side code only, and always derive the player from the event's own source.

local SCOOT = exports['9labs_scootbunker']

SCOOT:getStations()   -- every static and placed station, keyed by id
SCOOT:getPlans()      -- plans, accounts and the display currency
SCOOT:getRental(source)
SCOOT:rent(source, stationId, planId, account)
SCOOT:returnRental(source, stationId)

Return shapes

{ ok = true }

{ ok = true, rental = Rental }

{ ok = false, error = 'Translated message for the player' }

There is no machine-readable error code in v4.1.0. error is a localized display message — show it to the player and refresh state, never branch your phone logic on the English text.

getStations

Returns every static and creator-placed station, indexed by station id:

{
    id      = 'placed:...',
    coords  = { x = -1037.5, y = -2737.2, z = 19.10 },
    heading = 150.0,
    name    = 'Airport SCOOT',
    color   = '#00e5b4',
    blip    = {
        enabled = true,
        sprite  = 226,
        color   = 3,
        scale   = 0.75,
    },
    fixed    = false,
    version  = 'bunker', -- or 'tablet'
    terminal = nil,      -- tablet transform when present
    busy     = false,
}

busy is a live snapshot of the release animation; let the server revalidate availability at rent time rather than treating a fetched busy = false as a lock. fixed is true for stations from config.lua, and name, color, blip and terminal may be absent when defaults are used.

getPlans and getRental

getPlans returns the configured plans, the allowed accounts and the display currency. getRental returns the public rental form, or nil when there is none:

{
    plate       = 'SC123456',
    remaining   = 899,
    expiresAt   = 2000000899,
    netId       = 123,
    recoverable = false,
}

rent

local result = exports['9labs_scootbunker']:rent(source, 'static:1', 'short', 'bank')

if result.ok then
    print(('Scooter %s expires at %s'):format(
        result.rental.plate,
        result.rental.expiresAt
    ))
else
    print(result.error)
end

The player must be alive, on foot, in routing bucket 0 and within interactDistance of the station. Plan, account, price, balance, duplicate rental, cooldown, storage and station state are all checked again on the server.

returnRental

Ends the rental when the rented scooter is within rental.returnDistance of the selected station. The export validates ownership and the scooter's distance, but — unlike the built-in station callback — it does not itself require the player's ped to be next to the station, so a phone wrapper that wants the physical behaviour should apply its own proximity policy first. On success the rental is removed from storage, keys are removed where supported, the scooter is deleted and scootbunker:rentalEnded fires with reason returned.

Client exports

These work on the local player's streamed station state only.

local SCOOT = exports['9labs_scootbunker']

SCOOT:getLocations()        -- stations last synchronized to this client
SCOOT:getNearestStation()   -- stationId, distance (nil, nil when nothing is loaded)
SCOOT:openStation(stationId)
SCOOT:previewStation(stationId, delay, options)
SCOOT:clearPreview(stationId)

getLocations returns the station table last synchronized to this client, which may be empty for the first few seconds after startup. getNearestStation measures from the player to the main bunker coordinates and returns nil, nil when no station data is loaded. openStation runs the same open flow as walking up to it — the server still validates distance and station state — and it does not return the server mutation result.

previewStation

local started = exports['9labs_scootbunker']:previewStation(
    stationId,
    1.5,
    {
        scooter = true,
        keep    = false,
        cleanup = 45,
    }
)

A local demonstration cycle: no server call, no money, no rental, and the scooter is a local entity. scooter = false runs the cycle with an empty fork, keep = true leaves the preview scooter where it lands, and cleanup overrides the 45-second default. It returns false when the station is not streamed, is busy, or its bunker entity does not exist. clearPreview removes the local scooter when it is safe to delete.

Events and external UI

Client: stationOpened

Local client event, emitted only for bunker-only stations when rental.externalUi = true:

AddEventHandler('scootbunker:stationOpened', function(stationId, data)
    -- data.ok       == true
    -- data.station  station data
    -- data.plans    configured rental plans
    -- data.accounts allowed payment accounts
    -- data.balance  { cash = number, bank = number }
    -- data.rental   active rental or nil
    -- data.busy     current station busy snapshot

    OpenMyPhoneScootScreen(stationId, data)
end)

This is a local integration event, not a purchase endpoint. The rental itself must still go through your phone's server-side wrapper and the rent export.

Server: rentalStarted

Fires after the rental is stored and charged and the handover cycle has started — use it for phone refreshes, analytics or logs.

AddEventHandler('scootbunker:rentalStarted', function(src, info)
    -- info.station, info.plan, info.price, info.account,
    -- info.plate, info.minutes, info.expiresAt, info.netId
end)

Server: rentalEnded

Fires on a successful return, or on expiry for an online player.

AddEventHandler('scootbunker:rentalEnded', function(src, info)
    -- info.station
    -- info.reason == 'returned' or 'expired'
end)

Offline rentals are expired and cleaned up too, but an offline player has no current server id, so do not expect a rentalEnded callback with src for an expiry processed while the owner is disconnected.

Phone workflow

List from anywhere, rent at the station. Keep every mutation on the server side of your phone resource.

  1. Request getStations, getPlans and getRental from the phone's server code.
  2. Show stations, durations, prices and payment methods.
  3. Let the player pick a station and set a waypoint to its main coords.
  4. When the player reaches the station and is on foot, call the phone's server rent callback.
  5. Display the returned remaining, expiresAt and plate.
  6. Refresh on rentalStarted and rentalEnded.
  7. Return the scooter only once it is inside the configured return radius of a station.

Server side

local SCOOT = exports['9labs_scootbunker']

RegisterNetEvent('myphone:scoot:requestCatalog', function()
    local src = source

    TriggerClientEvent('myphone:scoot:catalog', src, {
        stations = SCOOT:getStations(),
        rental   = SCOOT:getRental(src),
        catalog  = SCOOT:getPlans(),
    })
end)

RegisterNetEvent('myphone:scoot:rent', function(stationId, planId, account)
    local src = source -- never take this value from the client payload
    local result = SCOOT:rent(src, stationId, planId, account)

    TriggerClientEvent('myphone:scoot:rentResult', src, result)
end)

RegisterNetEvent('myphone:scoot:returnRental', function(stationId)
    local src = source
    local result = SCOOT:returnRental(src, stationId)

    TriggerClientEvent('myphone:scoot:returnResult', src, result)
end)

Add an application-level cooldown to catalog requests if the phone can refresh them frequently; the mutation exports already apply their own validation and rent cooldown.

Client GPS routing

RegisterNetEvent('myphone:scoot:setRoute', function(coords)
    if type(coords) ~= 'table' then return end

    local x, y = tonumber(coords.x), tonumber(coords.y)
    if not x or not y then return end

    SetNewWaypoint(x + 0.0, y + 0.0)
end)

Security rules

What the protected server code validates, and what your integration must not do.

Validated on the server

  • Player and character identity.
  • Routing bucket, player health and the on-foot requirement for a purchase.
  • Real player coordinates for a purchase.
  • Station existence and current busy state.
  • Plan and payment-account allowlists.
  • The server-configured price and the current balance.
  • One active rental per character, per-owner and per-station locks, and request cooldowns.
  • Entity model spawn success and network ownership.
  • Persistent storage before and after payment.
  • Rental ownership and the rented scooter's distance for a return.

Integration rules

  • Call mutation exports only from server-side code.
  • Derive the player id from the network event or callback source.
  • Never accept a price, balance, expiry, plate, network id or owner identity from the client.
  • Treat catalog data as display data; the server revalidates it at action time.
  • Do not create a second client-side payment path around the rent export.
  • Rate-limit public phone catalog endpoints in your phone framework.
  • Display export errors, then refresh authoritative state.

Custom bridges

Both bridge files are Asset Escrow-exempt, so no protected file needs editing.

Framework, money and keys

Set framework = 'custom' and/or keys = 'custom', then implement the CUSTOM functions:

-- server/bridge.lua, CUSTOM block
Custom.identity(src)
Custom.getMoney(src, account)
Custom.removeMoney(src, account, amount, reason)
Custom.addMoney(src, account, amount, reason)
Custom.isAdmin(src)

-- used when keys = 'custom'
Custom.giveKeys(src, vehicle, plate)
Custom.removeKeys(src, vehicle, plate)

Custom.identity must return a stable, non-empty string for the current character — rentals are indexed by it, so a temporary server id will not do. Money functions must return accurate values and booleans: the resource checks the balance before charging, and calls addMoney only when transaction recovery requires a refund. vehicle in the key functions is a server entity handle.

Prompt, target and notifications

Set interaction = 'custom' and/or notify = 'custom', then implement:

-- client/bridge.lua, CUSTOM block
Custom.showPrompt(text, key)
Custom.hidePrompt()
Custom.addTarget(id, coords, radius, distance, options)
Custom.removeTarget(handle)
Custom.notify(data)

A custom target registration must return its removal handle, or nil when no target is created. The resource still handles the station action itself and the server still validates it.

Troubleshooting

The station appears but no scooter is dispensed

  • Confirm scooterModel is streamed and spelled exactly as its model name.
  • Confirm rental.vehicleType matches its vehicles.meta type.
  • Confirm OneSync is enabled.
  • Reconnect after a streamed model or YTYP change.
  • Set debug = true and read the client F8 console.

The phone rent call says the player is too far away

Expected. v4.1.0 lists stations remotely but starts rentals only at a physical station: the player must be within interactDistance, alive, on foot and in routing bucket 0.

A target does not appear

  • Verify the configured interaction name matches ox_target or qb-target exactly.
  • Start the target resource before 9labs_scootbunker.
  • For custom, verify Custom.addTarget returns a handle.
  • Tablet stations target the tablet position, not the bunker service point.

The menu does not use the external phone UI

  • Set rental.externalUi = true.
  • Listen for the local client event scootbunker:stationOpened.
  • Test with a bunker-only station — a physical tablet station deliberately keeps its DUI.

The player cannot return the scooter

  • Use a valid station id.
  • Bring the scooter within rental.returnDistance of that station.
  • With the built-in station UI, bring the player within interactDistance as well.
  • Wait until the original station has finished its dispensing cycle.

Money or keys do not work with a custom framework

  • Select framework = 'custom' and/or keys = 'custom'.
  • Implement the matching functions in server/bridge.lua.
  • Make sure the identity is stable per character.
  • Make sure the money functions return true only after the transaction succeeded.

A rental survived a restart but has no scooter

Expected recovery behaviour. Open a station while the rental still has time left and use Collect remaining rental; no new payment is taken.

When you ask for support

Include the resource version, the selected framework, keys, interaction and notification modes, the console and F8 errors, whether the station is static or placed and bunker-only or tablet, the configured scooter model and vehicleType, whether it also happens through the built-in UI, and the steps to reproduce it. Never post license keys, Tebex credentials, full server configs or framework credentials publicly.