Skip to main content

JavaScript SDK

A custom app or widget runs in an iframe. Reaching its own Lua works without RoadVR, and being reached from its own Lua works through sendToRoadVrApp. The SDK is the third direction: the page asking RoadVR itself what is going on, and being told when it changes.

Loading it

The path is relative to the RoadVR resource, so public/ belongs in it. There is nothing to add to your own files {} block — the file is served by RoadVR, which means there is no copy in your resource that can go stale against a newer headset.
Use the https://cfx-nui- form, not nui://. Both reach the same file, but the address decides what your page is standing on: a browser sends a Referer only from an http(s) document, so a page loaded over nui:// reaches every service on the web without one — and a service that checks it, YouTube being the loud example, refuses to serve you. The cfx-nui- form gives your page a real web origin, which is also what makes fetch and browser storage behave the way you expect.
Opened straight in a browser while you work on the page, the cfx-nui- address does not resolve at all and window.RoadVr is simply not there. Guard for it so the rest of your page still runs:
The global is RoadVr, matching the resource name. The product is written RoadVR; the identifier is not.

Getting started

Working examples

Three complete resources ship in examples/: roadvr_exampleapp, roadvr_examplewidget and roadvr_mediaapp — the last one is the Media app itself, and it uses watch('placement') and setActions() for real.

RoadVr.ready()

Promise<context>
Settles once the headset has answered, with the context object below.
Inside FiveM but with no headset on, nothing answers. After two seconds it settles anyway, with surface: null and usable defaults — rather than leaving your page waiting forever. Calling it twice is free: once it has settled it resolves immediately.

RoadVr.context

The same object ready() resolves with, kept up to date. Every event updates it before the handlers run, so a handler that would rather read the whole picture finds it already current.
string | null
The SDK contract version, not the resource version. It moves when a field is added, so you can check whether the headset you happen to be running in knows about something. null means nothing answered.
string | null
'app', 'widget', or null outside a headset.
object | null
Apps only. width and height are metres, not pixels — your page always lays out at 1280×800 CSS pixels however large the window stands in the world. Reading these as pixels gives you a layout that changes with window size.anchor is 'body' (follows the player) or 'world' (stays put).
object | null
Widgets only. size is metres of width — one number, because the height follows from aspect. mount is 'recess' or 'raised'.
object
light is a boolean, accent a hex colour, iconTheme the icon set name. palette is RoadVR’s own CSS custom properties — see applyTheme.
object
language is the player’s own interface choice, not the server’s Config.Locale. Those are two different settings, and mixing them up shows German text to a player who set English.
object
state is off, booting, passthrough, focus or shutdown. focus is the mode in which the player can actually click things.focused is this window being the focused one, not some window being focused.visible is false while the pause menu, the map or the cinema screen is up, and during a fast head turn. One field for all four, because the answer is the same in all four: stop animating.
object | null
null until you call watch('world'). See Weather and the clock.

RoadVr.on()

string
required
One of the events below.
function
required
Receives the changed subtree, not the whole context.
function
The counterpart. Calling it removes this one handler.

The events

close is the one to wire up first. It is the only warning your page gets that it is about to be taken down, and it is sent before the frame is detached — so whatever has to survive being closed gets written there.

message and the raw listener

RoadVr.on('message', …) and a plain window.addEventListener('message', …) carry the same pushes from your own Lua. The difference is that the SDK keeps its own traffic out of the first one; a raw listener sees both and has to tell them apart itself. Everything RoadVR forwards carries source: 'roadvr'. SDK traffic additionally carries channel: 'sdk'.

RoadVr.applyTheme()

HTMLElement
default:"document.documentElement"
Where to write the custom properties.
Writes RoadVR’s palette into your page and keeps it there when the player switches theme — call it once, after ready(). The names are the ones the interface uses itself: --vr-text, --vr-text-dim, --vr-text-3, --vr-glass-top, --vr-glass-bottom, --vr-chrome-top, --vr-chrome-bottom, --vr-separator, --vr-stroke, --fill-idle, --fill-hover, --fill-selected, --on-selected, --view-recessed, plus --vr-accent. Point your own variables at them with a fallback and the page still opens in a browser:

Watched topics

Two things are not sent unless you ask for them, because both tick and a ticker running for a page that never reads it is load nobody chose. Each one starts with its first watcher and stops with its last.

Weather and the clock

Once a second. The world object carries weather, temperature, hours, minutes, windSpeed, windBearing, sunrise, sunset, day, month, year and weekday. RoadVr.context.world is null until you watch it.

How far away your window is

Apps only — a widget hangs in a wall and does not move.
distance is metres from the player’s head to your window, four times a second and only while it actually changes. It is what the Media app turns its volume down with as you walk away, and what a display carrying too much detail to read from across the room can simplify itself on.
Nothing else about where anybody is standing travels — not the window’s position and not the player’s. One number is the whole answer to “am I near it”; the position would be a different question with a different answer.
A frame that goes away without calling unwatch still stops counting — a ticker does not stay on for a window nobody can see.

RoadVr.setActions()

Puts buttons in your own window’s ornament — the bar along the bottom edge that already holds the close button, the grabber and the tilt. Yours go to the left of the close button.
array
required
Up to three items of { id, label, icon, onSelect }. An empty array takes them away again.
That is the place for a control that has to stay reachable while your page cannot offer one — over a video, over a map, over anything that swallows pointer events.
string
Yours. It comes back as the payload of the action event, and it is how the SDK finds your onSelect again.
string
Finished text, not a locale key. Your page carries its own strings and RoadVR does not translate them — switch them yourself on the locale event and call setActions again.
string
Either a name from RoadVR’s own bundled set, spelled solar:alt-arrow-left-bold, or an SVG path in a 24×24 box. A name gets you the same filled glyph the close button beside it uses, but only for the icons RoadVR happens to bundle — a name it does not have logs a warning and draws nothing. A path always works and is drawn as an outline.
function
Runs in your page. The press travels as the id and the SDK finds your function again, so a second setActions with a shorter list cannot leave a handler behind for a button that is gone.
Three at most, and apps only. The ornament also holds the window’s own controls, and a fourth button would push those off the bar. A widget has no ornament — it hangs in a wall.

RoadVr.close()

Closes your own window.
The window is resolved from the frame the call came out of, never from something you pass in, so one app cannot close another’s window.
It does nothing for a widget. A widget hangs in a wall and belongs to the player; taking one off is theirs to do.

What the SDK cannot do

By design, this version is read plus events, plus two actions — closing your own window and hanging buttons in its ornament. There is no way to play a sound, post a notification into the control centre, resize your own window, or open another app. Nor does it hand out game data — vehicle, health, position and the rest belong in your own Lua, and RoadVR is not a general game API. The message protocol is built so those can be added later without breaking anything already written against it.

Custom Apps

Getting your page onto the home screen

Custom Widgets

Getting your page onto a wall

Client API

The Lua half

Discord Support

Questions about integrating