Quick navigation

Go to

Vehicles & driving

Network vehicles

Network vehicle identity, streaming, authority leases, seats and proxy replication, doors and glass, damage profiles, and the full asymmetric server and client Lua API.

64 min read14,162 wordsMarkdown
On this page12 sections

Create and manage network vehicles on the server. Resources with world.vehicles control canonical state and lifecycle; clients render nearby projections.

Passenger drive-by documents handheld window combat, automatic window synchronization, and Lua controls for enabling, restricting and reading the action on the client and server (Unstable .117, protocol 1.42).

Find weapon-equipped model IDs in the armed vehicle spawn catalogue, including mount declarations and special-purpose variants.

For weapon replication, release compatibility, damage policy, current limitations and the twelve client-side armament, ammo, heat and selection queries, see the dedicated armed vehicles and weapon Lua API guide.

Changing seats covers animated in-cabin moves, automatic driver takeover, cancellation and Lua lifecycle events.

Manifest

permissions { "world.vehicles", "vehicles.read", "vehicles.presentation", "vehicles.control" }

world.vehicles is a server permission. vehicles.read exposes the read-only client projection. vehicles.presentation is optional and permits a client resource to select the visual entry path for an occupant whose exact player, vehicle, and seat were already validated by the server. vehicles.control permits locally owned horn control and server-admitted seat requests for the local player. It does not grant authority over another player's vehicle.

Complete Lua API inventory

The vehicle surface is intentionally asymmetric:

Runtime Surface Mutation
Server Open77.vehicles.* Authoritative lifecycle, seats, lock state, horn routing, damage, detached parts, openings, paint, and transform.
Client Open77.vehicles.* State reads, locally owned horn control, local seat-switch requests and policy, and guarded remote-occupant presentation.
Client package open77_vehicles exports Read-only compatibility wrappers.
Server low level FiveM-style globals Raw implementation surface; prefer Open77.vehicles.*.

There is deliberately no client API for breaking or repairing glass, tyres, lights, bodywork, or detached panels. Only the current physics owner may publish native destructive state; other clients are protected render projections. Scripted mutation uses the authoritative server methods below.

All server lifecycle and state methods

Every method in this table requires world.vehicles.

Method Signature Return / behavior
Open77.vehicles.create (definition) Vehicle ID, or nil, reason.
Open77.vehicles.update (id, patch) boolean; replaces supplied canonical fields.
Open77.vehicles.get (id) Server snapshot or nil.
Open77.vehicles.all (bucket?) Array of server snapshots, ordered by ID.
Open77.vehicles.remove (id) boolean; removes any canonical vehicle.
Open77.vehicles.setTransform (id, transform) boolean; position plus yaw, revoking any active physics lease.
Open77.vehicles.getDamage (id) { body, glass, lights, tires, detachedParts }, or nil.
Open77.vehicles.setDamage (id, damage) boolean; combined damage update.
Open77.vehicles.repair (id, scope?) boolean; scope is glass, body, lights, tires, visual, mechanical, or full.
Open77.vehicles.setPaint (id, paint) boolean; publishes canonical primary/secondary paint.
Open77.vehicles.getPaint (id) { applied, primary, secondary }, or nil.
Open77.vehicles.resetPaint (id) boolean; restores the model's original paint.
Open77.vehicles.getProperties (id) The whole property table a garage stores, or nil, reason. Alias: getVehicleProperties.
Open77.vehicles.setProperties (id, props, options?) Applies one. Refuses a FiveM property with no counterpart by name. Aliases: setVehicleProperties, applyProperties.
Open77.vehicles.unsupportedProperties (table) Every such property name, mapped to why there is nothing to map it to.
Open77.vehicles.getAppearance (id) The entity appearance variant, or "" for the record's default.
Open77.vehicles.setAppearance (id, name?) Swaps the variant. Also needs world.vehicles.appearance to CHANGE one.
Open77.vehicles.getLightsHue (id) CrystalCoat lights hue 0..1, or nil. Alias: getHeadlightHue.
Open77.vehicles.setLightsHue (id, hue?) Tints the car's own lights; nil restores the record's colour.
Open77.vehicles.clearLightsHue (id) setLightsHue(id, nil).
Open77.vehicles.setHeadlightColor (id, color?) The same tint from an RGB colour. Alias: setHeadlightColour.
Open77.vehicles.setLocked (id, locked) boolean; atomically changes the durable entry lock.
Open77.vehicles.lock (id) Convenience alias for setLocked(id, true).
Open77.vehicles.unlock (id) Convenience alias for setLocked(id, false).
Open77.vehicles.isLocked (id) Canonical entry-lock boolean, or nil for an unknown vehicle.
Open77.vehicles.triggerHorn (id, durationMs?) Routes a reliable synchronized horn command to the owner or parked viewers.
Open77.vehicles.honk (id, durationMs?) Alias of triggerHorn; default duration is 250 ms.
Open77.vehicles.registerDamageProfile (record, profile) true; registers names inside the calling resource VM.
Open77.vehicles.getDamageProfile (record) This resource's profile or nil.
Open77.vehicles.warpPlayerIntoVehicle (playerId, vehicleId, seat, options?) Authoritatively assigns and eventually warps a player into any canonical vehicle.
Open77.vehicles.setPlayerIntoVehicle (playerId, vehicleId, seat, options?) Alias of warpPlayerIntoVehicle.
Open77.vehicles.forcePlayerOutOfVehicle (playerId, vehicleId?) Forces native exit and retains the seat until confirmation/timeout.
Open77.vehicles.removePlayerFromVehicle (playerId, vehicleId?) Alias of forcePlayerOutOfVehicle.
Open77.vehicles.taskPlayerEnter (playerId, vehicleId, seat, options?) Same assignment as warpPlayerIntoVehicle, reached through the vanilla entry animation.
Open77.vehicles.taskPlayerEnterVehicle (playerId, vehicleId, seat, options?) Alias of taskPlayerEnter.
Open77.vehicles.taskPlayerLeave (playerId, vehicleId?) Same ejection as forcePlayerOutOfVehicle; the leaving player's own exit is instant.
Open77.vehicles.taskPlayerLeaveVehicle (playerId, vehicleId?) Alias of taskPlayerLeave.
Open77.vehicles.adopt (playerId, description) Turns a client's description of a vanilla car into a canonical vehicle.
Open77.vehicles.setAdoptPolicy (policy, options?) Opens a routing bucket to adoption: none, driver or any.
Open77.vehicles.getAdoptPolicy (bucket?) Reads one bucket's adoption policy.
Open77.vehicles.setPlayerExitLocked (playerId, locked, vehicleId?) Sets the durable no-exit policy.
Open77.vehicles.getPlayerSeat (playerId) Canonical server seat assignment or nil.
Open77.vehicles.isPlayerExitLocked (playerId) Whether the canonical assignment is exit-locked.

Server seat-switch controls

These methods require world.vehicles.

Method Signature Return / behavior
Open77.vehicles.switchPlayerSeat (playerId, seat) true, transitionId on reservation, otherwise false, reason; animated move within the current car.
Open77.vehicles.getPlayerSeatSwitch (playerId) Active transition, idle nil, or false, reason on refusal.
Open77.vehicles.cancelPlayerSeatSwitch (playerId, transitionId) Cancels the matching token; retains the original seat.
Open77.vehicles.setPlayerAutoSeatSwitchEnabled (playerId, enabled) Adds or releases this resource's automatic driver-takeover veto.
Open77.vehicles.isPlayerAutoSeatSwitchEnabled (playerId) Effective server policy, or false, reason on refusal.

All server engine, light and siren methods

Every method in this table requires world.vehicles. Each writes one named bit of the durable flags and leaves the rest alone; see engine, lights and siren for why that matters and for the ten-second intent window that makes a setter stick.

Method Signature Return / behavior
Open77.vehicles.setEngine (id, on) true, or false, reason.
Open77.vehicles.startEngine (id) Alias for setEngine(id, true).
Open77.vehicles.stopEngine (id) Alias for setEngine(id, false).
Open77.vehicles.isEngineOn (id) Canonical boolean, or nil, reason.
Open77.vehicles.setDrivable (id, drivable) Out of service also cuts the engine, in one revision.
Open77.vehicles.setUndriveable (id, undriveable) Inverted spelling of setDrivable.
Open77.vehicles.isDrivable (id) Canonical boolean, or nil, reason.
Open77.vehicles.setFrozen (id, frozen) Pins the car where it stands; nothing electrical changes. See freezing a car.
Open77.vehicles.isFrozen (id) Canonical boolean, or nil, reason.
Open77.vehicles.setLights (id, mode) "off", "on", "high", or a boolean.
Open77.vehicles.setHighBeams (id, on) Raises or drops the beams without going dark.
Open77.vehicles.getLights (id) "off", "on" or "high", or nil, reason.
Open77.vehicles.areLightsOn (id) True for both "on" and "high".
Open77.vehicles.setSiren (id, on) Light bar and sound; they cannot be separated.
Open77.vehicles.isSirenOn (id) Canonical boolean, or nil, reason.
Open77.vehicles.setHealth (id, health) 0..1; out of range is refused, not clamped.
Open77.vehicles.getHealth (id) Canonical normalized health, or nil, reason.
Open77.vehicles.setEngineHealth (id, health) The same one pool under its FiveM name.
Open77.vehicles.getEngineHealth (id) The same one pool under its FiveM name.

All server transform, motion and policy methods

Every method in this table requires world.vehicles, and every one of them sees only server-spawned vehicles -- see what closest can and cannot find.

Method Signature Return / behavior
Open77.vehicles.getPosition (id) { x, y, z }, a vector3, or nil, reason.
Open77.vehicles.getHeading (id) Heading in degrees, or nil, reason.
Open77.vehicles.getYaw (id) Alias of getHeading.
Open77.vehicles.getSpeed (id) Metres per second, or nil, reason.
Open77.vehicles.getSpeedKph (id) Kilometres per hour, or nil, reason.
Open77.vehicles.getVelocity (id) { x, y, z } in m/s, or nil, reason.
Open77.vehicles.getDriver (id) The driving player id, or nil.
Open77.vehicles.owner (id) Who simulates the car, in what capacity and since when; see who owns a car.
Open77.vehicles.requestAuthority (id, playerId) Asks that one client simulate the car; refused by name, never a steal.
Open77.vehicles.setPerformance (id, profile) boolean, reason?; nil clears the ceiling.
Open77.vehicles.clearPerformance (id) boolean, reason?; rated performance again.
Open77.vehicles.getPerformance (id) { topSpeedKph, accelerationScale, taperKph }, or nil.
Open77.vehicles.explode (id) boolean, reason?; already_exploded the second time.
Open77.vehicles.setLockedForPlayer (id, playerId, locked) boolean, reason?; nil clears the exception.
Open77.vehicles.clearLockedForPlayer (id, playerId) boolean, reason?.
Open77.vehicles.setLockedForAll (id, locked) boolean, reason?; revokes every exception.
Open77.vehicles.lockForAll (id) Alias for setLockedForAll(id, true).
Open77.vehicles.unlockForAll (id) Alias for setLockedForAll(id, false).
Open77.vehicles.isLockedForPlayer (id, playerId) boolean, or nil, reason.
Open77.vehicles.setTimeToLive (id, ttlMs) boolean, reason?; 0 or nil cancels.
Open77.vehicles.setTtl (id, ttlMs) Alias of setTimeToLive.
Open77.vehicles.setPersistent (id, persistent) boolean, reason?.
Open77.vehicles.setDespawnWhenUnobserved (id, despawn) boolean, reason?.

All server vehicle-finding methods

Every method in this table requires world.vehicles. They see only server-spawned vehicles -- see what closest can and cannot find.

Method Signature Return / behavior
Open77.vehicles.nearby (anchor, radius?, options?) Array of proximity entries, nearest first.
Open77.vehicles.closest (anchor, options?) One entry, or a bare nil when nothing is in range.
Open77.vehicles.seatFree (id, seat) boolean, or nil, reason.
Open77.vehicles.occupantInSeat (id, seat) playerId, occupant, or nil when free.
Open77.vehicles.freeSeats (id) Canonical seat names in seat order, or nil, reason.
Open77.vehicles.seatName (seat) Canonical spelling of any accepted seat form.

All server body-damage methods

Body cells use Lua indexes 1..30 and normalized finite values 0..1.

