RedM Fireworks UI Reference
The on-screen prompt — the painted plaque with the title, distance readout and key prompts — is a React app rendered in NUI.
It ships pre-built
ui/dist is included in the release, and the manifest points ui_page at it. You do not need Node or npm to run this resource. npm is only required to change the React source or the stylesheet.
Everything under ui/src, ui/package.json and ui/vite.config.js is build input. None of it is listed in the manifest, so none of it is sent to clients.
Rebuilding
| Requirement | Notes |
|---|---|
| Node.js 18+ | Vite 5 does not run on Node 16 |
| Internet access | npm install pulls React 18.3.x and Vite 5.4.x |
cd ui
npm install
npm run buildThen restart rm_fireworks.
ui/package-lock.json ships with the resource, so npm ci gives a reproducible install matching the version the shipped ui/dist was built from. Use npm ci when you want an exact match, npm install when you are deliberately upgrading.
npm run build wipes ui/dist
The build sets emptyOutDir: true, so the whole folder is deleted and regenerated. Never hand-edit anything in ui/dist — edit ui/src and rebuild. Keep a copy of the shipped ui/dist if you want a guaranteed rollback.
Asset filenames are content-hashed, so a stale cache is not normally an issue. npm install creates ui/node_modules, which is build-only and large — delete it before zipping the resource.
Previewing in a browser
npm run dev serves the source; npm run preview serves a built ui/dist. Neither writes anything the game uses.
The plaque renders nothing until it receives a show message, because the component starts hidden. Paste this into the browser console:
window.postMessage({
action: 'rm_show',
data: {
mode: 'fuse', title: 'Detonator', useLabel: 'Place', cancelLabel: 'Cancel',
distanceLabel: 'Distance: ', keys: { use: 'G', cancel: 'R' },
distance: 12, canPlace: false, canCancel: true
}
}, '*')rm_update and rm_hide drive the other two states. The stylesheet sets a transparent background for NUI, so in a browser the plaque sits on the page's own background — that is expected.
Debugging the live page
If you rebuilt and the plaque is now blank in game, open the CEF developer tools: browse to http://localhost:13172 from a normal browser on the same machine and pick the rm_fireworks page.
Bundle 404s do not surface in F8
The client console stays silent while the page fails, which is why a broken build looks like a broken script. Check the network tab. If the bundle is requested from the resource root (/assets/...) rather than ui/dist/assets/..., the base setting was changed — see below.
The Lua-to-NUI bridge
Three Lua functions, three message actions. There are no NUI callbacks and no SetNuiFocus call anywhere — the plaque is display-only and never takes focus. All key handling happens in Lua.
| Lua function | Action | Behaviour |
|---|---|---|
RM_ShowPrompt(data) | rm_show | Replaces state with defaults merged with your data, then shows. Always sends, and clears the dedupe cache |
RM_UpdatePrompt(data) | rm_update | Shallow-merges into current state. No-op if the plaque is not shown. Skipped if the payload is byte-identical to the previous one |
RM_HidePrompt() | rm_hide | Hides. No-op if already hidden |
The dedupe is what makes the per-frame distance loop cheap: the fuse loop runs every frame but only sends when the floored distance or the canPlace flag changes — roughly one message per metre walked, not one per frame.
RM_ShowPrompt on an already-visible plaque does not replay the fade-in: visible is already true, so React re-renders the same element rather than remounting it, and a CSS animation only runs on insertion.
These three are plain globals in the resource's own bundle. They are not exported — and RM_Config is subject to the same boundary. Another resource cannot read either, whatever the load order.
Payload fields
| Field | Type | Default | Mode | Effect |
|---|---|---|---|---|
mode | string | "fuse" | both | "fuse" shows the distance row and gates on canPlace. Anything else hides it and gates on canPress |
title | string | "Detonator" | both | Gold heading, rendered uppercase by CSS |
useLabel | string | "Place" | both | Text next to the use key |
cancelLabel | string | "Cancel" | both | Text next to the cancel key |
distanceLabel | string | "Distance: " | fuse | Prefix. Include your own trailing space |
keys | table | { use = "G", cancel = "R" } | both | Characters in the two key boxes |
distance | number | 0 | fuse | The resource sends math.floor(distance) |
canPlace | boolean | false | fuse | false dims the use prompt to 35 % |
canPress | boolean | true | detonator | false dims the use prompt to 35 % |
canCancel | boolean | true | both | false removes the cancel prompt entirely |
Three things about keys:
- The merge is shallow at the top level only. An update carrying
keys = { use = "F" }replaces the whole object and the cancel box renders empty. Always send both sub-fields together. - It is passed by reference — the config table itself. Lua that mutates the payload's
keysmutates the shared config for the rest of the session. Build a new table if you need per-prompt text. - The letters are display text only. See Configuration → rebinding.
When the plaque is on screen
Both gaps are long enough that customers report them as bugs.
| Stage | Roughly | Plaque |
|---|---|---|
| Plant and three crate hauls | 13.9 s | None |
| Scripted 180° turn | 2 s | None |
| Fuse walk | until you act | rm_show, then one update per changed metre |
| Wiring chain | 8.8 s | None — hidden before it starts |
| Detonator entry | 1.8 s | None |
| Detonator held | until you act | rm_show, no updates |
| Use pressed on the detonator | — | Hidden immediately, before the bursts |
So nothing is on screen and nothing is cancellable for the first ~16 seconds, and there is roughly 10.6 s of no plaque between the two prompts.
Extending it
To add a field: add it to the defaults in App.jsx, render it, add a CSS rule, rebuild, then send it from Lua. Without a CSS rule the new line still renders, inheriting the plaque's centred cream text — usually not what you want.
To add a third mode, branch on data.mode exactly as the fuse distance row does. Anything that is not 'fuse' currently gates on canPress.
A clickable element needs focus you would have to add
There is no SetNuiFocus handling and no RegisterNUICallback in the resource today. Without focus a button will never receive the click.
Keep the payload shape stable between updates in a loop — the dedupe compares the JSON encoding, so sending a different set of keys each frame defeats it and puts a message on the bridge every frame.
The brush-stroke panel
The prompt is not a boxed panel. It is a painted brush stroke: a transparent PNG stretched behind the text, with a soft dark ellipse painted on top so the copy always has a solid ground where the stroke thins.
| File | Size | Status |
|---|---|---|
brush_1.png | 873 × 332 | Spare |
brush_2.png | 877 × 310 | Spare |
brush_3.png | 862 × 306 | Default |
Only brush_3 ends up in ui/dist/assets on a default build — only the brush the stylesheet actually references gets bundled.
The filter: drop-shadow is deliberate: it follows the PNG's alpha so the ragged edge casts the shadow. A box-shadow would draw the rectangle the brush exists to hide.
To swap, change the filename in the url() in the .rm-panel rule and rebuild. No manifest change is needed — Vite emits the image into ui/dist/assets/ and the manifest already ships that wildcard.
Your own artwork
- Transparent PNG. The whole effect depends on alpha. A JPG, or a PNG with a rectangle baked in, produces a visible box and a rectangular shadow.
- Roughly 3:1, landscape.
background-sizeis100% 100%, so the image is stretched on both axes with no aspect ratio preservation. A square or portrait source will be visibly squashed. - Around 800–900 px wide is enough. Anything past ~1200 px is wasted download.
- Keep the centre reasonably opaque. The text sits in the middle.
Do not put images in a ui/public folder
Vite copies public/ to the root of ui/dist, not into ui/dist/assets — and the manifest only ships index.html and assets/*. Anything landing at ui/dist/<file> is never streamed to clients and will 404. Always import the asset or reference it from CSS so Vite routes it through assets/.
Styling reference
All styling is in ui/src/style.css. No framework, no preprocessor, no CSS custom properties — colours are literal values.
| Selector | Key declarations |
|---|---|
.rm-container | position: absolute, bottom: 4vh, centred. Change bottom to move it vertically |
.rm-panel | min-width: 430px, padding: 30px 58px 32px, brush background, drop-shadow, fade-in |
.rm-title | font-size: 20px, letter-spacing: 2px, text-transform: uppercase, color: #c79e5a |
.rm-distance | font-size: 14px, color: #b8ab8f |
.rm-prompt.rm-disabled | opacity: 0.35 — applied when the action is unavailable |
.rm-key | 28 px box, border: 1px solid #c79e5a, background: rgba(12, 9, 6, 0.7) |
box-sizing: border-box is why the panel's padding sits inside its min-width rather than adding to it — keep that in mind when retuning either.
The title is uppercase because of that text-transform, not because of the config string.
Colours
| Value | Controls |
|---|---|
#c79e5a | Gold — title text and key box outline |
#e8dcc4 | Cream — prompt labels and body text |
#b8ab8f | Muted stone — the distance readout |
#f0e6d2 | The character inside the key box |
rgba(10, 7, 5, 0.5) | The ink pool behind the text. Raise the alpha for contrast, lower it for more brush texture |
The gradient has a second stop at zero alpha in the same ink colour. If you recolour the ink pool, change both stops or the fade drifts toward the old hue.
#c79e5a and #e8dcc4 each appear in two rules and the text-shadow colour in three — search the whole file for each value rather than fixing the first hit.
The font is 'Georgia', 'Times New Roman', serif. Only fonts installed on the player's machine, or a font file you add and register with @font-face, will resolve — NUI does not fetch remote fonts reliably, so do not rely on a Google Fonts URL.
Scaling for high resolutions
Every size is a fixed pixel value; only bottom: 4vh is viewport-relative. NUI renders at native resolution, so the plaque occupies half the screen fraction at 4K that it does at 1080p.
There is no config option. To scale it:
.rm-container {
transform: translateX(-50%) scale(1.5);
transform-origin: bottom center;
}Keep translateX(-50%) first or the plaque stops being centred. Scaling the container scales the brush with it, which is why 800–900 px sources are recommended.
Do not change base
base: './',That is what makes the built index.html reference its bundle relatively. With Vite's default base: '/', those become /assets/... — and NUI serves each resource from its own origin, so that resolves to the resource root instead of ui/dist/assets/ where the bundle actually lives.
The JS and CSS 404, and the plaque silently never appears. No Lua error, no in-game console output. The symptom looks like a broken script, not a broken build.
Do not change outDir or assetsDir either, unless you also edit the ui_page and files entries in the manifest to match.
Known limitations
- The plaque is display-only — nothing in it is clickable without code you add.
RM_UpdatePromptis silently ignored unless the plaque is currently shown.RM_ShowPrompton a visible plaque does not replay the fade-in.keysis passed by reference; mutating the payload mutates the shared config.KeyLabelsand the control hashes are independent.RM_Configand the three prompt functions are resource-local.Language[1]and[4]are dead entries, and[2]is the title in both modes.- The distance unit
mis hardcoded in the JSX and needs a rebuild to change. - All sizing is fixed pixels, so it does not scale with resolution.
- No plaque for the first ~16 s, or for the ~10.6 s between the two prompts.
- Nothing about player state is checked, and the plaque is never suppressed for it.