Framework Compatibility
F5 Combat HUD talks to your framework through a bridge. Everything framework-specific lives in bridge/; the rest of the resource only ever calls Bridge.*. The bridge asks your framework for exactly five things:
| What | Used for |
|---|---|
| The core object | Reaching everything below |
| Who the player is | The key every setting, profile and K/D row is saved under |
| Whether the character is in the world | Loading the HUD at the right moment |
| Which group a player belongs to | Marketplace moderation and per-group section overrides |
| The name of a weapon | The weapon label in the kill feed |
Nothing else in the resource touches your framework.
bridge/ ships as editable source — it is not protected. The custom adapter described below is the no-code path; for anything more exotic you can edit the bridge files directly. See Editing the Bridge Directly.
Supported Frameworks
| Framework | Detection resource | mode value |
|---|---|---|
| QBox Core | qbx_core | 'qbx' |
| QBCore | qb-core | 'qb' |
| ESX | es_extended | 'esx' |
| A fork of any of the three | — | 'custom' |
Auto-Detection
F5Cfg.Framework = {
mode = 'auto', -- 'auto' | 'qb' | 'qbx' | 'esx' | 'custom'
}
With mode = 'auto' the bridge takes the first started resource in this order:
qbx_core→qbxqb-core→qbes_extended→esx
A resource counts as running when its state is started or starting.
Many QBox servers still keep qb-core around for older scripts. Checking qbx_core first means such a server is read as QBox rather than QBCore — which is what you want, because the two cores expose different APIs.
If nothing matches, the resource does not run:
[f5_combathud][bridge] no supported framework detected (qbx_core, qb-core, es_extended) — set F5Cfg.Framework.mode in config/main.lua
[f5_combathud] no supported framework detected — see above for details
Forcing a Framework
Set mode explicitly when the guess is wrong:
F5Cfg.Framework = { mode = 'qb' }
Two things to know about a forced mode:
-
An unknown value is rejected with
F5Cfg.Framework.mode = "..." is not one of: auto, qb, qbx, esx, custom (config/main.lua), and the resource refuses to start. -
If the named framework's resource is not running, the bridge prints a warning and still uses that adapter:
[f5_combathud][bridge] F5Cfg.Framework.mode = "esx", but resource es_extended is not running — start it before f5_combathud, or set mode = 'auto'Every call then fails to reach a core, which surfaces as
cannot reach the es_extended core object — make sure that resource starts before f5_combathud.
The bridge does not grab the core object at startup — it resolves it on first use and caches it. A framework that finishes starting after f5_combathud still works, as long as it is up before the first player loads.
Per-Framework Behaviour
| Concern | QBCore (qb) | QBox (qbx) | ESX (esx) |
|---|---|---|---|
| Core object | exports['qb-core']:GetCoreObject() | exports.qbx_core | exports['es_extended']:getSharedObject() |
| Player object | Core.Functions.GetPlayer(src) | core:GetPlayer(src) | ESX.GetPlayerFromId(src) |
| Character id | PlayerData.citizenid | PlayerData.citizenid | xPlayer.identifier |
| Display name | charinfo.firstname + lastname | charinfo.firstname + lastname | xPlayer.getName() |
| Character loaded (client) | LocalPlayer.state.isLoggedIn, else citizenid | LocalPlayer.state.isLoggedIn, else citizenid | ESX.IsPlayerLoaded(), else ESX.PlayerLoaded |
| Loaded / unload events | QBCore:Client:OnPlayerLoaded / QBCore:Client:OnPlayerUnload | QBCore:Client:OnPlayerLoaded / QBCore:Client:OnPlayerUnload | esx:playerLoaded / esx:onPlayerLogout |
| Group check | ACE group.<name> | ACE group.<name> | xPlayer.getGroup(), then ACE group.<name> |
| Weapon label | Core.Shared.Weapons[hash] | core:GetWeapons() (cached once) | ESX.GetWeaponFromHash(hash) |
When the framework has no entry for a weapon, the kill feed still works: the icon comes from config/weapons.lua, and the weapon text falls back to the weapon's spawn name. With the shipped defaults that difference is invisible — the feed shows the icon and no text unless a player turns on Show weapon name, or the icon PNG is missing.
The QBox adapter calls GetWeapons() once and keeps the result for the lifetime of the client session. If you add weapons to QBox at runtime, a player has to reconnect (or you restart the resource) before the kill feed knows their labels.
The Save Identifier
Every setting, profile, K/D row, marketplace listing and ban is stored under the player's native FiveM license, not a framework field:
license:110000112345678
The bridge takes license and falls back to license2; only if the client has neither does it fall back to the framework's own field (PlayerData.license on QB/QBox, xPlayer.license then xPlayer.identifier on ESX).
That has three consequences worth knowing:
- Nothing moves when you switch framework. The same player keeps their crosshair, profiles and K/D on QBCore, QBox and ESX alike.
- One HUD per account. All of a player's characters share the same crosshair, the same profiles and the same K/D — a crosshair is a client preference, not roleplay property.
- A player without a license identifier cannot be served. Every save and load resolves the player through the bridge first and returns early when it comes back empty.
xPlayer.identifier (and citizenid on QB/QBox) is carried as charId and used only in Discord webhook messages, never as a database key.
Custom Framework Adapter
mode = 'custom' is for a fork of one of the three supported cores — a renamed core with the same semantics. You name the framework yours derives from, then override only what your fork actually changed. Anything left nil keeps the base's behaviour, and most forks rename nothing but their core resource — that is getCore on its own.
F5Cfg.Framework = {
mode = 'custom',
custom = {
base = 'qb', -- REQUIRED: 'qb' | 'qbx' | 'esx'
getCore = nil, -- function() return exports['my-core']:GetCoreObject() end
-- Server
getPlayer = nil,
identifierOf = nil,
charIdOf = nil,
nameOf = nil,
hasGroup = nil,
-- Client
isPlayerLoaded = nil,
weaponInfo = nil,
-- Lifecycle
playerLoadedEvent = nil,
playerUnloadEvent = nil,
onPlayerLoaded = nil,
onPlayerUnload = nil,
},
}
getCore runs on both sidesconfig/main.lua is a shared script, so every function you write in custom is compiled into the client and the server. getCore is called by both — a version that uses a server-only export or native will break the HUD on the client while the server looks fine. getPlayer, identifierOf, charIdOf, nameOf and hasGroup are only ever called on the server; isPlayerLoaded and weaponInfo only on the client.
base is mandatory
[f5_combathud][bridge] F5Cfg.Framework.custom.base = "nil" is not one of: qb, qbx, esx — a custom framework must name the one it derives from (config/main.lua)
Without a valid base there is no framework at all and the resource refuses to start. base decides which adapter file loads, which events are wired, and how a player object is read.
The shipped config already carries base = 'qb', so this error only appears if you delete the line. Switching to mode = 'custom' for an ESX or QBox fork and forgetting to change base inherits QBCore behaviour silently — check it first.
The contract
| Field | Side | Signature | Left nil |
|---|---|---|---|
getCore | both | function() -> core | The base's own core export is used. A function that errors or returns nil logs F5Cfg.Framework.custom.getCore failed or returned nil, and does not fall back to the base getter |
getPlayer | server | function(src) -> raw player, or nil when not loaded | The base's player lookup is used |
identifierOf | server | function(raw, src) -> string | Native license → license2 → the base's field. This is the database key |
charIdOf | server | function(raw, src) -> string | The base's character id. Only ever printed to Discord |
nameOf | server | function(raw, src) -> string | The base's character name. Shown on marketplace listings |
hasGroup | server | function(src, group) -> boolean | The base's group check. Drives ModeratorAces and SectionOverrides |
isPlayerLoaded | client | function() -> boolean | The base's check — true once the character is in the world |
weaponInfo | client | function(hash) -> { label = , name = } or nil | The base's weapon lookup. label is the kill feed text, name picks the icon |
playerLoadedEvent | client | string | The base's event name is registered |
playerUnloadEvent | client | string | The base's event name is registered |
onPlayerLoaded | client | function(emit) | Not used — the event above is registered instead |
onPlayerUnload | client | function(emit) | Not used — the event above is registered instead |
The return value of getPlayer is fed through the base's normalizer, so a fork that keeps PlayerData.charinfo gets charId and name for free; only what your fork renamed needs its own function. Whatever identifierOf returns is trusted as-is, and when it returns nil the bridge still falls back to the native license — a fork can never accidentally save under an empty key.
Lifecycle
A fork that only renamed the two client events gives their names:
custom = {
base = 'qb',
playerLoadedEvent = 'mycore:client:OnPlayerLoaded',
playerUnloadEvent = 'mycore:client:OnPlayerUnload',
}
A fork that also changed what those events pass wires the handler itself and calls emit():
custom = {
base = 'qb',
onPlayerLoaded = function(emit)
AddEventHandler('mycore:characterSpawned', function(_, _) emit() end)
end,
onPlayerUnload = function(emit)
AddEventHandler('mycore:characterDespawned', function() emit() end)
end,
}
When onPlayerLoaded / onPlayerUnload are given, the base registers nothing — the event name field for that side is ignored. Both hooks are client-side only.
Examples
A fork that only renamed its core resource
F5Cfg.Framework = {
mode = 'custom',
custom = {
base = 'qb',
getCore = function() return exports['my-core']:GetCoreObject() end,
},
}
A fork with its own permission system
F5Cfg.Framework = {
mode = 'custom',
custom = {
base = 'qbx',
getCore = function() return exports['my-core'] end,
hasGroup = function(src, group)
return exports['my-perms']:PlayerHasGroup(src, group)
end,
},
}
hasGroup fully replaces the base check — the ACE fallback is gone, so return true for every group you want to count, including your own ACEs.
An ESX fork that stores identity elsewhere
F5Cfg.Framework = {
mode = 'custom',
custom = {
base = 'esx',
getPlayer = function(src) return exports['my-core']:GetPlayer(src) end,
identifierOf = function(raw, src) return raw.licenseId end,
nameOf = function(raw) return raw.displayName end,
},
}
identifierOf re-keys everythingThe identifier is the primary key of every table. Point it at a different field and every existing player looks brand new — old settings, profiles, K/D, listings and bans stay in the database under the old key and are never read again. Decide this before launch.
Groups and Permissions
Bridge.HasGroup(src, group) is what backs F5Cfg.ModeratorAces and F5Cfg.SectionOverrides:
- QBCore / QBox — the ACE
group.<name>only. - ESX — the group on the character first (
xPlayer.getGroup() == name), then the ACEgroup.<name>.
Moderation additionally accepts the bare ACE itself (command.combathud_admin), so the shipped list works on all three frameworks without editing. See Permissions.
The Callback Layer
Client and server talk over the bridge's own request/response pair — f5_combathud:bridge:cbReq and f5_combathud:bridge:cbRes — rather than a framework's callback API. That is why the resource needs no QBCore.Functions.CreateCallback equivalent on ESX.
A request that gets no answer within 60 seconds is dropped and logged:
[f5_combathud][bridge] server callback timed out after 60s: f5_combathud:server:getData
In practice this only shows up when the server side stopped (an unfinished database migration, a stopped resource) — the menu will look like it is hanging on load.
Editing the Bridge Directly
bridge/ is yours. The layout:
| File | Contains |
|---|---|
bridge/init.lua | Detection, the callback layer, lifecycle plumbing, shared helpers |
bridge/client/qb.lua, qbx.lua, esx.lua | Client adapters — one guard line, then the framework calls |
bridge/server/qb.lua, qbx.lua, esx.lua | Server adapters — player normalization and group checks |
bridge/client/custom.lua, bridge/server/custom.lua | Apply the custom overrides on top of the base |
Each adapter begins with if Bridge.Base ~= 'qb' then return end, so only the one matching your framework ever runs. If you edit an adapter, keep the shape of what it returns — GetPlayer must return a table with src, identifier, charId, name and raw, and GetWeaponInfo a table with label and name (or nil).
An edited bridge file is yours to re-apply after every update. Everything in the table above can be expressed through F5Cfg.Framework.custom instead, which survives updates untouched.
See Also
- Installation — boot order and the first start
- Configuration → Framework — the config keys themselves
- Permissions — moderators, ACEs and per-group overrides
- Database — what is stored under the identifier
- Troubleshooting — every bridge message and its cause