Method Signature Return / behavior
Open77.vehicles.setBodyDamage (id, values) Replaces the exact 30-value grid.
Open77.vehicles.setBodyCell (id, cell, value) Sets one normalized cell.
Open77.vehicles.damageBodyCell (id, cell, amount) Adds damage and clamps the result to 0..1.
Open77.vehicles.repairBodyCell (id, cell) Sets one cell to zero.
Open77.vehicles.setBodyZone (id, zone, value) Sets every cell in a named zone or explicit index array.
Open77.vehicles.damageBodyZone (id, zone, amount) Adds and clamps damage across a zone.
Open77.vehicles.repairBodyZone (id, zone) Clears a zone.

Open77.vehicles.bodyZones contains backLeft, back, backRight, left, center, right, frontLeft, front, frontRight, lower, roof, and all. A damage profile can override or extend the zone map for one vehicle record.

All server glass, light, and tyre methods

Glass and light indexes are 0..31. Tyre indexes are 0..3. Glass ordering comes from the specific vehicle record's Destruction.Glass list; it is not equivalent to the four openable side-window indexes.

Method Signature Return / behavior
Open77.vehicles.setGlassMask (id, mask) Replaces the 32-bit broken-glass mask.
Open77.vehicles.setGlassBroken (id, glass, broken) Sets one numeric or profile-named glass bit.
Open77.vehicles.breakGlass (id, glass) Sets one glass bit.
Open77.vehicles.repairGlass (id, glass) Clears one glass bit.
Open77.vehicles.breakAllGlass (id, count?) Breaks the first count bits; default is all 32.
Open77.vehicles.repairAllGlass (id) Clears the entire glass mask.
Open77.vehicles.setLightMask (id, mask) Replaces the 32-bit broken-light mask.
Open77.vehicles.setLightBroken (id, index, broken) Sets or clears one light bit.
Open77.vehicles.breakLight (id, index) Sets one light bit.
Open77.vehicles.repairLight (id, index) Clears one light bit.
Open77.vehicles.repairAllLights (id) Clears the complete light mask.
Open77.vehicles.setTireMask (id, mask) Replaces the four-bit broken-tyre mask.
Open77.vehicles.setTireBroken (id, index, broken) Sets or clears one tyre bit.
Open77.vehicles.breakTire (id, index) Sets one tyre bit.
Open77.vehicles.repairTire (id, index) Clears one tyre bit.
Open77.vehicles.repairAllTires (id) Clears the complete tyre mask.

All server door and openable-window methods

Doors are a six-bit reversible state. Openable windows are a separate four-bit reversible state; neither mask represents broken glass.

Method Signature Return / behavior
Open77.vehicles.setDoorMask (id, mask) Replaces the door/trunk/hood mask; range 0..63.
Open77.vehicles.setDoorOpen (id, door, opened) Sets one opening bit.
Open77.vehicles.openDoor (id, door) Opens one door, trunk, or hood.
Open77.vehicles.closeDoor (id, door) Closes one door, trunk, or hood.
Open77.vehicles.isDoorOpen (id, door) boolean, or nil for an unknown vehicle.
Open77.vehicles.setWindowOpen (id, window, opened) Sets one side-window opening bit.
Open77.vehicles.openWindow (id, window) Opens one side window.
Open77.vehicles.closeWindow (id, window) Closes one side window.
Open77.vehicles.isWindowOpen (id, window) boolean, or nil for an unknown vehicle.

Detached vehicle parts

Detached parts are a separate, monotonic 16-bit damage channel. They are reported by the native VehicleOnPartDetachedEvent, retained by the server, replayed to streamed observers, and included in late join. REDengine 2.31 exposes no proven safe reattachment primitive for a live vehicle, so clearing a detached bit is rejected; respawn the vehicle to restore its panels.

Method Signature Return / behavior
Open77.vehicles.setDetachedPartMask (id, mask) Adds a canonical mask in range 0..65535; rejects bit removal.
Open77.vehicles.setPartDetached (id, part, true) Detaches a named/indexed part; false is rejected.
Open77.vehicles.detachPart (id, part) Convenience form of setPartDetached(..., true).
Open77.vehicles.isPartDetached (id, part) boolean, or nil for an unknown vehicle.

Open77.vehicles.detachedParts maps trunk, hood, hoodLeft, hoodRight, the front-door variants doorFrontLeft[A-C] / doorFrontRight[A-C], doorBackLeft, doorBackRight, bumperFront, and bumperBack to bit indexes 0..15.

The remaining Open77.vehicles.update fields are health, flags, primaryColor, secondaryColor, doors, windows, tires, bodyDamage, brokenGlass, brokenLights, and detachedParts.

Exact client methods

Method Permission Signature Return / behavior
Open77.vehicles.get vehicles.read (id) One streamed client snapshot or nil.
Open77.vehicles.all vehicles.read () All currently streamed snapshots; empty when unavailable or denied.
Open77.vehicles.getPaint vehicles.read (id) Read-only { applied, primary, secondary }, or nil.
Open77.vehicles.isLocked vehicles.read (id) Reads the replicated durable entry lock, or nil.
Open77.vehicles.triggerHorn / honk vehicles.control (id, durationMs?) Sounds a streamed vehicle only when this client is its current physics owner.
Open77.vehicles.getPlayerSeat vehicles.read (playerId?) Replicated seat assignment; omitted ID selects the local player.
Open77.vehicles.isPlayerExitLocked vehicles.read (playerId?) Replicated exit-lock state, or nil without an assignment.
Open77.vehicles.isDoorOpen vehicles.read (id, door) Reads the canonical six-bit door state.
Open77.vehicles.isWindowOpen vehicles.read (id, window) Reads the canonical four-bit opening state, not broken glass.
Open77.vehicles.warpPlayerIntoVehicle vehicles.presentation (playerId, vehicleId, seat) Instantly presents an already-authorized remote occupant.
Open77.vehicles.taskPlayerEnterVehicle vehicles.presentation (playerId, vehicleId, seat) Guarded staged entry presentation for an already-authorized remote occupant.
Open77.vehicles.adoptable vehicles.read (options?) Every engine-spawned vehicle near the player that the server does not own, nearest first.
Open77.vehicles.describeAdoptable vehicles.read (engineEntity) The same description for one named chassis.
Open77.vehicles.releaseAdopted vehicles.adopt (engineEntity) Hands one engine-spawned chassis back to the population system.
Open77.vehicles.setPerformance vehicles.performance (id, profile) Caps one vehicle's top speed and pickup.
Open77.vehicles.clearPerformance vehicles.performance (id) Removes that vehicle's cap.
Open77.vehicles.setPerformanceClass vehicles.performance (record, profile) Caps every present and future vehicle of one TweakDB record.
Open77.vehicles.clearPerformanceClass vehicles.performance (record) Removes that record's cap.
Open77.vehicles.clearAllPerformance vehicles.performance () Drops every cap, instance and class.
Open77.vehicles.setControlLock vehicles.performance (id, locked) Suppresses or restores acceleration and brake/reverse input for a stationary vehicle.
Open77.vehicles.setFrozen vehicles.performance (id, frozen) Disables or restores the native chassis physics mask without changing ownership or the mounted workspot.
Open77.vehicles.ratedTopSpeed vehicles.read (id) Gearing rating of the vehicle's record in km/h, or nil.
Open77.vehicles.nearby vehicles.read (radius, options?) Replicated vehicles within a radius, nearest first.
Open77.vehicles.closest vehicles.read (radius?) or (options?) The nearest one, or nil, "no_vehicle_in_range".
Open77.vehicles.aimed vehicles.read () The one the local player is looking at, or nil, reason.
Open77.vehicles.fromEntity vehicles.read (entityId) Snapshot for an Open77 or REDengine entity id.
Open77.vehicles.seatFree vehicles.read (id, seat) Reads the replicated occupancy ledger.
Open77.vehicles.occupantInSeat vehicles.read (id, seat) Player id in that seat, or nil when free.
Open77.vehicles.switchSeat vehicles.control (seat) Submits a local in-cabin move for server admission; true means queued.
Open77.vehicles.cancelSeatSwitch vehicles.control (transitionId) Submits cancellation of the current transition token.
Open77.vehicles.seatSwitchState vehicles.read () { active, autoSwitchEnabled, vehicleId?, fromSeat?, id?, toSeat? }, or nil, reason.
Open77.vehicles.setAutoSeatSwitchEnabled vehicles.control (enabled) Adds or releases this resource's local automatic takeover veto.
Open77.vehicles.isAutoSeatSwitchEnabled vehicles.read () Effective client and server policy, or nil, reason.

Client constants are Open77.vehicles.doors, Open77.vehicles.windows, and Open77.vehicles.seats. They are tables, not callable methods.

Vehicle snapshots include the durable fields above, including the derived locked boolean, plus live read-only drivetrain and network telemetry. speed, rpm, rpmMax, throttle, brake, steering, wheelRotation, suspensionLongitudinal, suspensionTransversal, burnout, gear, onGround, and reversing describe the latest authoritative motion. The derived normalized fields longitudinalSlip, lateralSlip, and totalSlip drive remote tire audio and skid effects. Network diagnostics are bufferedSamples, interpolationDelayMs, packetAgeMs, and extrapolating; they are intended for telemetry and debugging, not gameplay authority.

Official package exports

The open77_vehicles client package exposes these read-only wrappers:

Export Signature Result
get get(id) Compatibility wrapper over Open77.vehicles.get.
all all() Compatibility wrapper over Open77.vehicles.all.
getPlayerSeat getPlayerSeat(playerId?) Compatibility wrapper over Open77.vehicles.getPlayerSeat.
isPlayerExitLocked isPlayerExitLocked(playerId?) Compatibility wrapper over Open77.vehicles.isPlayerExitLocked.

Call them with Open77.exports.call("open77_vehicles", "get", id); no exports.<resource>: proxy exists. The package intentionally exposes no mutation export.

Low-level server globals

These globals are public for framework compatibility, but the namespaced API above supplies structured tables, defaults, and helper validation.

Global Signature
CreateVehicle (record, x, y, z, yaw, bucket, appearance, health, flags, primaryR, primaryG, primaryB, secondaryR, secondaryG, secondaryB)
UpdateVehicleState (id, health, flags, primaryR, primaryG, primaryB, secondaryR, secondaryG, secondaryB, doors, windows, tires, bodyDamage30, brokenGlass, brokenLights, detachedParts)
SetVehicleTransform (id, x, y, z, yaw)
RemoveVehicle (id)
GetVehicle (id)
GetVehicles (bucket?)
SetVehicleLocked (id, locked)
TriggerVehicleHorn (id, durationMs?)
SetPlayerIntoVehicle (playerId, vehicleId, seat, moveBucket, exitLocked)
ForcePlayerOutOfVehicle (playerId, vehicleId?)
SetPlayerVehicleExitLocked (playerId, locked, vehicleId?)
GetPlayerVehicleSeat (playerId)

Server API

local id, reason = Open77.vehicles.create({
    record = "Vehicle.v_standard2_archer_hella_player",
    appearance = "default",
    position = { x = -1607.4, y = 1268.2, z = 18.1 },
    yaw = 90.0,
    bucket = 0,
    health = 1.0,
    flags = Open77.vehicles.flags.locked,
    primaryColor = { r = 22, g = 105, b = 180 },
    secondaryColor = { r = 8, g = 15, b = 24 },
})

Open77.vehicles.create(definition)

Creates a generation-checked 64-bit vehicle id. Required fields are record and position. Optional fields are appearance, yaw, bucket, health, flags, primaryColor, secondaryColor, and paint = { primary, secondary }. Returns id, or nil, reason.

Open77.vehicles.update(id, patch)

Updates durable state. Supported fields are health, flags, primaryColor, secondaryColor, doors, windows, tires, bodyDamage, brokenGlass, brokenLights, detachedParts, and the nested damage = { body, glass, lights, tires, detachedParts } form. windows means opened windows; it is deliberately separate from brokenGlass. Door bits are front-left, front-right, back-left, back-right, trunk, and hood. Window/tire bits use the first four positions.

local flags = Open77.vehicles.flags.engineOn | Open77.vehicles.flags.lightsOn
Open77.vehicles.update(id, { flags = flags, health = 0.85 })

Authoritative paint

See the dedicated vehicle paint guide for the runtime split, supported color formats, initial paint, events, synchronization semantics, and native model limitations.

assert(Open77.vehicles.setPaint(id, {
    primary = "#000000",
    secondary = { r = 20, g = 40, b = 60 },
}))

local paint = Open77.vehicles.getPaint(id)
print(paint.applied, paint.primary.r, paint.secondary.b)

assert(Open77.vehicles.resetPaint(id))

