A TypeScript framework for FiveM. You install it into a resource project like any npm package and get a consistent, object-oriented API over the natives instead of calling them raw in every script.
fx.events.add, fx.events.addCommand, a player pool, entity objects, and a real Vector3 class with math on it. The difference is that everything is typed.
Package name is
@panda0day/fivexand the global export isfx. Rename to taste before publishing.
FiveM never loads node_modules at runtime. The client runtime is a V8 isolate with no module resolution, and server scripts run as a single bundled file. So this framework isn't loaded by FiveM directly — it's a normal library that your resource's bundler (esbuild) inlines into your client.js / server.js at build time.
That's the whole reason it "works the same in every project": FiveM only ever sees the final bundle, and your framework code is baked into it.
Because client and server expose different natives, the package has three entry points:
@panda0day/fivex/client // client-only API + shared
@panda0day/fivex/server // server-only API + shared
@panda0day/fivex/shared // Vector3, math, event names — no natives
Importing /client can never pull server natives onto the client, and vice versa.
npm install @panda0day/fivexYou also need the CitizenFX typings in your project (they're peer dependencies):
npm install -D @citizenfx/client @citizenfx/serverClient:
import { fx, Vector3 } from '@panda0day/fivex/client';
fx.events.addCommand('up', () => {
fx.player.position = fx.player.position.add(new Vector3(0, 0, 5));
});
fx.events.addNet('fivex:notify', (message: string) => {
console.log(message);
});Server:
import { fx } from '@panda0day/fivex/server';
fx.events.addCommand('kickall', () => {
fx.players.forEach((p) => p.drop('server reset'));
});
fx.events.addNet('fivex:ready', (player) => {
player.outputChatBox(`Welcome, ${player.name}`);
fx.players.broadcast(`${player.name} joined`);
});Natives are still global functions, so anything the framework doesn't wrap you just call directly:
const ped = PlayerPedId();
SetPedArmour(ped, 100);Local events stay on one side. Networked events cross the wire.
// client
fx.events.add('someLocalEvent', handler); // same-side (on)
fx.events.addNet('fromServer', handler); // from server (onNet)
fx.events.call('someLocalEvent', ...args); // emit locally
fx.events.callRemote('toServer', ...args); // client -> server (emitNet)
// server
fx.events.add('someLocalEvent', handler);
fx.events.addNet('fromClient', (player, ...args) => {}); // note: player is first argOn the server, addNet hands you a Player as the first argument. See "The source footgun" below for why that matters.
// client
fx.events.addCommand('heal', (args, raw) => { /* ... */ });
// server — caller is a Player, or null for console
fx.events.addCommand('setjob', (player, args) => {
if (!player) return; // ran from server console
// ...
});Server side, fx.players is a pool:
fx.players.at(source); // Player for a specific server id
fx.players.toArray(); // Player[]
fx.players.forEach(fn);
fx.players.call('event', ...args); // emit to all clients
fx.players.broadcast('message'); // chat to allA Player wraps a server id and gives you the common operations:
player.name;
player.ped; // the ped entity handle
player.position; // Vector3 (read)
player.getIdentifier('license:');
player.call('event', ...args);
player.outputChatBox('text');
player.drop('reason');Client side, fx.player is the local player. It's a LocalPlayer, which extends Ped, which extends Entity, so it has position, health, armour and so on directly:
fx.player.position;
fx.player.health = 200;
fx.player.currentVehicle; // Vehicle | null
fx.player.serverId;Everything physical in the game is an entity handle. The client classes mirror that with inheritance:
Entity position, health, model, heading, distanceTo, delete
├─ Ped armour, isInVehicle, isDead
│ └─ LocalPlayer
└─ Vehicle speed, engineOn, repair
import { Vehicle } from '@panda0day/fivex/client';
const veh = fx.player.currentVehicle;
if (veh) {
veh.engineOn = true;
veh.repair();
}The server has a lighter Entity (reads work; most writes to player peds don't — see below), and Player is composition rather than inheritance: a player has a ped, it isn't one.
Vector3 is a real class, not a plain {x, y, z}. It's structurally compatible with what natives expect, so you can pass it straight into SetEntityCoords and friends.
import { Vector3, clamp, lerp } from '@panda0day/fivex/shared';
const a = new Vector3(0, 0, 0);
const b = new Vector3(10, 0, 0);
a.add(b);
a.distanceTo(b);
a.normalize();
a.lerp(b, 0.5);
Vector3.from(GetEntityCoords(ped, true)); // wrap a native resultshared also has the usual helpers: clamp, lerp, remap, toRadians, toDegrees, randomInt, randomFloat.
These are the parts worth having a framework for. Both are FiveM footguns that are easy to get wrong.
Inside a server networked event, FiveM exposes the calling player as a global source that's only valid synchronously — the first await invalidates it, and you silently get the wrong player. addNet captures it immediately and passes you a Player, so you never touch the global and never race it:
fx.events.addNet('buyItem', async (player, itemId) => {
// player is already resolved and safe to use after awaits
await db.charge(player.getIdentifier('license:'), itemId);
player.outputChatBox('Purchased.');
});Under OneSync the owning client is authoritative over its own ped. A server-side SetEntityHealth on a player gets reverted on the next sync tick, so it looks like it works and then snaps back. Setting health, armour or position on a Player therefore routes an internal event to that client, which applies the change locally where it actually sticks:
player.setHealth(200); // not player.health = 200
player.setPosition(coords);The framework registers the client-side handlers for these automatically, so you only call the server method. Reads (player.health, player.position) are direct because reading the synced value server-side is fine.
Note that this is inherent to the game's trust model: the client can ignore or fake anything you tell it to set on itself. Keep anything security-sensitive (money, permissions) in server state, never in ped state.
A resource that uses the framework needs a bundler step. Minimum setup:
package.json
{
"scripts": { "build": "node build.js" },
"devDependencies": {
"@citizenfx/client": "latest",
"@citizenfx/server": "latest",
"@panda0day/fivex": "latest",
"esbuild": "^0.20.0",
"typescript": "^5.4.0"
}
}tsconfig.json — use moduleResolution: "bundler" (or node16) or TypeScript won't read the package's subpath exports and the /client and /server types won't resolve:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}build.js
const esbuild = require('esbuild');
const base = { bundle: true, platform: 'node', target: 'node16', format: 'cjs' };
Promise.all([
esbuild.build({ ...base, entryPoints: ['src/client/index.ts'], outfile: 'dist/client.js' }),
esbuild.build({ ...base, entryPoints: ['src/server/index.ts'], outfile: 'dist/server.js' }),
]).then(() => console.log('built'));fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
client_script 'dist/client.js'
server_script 'dist/server.js'Then npm run build, drop the resource in your server, and ensure it.
The framework builds with tsc (not esbuild) because it needs to emit .d.ts files for consumers:
npm run build # tsc -p tsconfig.client.json && tsc -p tsconfig.server.jsonClient and server compile against separate tsconfigs so each side only sees its own natives. shared is included in both.
For live development against a real resource, use npm link:
# in the framework
npm link
# in your resource project
npm link @panda0day/fivexRun tsc -w on the framework and your resource re-bundles against the changes.
instanceofdoesn't work across resources. Each resource bundles its own copy of the framework, so anEntityfrom one resource isn'tinstanceoftheEntityin another. This never matters inside a single resource; just don't rely on it across resource boundaries.- Entity wrappers aren't cached.
fx.player.currentVehiclereturns a fresh object each call, so===won't hold for "the same" vehicle. Add a pool keyed by handle if you need identity.