The idea
Our QBCore RP server had an arcade cabinet prop sitting in a few locations around the map. It was purely decorative. Players would walk past it every session and nothing happened.
I wanted it to actually work — walk up, press E, and play a real game inside the game.
The result: exs-arcade — a fully playable in-game arcade system built with Svelte + TypeScript for the UI, Lua for FiveM integration, and a server-side ranking system backed by MySQL.
What's inside
Four playable games, each with its own engine and Svelte component:
- Block Puzzle (Tetris) — classic falling block game
- Alien Shooter (Space Invaders) — wave-based shooter
- Brick Breaker (Breakout) — paddle and ball
- Racing — time attack mode
Plus a global ranking board that tracks top scores per game and an overall leaderboard combining all scores.
How it works
The architecture is split into three layers.
Lua client detects nearby arcade cabinet props and shows an interaction prompt. When the player presses E, it freezes their character and opens the NUI (web-based UI overlay).
local obj = GetClosestObjectOfType(
coords.x, coords.y, coords.z,
Config.InteractDistance,
Config.CabinetModel,
false, false, false
)
if obj ~= 0 then
DrawText3D(objCoords.x, objCoords.y, objCoords.z + 1.0, '[E] Play Arcade')
if IsControlJustReleased(0, 38) then -- E key
FreezeEntityPosition(PlayerPedId(), true)
openArcade()
end
end
Svelte NUI handles the full game UI — main menu, individual game screens, score submission, and the ranking board. Each game is a self-contained Svelte component with its own TypeScript engine.
Node.js server handles score submission, validation, and ranking queries via oxmysql. It also deduplicates rankings so only each player's personal best counts toward the leaderboard.
The ranking system
Scores are stored in MySQL with a game_id field. The server deduplicates by player so one person can't flood the top 10 — only their best run counts.
For racing, scores are sorted ascending (lower time = better). For all other games, scores are sorted descending. The server handles this automatically based on game type.
var TIME_ATTACK_GAMES = ['racing'];
var SCORE_GAMES = ['tetris', 'invaders', 'breakout'];
var order = isTimeAttack(gameId) ? 'ASC' : 'DESC';
There's also an overall leaderboard that sums each player's personal bests across all score-based games — giving a "best arcade player on the server" title.
Cabinet detection — two modes
The script supports two ways to trigger the arcade:
Prop-based (default): Automatically detects any prop_arcade_01 object within range. No coordinate setup needed — if the prop is in the world, it works.
Location-based: Define exact coordinates in config for tighter control over where the arcade is accessible.
Config.UseLocations = false -- true = coordinate mode, false = prop detection
Tech stack breakdown
| Layer | Tech |
|---|---|
| FiveM client | Lua (QBCore) |
| UI framework | Svelte 5 + TypeScript |
| Build tool | Vite |
| Server logic | Node.js (FiveM native) |
| Database | MySQL via oxmysql |
Building the UI in Svelte rather than plain HTML/JS made the game state management dramatically cleaner. Each game component owns its own engine instance and lifecycle — no global state leaks between games.
What's next
A rhythm game component is already stubbed out in the config (marked enabled: false). That's Phase 4. For now the four working games keep players busy.
Next up in this series: Vol.4 — Customizing ox_inventory for QBCore: adding item categories, weight display, and a custom search UI.
Built with Claude Opus as my coding partner. I designed the system architecture and UX — the AI handled implementation. That's how this whole series works.
Questions or want to see a specific game engine in detail? Drop a comment.
Top comments (2)
Input handling for something like Tetris in FiveM is always the pain point. Were you using NUI postMessage for the key events, or did you go full canvas with keyboard capture? The sync between server authoritative state and client rendering is where it gets gnarly — especially for anything timing-sensitive like Breakout.
Curious if you considered embedding via an HTML5 canvas vs rendering game objects as native ped interactions. Canvas would've been simpler for the game logic but then you lose the ability to use FiveM's built-in animations and PED positioning naturally.
Solid work though, this is the kind of creative stretch that separates FiveM servers from each other.
First, sorry for the late reply!
Just to be upfront — I design the architecture and specs, and Claude handles the technical side as my partner. So this reply is actually from him.
Great questions, thanks for digging in!
For input, it's neither pure postMessage nor a global canvas keyboard capture — each Svelte game component just attaches its own keydown listener once
SetNuiFocus(true, true)hands input to the NUI. The Lua side only sends high-levelSendNUIMessageactions likeopen/close/scoreResult, and the NUI talks back withfetch('https://exs-arcade/...')mapped toRegisterNUICallback. Standard FiveM pattern, nothing fancy.On the server-authoritative vs client-rendering point — honest answer, we didn't try to make it truly authoritative. The whole game loop (collision, physics, timing) runs client-side inside the Svelte component, and the server only sees the final score. It does basic sanity checks (type/range, MAX_SCORE cap, a 2s submit cooldown per citizenid) and dedupes to personal-best per player, but a determined cheater could absolutely fake a score. For a single-player arcade leaderboard on an RP server we figured that tradeoff was fine — if it were PvP-affecting we'd have to actually replay inputs server-side.
And yeah — canvas (well, Svelte components rendering to canvas) over native PED was the call for exactly the reason you said: iteration speed on game feel. Doing Breakout's paddle/ball physics through PED interactions would've been masochism.
Thanks again for the thoughtful read.