Colors accept "#RRGGBB", keyed { r, g, b }, or positional { r, g, b } tables. Supplying only primary uses the same color for both channels. Paint has a dedicated paintApplied state, so pure black is not confused with reset. Current viewers, stream-in projections, and late joiners converge on the same server state. Paint is a cross-resource control API: a resource holding world.vehicles may style a canonical vehicle created by another resource. The same cross-resource rule applies to all server vehicle mutations; the creator field is never consulted as an authorization check. The stock CrystalCoat/TWINTONE interaction and popup are suppressed during an authenticated Open77 session, so a client cannot keep a private local paint override.

Arbitrary RGB display still requires the REDengine vehicle model to expose its generic paint component. Unsupported records keep their authored appearance; the client retries after attachment without turning a model limitation into divergent canonical state.

Vehicle properties, and what Cyberpunk does not have

Open77.vehicles.getProperties(id) and setProperties(id, props) are the Cyberpunk form of the GetVehicleProperties / SetVehicleProperties pair every FiveM framework ships. The round trip is the contract: setProperties(id, getProperties(id)) leaves the vehicle observably unchanged, and a table stored in a database and applied to a fresh vehicle of the same record reproduces it. The individual setters below are conveniences on top of that.

-- Park the car.
local props = Open77.vehicles.getProperties(vehicleId)
store(plate, json.encode(props))

-- Fetch it back, tomorrow, on another instance.
local stored = json.decode(load(plate))
local id = Open77.vehicles.create({ record = stored.record, position = spot, yaw = 90.0 })
local ok, reason = Open77.vehicles.setProperties(id, stored)

The table is plain data β€” strings, numbers, booleans and tables β€” so it survives json.encode and a database column unchanged:

{
  version = 1,
  record = "Vehicle.v_standard2_archer_hella_player",
  appearance = "archer_hella__rayfield",   -- "" is the record's default
  paint = {
    applied   = true,
    primary   = { r = 22, g = 105, b = 180 },
    secondary = { r = 8,  g = 15,  b = 24 },
    lightsHue = 0.55,                      -- absent when the record's own colour is used
  },
  health = 0.83,
  engineOn = false, lights = "off", siren = false,
  locked = true, drivable = true, invulnerable = false, immortal = false,
  doors = 0, windows = 0,
  damage = {
    body = { --[[ 30 cells, 0..1 ]] },
    glass = 0, lights = 0, tires = 0, detachedParts = 0,
    destroyed = false, exploded = false,
  },
  performance = { topSpeedKph = 60.0, accelerationScale = 0.5, taperKph = 10.0 },
}

It deliberately does not carry where the car was standing. Position, heading, velocity, occupants, routing bucket and revision are not properties of the car, and a table that carried them would tempt a caller into teleporting by accident. setTransform and setBucket still own those.

version decides how absence is read

This is the only rule you have to remember, and it is the one that makes both halves of the contract work at once.

Table Meaning An absent optional field
carries version = 1 β€” every table getProperties returns a complete description of a car means this car has none: the lights hue and the performance ceiling are cleared
no version a patch is left exactly as it was

So setProperties(id, { paint = { primary = "#FF0000" } }) is a repaint and nothing else, while setProperties(id, storedTable) restores a car down to the absence of a tint.

What Cyberpunk actually has

Vehicle visual customisation on build 2.31 is one engine component, vehicleVisualCustomizationComponent, and one struct, GenericTemplatePersistentData, whose entire content is primary RGB, secondary RGB and a lights hue. Beside it sits the entity appearance variant β€” a name from the vehicle's own template, which is how the game ships one Quadra in several trims. That is the whole vocabulary:

appearance variant  +  primary RGB  +  secondary RGB  +  lights hue
Method Signature What it does
Open77.vehicles.getProperties (id) The whole table above, or nil, reason. Alias: getVehicleProperties.
Open77.vehicles.setProperties (id, props, options?) Applies it. Aliases: setVehicleProperties, applyProperties.
Open77.vehicles.getAppearance (id) The variant name, or "".
Open77.vehicles.setAppearance (id, name?) Swaps the variant. nil restores the record's default.
Open77.vehicles.getLightsHue (id) 0..1, or nil for the record's own colour. Alias: getHeadlightHue.
Open77.vehicles.setLightsHue (id, hue?) Tints the car's own lights.
Open77.vehicles.clearLightsHue (id) Back to the record's colour.
Open77.vehicles.setHeadlightColor (id, color?) The same tint from "#RRGGBB" or { r, g, b }. Alias: setHeadlightColour.
Open77.vehicles.unsupportedProperties (table) Every FiveM property name with no counterpart, mapped to why.
Open77.vehicles.setAppearance(id, "archer_hella__rayfield")
Open77.vehicles.setHeadlightColor(id, "#00D8FF")

setAppearance β€” and the appearance key of setProperties β€” needs world.vehicles.appearance in addition to world.vehicles. It is the one vehicle write that changes what the world looks like rather than what a car does: it reaches a native entity-appearance reschedule on every viewer at once, for every car a resource can name. The same argument gave adoption its own string. The permission gates a change, never a restatement: writing the variant a car already wears is accepted without it, which is what keeps setProperties(id, getProperties(id)) working for a resource that never touches a look.

The variant name is not validated by the server, because only the client holds the entity template. A name the model does not define is a no-op on each projection and the canonical value stays as written. Open77.data.vehicle(record) looks a record up but does not enumerate variants β€” the shipped 2.31 catalogue does not carry them.

What Cyberpunk does not have

These FiveM property names have no counterpart in this engine. Not "unimplemented" β€” absent: the 2.31 RTTI carries no type for any of them. setProperties refuses such a key by name rather than dropping it, because a garage that silently loses two thirds of its table is worse than one that fails loudly on day one:

local ok, reason = Open77.vehicles.setProperties(id, portedTable)
-- false, "unsupported_property:modEngine"
FiveM property Why there is nothing to map it to
modKit, mods, modEngine, modBrakes, modTransmission, modSuspension, modArmour, modTurbo There is no mod-slot system at all. A model's look is its appearance variant plus the two CrystalCoat colours; its performance is setPerformance.
wheels, wheelType No wheel-type system: wheels are meshes inside the appearance variant.
wheelColor No per-wheel colour channel in GenericTemplatePersistentData.
windowTint No window tint on 2.31 β€” the RTTI carries no type for one.
neonEnabled, neonColor No underglow. paint.lightsHue tints the car's own lights instead.
xenonColor No xenon index. paint.lightsHue is the continuous equivalent.
livery, roofLivery No livery index. The appearance variant is the nearest thing, and it is a name, not a number.
extras No numbered extras. Removable bodywork is damage.detachedParts, and it is one-way.
color1, color2, pearlescentColor, interiorColor, dashboardColor Cyberpunk paint is RGB, not palette indices, and there is one body pair and nothing else. Use paint.primary / paint.secondary.
dirtLevel No dirt channel. Body wear is the 30-cell damage.body grid.
tyreSmokeColor No tyre smoke colour channel.
plateText, plateIndex Cyberpunk has no readable or writable plate. Carry it in a state bag -- the recipe is on that page.
fuelLevel No fuel model. Fuel is a state-bag field owned by the bundled open77_fuel sample: exports.open77_fuel:level(id) reads it, :set(id, litres) and :refuel(id) write it.

Open77.vehicles.unsupportedProperties is the same list as a table, keyed by property name, so a porting script can ask before it writes:

for name in pairs(portedTable) do
  local why = Open77.vehicles.unsupportedProperties[name]
  if why then print(("dropping %s: %s"):format(name, why)) end
end
local ok, _, skipped = Open77.vehicles.setProperties(id, portedTable, { ignoreUnsupported = true })

Refusals, and the order of application

record is checked, never applied: REDengine cannot change a spawned entity's record, so a Quadra table handed to a Thorton answers record_mismatch rather than half-restoring. Detached parts are one-way on a live projection β€” there is no validated reattach on 2.31 β€” so a table asking for fewer than the car has already lost answers detached_parts_not_reattachable; respawn and apply the table to the new car.

The order inside setProperties is deliberate and worth knowing if you write your own:

  1. health, paint, doors, windows and the durable damage go in one update, so no viewer ever sees a half-restored car;
  2. the electrical bits then go through setEngine, setLights, setSiren and setDrivable β€” because those are what record the server intent that stops the physics owner's next durable report from undoing them. Writing them as raw flags looks like it worked and then quietly reverts about half a second later;
  3. the visual template and the performance ceiling follow on their own durable channels.

The appearance and the lights hue need no such intent window, and that is structural rather than lucky: the physics owner's durable report has no appearance field and no colour field of any kind, so the merge that undid the sirens cannot reach either value. On the client the variant goes through entEntity::ScheduleAppearanceChange and is held on the replica until the projection attaches β€” measured at up to about 3.7 s after the create β€” then re-applied if the entity is torn down and streamed back.

Entry lock and synchronized horn

Use the dedicated lock methods instead of reading, changing, and replacing the complete flags mask:

-- Server: durable, authoritative entry policy.
assert(Open77.vehicles.lock(vehicleId))
assert(Open77.vehicles.isLocked(vehicleId) == true)
assert(Open77.vehicles.unlock(vehicleId))

-- Server: delivered reliably to the client currently simulating the vehicle.
local ok, reason = Open77.vehicles.triggerHorn(vehicleId, 400)
assert(ok, reason)

setLocked(id, locked), lock(id), and unlock(id) update only the locked bit. The server sends the resulting canonical state to current viewers and includes it in stream-in and late-join state. On each client, the replicated bit synchronously removes every mount choice from the vehicle and is checked again at the mounting boundary, so stale interaction UI cannot start an animation that the server would later reject. Ordinary entry and seat claims are rejected while locked. An explicit server warpPlayerIntoVehicle remains an administrative/gamemode override and may bypass the entry lock. This vehicle entry lock is separate from setPlayerExitLocked, which keeps one existing occupant inside, and from the client-only setControlLock/setFrozen grid controls.

triggerHorn(id, durationMs?) and its honk alias accept 100..2000 ms and default to 250 ms. A server call is a reliable one-shot command bound to the vehicle's current authority epoch. With a physics owner, only that owner injects the native horn event and the normal motion stream mirrors the edge to observers. An unoccupied parked vehicle intentionally has no physics lease; in that case the server addresses every current streamed projection directly instead of rejecting the request or manufacturing a bystander owner. The event is transient and is not replayed to a late joiner.

A client resource with vehicles.control may call the same triggerHorn/honk API directly, but only for a streamed vehicle whose snapshot has locallyOwned == true. Remote attempts fail with not_physics_owner; use the server API when gameplay code needs to sound an arbitrary canonical vehicle. Open77.vehicles.isLocked(id) is available client-side with vehicles.read.

Available flags:

Constant Value Constant Value
Open77.vehicles.flags.engineOn 1 Open77.vehicles.flags.locked 2
Open77.vehicles.flags.destroyed 4 Open77.vehicles.flags.exploded 8
Open77.vehicles.flags.invulnerable 16 Open77.vehicles.flags.immortal 32
Open77.vehicles.flags.lightsOn 64 Open77.vehicles.flags.highBeams 128
Open77.vehicles.flags.sirenOn 256 Open77.vehicles.flags.paintApplied 512

Combine flags with Lua 5.4 bitwise operators (|, &, ~). Never replace the complete mask when you only intend to toggle one bit without first reading the current canonical value.

Doors, trunk, hood, and windows

Openings have named, persistent server APIs. They are replicated to current viewers and included in stream-in/late-join state. Live changes use the vehicle's native animation; the initial streamed state is applied immediately so an already-open trunk does not visibly replay from closed.

Open77.vehicles.openDoor(id, "trunk")
Open77.vehicles.closeDoor(id, "hood")
Open77.vehicles.setDoorOpen(id, Open77.vehicles.doors.frontRight, true)

if Open77.vehicles.isDoorOpen(id, "trunk") then
    -- Server-side inventory logic can now expose the trunk contents.
end

Open77.vehicles.openWindow(id, "frontLeft")
Open77.vehicles.closeWindow(id, Open77.vehicles.windows.frontLeft)

Door names are frontLeft, frontRight, backLeft, backRight, trunk, and hood. Snake-case cabin aliases are also accepted. Window names are the first four door names. Low-level setDoorMask(id, mask) and update(id, { doors = mask, windows = mask }) remain available for frameworks that already store bitfields.

Index / bit Door Openable window
0 frontLeft / front_left frontLeft
1 frontRight / front_right frontRight
2 backLeft / back_left backLeft
3 backRight / back_right backRight
4 trunk β€”
5 hood β€”

Natural player interactions are observed too: opening or closing a trunk/hood in the world updates the canonical server state. A reversible opening report is accepted only from the current driver or a streamed player within 15 metres; a distant client cannot toggle another vehicle.

