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 (0)