# NullCore: Why I'm Building My Own FiveM Framework

By AJTheDev — 2026-07-10
Canonical: https://ajthe.dev/blog/posts/nullcore-fivem-framework.html

---

I've spent the better part of a decade in and around FiveM, and a huge chunk of that living inside QBCore. I wrote a whole post a while back on [why I use QB-Core over ESX](https://ajthe.dev/blog/posts/why-qbcore-over-esx.html), and I stand by every word of it — QB won on simplicity, docs and exports. But there's a difference between "the best of the options in front of me" and "the thing I'd build if I started from scratch." After enough years patching around the same structural problems, I finally admitted I wanted the second one. So I'm building it. It's called **NullCore**.

## Why bother, when QB and ESX already exist?

Because both of them are the product of years of accretion, and it shows. The fastest way to be genuinely different isn't to add more — it's to be *correct and small* where they're sprawling and stringly-typed. NullCore is a clean-room replacement for ESX, QB-Core and Qbox: server-authoritative, modular, and typed by convention. One developer can own it end to end, and a resource author should be able to learn it in an afternoon.

The design starts by being honest about what the incumbents got *right*, because there's plenty. I'm keeping `GetPlayerServerId` as the one canonical identity across the client/server boundary. I'm keeping statebag-driven replication as the default sync for player metadata — it's zero-cost when idle and auto-syncs late joiners, which is genuinely the best modern idea in the ESX/QB lineage. I'm keeping the metadata-driven player model, the bound-method ergonomics (`Player.Functions.AddMoney(...)`), data-driven hot-reloadable jobs and grades, and the hard-won lesson that inventory and the DB layer belong *out* of core, behind exports.

## What I'm deliberately throwing away

This is where the opinion lives. Every one of these is a specific thing that's bitten me or someone I've helped, on a real server, more than once.

- **The god-object.** The mutable global `ESX` / `QBCore` table — and especially ESX's resurrected `getSharedObject` event — is gone. NullCore uses explicit, `require`-able imports and a thin compat export instead. Other resources pull the typed `NullCore` namespace in via an import file, not a global.
- **Closure-per-player objects.** ESX allocates roughly 60 closures per player object. NullCore uses *one* shared method table via an `__index` metatable, with a thin bound wrapper for the nice call syntax. No per-player closure soup.
- **Linear-scan money and inventory.** Accounts and metadata are keyed maps — O(1) — never `pairs`-scanned on a hot path.
- **Stringly-typed grade keys.** Grades are integers end to end. No `tostring(grade)`, ever. That single change is the number-one migration fix Qbox had to make, and I'd rather just never have the bug.
- **Identifier-as-primary-key.** Characters key on an immutable synthetic `citizenid`; a separate identity table maps every license / discord / fivem identifier back to an account.
- **"The cache is the truth" money.** This is the big one, so it gets its own section.

## Money is the whole ballgame

If a framework gets one thing catastrophically wrong, it's money — dupes, lost updates, negative balances that shouldn't exist. The classic failure is read-modify-write in Lua with the cache treated as authoritative. Two things happen in the same frame, they race, and someone's now got money that came from nowhere.

NullCore's rule is blunt: **the database row is the single runtime source of truth for money.** The Lua `Player.money` table and the replicated statebag are write-through projections — never authorities. Every economy mutation for a given `citizenid` runs through a single in-process queue, so a paycheck tick and a purchase can't interleave. Decrements are a conditional, atomic `UPDATE ... WHERE citizenid = ? AND bank >= ?` that asserts exactly one row changed, so the database itself enforces that you can't go negative. After a successful mutation the new balance is read back *within the same transaction*, and that value — never a Lua-computed sum — is what the cache and statebag are set to.

It's all integer cents, never floats, and there's one money API — no `addMoney`/`addAccountMoney` twin, no `SetMoney` that invites lost-update bugs. It's addressed by `citizenid`, so it works whether the character is online or offline. Every mutation carries a mandatory `reason` for the audit log, including the *denied* ones. That's not gold-plating; that's the spec the entire framework rests on.

## The "don't reinvent the wheel" line

The temptation with a project like this is to rebuild everything and lose a year. I'm not doing that. The defensible boundary for a solo developer is: build the core, depend on commodities.

So NullCore depends on **oxmysql** for the database driver, **ox_lib** for UI/callbacks/zones, and **ox_inventory** for inventory — and it reinvents *only* the player, character, money, job and gang core, plus the database schema. Rebuilding a MySQL driver or ox_inventory's slot-and-weight system would burn exactly the months I need for the actual IP. The one piece of real work there is a maintained `nullcore` bridge module for ox_inventory, pointed at my tables (`characters.citizenid`, `owned_vehicles.plate`), so the entire existing ox ecosystem lights up on day one. Interop is distribution.

And I'm making a clean break, not a compatibility promise. NullCore's data model is deliberately incompatible with ESX/QB — integer grades, cents, a normalised schema. Faithfully emulating `QBCore.Functions` or ESX's `xPlayer` across those boundaries is a multi-month sub-project each, with a permanent "works for 80% of scripts, subtly breaks 20%" support tax. So live ESX/QB shims are explicitly cut from launch — maybe later, demand-driven, never a launch commitment. The migrator that does ship has a money-conservation invariant: it sums every account across every character before and after, and if the totals don't match to the cent, it aborts.

## Where it actually is right now

This isn't a pitch deck — Phase 0 exists and runs. Phase 0 is the runnable skeleton: DB bootstrap with auto-applied schema, identity resolution, the Player object, crash-safe persistence, and the `server.cfg` ACE authority layer so multiple players can be on at once at different permission tiers.

That authority layer is the whole point of Phase 0, and it's testable in-game. The source of truth is `server.cfg` ACEs — tiers are principals from `nc_player` up to `nc_owner` that inherit upward, and NullCore only ever *reads* them via `IsPlayerAceAllowed`. Join with two clients and you can prove it: `/nc` prints your resolved tier, `/coords` is gated to trusted-and-up, `/kick` needs mod, and `/setgroup` needs admin and is rank-clamped so you can never promote anyone at or above your own tier. A plain player running `/kick` gets a denial that lands in the audit log. That difference is the entire lesson of Phase 0: the client can never grant itself anything.

There's also a pure-Lua unit test suite — tier resolution, name sanitisation, citizenid generation, identifier parsing — that runs under busted with no FXServer and no database. Because a framework whose entire pitch is "correct and small" has to be able to *demonstrate* correctness, not just claim it.

The MVP is Phase 0 plus Phase 1: join, make a validated character, spawn, hold money (online and offline), have a job and a society account, and persist correctly under failure. After that it's inventory bridge, vehicles and garages, admin tooling, and — because a framework is really a *distribution*, not a core resource — a lean txAdmin recipe that bundles the whole thing.

Why "NullCore"? It reads as "the core substrate everything runs through," it's got zero naming collision with the incumbents, and it gives a crisp `exports.nullcore:` surface. That's the plan, and it's underway.

If you run a server, write FiveM scripts, or just have strong opinions about how a framework *should* handle money — I want to hear them. [Come argue with me on Discord](https://discord.gg/d39aaZXAjh). This is exactly the kind of thing that gets better with pushback.