Damage and repair API

Damage is canonical server state and is replayed to current viewers, stream-in clients, and late joiners. The client observation channel can only add damage. Only the server resource owning the vehicle can repair it.

-- Glass indices are zero-based indices into this model's Destruction.Glass list.
Open77.vehicles.breakGlass(id, 0)
Open77.vehicles.repairGlass(id, 0)
Open77.vehicles.breakAllGlass(id)       -- all 32 mask bits
Open77.vehicles.breakAllGlass(id, 6)    -- first six glass records
Open77.vehicles.repairAllGlass(id)

Open77.vehicles.setTireBroken(id, 0, true)
Open77.vehicles.repairTire(id, 0)
Open77.vehicles.setLightBroken(id, 2, true)
Open77.vehicles.repairLight(id, 2)

Open77.vehicles.damageBodyCell(id, 13, 0.25) -- cells are Lua indices 1..30
Open77.vehicles.damageBodyZone(id, "front", 0.40)
Open77.vehicles.repairBodyZone(id, "front")
Open77.vehicles.repair(id, "visual")
Open77.vehicles.repair(id, "full")

Available body zones are backLeft, back, backRight, left, center, right, frontLeft, front, frontRight, lower, roof, and all. The low-level API remains exposed for custom damage systems:

local damage = Open77.vehicles.getDamage(id)
damage.body[14] = 0.9
damage.glass = damage.glass | (1 << 3)
Open77.vehicles.setDamage(id, damage)

Open77.vehicles.setBodyDamage(id, thirtyNormalizedValues)
Open77.vehicles.setGlassMask(id, 0x15)
Open77.vehicles.setLightMask(id, 0x02)
Open77.vehicles.setTireMask(id, 0x05)

Glass ordering is record-specific. A resource can register readable names instead of spreading numeric indices throughout gameplay code:

Open77.vehicles.registerDamageProfile("Vehicle.v_standard2_archer_hella_player", {
    glass = { windshield = 0, rearWindow = 1, frontLeft = 2, frontRight = 3 },
    bodyZones = { engineBay = { 13, 14, 15 } },
})

Open77.vehicles.breakGlass(id, "windshield")
Open77.vehicles.damageBodyZone(id, "engineBay", 0.5)

Open77 intentionally does not ship guessed glass names: the Destruction.Glass order differs by vehicle record. Numeric glass/light indices are 0..31, tyre indices are 0..3, and all body values are finite normalized values in 0..1.

Other server calls

Open77.vehicles.setTransform(id, { x = 10, y = 20, z = 30, yaw = 180 })
Open77.vehicles.get(id)
Open77.vehicles.all()
Open77.vehicles.all(bucket)
Open77.vehicles.remove(id)

setTransform is server-authoritative: it revokes an active physics lease, advances the authority epoch, and publishes the complete canonical transform to every current viewer.

get and all also return the canonical seat ledger:

local vehicle = Open77.vehicles.get(id)
for _, occupant in ipairs(vehicle.occupants) do
    print(occupant.playerId, occupant.seat)
end

Seat names are seat_front_left, seat_front_right, seat_back_left, and seat_back_right. seat_front_left is the only driver seat. Ordinary player entry still comes from native mount detection and server validation; an owning server resource can also write the ledger through the forced-seat API below.

Authoritative player seats

Use the server version of warpPlayerIntoVehicle when a gamemode must put a player directly into a vehicle rather than waiting for a local enter interaction:

local ok, reason = Open77.vehicles.warpPlayerIntoVehicle(
    playerId,
    vehicleId,
    Open77.vehicles.seats.driver,
    {
        moveBucket = true, -- default: move the player into the vehicle bucket
        exitLocked = true, -- optional: block manual exit until the server releases it
    })
assert(ok, reason)

Accepted seats are the FiveM-compatible numbers -1 (driver), 0 (front passenger), 1 (rear-left), and 2 (rear-right), the values in Open77.vehicles.seats, or these names: driver, frontPassenger, rearLeft, rearRight, frontLeft, frontRight, backLeft, backRight, and the canonical seat_* strings.

The assignment is durable network state, not a one-shot client event. The server reserves the seat first and carries forcedEntry in every occupancy snapshot until the affected client confirms the native mount. If the vehicle is not streamed yet, streams after a routing-bucket move, streams out and back in, or its local projection is replaced, the client retries against the current generation-checked vehicle entity. Occupancy pins the target vehicle into that player's interest set during convergence, so a stale or temporarily missing player-position snapshot cannot strand the instruction.

Forced assignment intentionally bypasses distance and the vehicle's locked flag. It still rejects unknown players, destroyed/exploded vehicles, and occupied seats. Seat operations are deliberately cross-resource: any server resource granted world.vehicles may assign, eject, or lock a player in any canonical vehicle, including one created by another resource. By default the player moves into the vehicle routing bucket; pass { moveBucket = false } to fail with wrong_bucket instead. Dead and pending revive/respawn players are rejected as player_unavailable.

-- Unlock later without changing the seat.
assert(Open77.vehicles.setPlayerExitLocked(playerId, false, vehicleId))

-- Or force an exit even while the exit lock is enabled.
assert(Open77.vehicles.forcePlayerOutOfVehicle(playerId, vehicleId))

Entering with the vanilla animation

Open77.vehicles.taskPlayerEnter is warpPlayerIntoVehicle with one bit changed. Everything authoritative is identical -- the seat is reserved in the same write, the bucket moves the same way, and every refusal reason is the warp's, verbatim. What changes is only how the affected client reaches the seat: instead of being placed in it in one frame, it mounts through the mounting facility with its own pose preserved and plays the authored OccupantSlots entry, so the body slides in.

local ok, reason = Open77.vehicles.taskPlayerEnter(
    playerId, vehicleId, Open77.vehicles.seats.driver,
    { moveBucket = true, exitLocked = false })
assert(ok, reason)

-- The player-oriented alias, next to warpIntoVehicle / forceOutOfVehicle:
Open77.players.taskEnterVehicle(playerId, vehicleId, "driver")

Three things this is not, due to engine limits:

  • It does not walk the player to the door. The approach is an NPC behaviour (ApproachVehicleDecorator), and the single attempt to drive an Open77 network puppet through it crashed the observing client about three seconds after the vehicle streamed. The player is mounted where they stand. The upside is that the obvious failure of a walk-to-door task -- a player stuck walking toward a car that has driven off -- cannot happen here.
  • It cannot strand anybody. The animation is an attempt in front of the warp, never instead of it. If the staged mount cannot be started, or if the native mounting relation is not readable within three seconds, the client warps and the seat is reached anyway. warpPlayerIntoVehicle remains the call that never depends on an animation at all.
  • There is no animated exit. Open77.vehicles.taskPlayerLeave exists, and it is the same durable ejection as forcePlayerOutOfVehicle: the leaving player's own exit is instant. Driving that player's authored exit workspot inside the mount release window faulted on the engine's release-dispatch thread and the workspot never started once in any session, so the client does not attempt it. Observers are unaffected -- a remote occupant's exit has always been animated on their proxy, so only the ejected player's own screen skips it.

Cancelling a task in flight is forcePlayerOutOfVehicle (or taskPlayerLeave): a forced exit overrides a pending forced entry, and the client's animated attempt ends with it. Nothing else is needed, because the attempt is bounded rather than open-ended.

Open77.vehicles.get(id).occupants[i].animatedEntry, and the same field on the client's vehicle read, says whether a pending forced entry carries the modifier. It is a read for every client but the one being seated.

While exitLocked is active, the local vanilla unmount callback is rejected and reconciliation remounts divergent state. A manual seat switch is rejected too because REDengine begins it by unmounting the current workspot. forcePlayerOutOfVehicle overrides that policy and keeps the canonical seat reserved until native unmount confirmation; an eight-second transition timeout cleans up a disconnected or non-cooperating client. Passing the optional vehicleId to exit/lock operations is recommended: it prevents stale gamemode code from modifying a newer assignment.

Read the same assignment through either namespace:

local seat = Open77.vehicles.getPlayerSeat(playerId)
-- Equivalent player-oriented alias:
seat = Open77.players.getVehicleSeat(playerId)

if seat then
    print(seat.vehicleId, seat.seat, seat.exitLocked)
end

The seat table contains playerId, vehicleId, seat, flags, entering, exiting, forcedEntry, exitLocked, and forcedExit. Open77.vehicles.isPlayerExitLocked(playerId) is a boolean convenience query. Server aliases are Open77.players.warpIntoVehicle, forceOutOfVehicle, and setVehicleExitLocked.

Normal failures return false, reason. Stable reasons are permission_denied:world.vehicles, vehicles_unavailable, invalid_player_id, invalid_vehicle_id, invalid_seat, invalid_argument, player_not_found, player_unavailable, vehicle_not_found, wrong_bucket, seat_occupied, not_occupant, vehicle_unavailable, exit_locked, and seat_operation_failed.

Animated changes inside a car

Use switchPlayerSeat on the server or switchSeat on the client when the player is already seated. The destination is reserved while the source stays occupied, and the in-cabin animation is replicated to other players. These APIs require matching protocol 1.44 client/server builds and their animation assets.

-- server.lua; permission: world.vehicles
local ok, tokenOrReason = Open77.vehicles.switchPlayerSeat(playerId, 'rearRight')
if not ok then print(tokenOrReason) end

-- Keep the front passenger in their seat when the driver leaves.
Open77.vehicles.setPlayerAutoSeatSwitchEnabled(playerId, false)

true accepts or queues a request; it does not mean the move is finished. Server events are onPlayerVehicleSeatSwitchStarted, onPlayerVehicleSeatSwitchCompleted and onPlayerVehicleSeatSwitchFailed, with (playerId, vehicleId, transitionId, fromSeat, toSeat, reason). Client equivalents use open77:vehicleSeatSwitchStarted, open77:vehicleSeatSwitchCompleted and open77:vehicleSeatSwitchFailed, with (vehicleId, transitionId, fromSeat, toSeat, reason). The extra client open77:vehicleSeatSwitchDecision receives (vehicleId, transitionId, seat, status, reason) after a request.

Use the seat-switching guide for all ten functions, seat aliases, state fields, cancellation, automatic takeover, event payloads, resource-owned policy and error handling. It includes server commands and client menu examples. Automatic takeover can be disabled without disabling explicit seat requests.

Server snapshot fields

Open77.vehicles.get and each entry returned by all contain:

Group Fields
Identity id, resource, record, appearance, revision
World bucket, x, y, z, position
Transform heading, yaw, orientation = { x, y, z, w }
Motion velocity, angularVelocity, speed, speedKph, onGround, reversing, moving
Authority physicsOwner, authorityEpoch
Durable state health, flags, locked, exploded, destroyed, paintApplied, primaryColor, secondaryColor, paint, doors, windows, tires, brokenGlass, brokenLights, detachedParts
Paint primaryR, primaryG, primaryB, secondaryR, secondaryG, secondaryB
Body bodyDamage[1..30]
Damage view damage = { body, glass, lights, tires, detachedParts }
Lifetime persistent, despawnWhenUnobserved, ttlMs (absent when there is no deadline)
Performance performance = { topSpeedKph, accelerationScale, taperKph } (absent when uncapped)
Seats occupants[] = { playerId, seat, flags, entering, exiting, forcedEntry, exitLocked, forcedExit, switching, switchId, switchSeat, autoSwitchEnabled }, plus driverPlayerId

x, y and z are unchanged; position is the same point as a plain { x, y, z } table, which is what an Open77 vector3 is, so #(a.position - b.position) works on it without a conversion. heading and yaw are one value under both names -- heading is what the player read and respawn use, yaw is what create and setTransform take -- and it is extracted from the full quaternion rather than from qz/qw alone, so a car on a slope reports the heading it is actually facing.

The motion group is the last thing the physics owner reported. A vehicle with no owner is not being simulated by anyone, so losing the lease -- a driver stepping out, disconnecting, or the lease expiring -- zeroes velocity, angularVelocity, speed and the dynamics bits rather than leaving a parked car claiming the speed it had when its driver got out. A server setTransform clears them for the same reason. moving is exactly physicsOwner ~= 0 and speed > 0.1, which is the check a speed camera wants to make before it reads speedKph.

driverPlayerId is absent, not zero, when nobody is driving, and absent while an entry animation is still running: a reserved seat is not yet a driver.

Validation constraints are health = 0..1, RGB channels 0..255, finite world coordinates with an absolute maximum of 1,000,000, a record length up to 256 characters, an appearance length up to 128, flags limited to the documented ten bits, exactly 30 normalized body values, doors 0..63, and windows/tyres 0..15.

