PlayDeck
Home / Course / NUI for People Who Do Not Know Web Dev
Track A · Module 8

NUI for People Who Do Not Know Web Dev

NUI is where most beginners stall, because suddenly there is HTML sitting next to your Lua and it feels like a different job. It is not. You need five ideas and one rule: never trust the browser. This lesson gives you both, plus a sandbox you can poke without launching a server.

What NUI actually is

NUI is a Chromium browser glued on top of the game screen. Your resource ships a web page, FiveM renders it as a transparent layer over GTA, and you talk to it with messages. That is the whole model. The "UI" in your script is a web page floating above the world — invisible until you tell it to show itself.

There are exactly two directions of traffic. Learn them as a pair:

Everything else is decoration.

The minimum web you need

Three files. HTML is structure, CSS is looks, JS is behavior. Here is a complete ID-card panel.

<!-- html/index.html -->
<!DOCTYPE html>
<html>
<head><link rel="stylesheet" href="style.css"></head>
<body>
  <div id="card" class="hidden">
    <h2 id="name"></h2>
    <p id="job"></p>
    <button id="close">Close</button>
  </div>
  <script src="app.js"></script>
</body>
</html>
/* html/style.css */
body { margin: 0; background: transparent; } /* the game must show through */
.hidden { display: none; }                   /* default state: invisible */
#card {
  position: absolute; top: 20%; left: 40%;
  padding: 20px; border-radius: 12px;
  background: rgba(20,20,20,0.9); color: #fff;
  font-family: sans-serif;
}
// html/app.js
const card = document.getElementById('card');

// Lua -> browser messages land here
window.addEventListener('message', (event) => {
  const data = event.data;
  if (data.action === 'showCard') {
    document.getElementById('name').textContent = data.name;
    document.getElementById('job').textContent = data.job;
    card.classList.remove('hidden');
  }
});

// browser -> Lua: tell the client to release focus
document.getElementById('close').addEventListener('click', () => {
  card.classList.add('hidden');
  fetch(`https://${GetParentResourceName()}/closeCard`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({})
  });
});

The one trick that trips everyone: transparent body, hidden by default. Forget either and you get a black wall over your screen.

The manifest is the contract

CEF only loads files you explicitly list. Miss one and you get a blank panel and no error — the single most common beginner bug.

-- fxmanifest.lua
fx_version 'cerulean'
game 'gta5'

ui_page 'html/index.html'

files {
  'html/index.html',
  'html/style.css',
  'html/app.js',
}

shared_script '@ox_lib/init.lua' -- modern, ox-first
client_scripts { 'client.lua' }
server_scripts { 'server.lua' }

dependencies { 'ox_lib' }

Two-way traffic: messages and callbacks

Here is the Lua side. Notice where authority lives: the server decides what the card says, the client only displays it.

-- client.lua
local showing = false

RegisterCommand('idcard', function()
    if showing then return end

    -- Ask the SERVER for the real data. Never build identity on the client.
    local info = lib.callback.await('playdeck:getIdInfo', false)
    if not info then return end

    showing = true
    SetNuiFocus(true, true)        -- hand keyboard + mouse to the browser
    SendNUIMessage({
        action = 'showCard',
        name = info.name,
        job = info.job,
    })
end, false)

-- The browser calls this when the player clicks Close.
RegisterNUICallback('closeCard', function(_, cb)
    showing = false
    SetNuiFocus(false, false)      -- give control BACK to the game
    cb('ok')                       -- always answer, or the fetch hangs forever
end)
-- server.lua
lib.callback.register('playdeck:getIdInfo', function(source)
    local player = exports.qbx_core:GetPlayer(source) -- pin ONE framework
    if not player then return nil end

    local char = player.PlayerData.charinfo
    return {
        name = ('%s %s'):format(char.firstname, char.lastname),
        job  = player.PlayerData.job.label,
    }
end)

SendNUIMessage posts a JSON table your JS reads in the message listener. RegisterNUICallback registers a name the browser hits with fetch. The URL https://<resource>/closeCard is not a real website — it is how CEF routes a fetch back into your Lua.

Focus, failure modes, and where AI gets it wrong

SetNuiFocus(hasFocus, hasCursor) decides whether input goes to the page or the game. The brutal bug: open with SetNuiFocus(true, true), then close the panel without setting it back to false, false. The player is now frozen — no movement, mouse trapped on an invisible page. Every "show" needs a matching "release."

The rest of the classic list:

Now the AI traps, because you will use AI for this:

Practice

No FiveM needed — use the PlayDeck browser sandbox, which fakes SendNUIMessage and NUI callbacks in a plain browser.

  1. Paste the three web files above into the sandbox.
  2. In the sandbox Lua console, fire SendNUIMessage({action = 'showCard', name = 'Lee Ash', job = 'Mechanic'}). The card appears.
  3. Click Close. Watch the console log a closeCard fetch with its payload — that is your browser-to-Lua round trip.
  4. Break it on purpose: change action = 'showCard' to action = 'show'. Nothing happens. That proves the action string is a contract both sides must agree on — the source of half of all "my UI does not open" tickets.

Recap

Learn it by building it

PlayDeck is an original course on building GTA roleplay scripts with AI — Lua, frameworks, NUI, debugging, and a browser sandbox to test every lesson without booting the game.

Browse the course