> ## Documentation Index
> Fetch the complete documentation index at: https://docs.roadshop.org/llms.txt
> Use this file to discover all available pages before exploring further.

# JavaScript SDK

> What a page inside a RoadVR window can read and be told about

# 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

```html theme={null}
<script src="https://cfx-nui-roadvr/public/sdk/roadvr.js"></script>
```

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.

<Note>
  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.
</Note>

<Warning>
  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:

  ```js theme={null}
  if (!window.RoadVr) return
  ```
</Warning>

<Note>
  The global is `RoadVr`, matching the resource name. The product is written
  RoadVR; the identifier is not.
</Note>

## Getting started

```js theme={null}
;(async function () {
  if (!window.RoadVr) return

  const ctx = await RoadVr.ready()

  RoadVr.applyTheme()

  RoadVr.on('theme', (t) => document.body.classList.toggle('is-light', t.light))
  RoadVr.on('close', () => save())
})()
```

<Card title="Working examples" icon="folder-code">
  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.
</Card>

## RoadVr.ready()

<ResponseField name="return" type="Promise<context>">
  Settles once the headset has answered, with the context object below.
</ResponseField>

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.

```js theme={null}
{
  version: '0.1.0',
  surface: 'app',                                     // or 'widget', or null
  app:     { id, name },                              // apps only
  widget:  { kind, id, size, aspect, mount },         // widgets only
  window:  { id, width, height, anchor, distance },   // apps only
  theme:   { light, accent, iconTheme, palette },
  locale:  { language },
  headset: { state, focused, visible },
  world:   null,
}
```

<ResponseField name="version" type="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.
</ResponseField>

<ResponseField name="surface" type="string | null">
  `'app'`, `'widget'`, or `null` outside a headset.
</ResponseField>

<ResponseField name="window" type="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).
</ResponseField>

<ResponseField name="widget" type="object | null">
  Widgets only. `size` is metres of **width** — one number, because the height
  follows from `aspect`. `mount` is `'recess'` or `'raised'`.
</ResponseField>

<ResponseField name="theme" type="object">
  `light` is a boolean, `accent` a hex colour, `iconTheme` the icon set name.
  `palette` is RoadVR's own CSS custom properties — see
  [applyTheme](#roadvr-applytheme).
</ResponseField>

<ResponseField name="locale" type="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.
</ResponseField>

<ResponseField name="headset" type="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.
</ResponseField>

<ResponseField name="world" type="object | null">
  `null` until you call `watch('world')`. See [Weather and the
  clock](#weather-and-the-clock).
</ResponseField>

## RoadVr.on()

<ParamField path="event" type="string" required>
  One of the events below.
</ParamField>

<ParamField path="handler" type="function" required>
  Receives the changed subtree, not the whole context.
</ParamField>

<ResponseField name="return" type="function">
  The counterpart. Calling it removes this one handler.
</ResponseField>

```js theme={null}
const off = RoadVr.on('theme', paint)
off()
```

### The events

| Event       | Payload                                              | When                                                           |
| ----------- | ---------------------------------------------------- | -------------------------------------------------------------- |
| `theme`     | `{ light, accent, iconTheme, palette }`              | light/dark toggled, accent or icon set changed                 |
| `locale`    | `{ language }`                                       | the player switched interface language                         |
| `world`     | the world object                                     | weather and clock tick — only after `watch('world')`           |
| `placement` | `{ distance }`                                       | your window moved — only after `watch('placement')`, apps only |
| `focus`     | `{}`                                                 | your window became the focused one                             |
| `blur`      | `{}`                                                 | your window stopped being it                                   |
| `visible`   | `{}`                                                 | the layer came back                                            |
| `hidden`    | `{}`                                                 | pause menu, map, cinema screen, fast head turn                 |
| `resize`    | `{ width, height }` for apps, `{ size }` for widgets | dragged to a new size, or the wheel turned                     |
| `anchor`    | `{ anchor }`                                         | switched between following the player and staying put          |
| `action`    | `{ id }`                                             | one of your ornament buttons was pressed                       |
| `close`     | `{}`                                                 | your window or widget is going away                            |
| `message`   | your payload                                         | a push from your own Lua                                       |

<Tip>
  **`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.
</Tip>

### `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()

<ParamField path="element" type="HTMLElement" default="document.documentElement">
  Where to write the custom properties.
</ParamField>

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:

```css theme={null}
:root {
  --text: var(--vr-text, rgba(255, 255, 255, 0.96));
  --dim: var(--vr-text-dim, rgba(255, 255, 255, 0.55));
  --fill: var(--fill-idle, rgba(255, 255, 255, 0.13));
  --accent: var(--vr-accent, #0a84ff);
}
```

## 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

```js theme={null}
RoadVr.watch('world')

RoadVr.on('world', ({ weather, hours, minutes, temperature }) => {
  clock.textContent = `${hours}:${String(minutes).padStart(2, '0')}`
})

RoadVr.unwatch('world')
```

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.

```js theme={null}
RoadVr.watch('placement')

RoadVr.on('placement', ({ distance }) => fade(distance))

RoadVr.unwatch('placement')
```

`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.

<Info>
  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.
</Info>

<Info>
  A frame that goes away without calling `unwatch` still stops counting — a
  ticker does not stay on for a window nobody can see.
</Info>

## 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.

<ParamField path="list" type="array" required>
  Up to three items of `{ id, label, icon, onSelect }`. An empty array takes
  them away again.
</ParamField>

```js theme={null}
RoadVr.setActions([
  {
    id: 'back',
    label: 'Back',
    icon: 'solar:alt-arrow-left-bold',
    onSelect: () => showTheList(),
  },
])

RoadVr.setActions([])   // 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.

<ParamField path="id" type="string">
  Yours. It comes back as the payload of the `action` event, and it is how the
  SDK finds your `onSelect` again.
</ParamField>

<ParamField path="label" type="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.
</ParamField>

<ParamField path="icon" type="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.
</ParamField>

<ParamField path="onSelect" type="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.
</ParamField>

<Warning>
  **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.
</Warning>

## RoadVr.close()

Closes **your own** window.

```js theme={null}
RoadVr.close()
```

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.

<Note>
  It does nothing for a widget. A widget hangs in a wall and belongs to the
  player; taking one off is theirs to do.
</Note>

## 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.

## Related

<CardGroup cols={2}>
  <Card title="Custom Apps" icon="pickaxe" href="/roadvr/custom-apps">
    Getting your page onto the home screen
  </Card>

  <Card title="Custom Widgets" icon="frame" href="/roadvr/custom-widgets">
    Getting your page onto a wall
  </Card>

  <Card title="Client API" icon="code" href="/roadvr/api/client">
    The Lua half
  </Card>

  <Card title="Discord Support" icon="discord" href="https://discord.gg/2nZrmmvM2q">
    Questions about integrating
  </Card>
</CardGroup>