Server events

AddEventHandler("onVehicleCreated", function(id, resource, record) end)
AddEventHandler("onVehicleUpdated", function(id, revision) end)
AddEventHandler("onVehicleRemoved", function(id, reason) end)
AddEventHandler("onVehicleAuthorityChanged", function(id, owner, epoch, reason) end)
AddEventHandler("onVehicleOccupancyChanged", function(id, revision)
    local canonical = Open77.vehicles.get(tonumber(id))
end)

-- The three derived seat transitions. Same ledger, already diffed.
AddEventHandler("onPlayerEnteringVehicle", function(playerId, vehicleId, seat) end)
AddEventHandler("onPlayerEnteredVehicle", function(playerId, vehicleId, seat) end)
AddEventHandler("onPlayerLeftVehicle", function(playerId, vehicleId, seat) end)

AddEventHandler("onVehicleDamageChanged", function(id, revision)
    local damage = Open77.vehicles.getDamage(tonumber(id))
end)

AddEventHandler("onVehiclePaintChanged", function(id, revision)
    local paint = Open77.vehicles.getPaint(tonumber(id))
end)

The same creation and removal also reach onEntityCreated(kind, id, resource) and onEntityRemoved(kind, id, reason) with kind = "vehicle", for a resource that declares world.entities.observe and wants every registry under one name. The mirror is raised by the same statement as the event above it, so the two can never disagree β€” see entity lifecycle events.

Server runtime event arguments arrive as strings. Preserve the ID as an opaque value unless the called binding explicitly requires an integer. onVehicleUpdated fires for every canonical state update; onVehicleDamageChanged and onVehiclePaintChanged are narrower channel-specific signals.

Enter and exit, derived

onVehicleOccupancyChanged is a ledger signal: it says the occupant list changed and leaves the resource to fetch the vehicle and diff the list itself to learn what actually happened. Every gamemode wrote that diff, and most of them got the same two cases wrong.

The three events above are that diff, done once, in the one place the occupancy ledger is published. They are derived, never authored -- there is no second source of truth to drift from onVehicleOccupancyChanged, and any future seat mutation path gets correct transitions without new plumbing. The rules, each one paid for by a shape the raw ledger makes ambiguous:

  • onPlayerEnteringVehicle fires when an occupant appears carrying the entering flag: the seat is reserved and the entry animation is running. Nobody is in the car yet, and a second warp into that seat is already refused.
  • onPlayerEnteredVehicle fires when the player is actually seated -- either straight away (a direct entry, or a server warpPlayerIntoVehicle, neither of which has an animation) or when a pending entering is confirmed. It fires exactly once per entry: a client re-announcing BeginEnter from a seat it already holds does not make it fire again.
  • onPlayerLeftVehicle fires when a seated player leaves that seat -- by exit, seat switch, disconnect, incapacitation, or the vehicle being deleted out from under them.
  • An entry that is never confirmed raises no left. The reservation times out, the seat frees, and because nobody was ever in the car there is nothing to report leaving it. entered and left are therefore balanced pairs, which is what makes a seat map built on them safe.
  • A seat switch is left(old) then entered(new), in that order, because the service frees the old seat before taking the new one. Moving between two vehicles names the right vehicle in each half.

Arguments are playerId, vehicleId, seat, with the seat as the canonical seat_front_left spelling every other vehicle read uses. They deliberately carry no revision: a revision is what you need in order to diff a ledger, and the diff has already been done. Open77.vehicles.get(id) still answers for anything else the handler wants.

The three names are reserved platform events. TriggerEvent("onPlayerEnteredVehicle", ...) from a resource answers false, "reserved_event" -- a forged one would tell every other resource that a player it does not own just took the wheel of a car it does not own.

local seatOf = {}

AddEventHandler("onPlayerEnteredVehicle", function(playerId, vehicleId, seat)
    playerId, vehicleId = tonumber(playerId), tonumber(vehicleId)
    seatOf[playerId] = { vehicle = vehicleId, seat = seat }
    -- A keys resource acts on the driver seat only, and acts once: this is the
    -- moment the player is actually in, not the moment they reached for the door.
    if seat == "seat_front_left" and not hasKeys(playerId, vehicleId) then
        Open77.vehicles.setEngine(vehicleId, false)
    end
end)

AddEventHandler("onPlayerLeftVehicle", function(playerId)
    seatOf[tonumber(playerId)] = nil
end)

Every resource granted world.vehicles can mutate, move, seat players in, or remove every canonical vehicle. The resource snapshot field is provenance, not an access-control boundary. Stopping or reloading the creating resource still removes the vehicles it created and clears their assignments, so temporary entities retain deterministic lifecycle cleanup.

Adopting a vanilla car

Every read on this page so far answers from the canonical registry, which holds only vehicles the server created. The traffic car a player is standing next to was spawned by REDengine population on that player's own machine, has no canonical id, and is invisible to every other client β€” which is why Open77.vehicles.aimed() answers unknown_entity when the player is plainly looking at a car.

Adoption is how such a car becomes real for everybody. It is what a car-theft or world-car RP server needs: walk up to a Hella, make it canonical, drive it, and have the rest of the session see you do it.

What adoption actually does

It takes the car's identity, not the engine's entity. That is the entire design and it is a deliberate answer to how this could go wrong.

The engine owns those cars. It spawned them, it moves them, and it deletes them whenever it likes, with no notification this plugin can rely on. A canonical vehicle bound to such an entity would quietly become a ghost the moment the engine took it back β€” and a vehicle that vanishes without a removal is worse than one that was never adopted.

So nothing is bound. The sequence is:

  1. the client describes the car β€” record, pose, paint, damage;
  2. the server creates an ordinary canonical vehicle from that description, with the same lifetime, the same reaper and the same projection path as a car a garage spawned;
  3. the client hands its vanilla chassis back to the population system.

After step 3 the engine owns nothing the server believes it owns, and the adopted car is a vehicle like any other: get, update, setLocked, setPerformance, setTimeToLive and remove all behave exactly as they do for a create. Only get(id).adopted, adoptedFrom and adoptedEngineEntity remember where it came from, and they are provenance β€” they grant nothing.

Adoption replaces the vanilla chassis with a network projection. A visible gap can occur while the replacement streams in; its duration depends on asset loading.

The three calls

On the client, with vehicles.read:

-- Nearest first. `radius` defaults to 25 m, `limit` to 8.
local offered = Open77.vehicles.adoptable({ radius = 8, limit = 4 })
for _, car in ipairs(offered or {}) do
    print(car.record, car.distance, car.adoptable)
end

An entry carries everything the server's adopt reads, spelled the same, so it can be handed over untouched. engineEntity and recordId are fixed 0x strings, because a REDengine hash does not survive a Lua number.

adoptable is false β€” and record empty β€” when the car's TweakDBID is not in the shipped reverse table. The engine hands out ids, not names: a TweakDBID is CRC32 of the record name plus its length, a shipping build keeps no reverse table, and gamedataVehicle_Record has no Name accessor. So the 1376 known 2.31 vehicle records are carried in the client, every one of them verified by recomputing its own id, and a car outside that set reports its raw id and says it cannot be adopted rather than inventing a name.

On the server, with world.vehicles and world.vehicles.adopt:

Open77.vehicles.setAdoptPolicy("driver")          -- bucket 0
Open77.vehicles.setAdoptPolicy("any", { bucket = 7 })
print(Open77.vehicles.getAdoptPolicy(0))          -- "driver"

Every bucket starts at "none": nothing is adoptable until a resource says so. "driver" accepts only a car the proposing player is standing at, within six metres β€” the car-theft shape. "any" widens that to sixty metres; it does not remove the check.

Back on the client, with vehicles.adopt, once the server has agreed:

Open77.vehicles.releaseAdopted(car.engineEntity)

That deletes the vanilla chassis through the population system β€” the same call the world sanitizer already makes every second β€” and it refuses a chassis this client was not offered in the last minute, a car the server owns, and the car the player is sitting in.

End to end

-- client
local car = (Open77.vehicles.adoptable({ radius = 6, limit = 1 }) or {})[1]
if car and car.adoptable then TriggerServerEvent("garage:adopt", car) end

RegisterNetEvent("garage:adopted", function(engineEntity, vehicleId)
    Open77.vehicles.releaseAdopted(engineEntity)
    print("that Hella is now vehicle " .. vehicleId)
end)
-- server
RegisterNetEvent("garage:adopt", function(description)
    -- `source`, never a field out of the payload: the placement checks are the
    -- entire defence, and they have to run against the real sender.
    local id, reason = Open77.vehicles.adopt(source, description)
    if not id then return print(reason) end
    TriggerClientEvent("garage:adopted", source, description.engineEntity, id)
end)

What the server checks, and what it cannot

Everything in a description was read on the proposing client and none of it is trusted. The server decides whether a car appears at all, in which bucket and where; the description only decides what it looks like, which is exactly the authority a resource already has through create. So the checks are about placement:

  • the player's bucket must have a policy other than none (adopt_policy_closed);
  • the player must actually be there, within the policy's radius (too_far), and must have a known position at all (player_unavailable);
  • no adopted car of the same record may already stand within three metres in that bucket (duplicate_adoption) β€” traffic is spawned per client, so two players on one corner are each describing their own car, and without this rule the corner fills with copies;
  • the same chassis proposed twice by the same player answers with the id of the first vehicle and the reason already_adopted, because a retry after a dropped acknowledgement is the ordinary way that happens.

Two things it deliberately does not do. It does not verify that the record exists β€” a made-up but well-formed record string produces a canonical vehicle no client can project, exactly as create would. And it does not know whether an NPC was driving the car: the only way to ask is a scripted function, and calling one without a script frame ends the process. adoptable() reports speed so a resource can prefer parked cars, and "driver" policy plus a player at the door is the shape that avoids the question.

Client API

The client state surface is intentionally read-only:

local vehicle = Open77.vehicles.get(id)
local streamed = Open77.vehicles.all()
local trunkOpen = Open77.vehicles.isDoorOpen(id, "trunk")
local hoodOpen = Open77.vehicles.isDoorOpen(id, Open77.vehicles.doors.hood)
local entryLocked = Open77.vehicles.isLocked(id)
local mySeat = Open77.vehicles.getPlayerSeat()
local remoteSeat = Open77.players.getVehicleSeat(remotePlayerId)
local cannotExit = Open77.vehicles.isPlayerExitLocked()

-- Requires vehicles.control and local physics ownership.
local ok, reason = Open77.vehicles.triggerHorn(id, 300)

Performance ceilings

This is the client-local form. It is unchanged and still works; the server-authoritative form, which takes the same three numbers and replicates them to every viewer, is Performance, decided by the server. Use the server form for anything a player must not be able to opt out of -- a tuning shop, a speed limit inside an instance -- and this one for a client's own presentation.

A roster of cars is only a real choice when no single car simply wins. Cyberpunk 2077 2.31 exposes no speed setter, no writable drive model and no writable input system, so Open77 applies a ceiling the way an engine control unit does: it withdraws driver throttle as the ceiling approaches, at the exact point in the drive update where REDengine reads it. The car still accelerates normally, then the top of its acceleration curve flattens. Nothing fights the physics solver, so there is no judder and no rubber-banding.

-- Every Archer Hella in this gamemode tops out at 140 km/h.
Open77.vehicles.setPerformanceClass("Vehicle.v_standard2_archer_hella_player", {
  topSpeedKph = 140,
})

-- Same ceiling, and half the pickup, for one specific spawned car.
Open77.vehicles.setPerformance(vehicleId, {
  topSpeedKph = 140,
  taperKph = 15,
  accelerationScale = 0.5,
})

Open77.vehicles.clearPerformanceClass("Vehicle.v_standard2_archer_hella_player")
Open77.vehicles.clearAllPerformance()

-- Hold a stopped car on a starting grid, then release it on authoritative GO.
assert(Open77.vehicles.setControlLock(vehicleId, true))
assert(Open77.vehicles.setFrozen(vehicleId, true))
assert(Open77.vehicles.setFrozen(vehicleId, false)) -- wake chassis first
assert(Open77.vehicles.setControlLock(vehicleId, false))
Field Default Meaning
topSpeedKph 0 The ceiling. Zero leaves the top end alone, so a profile can cap pickup only.
taperKph 12 How wide the roll-off below the ceiling is. Wider is softer. Values under 3 are refused, because a band that narrow makes the limiter hunt and the hunting reads as lag.
accelerationScale 1 Throttle authority at every speed, in (0, 1]. This is how a car is made to pick up more slowly rather than only to top out lower.

