Brava Dashboard

Admin panel for Cidade Brava, a FiveM roleplay server. Thirteen screens for four people. The part worth telling is not the interface: it is what sits between a page on Vercel and a game server running on a home machine, with no public IP, in Lua.

Role
Panel, API and integration
Context
Client, server built by a team of three
Status
In internal use, with known limits
Access
Private, behind a login

Four users, and that changes everything

Cidade Brava is a GTA roleplay server. Whoever runs it needs to know who is online, how much money circulates in the in-game economy, which organisations are active, and what the moderation team has been doing. All of that already existed inside the game, scattered across console commands, an in-game tablet and the F8 console. None of it can be checked from a phone, or from anywhere other than the server's own PC.

The audience is four administrators. That number is the project's most important premise, and the plan records it as something not to revisit. It rules out, in one stroke, roles, granular permissions, e-mail invitations, onboarding, legacy browser support and mobile optimisation. A panel for four known people, on desktop, is a different problem from a panel for a thousand strangers, and pretending otherwise would cost weeks.

Half the plan is a list of what would not be built: staff chat, heatmap, live spectating, command catalogue, tickets, jobs, global search, moderation notes on players. All of it either already works better inside the game, or is support work nobody there asked for. The list of vetoes is longer than the list of screens.

The server was built by a team of three. This case covers the web panel, the API that feeds it and the integration between them, which were mine. The in-game Lua scripts that already existed on the server are not mine and are not counted here, with one exception in section IV, where I had to touch someone else's code for the integration to work.

A dash is worth more than a pretty number

When a value does not exist, the screen shows a dash. Not a zero, not a fallback to sample data, not an estimate. And three indicators on the home screen still look like that today, in plain sight.

The panel started as a visual mockup: thirteen screens with hand-written sample data, to validate layout and navigation before touching the server. That worked well, and left a trap. When the real data arrived, the home screen still had seven blocks of numbers hard-coded from the mockup. The screen looked live and was not.

The easy way out would be to keep the sample data as a fallback: if the API does not answer, show what the mockup showed. The screen is never empty and nobody complains. That is exactly why it is bad. A panel that always has a number cannot be used to decide anything, because whoever looks at it cannot tell the data from the decoration.

So the seven blocks came out and the dash went in. Weekly sessions, weekly active players and revenue in local currency still show a dash today: the first two depend on session tracking the server does not keep, the third on a payment provider that does not exist yet. The panel says it does not know, instead of inventing. It is the same rule the rest of this portfolio follows, and here it lives inside the product.

A thin adapter, on purpose

The game server already had around seventy admin functions written in Lua, with the database queries done. The question was how to reach them from outside.

  1. 01 No business logic in the API The Lua resource I wrote only does authentication, routing and the matching function call. It duplicates not one line of SQL. If the server's admin side evolves, the panel inherits it for free, and there is no second copy of the same query silently drifting.
  2. 02 A tunnel instead of an IP The game server runs on a home machine behind NAT, with no public IP and no TLS, because FiveM's HTTP is plain text. A Cloudflare tunnel solves all three at once: DNS, certificate and NAT traversal, without opening a port on anyone's router.
  3. 03 One hook per endpoint, with a fallback Every screen reads through its own hook, and the hook knows three paths: demo mode, real data, and an in-memory cache when the API fails. It made it possible to migrate one screen at a time instead of switching everything at once, and to keep working with the server turned off.
  4. 04 Charts written by hand Six chart types in inline SVG, no library. It is not bravado: these are small series with a very specific style, and the smallest of the usual libraries would weigh more than all six together.

Three bugs that only exist at the seam

On the day of connecting the panel to production, nothing showed up. The three causes were chained, and none of them appears when you test either side on its own.

Requests hung forever. The server's RPC framework has two independent transports: one for the game to talk to the server, another for one resource to talk to another. The admin resource had only registered the first, because it was written with in-game use in mind. My API called through the second, the event went out and nobody was listening. No error, no timeout: the call waited for a reply that would never come. The fix was one line, registering the same function table on both transports, and the cost was touching code that was not mine.

With the transport fixed, everything answered access denied. Each admin function starts by identifying the caller and checking their rank. In a resource-to-resource call there is no player on the other end, so that identifier comes back empty, and all of them left through the unauthorised door. The pattern I applied to the seventeen functions the panel uses leaves the player path booleanly identical to the original and only lets through the zero-origin case, which is the internal call. That one had already been authenticated earlier, at the HTTP edge.

Then the front end started breaking when iterating over lists. The admin functions already return an object with the list inside, and my handlers wrapped that again in an object with the same field name. The front end received the list two levels below where it expected and tried to iterate over an object. Only three endpoints had the problem: in the others the nesting was intentional and the front end already relied on it. Fixing it wholesale would have broken the ones that were right.

The lesson written into the repository: when a resource is going to be consumed by another resource, register both transports from the start, because it costs two lines and the failure shows up as an infinite wait, which is the most expensive symptom to diagnose. And what I would do differently: a smoke test crossing the whole stack, from HTTP down to the database, before migrating thirteen screens. All three bugs were at the seam, and I had tested each side on its own.

Three authentications, two thrown away

The part of the project where I wrote the most code that did not survive, and the one most worth telling.

First: the in-game identifier

Login with the internal number the game uses for a player, plus a password. It worked, and it was bad for humans: an opaque number is not memorable, cannot be recovered, and ties the admin account to having a character in the game. Replaced by e-mail and password.

Second: our own panel, in Lua

Our own admin table, salted hashing computed in the database, token sessions, and a console command to register, reset passwords and revoke access. Zero external dependencies. Fully implemented and documented.

The question that knocked it down

Right after it was done, one question: why the console, when a managed service exists? Forgot my password becomes walking to the server machine and running a command. Inviting someone becomes building a sign-up screen. The cost was in daily use, not in the implementation, and the implementation was the only part that was finished.

Third: managed identity

The custom table, the console command and the whole file were deleted the next day. Password recovery, sign-up and access logging became someone else's problem. The front end had been sending e-mail and password since the previous step, so it needed not one line of change.

Verifying without a shared key

The Lua side validates the token by asking the provider, caching the answer for five minutes, instead of verifying the signature locally. Local verification would be a day of work, would require a key in the server's config file and a hand-written HMAC. The cached call is simpler and has less surface to get wrong.

The price, named before it is charged

An external dependency now exists: if the provider goes down, nobody logs in. The five-minute cache limits the damage to new logins and token refresh; whoever is already inside stays in. It is written down as a conscious decision, not discovered afterwards.

What exists today

Screens
13
On real data
9
Recorded decisions
30
Users
4

Also: fourteen server processes monitored live, six chart types written in SVG, and a 186 KB compressed bundle. The thirty decisions live in a file in the repository, each with its reasoning and, where it applies, the condition to undo it.

What is not finished, and why. The tunnel comes up in a terminal window left open on the server machine: installing it as a service needs local administrative privilege I do not have there. Two storage endpoints are still served from sample data, waiting on the server side. Three indicators show the dash from section II. None of this has been audited for accessibility and the responsive layout was never tested on a physical device: these are accepted limits for a four-person desktop panel, and they are written here for the same reason they are written in the repository.

  • React 19
  • TypeScript
  • Vite 6
  • Tailwind CSS 4
  • Lua
  • FiveM
  • MariaDB
  • Supabase Auth
  • Cloudflare Tunnel
  • Vercel

Private panel, no public link. Want to see the rest of the work, or talk about a role?

Updated 5 August 2026