Skip to content

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

RequirementNotes
Node.js 18+Vite 5 does not run on Node 16
Internet accessnpm install pulls React 18.3.x and Vite 5.4.x
bash
cd ui
npm install
npm run build

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

js
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 functionActionBehaviour
RM_ShowPrompt(data)rm_showReplaces state with defaults merged with your data, then shows. Always sends, and clears the dedupe cache
RM_UpdatePrompt(data)rm_updateShallow-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_hideHides. 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

FieldTypeDefaultModeEffect
modestring"fuse"both"fuse" shows the distance row and gates on canPlace. Anything else hides it and gates on canPress
titlestring"Detonator"bothGold heading, rendered uppercase by CSS
useLabelstring"Place"bothText next to the use key
cancelLabelstring"Cancel"bothText next to the cancel key
distanceLabelstring"Distance: "fusePrefix. Include your own trailing space
keystable{ use = "G", cancel = "R" }bothCharacters in the two key boxes
distancenumber0fuseThe resource sends math.floor(distance)
canPlacebooleanfalsefusefalse dims the use prompt to 35 %
canPressbooleantruedetonatorfalse dims the use prompt to 35 %
canCancelbooleantruebothfalse 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 keys mutates 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.

StageRoughlyPlaque
Plant and three crate hauls13.9 sNone
Scripted 180° turn2 sNone
Fuse walkuntil you actrm_show, then one update per changed metre
Wiring chain8.8 sNone — hidden before it starts
Detonator entry1.8 sNone
Detonator helduntil you actrm_show, no updates
Use pressed on the detonatorHidden 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.

FileSizeStatus
brush_1.png873 × 332Spare
brush_2.png877 × 310Spare
brush_3.png862 × 306Default

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-size is 100% 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.

SelectorKey declarations
.rm-containerposition: absolute, bottom: 4vh, centred. Change bottom to move it vertically
.rm-panelmin-width: 430px, padding: 30px 58px 32px, brush background, drop-shadow, fade-in
.rm-titlefont-size: 20px, letter-spacing: 2px, text-transform: uppercase, color: #c79e5a
.rm-distancefont-size: 14px, color: #b8ab8f
.rm-prompt.rm-disabledopacity: 0.35 — applied when the action is unavailable
.rm-key28 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

ValueControls
#c79e5aGold — title text and key box outline
#e8dcc4Cream — prompt labels and body text
#b8ab8fMuted stone — the distance readout
#f0e6d2The 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:

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

js
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_UpdatePrompt is silently ignored unless the plaque is currently shown.
  • RM_ShowPrompt on a visible plaque does not replay the fade-in.
  • keys is passed by reference; mutating the payload mutates the shared config.
  • KeyLabels and the control hashes are independent.
  • RM_Config and 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 m is 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.

Documentation for RedMorrow. Scripts are licensed per server — redistribution is not permitted.