Precedence is instance, then record class, then a default set by setDefaultPerformance on the native API. A car matched by nothing is untouched. Caps are dropped automatically when the vehicle despawns, and they do not survive a client restart.

Open77.vehicles.ratedTopSpeed(id) returns the gearing rating of the vehicle's TweakDB record: the speed at which top gear reaches its rated maximum engine RPM. It is the right number for ranking a roster and for picking a ceiling that no car in the roster has to be slowed much to meet. It is not the measured terminal velocity β€” torque and aerodynamic drag carry real top speed above it.

This is balance, not anti-cheat. The ceiling is applied by the client that simulates the vehicle, and a modified client can decline to apply it. It equalises a roster among players who are running the stock client; it enforces nothing against a determined one. The server currently cannot corroborate it either β€” the server-side vehicle snapshot carries no velocity, so there is nothing to check a reported speed against. Treat a speed ceiling as a game-design tool, on the same footing as which cars the roster offers at all.

setControlLock is the hard stationary counterpart to the soft performance governor. While locked, the native drive update consumes zero acceleration and zero brake/reverse input and clears burnout. Releasing the lock restores the input snapshot on the next frame; no velocity or transform is overwritten, so there is no rubber-banding. Engage it only after placing a stopped vehicle. The same client-side trust boundary applies: it is a gamemode start lock, not an anti-cheat primitive.

setFrozen is the physical half of a real starting-grid hold. It calls the verified REDengine vehicleBaseObject::EnablePhysics path for the complete chassis group, while preserving the local physics-owner lease, driver workspot and player-controlled state. This stops inertia, gravity and external impulses; the API does not repeatedly teleport the vehicle. On release it restores the mask and wakes the rigid body. Pair it with setControlLock: freeze after inputs are locked, then thaw before restoring inputs on GO. Calls are idempotent and an unknown or not-yet-streamed vehicle fails closed so a resource can retry.

Where the number should live: a gamemode should not hard-code it. Declare it as a resource tunable so a server owner can edit it in Warden, and have the resource apply the value it reads. A ceiling is a match parameter, so nextMatch is the right apply timing β€” moving it under two people already racing is not a live update.

Trusted presentation resources can request an entry presentation or use an explicit instant warp:

-- FiveM-compatible seat numbers: driver=-1, front passenger=0,
-- rear-left=1, rear-right=2.
local ok, reason = Open77.vehicles.taskPlayerEnterVehicle(
    playerId, vehicleId, Open77.vehicles.seats.frontPassenger)

-- Recovery, stream reconstruction, teleport-oriented game modes, or tests.
ok, reason = Open77.vehicles.warpPlayerIntoVehicle(
    playerId, vehicleId, Open77.vehicles.seats.driver)

Named seats (driver, frontPassenger, rearLeft, rearRight, frontLeft, frontRight, backLeft, backRight, and canonical seat_* names) are accepted too. These functions operate only on remote player proxies. They return false, "occupancy_mismatch" unless the replicated server ledger already contains that exact tuple. They cannot grant a seat, move the local player, steal a vehicle, or change physics authority. This is the client meaning of warpPlayerIntoVehicle; the identically named server method creates the authoritative assignment. Client and server resources run in separate Lua states, so the same name cannot accidentally cross that boundary.

taskPlayerEnterVehicle uses Open77's guarded staged door/workspot path for a live remote entry, with a bounded instant-warp fallback. It remains presentation-only.

getPlayerSeat(playerId?) and its Open77.players.getVehicleSeat alias return the replicated assignment. Omit the ID to query the local player. In addition to the server fields, a client result contains streamed and entity; entity is the current ephemeral local projection handle and must not be cached across stream-out. isPlayerExitLocked(playerId?) returns nil when no assignment is replicated, otherwise the canonical boolean.

Client snapshot fields

The client snapshot deliberately differs from the server snapshot:

Group Fields
Identity id, record, revision
Local projection entity, engineEntity, streamed, locallyOwned
Authority physicsOwner, authorityEpoch
World position (a plain { x, y, z }, which is what an Open77 vector3 is)
Transform orientation = { x, y, z, w }, heading, yaw
Durable state health, flags, locked, doors, windows, tires, brokenGlass, brokenLights, detachedParts
Control state engineOn, lights ("off"/"on"/"high"), sirenOn, undriveable, drivable, frozen
Body and damage bodyDamage[1..30], damage = { body, glass, lights, tires, detachedParts }
Drivetrain speed (m/s), speedKph, rpm, rpmMax, throttle, brake, gear, burnout
Wheels/suspension steering, wheelRotation, suspensionLongitudinal, suspensionTransversal, onGround, reversing
Seats occupants[] = { playerId, seat, flags, entering, exiting, forcedEntry, exitLocked, forcedExit, switching, switchId, switchSeat, autoSwitchEnabled }

position and orientation come from the same source: the latest replicated motion when one exists, the canonical create otherwise, so a proximity query answers for a car whose REDengine projection has not attached yet. heading and yaw are one value derived from orientation with the same full-quaternion extraction the server uses, so the two runtimes never disagree about which way a car points. speed stays metres per second, the unit every Open77 read uses; speedKph is the same number converted once, because performance ceilings and speed limits are authored in km/h.

entity is an ephemeral, generation-checked Open77 handle for the local projection. engineEntity is diagnostic engine identity. Neither is the durable server vehicle ID, and neither should be cached after stream-out. Client snapshots do not include server ownership metadata such as resource, bucket, or world coordinates. Paint is available both in the snapshot and through Open77.vehicles.getPaint(id).

There is no streamer to ask, and no RequestModel / HasModelLoaded loop to write. Streaming is implicit: the server registry streams a vehicle to every client inside its radius, and the client attaches its projection when the engine has spawned it -- measured up to 3.7 s after the create arrives. create returns before that; get(id).streamed says whether this client has the body, open77:vehicleCreated says the create arrived. Open77.vehicles.whenStreamed folds both into one promise, which is the whole of what a ported loading loop was waiting for:

-- Requires vehicles.read. Resolves with the get(id) snapshot once streamed == true --
-- before the call returns when it already is -- and rejects with `timeout`
-- (default 15000 ms, clamped to 1..120000).
CreateThread(function()
    local pending, reason = Open77.vehicles.whenStreamed(vehicleId, 10000)
    if not pending then return print("cannot wait: " .. tostring(reason)) end
    local car, why = pending:await()
    if not car then return print("never streamed in: " .. tostring(why)) end
    Open77.blips.create({ entity = car.entity, sprite = "taxi", label = "Your taxi" })
end)

It listens for open77:vehicleCreated and polls the snapshot every 100 ms until it is attached; the one-shot handler is removed on resolve, on timeout and when the resource stops. nil, reason only for a bad id, a bad timeout or a missing permission -- an id this client has never seen is waited for and times out, because "not here yet" and "not anywhere" look the same from a client. There is deliberately no server counterpart: a vehicle's steward is elected by a driver's claim, never by a streamed report, so the server holds no signal that means "somebody has this car"; Open77.npcs.whenReady exists on the server because NPCs do report readiness (npcs.md).

Client events:

AddEventHandler("open77:vehicleCreated", function(id) end)
AddEventHandler("open77:vehicleRemoved", function(id, reason) end)
AddEventHandler("open77:vehicleAuthorityChanged", function(id, ownerPlayerId) end)
AddEventHandler("open77:vehicleOccupancyChanged", function(id, revision) end)

-- The local player's own seat transitions, derived from the same replicated
-- ledger. Seat is a canonical `seat_*` name.
AddEventHandler("open77:vehicleEntering", function(id, seat) end)
AddEventHandler("open77:vehicleEntered", function(id, seat) end)
AddEventHandler("open77:vehicleLeft", function(id, seat) end)

-- Electrical edges, so a HUD does not have to keep its own copy of `flags`.
AddEventHandler("open77:vehicleEngineChanged", function(id, state) end)   -- "on" / "off"
AddEventHandler("open77:vehicleLightsChanged", function(id, mode) end)    -- "off" / "on" / "high"
AddEventHandler("open77:vehicleSirenChanged", function(id, state) end)    -- "on" / "off"
AddEventHandler("open77:vehicleDrivableChanged", function(id, state) end) -- "yes" / "no"
AddEventHandler("open77:vehicleDamageChanged", function(id, revision) end)
AddEventHandler("open77:vehiclePaintChanged", function(id, revision) end)

The reference resource also emits open77:vehicleOwnerChanged(id, ownerPlayerId) and open77:vehicleSeatsChanged(vehicleSnapshot, revision). The latter resolves the fresh snapshot before dispatch, unlike the lower-level occupancy event.

Seat and proxy replication

  1. REDengine reports the local player's real mount and slot.
  2. The client requests that seat; it never assigns itself locally in the network ledger. A server resource may instead create a forced assignment for teleport-oriented gameplay.
  3. The server checks vehicle id, routing bucket, 15-metre proximity, lock/destruction state, one-seat-per-player, and one-player-per-seat.
  4. A reliable ordered occupancy snapshot is sent to every vehicle viewer. Durable forced-entry, exit-lock, and forced-exit flags remain present until native confirmation, so late stream-in and projection replacement replay the policy rather than losing a one-shot command.
  5. Each observing client mounts the corresponding remote player proxy into the streamed vehicle and exact seat. A live addition runs the native NPC approach/door/workspot behavior; an initial stream snapshot uses the instant warp so it does not replay an old entrance. When the snapshot removes the player, Open77 discards that disposable native proxy and recreates it from the next authoritative player snapshot. This avoids reusing a REDengine puppet whose vehicle workspot left its locomotion representation inactive.
  6. Root player movement is suspended only after the native mount is confirmed, avoiding a transform fight between pedestrian interpolation and the vehicle mounting system. A fresh pedestrian controller is installed on the replacement proxy after exit.

If an ordinary local engine mount disagrees with the server for two seconds (for example a locked seat was rejected), Open77 unmounts the player. A forced entry or exit-lock mismatch is reconciled immediately whenever the target projection exists. Disconnect, vehicle removal, seat change, and confirmed exit clear the server ledger. Leaving the driver seat also revokes physics authority.

Damage, electrical state, and horn

Protocol 1.13 extends the reliable observation channel for state changed by REDengine. The current physics owner captures normalized health, the native 30-cell body-destruction grid, broken glass/light bitfields, flat tyres, engine state, headlight mode, six door states, and four window states while driving. Streamed clients also watch monotonic destructive changes on nearby vehicles, so gunfire, collisions, fire, and explosions are reported even when the target vehicle is parked and has no physics owner. Nearby clients also report reversible opening changes. The server accepts damage witness reports only from the vehicle's current interest set and merges damage monotonically; a client can add damage but cannot repair a vehicle or change its electrical state. Opening changes additionally require a position within 15 metres. Repairs and arbitrary opening mutations remain explicit server-resource operations.

The canonical state is sent to current viewers and embedded in VehicleCreate for stream-in and late join. A player connecting after a collision or explosion receives the same health, dents, broken glass/lights, tyres, destroyed state, and native explosion event instead of a pristine local projection.

When the driver exits, disconnects, dies, loses the lease, or is revoked, the server clears engineOn, lightsOn, highBeams, and sirenOn before publishing the new authority epoch. This prevents parked vehicles from retaining engine audio or headlights on one client.

Engine, lights and siren

These four bits have always existed in flags and have always been applied by every client. What did not exist was a way to write one of them. A keys resource that wanted to cut an engine had to read get(id).flags, clear bit 0, and post the whole bitfield back through update -- a read-modify-write on state shared by every resource holding world.vehicles. Two resources doing it in the same tick each overwrite the other's bit, because each learned the flags before the other wrote them.

setEngine, setDrivable, setLights, setHighBeams and setSiren write one named bit and leave every other flag exactly as it was. flags and update are unchanged and keep working as they always did; these are another way into the same canonical field, not a replacement.

The intent window, and why a setter without one is unreliable

The physics owner publishes what its own projection reads back every 500 ms, and the server merges the electrical bits by replacement. So a setEngine(id, true) races the report that was already in flight when it was called -- and loses. The bit lands, the stale report lands a moment later, and the API looks broken at random. This is the same mechanism that made Pursuit's cop sirens go dark about five seconds after every spawn for a month.

Each electrical write therefore records a ten-second intent that outranks the owner's report for the bits it names. It is a window, not a lock:

  • the intent is released the moment the owner reports the same value -- proof that its projection applied it -- so a driver who then flips their own headlights is obeyed immediately;
  • a bit the owner can never report (the siren) is held until the window expires, then dropped, so the ledger can never get permanently stuck;
  • the ten-second window allows for stream-in and the first owner report.

