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.
- Extract the delivered
9labs_scootbunker folder into your server's resources directory. - Make sure the configured scooter model is streamed, and that
rental.vehicleType matches the type declared in its vehicles.meta. - Start the framework, target and key resources before SCOOT.
- Give administrators access to the station creator.
- Configure
config.lua, start the resource, and reconnect once after a model or YTYP update so the streamed assets refresh. - 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
| Value | Behaviour |
auto | First running resource, in this order: Qbox, QBCore, ESX; otherwise standalone. |
qbx | Uses qbx_core. |
qb | Uses qb-core. |
esx | Uses es_extended. |
standalone | Persistent SCOOT rental credits stored per FiveM license. |
custom | The 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
| Value | Behaviour |
auto | qbx_vehiclekeys, then qb-vehiclekeys, when one is running. |
qbx | Qbox vehicle keys. |
qb | QBCore vehicle keys. |
none | No keys are issued or removed. |
custom | Custom.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'
| Value | Behaviour |
9labs_ui | Built-in E prompt. No external target or UI library required. |
ox_lib | ox_lib TextUI prompt, falling back to the built-in prompt when ox_lib is unavailable. |
ox_target | ox_target eye interaction only, no E prompt. |
qb-target | qb-target eye interaction only, no E prompt. |
custom | The 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.
- Request
getStations, getPlans and getRental from the phone's server code. - Show stations, durations, prices and payment methods.
- Let the player pick a station and set a waypoint to its main
coords. - When the player reaches the station and is on foot, call the phone's server rent callback.
- Display the returned
remaining, expiresAt and plate. - Refresh on
rentalStarted and rentalEnded. - 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.