DEV Community

Tack k
Tack k

Posted on

I Built a Playable Arcade Inside My FiveM Server — Tetris, Breakout, Space Invaders and More

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
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
winstondevtad profile image
Winston

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.

Collapse
 
tackk3 profile image
Tack k • Edited

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-level SendNUIMessage actions like open / close / scoreResult, and the NUI talks back with fetch('https://exs-arcade/...') mapped to RegisterNUICallback. 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.