What the siren can and cannot do

Siren lights and sound share one switch. vehicleBaseObject.ToggleSiren(Bool) has no separate channels, so the API cannot control them independently.

Owner snapshots do not include a native siren readback. Durable reports therefore echo the canonical siren value instead of reading it from the vehicle.

Indicators and interior lighting

Indicator and interior-light controls are not supported by this API.

What setDrivable actually does

Out of service cuts the engine in the same canonical revision and locks the simulated driver's throttle and brake on every projection. Both halves matter: a car whose engine is off but whose inputs are live can still be rolled along, and a car whose throttle is dead while its engine idles is a bug report waiting to happen. Putting it back in service does not restart the engine -- that is setEngine.

Like the entry lock, undriveable is server-authored and absent from every owner report, so no client can clear it.

Freezing a car

setFrozen(id, true) is the server half of FiveM's FreezeEntityPosition for a vehicle, and it is deliberately only that half -- the control lock above is setUndriveable, and the two compose. Three things happen in one canonical revision:

  • the frozen bit goes up (Open77.vehicles.flags.frozen, bit 11, readable through isFrozen and on the raw flags);
  • the motion facts are pinned: speed, velocity, angularVelocity, onGround and reversing read as a car at rest, and stay that way;
  • the physics owner's transform reports are discarded while the bit is up -- not merged, and not punished either. The lease is renewed and the tick consumed exactly as an accepted report would be, because the owner is doing nothing wrong by reporting a car it still simulates, and revoking it would put the steward election and the freeze into a two-second flap. Nothing is logged for a discard: twenty a second is not a diagnostic.

On every client the bit maps to the same native physics mask the observer projection already uses, so inertia, gravity and impulses stop too -- the car is not merely refused by the server, it does not move on the driver's screen. A driver pressing the throttle revs an engine that goes nowhere, and undriveable is not implied: an impound wants both bits, a starting grid wants the freeze alone with the engine running.

-- Impound: nobody drives it, nobody pushes it, and it survives the lot emptying.
Open77.vehicles.setUndriveable(id, true)
Open77.vehicles.setFrozen(id, true)
Open77.vehicles.setPersistent(id, true)

-- Release, in the order a driver expects: inputs first, then the body.
Open77.vehicles.setFrozen(id, false)
Open77.vehicles.setDrivable(id, true)

Freezing pins the last accepted canonical transform. A moving owner can briefly render past that position because of network delay. Prefer setFrozen to a read-modify-write update of the frozen flag; both use the same authoritative pin.

Like undriveable, frozen is server-authored, absent from every owner report and preserved across them, so no client can clear it. It is a wire change: the accepted vehicle flag mask widened from bits 0..10 to 0..11, and a client that does not know the bit refuses the whole state frame as invalid, so the server and the client that carry it must be built together.

Health is one pool

setEngineHealth and getEngineHealth are the FiveM-shaped names for setHealth/getHealth, which are the same normalized 0..1 pool get(id).health reads. Cyberpunk models one vehicle health pool; there is no separate engine pool to damage, so at 0 the car is a wreck rather than a running car with a dead engine. A car that must stay whole but refuse to move is setDrivable(id, false).

Per-vehicle gameplay data a server invents -- fuel, keys, a plate, an owner, an insurance record -- does not belong in these flags. It belongs in a state bag on the vehicle, which replicates to the clients that care and costs the canonical ledger nothing. The bundled open77_fuel resource is the reference for exactly that pattern, and the plate recipe is the two-line version.

Horn state is intentionally transient rather than durable. The native VehicleComponent horn latch is sampled in the realtime motion stream and observers call ToggleHorn only on edges. Receivers force it off after 350 ms without a fresh owner packet and on every authority change, so packet loss can never leave a remote horn stuck on.

There is no setHorn, setRpm, setSteering, or setWheelRotation Lua method. Those values describe native driver input or physics and cannot be authored as durable script state. Seat assignment is server-only and uses the explicit forced-seat API; client scripts can only read the ledger or present a tuple the server already authorized. Engine, lights, high beams, siren, locks, destruction, immortality, and invulnerability are the scriptable vehicle flags bits.

Top speed is the one performance value that is scriptable, and it is scriptable in a different way: not as durable server state but as a client-side ceiling. See Performance ceilings.

Server-authoritative vehicle policy

Four capabilities that only the server can honestly decide: a performance ceiling, an explosion, who a car is locked for, and how long it lives.

Performance, decided by the server

Open77.vehicles.setPerformance(id, { topSpeedKph, accelerationScale, taperKph }) is the server-authoritative form of the client-local governor. The three numbers are the same, and they end up in the same native throttle policy; the difference is only who decides them. A tuning shop, or a speed limit inside an instance, cannot be a decision the client makes.

-- Server. Everything inside this instance is limited to 60 km/h.
for _, entry in ipairs(Open77.vehicles.nearby(centre, 200.0, { bucket = instanceBucket })) do
  Open77.vehicles.setPerformance(entry.id, { topSpeedKph = 60.0, taperKph = 10.0 })
end

The ceiling is replicated to every viewer, not only to whichever client currently owns the physics. Ownership changes every time a driver steps in or out, and a ceiling chased across those transitions would be missing for exactly the first second of every drive -- the second a speed limit has to hold. Each client applies it to its own projection; the native governor only has throttle to clamp on the machine actually simulating the car, so sending it wide costs a no-op on observers and removes a whole class of hand-off bug.

Why it does not need an intent window. The electrical setters need one because the physics owner republishes those bits every 500 ms and the server merges them by replacement, so a write racing an in-flight report loses. The performance ceiling is not a field that report carries at all, so there is nothing for the merge to overwrite. The race that does exist is on the client, where the ceiling routinely arrives before the projection has attached -- a server-created vehicle has been measured taking 3.7 s to stream in -- and it is answered the same way the intent window answers its race: the client holds the server intent on the replica and applies it when the entity appears, retrying every frame until the governor accepts it, and again if the projection re-attaches.

The envelope is the client governor's envelope, enforced on the Lua boundary, in the registry and on both sides of the wire: topSpeedKph 0..1000, accelerationScale in (0, 1], taperKph 3..200 with a default of 12. The taper floor is not arbitrary -- below about 3 km/h the roll-off is short enough that the limiter hunts around the ceiling and it reads as stutter, which is why the client-local form has refused that band since it shipped. A server cap must not be a way around a rule somebody measured, so an unnamed taper takes the native default rather than zero.

Read it back with Open77.vehicles.getPerformance(id), or get(id).performance. Clearing it is clearPerformance(id), setPerformance(id, nil), or any profile that asks for nothing -- no ceiling and full throttle authority. The taper is not part of that test: a roll-off width around a ceiling that does not exist changes nothing, which is how the native profile decides it too.

Explode

Open77.vehicles.explode(id) writes the canonical ledger: health to zero, destroyed and exploded raised, one revision. Every client that has the vehicle streamed then reconciles its own projection through the damage path that already replays a wreck to a late joiner, which ends in REDengine's own explosion on that machine. The blast, the sound and the physics are the engine's, on every viewer at once; the server never touches an entity.

Three things it is worth being exact about, because a keys or a police resource will hit all three:

  • Occupants stay occupants. The seat ledger is not touched. Nobody is ejected, no seat is freed, no life state changes, and the server sends no damage to any player -- inside the car or standing next to it. What the blast does to a body is the engine's own damage model running on each client, and it reaches the server the ordinary way, through the player damage report. A resource that wants the occupants dead has to say so itself.
  • It is terminal. A second call is refused with already_exploded rather than re-detonating a wreck. A vehicle that is merely destroyed -- written off by collisions, never blown up -- is still a legitimate target.
  • An owner report cannot undo it. Health merges by minimum and the destruction bits by OR, both monotone, so a report taken a moment before the explosion and claiming a pristine car does not bring the vehicle back to life.

Locking a car for one player

The durable entry lock has always been all-or-nothing, which is the wrong shape for keys: a car is locked for the street and open for the two people holding keys. Per-player exceptions express that directly.

Open77.vehicles.setLockedForAll(id, true)              -- the street
for _, holder in ipairs(keyHolders) do
  Open77.vehicles.setLockedForPlayer(id, holder, false) -- the exceptions
end

The canonical bit stays locked, so a late joiner is told the right thing with no bookkeeping, and a key holder who disconnects takes their exception with them. isLockedForPlayer(id, playerId) is the single function that decides what a client is told, and the substitution happens at the point where state reaches a socket -- both the periodic state packet and the create a viewer gets on stream-in -- so no fan-out path can disagree with it. Nothing per-player is ever written into the canonical flags, which is also why no owner report can disturb it: Locked is outside the set an owner report is allowed to carry, and a report containing it is refused outright.

setLockedForAll revokes every exception as well as moving the canonical bit. That is the point, not a side effect: "lock this for everyone" has to mean everyone, including the holders excepted a minute ago. Open77.vehicles.setLocked is unchanged and moves only the canonical bit, leaving exceptions standing.

Lifetime

Props have had ttlMs since they shipped; vehicles had nothing, so a garage had to sweep its own.

Policy Where Meaning
ttlMs create definition, setTimeToLive(id, ms) Remove the vehicle this many milliseconds from now. 0 or nil cancels. Capped at seven days.
persistent create definition, setPersistent(id, bool) Nothing automatic removes this vehicle.
despawnWhenUnobserved create definition, setDespawnWhenUnobserved(id, bool) Remove it once nobody has had it streamed for thirty seconds.
local id = Open77.vehicles.create({
  record = "Vehicle.v_standard2_thorton_galena_player",
  position = spot, yaw = 90.0,
  ttlMs = 10 * 60 * 1000, despawnWhenUnobserved = true,
})

persistent has one meaning, deliberately: no time-to-live takes it, the unobserved sweep does not take it, and it survives the stop of the resource that created it. An explicit Open77.vehicles.remove still takes it -- persistence is protection from the reapers, not from the owner. Taking persistence away hands the vehicle back to any deadline it still carries.

The unobserved window is not zero because interest is recomputed against the last received player snapshot, and a loading screen or a bucket change briefly empties a viewer set that is about to refill; thirty seconds rides that out. The clock runs for a vehicle nobody has ever seen too, which is what a garage wants -- a car spawned for a player who never turns up still goes. A viewer coming back resets the clock rather than pausing it, and turning the flag off forgets it, so turning it back on later does not inherit a deadline from a window nobody was counting.

The reaper runs once per tick, before interest is reconciled, for the same reason the prop reaper does: a vehicle whose lifetime ends must leave as a real removal the client tears down, not by quietly dropping out of the next interest pass. Its removal reason is 3.

A deadline takes an occupied vehicle too. A time-to-live a resource authored and then silently declined to honour because someone happened to be sitting in the car is a worse surprise than the removal, and the seat ledger is closed out properly either way -- every occupant gets their onPlayerLeftVehicle. A garage that must not yank a car from under its driver clears the deadline on onPlayerEnteredVehicle, or marks the vehicle persistent. The unobserved sweep cannot hit an occupied vehicle at all: a seated player always has their vehicle streamed, so it always has a viewer.

Finding a vehicle

Every vehicle interaction in an RP server starts with "the closest vehicle", and until these existed there was no way to ask.

Runtime Call Answers
Client Open77.vehicles.closest(radius?) The nearest replicated vehicle to the local player.
Client Open77.vehicles.nearby(radius, options?) All of them within a radius, nearest first.
Client Open77.vehicles.aimed() The one the player is looking at, right now.
Client Open77.vehicles.fromEntity(entityId) The vehicle wearing an entity id.
Client Open77.vehicles.seatFree(id, seat) / occupantInSeat(id, seat) Seat occupancy, from the replicated ledger.
Server Open77.vehicles.nearby(anchor, radius?, options?) All registered vehicles near a point or a player.
Server Open77.vehicles.closest(anchor, options?) The nearest one.
Server Open77.vehicles.seatFree(id, seat) / occupantInSeat(id, seat) / freeSeats(id) Canonical seat occupancy.

The server calls reuse the anchor, bucket and limit conventions of Open77.players.nearby exactly: the anchor is a position table (a vector3 is one) or a player id, a player anchor defaults to that player's own routing bucket, bucket = false opts back out to every bucket, and ties are broken by vehicle id so the order does not reshuffle between two calls with the same input.

The two sides say "nothing in range" differently, and the difference is deliberate. The server answers a bare nil with no reason, matching Open77.players.closest: that is how a caller tells "the search worked and the street is empty" from "the search failed". The client names the case no_vehicle_in_range, because it has near neighbours a resource genuinely has to tell apart -- no_local_position when the player has no body yet, no_session before there is a roster at all.

-- Client: the car the player is standing at, or the one they are looking at.
local car = Open77.vehicles.closest(6.0) or Open77.vehicles.aimed()
if car then Open77.net.emitServer("keys:tryUnlock", car.id) end

-- Server: an empty seat in the nearest car in this player's own bucket.
local nearest = Open77.vehicles.closest(playerId, { radius = 10.0, occupied = false })
if nearest then
    local free = Open77.vehicles.freeSeats(nearest.id)
    if free[1] then
        Open77.vehicles.warpPlayerIntoVehicle(playerId, nearest.id, free[1])
    end
end

What closest can and cannot find

Only server-spawned vehicles are in the registry. A player who walks up to a vanilla traffic car is standing next to something REDengine population spawned on their own client: it has no canonical id, no replica, and no entry in any of these calls. closest() will happily answer with a registered car forty metres away while the player leans on a taxi, and that is the expected behaviour rather than a bug.

Two consequences a server creator will otherwise trip over:

  • a resource that must not act on the wrong car has to check distance itself, because "nearest registered" is not "nearest";
  • Open77.vehicles.aimed() answers unknown_entity far more often than no_target. The player really is looking at a car; that car is simply traffic nobody created.

Adopting vanilla traffic into the registry is a separate capability that does not exist yet.

Seat occupancy

seatFree and occupantInSeat read the occupancy ledger, not the local projection, so the client and the server agree about who is where. A seat held by an occupant whose entry animation is still running counts as taken: the seat is reserved and a second warp into it is refused, so reporting it free would be a lie the caller then trips over one line later.

During an animated seat switch, both source and destination count as taken. The occupant's seat stays at the source; switching, switchId and switchSeat identify the reserved destination. switchId is an opaque string.

freeSeats(id) returns canonical seat names in driver, frontPassenger, rearLeft, rearRight order, so freeSeats(id)[1] is a deterministic "put them anywhere" seat. It considers all four seats of the standard layout; the registry does not know a two-seat record's seat count, so a two-seater still reports its rear seats free.

Authority and streaming

  • Stream-in radius: 350 metres.
  • Stream-out radius: 425 metres, providing hysteresis.
  • Only the canonical front-left occupant requests a two-second physics lease automatically.
  • Valid owner motion renews the lease.
  • Exit, timeout, disconnect, stale epoch, or an implausible jump revokes it.
  • Create/remove/state/authority use reliable ordered delivery.
  • Motion uses unreliable sequenced delivery and is coalesced client-side.
  • Observers render through an adaptive jitter buffer. It targets 75-180 ms from the measured packet interval and jitter, uses cubic Hermite position interpolation plus shortest-path quaternion slerp, and predicts linear/angular motion for at most 180 ms after the newest sample.
  • Duplicate/stale ticks are rejected and every authority-epoch change resets history. Throttle, brake, burnout, and tire-slip transients decay after 350 ms without fresh owner motion.
  • The owner also retains every outgoing motion sample locally. Because the server does not echo unreliable motion to its sender, this retained sample is the handoff pose when authority is released; exiting a vehicle therefore cannot fall back to its original spawn transform.
  • An observer projection uses REDengine's whole-vehicle movement path: simple movement, physics masking, and ForceMoveTo with the interpolated pose. Open77 deliberately does not make streamed vehicles kinematic: runtime tests showed REDengine did not reconstruct a driveable backend when local ownership was later acquired. On local ownership, Open77 clears the observer physics mask explicitly, disables simple movement, restores player control, enables transform updates, and wakes native physics.
  • Direct manipulation of a mesh PhysicalBodyInterface remains deliberately disabled: that prototype crashed the second client during stream-in. Private vehicle entry points are accepted only when their 2.31 relocation resolves to the exact audited executable RVA.

Who owns a car

Open77.vehicles.owner(id) answers FiveM's NetworkGetEntityOwner with more than a number, because the lease has more than one shape. It requires world.vehicles and answers nil, reason for an unknown id.

Field Meaning
physicsOwner, authorityPlayerId The client simulating the car; 0 when parked. Both names carry the same value -- the vehicle read has always said physicsOwner, the NPC read says authorityPlayerId, and a script reading both should not learn two words.
driver The seated controller -- the front-left occupant, or any occupant of an AV -- whether or not they own the lease. Absent when nobody is.
steward The owner holds the lease without being the driver: the parked-car simulation the platform hands to the nearest occupant, or a script's own grant.
aiDriven An NPC drive task holds the lease.
epoch The authority epoch that owner holds.
since, ageMs When the owner last changed, on the GetGameTimer() clock, and how long ago that was.
reason Why it last changed: driverclaim, driverrelease, leaseexpired, playerdisconnected, validationfailed, serverrevoke, or none for a platform steward grant and for a car nobody has owned yet.
leaseMs How long the current lease still has, when there is one.
-- A speed camera only trusts a car somebody is actually driving.
local owner = Open77.vehicles.owner(id)
if owner and owner.driver and not owner.aiDriven then
    fine(owner.driver, Open77.vehicles.getSpeedKph(id))
end

Open77.vehicles.requestAuthority(id, playerId) is NetworkRequestControlOfEntity as a request. It returns the owner table after the grant, or nil, reason, and the reasons are the election's own eligibility made explicit:

Reason Means
driven Somebody else is the seated controller. A driven car is never taken from its driver -- this is the rule that makes the call a request rather than an order.
ai_driven An NPC drive task holds the lease; re-granting it would strand the task.
not_streamed The player has no body for this car yet. A client cannot simulate what it has not spawned.
too_far Beyond the 350 m stream-in radius.
wrong_bucket The player is in another routing bucket.
player_unavailable No fresh position for the player -- the same two-second rule every claim applies.
already_owner Not a refusal: the call returns the read, and nothing is re-granted.
invalid_player_id, vehicle_not_found The usual.

The requested player being the driver already is the one case that is not a steal, and it is granted. A grant is a lease like any other: the client keeps it by reporting, which its parked-pose loop does twice a second once the car has been attached for five seconds, so a grant to a client that has only just streamed the car in can lapse two seconds later -- and owner then says so. That is what a lease is, not a defect of the request.

-- Hand a parked car to the player who just bought it, so their client simulates it
-- while they walk around it. Refused by name if somebody is sitting in it.
local read, reason = Open77.vehicles.requestAuthority(id, buyerId)
if read == nil then print("not yet: " .. reason) end

There is no Open77.vehicles.entity(id): the client's Open77.vehicles.get(id).entity field is the REDengine entity, and Open77.vehicles.fromEntity(entity) is the way back, which is all NetworkGetNetworkIdFromEntity ever needed. NPCs have the same read, Open77.npcs.owner(id), in the same shape (authorityPlayerId, physicsOwner, epoch, since, ageMs, reason, leaseMs, plus readyClients, the election's candidates) and no request: an NPC's authority is elected from the clients whose projection reported ready, and a script has no better information than the election about which of them should simulate a body.

Drivetrain, wheels, and engine audio

The live vehicle blackboard and input state are appended to every owner motion sample. RPM, maximum RPM, gear, speed, and longitudinal/transversal suspension forces are read from the 2.31 VehicleDef blackboard. Throttle, brake, burnout, reverse, and on-ground state come from the native vehicleBaseObject. Observers smooth these values, write them into their streamed vehicle's blackboard, and update entity-scoped mechanical audio parameters at 30 Hz. Engine pitch, load, braking, gear, and lateral load therefore follow the network owner instead of being inferred independently by each client.

Remote engine audio starts when the canonical engine state, a remote driver, or fresh drivetrain activity proves the engine active. It stops when the engine and driver are inactive, the vehicle streams out, or authority becomes local. This avoids initial silence while the durable engine bit lags the realtime motion. Two switchable strategies exist (debug bridge vehicle.audiomode_<0-3>: 0 off, 1 traffic, 2 mechanical, 3 both; default mechanical): mechanical engages the vanilla driver-mix state machine (vehicleAudioEvent OnPlayerDriving) and feeds the measured 2.31 RTPC names (paramEngineRPM, paramVehicleSpeed, paramWheelAngularSpeed, veh_speed, veh_accel, veh_engine_throttle_input, paramLongSLip, paramLatSlip, and paramTotalSlip) from the replicated motion at 30 Hz, so pitch, load, and tire stress follow the network owner. Each engine value is published to the vehicle entity scope consumed by its model-specific gameaudioVehicleAudioComponent, plus the authored engine and general emitters for spatial voices. Traffic is a diagnostic-only mode: it plays the discrete <audioResourceName>_traffic_engine_loop Wwise pair resolved from the live TweakDB record, but that simplified traffic voice cannot reproduce the local vehicle's complete mechanical timbre. Four global tyre loops target the template's named wheel emitters and receive their RTPC slip values independently, which preserves spatial tyre roll and squeal. Replicated gear transitions additionally play the model's _gear_up/_gear_down one-shots.

Observer tire smoke uses base\fx\vehicles\_skid_marks\skid_fx.effect; the asphalt trail is a separate native base\fx\vehicles\_wheels\skid_marks\sport\v_skid_mark_s_m_01.effect backed by trail_decal.mt. A deterministic slip signal is derived from chassis-relative velocity, brake, throttle, and burnout. Hysteresis prevents VFX flicker; one pair of emitters per rear wheel (one centered pair on bikes) follows safe chassis-relative contact points. When slip stops, the native effect loop is broken instead of killed: smoke fades for about 2.5 seconds and trail decals remain for their roughly 31-second authored lifetime. Stream-out, despawn, or authority handoff still performs an immediate cleanup. Exact surface-material particles are outside the v1 contract.

Body damage is replayed only after the streamed vehicle's authored components have attached. Broken-glass bits are resolved against that exact vehicle record's destruction-glass list and then applied through native glass events, so custom/model-specific window component names are preserved for current viewers and late joiners.

steering is normalized to -1..1 and wheelRotation is a wrapped radian phase. REDengine 2.31 does not expose its live steering input through RTTI, but Open77's audited 2.31 adapter reads the native lateral vehicle input at vehicleBaseObject+0x278. The owner therefore transmits real keyboard/controller steering even while stationary. The authoritative wrapped wheel phase initializes the observer and replicated velocity advances it every rendered frame without packet-phase feedback. These values remain available in read-only client snapshots. Open77 does not currently write the observer's hard-transform wheel bindings: their parent animation graph can be incomplete immediately after streamed attachment, and querying that graph caused a reproducible null dereference inside REDengine on both clients. The locally driven vehicle keeps its native wheel animation and real physics; a lifecycle-safe observer chassis adapter remains required for guaranteed visual wheel pose.

The server validates bucket, claim proximity, owner-to-vehicle proximity, generation/epoch, monotonic tick, finite values, velocity/RPM/input/suspension bounds, and travelled distance. Client scripts cannot claim authority directly.

Multiplayer spawn policy

There is no client-side vehicle creation API. The entire native vehicle.* developer-command module is absent from the client build; the documented commands below are Lua server commands and therefore use command ACLs. During a multiplayer connection, every vehicle spawn notification is checked against the live network registry. A REDengine vehicle without a server-issued 64-bit Open77 vehicle id is removed immediately; merely having a local dynamic-entity handle does not count. Entering an unknown vehicle is independently detected and forcibly unmounted.

This policy complements the multiplayer REDscript suppression of traffic, summons, and ambient vehicle producers. It is an identity boundary, not just a traffic-density setting.

Reference resource and commands

resources/system/open77_vehicles is the runnable example. It registers:

vehicle.list [bucket]
vehicle.create <x> <y> <z> [yaw] [model] [bucket]
vehicle.create.player <playerId> [model]
vehicle.remove <id>
vehicle.engine <id> <on|off>
vehicle.lock <id> <on|off>
vehicle.health <id> <0..1>
vehicle.damage.dump <id>
vehicle.glass.break <id> <glassIndex>
vehicle.glass.repair <id> <glassIndex|all>
vehicle.repair <id> [glass|body|lights|tires|visual|mechanical|full]
vehicle.paint <id> <r> <g> <b> [r2 g2 b2]
vehicle.paint.reset <id>

vehicle.list and vehicle.damage.dump are read-only. Mutation commands are restricted. Grant the corresponding exact command.vehicle.* ACL permissions before using them from chat.

Vehicle record names are listed in the project vehicle catalog. Prefer player variants such as Vehicle.v_standard2_archer_hella_player; quest and traffic variants are valid records but are not guaranteed to be pilotable.

// For agents & LLMsthis page as Markdownllms.txtllms-full.txt

// Guides Β· Lua API

Search the docs

1,905 guides and functions indexed

Try