Reference
The resource API
Everything a server-side resource is handed, transcribed from the interface the server exposes today.
A resource is a folder under server/resources with an index.ts that exports init. The server loads every folder it finds at startup — there is no manifest requirement, no enable list and no build step. Resources are plain modules and they are not sandboxed: they run with the server's rights.
A resource imports from "#server" and nothing else. Everything it can reach is on the api it is handed; nothing else about the mod is reachable, and nothing in a resource is reachable from a client. Where a note below reads like a warning, it is because the interface itself carries that warning.
Names the engine defines — vehicle models today, more as they are extracted — are generated into literal unions, so an editor completes them and a typo does not compile. Names arriving from the wire keep their string type and go through a guard instead.
A resource, start to finish
The shape server/resources/core uses: greet, remember, locate, and set one clock for everyone.
resources/precinct/index.ts
1
import { arg, API_VERSION, type ResourceApi, type ResourceManifest } from "#server";
2
import { db } from "./db.ts";
3
4
export const manifest = { api: API_VERSION } satisfies ResourceManifest;
5
6
export function init(api: ResourceApi) {
7
api.on("playerJoined", ({ player }) => {
8
api.broadcast(`${player.name} signed in at the precinct.`);
9
});
10
11
// A look is plain JSON -- head, outfit, hat, the whole face layer. The mod
12
// ships no database: put it in yours, keyed by identity and never by name.
13
api.on("appearanceChanged", ({ player, look }) => {
14
db.characters.save(player.identity, look);
15
});
16
17
// The schema names and parses the arguments; the handler gets them typed.
18
api.command("goto", { args: [arg.num("x"), arg.num("y"), arg.num("z")] },
19
(player, { x, y, z }) => api.teleport(player.id, { x, y, z }));
20
21
// One clock for everyone; the engine reads the hour every frame.
22
api.setTimeOfDay(23.5);
23
api.setTimeRatio(0);
24
}
Events
One typed map, one object per event, so an event can gain a field without breaking a handler written before it. on returns an unsubscribe and takes { once, signal }; either way the subscription is dropped when the resource stops. A handler may be async — a rejected promise is logged against the resource and never reaches the process.
api.on("playerJoined", ({ player }) => void): UnsubscribeThe player is on the wire and holds a body slot.
player.identityis the key to store anything by, and it is branded apart from a name so the type system keeps that rule rather than a comment.api.on("playerLeft", ({ player, reason }) => void)reasonis the server's own string: a clean disconnect, a timeout, or a kick.api.on("chat", ({ player, text, cancel }) => void)Call
cancel()to swallow the message. An async handler cannot cancel, because the decision is needed before its promise settles.api.on("appearanceChanged", ({ player, look }) => void)Fires after a look was accepted. The mod has no built-in idea of what a character IS beyond its appearance, so persistence rules belong here.
api.on("playerWarped", ({ player, why, count }) => void)A state the server refused as impossible movement. Nothing is done about it automatically — a case load can produce one, so what counts as cheating is the server's call, not the mod's.
api.on("vitalsChanged", ({ player, vitals, previous }) => void)Owner-reported health, not a server decision about a hit.
api.on("shotFired", ({ player, shot }) => void)A validated firing report. It does not assert a hit and it does not apply damage.
Lifecycle
A resource can be stopped, which is what makes it reloadable. Every subscription, timer, command and export is owned by the resource that made it and goes when it does.
export function init(api: ResourceApi): void | Promise<void>The entry point. It is awaited, and a failure is logged with the half-loaded resource dropped rather than left half-registered.
export function shutdown(): voidOptional. Runs before the registrations are cleared.
api.signal: AbortSignalAborted when the resource stops. Hand it to anything that takes one — fetch, a stream, node:timers/promises — and it unwinds with the resource.
api.every(ms, run): UnsubscribeAn interval the loader owns, so it cannot outlive what registered it.
api.after(ms, run): Unsubscribeapi.log(line: string): voidPrefixed with the resource name.
Commands
A command declares its arguments once. The handler's parameter object is inferred from that schema, and the usage line and the /help entry are generated from the same declaration rather than written a second time by hand.
api.command(name, { description, args, permission }, handler)argsis a tuple of specs and the handler receives named, parsed values. A token that does not fit answers with the generated usage line.api.command(name, handler)Raw form: the handler receives the tokens and parses them itself.
arg.num(name) · arg.int(name) · arg.text(name)arg.choice(name, [...])Renders the choices into the usage line as well.
arg.player(name)Resolves against the roster by id or exact name, so the handler is handed a PlayerId belonging to somebody who is actually connected.
arg.rest(name)Everything left, joined. Must come last.
arg.optional(spec)The handler's value becomes
T | undefined, and forgetting to check it does not compile./helpBuilt in unless a resource claims the verb. Lists only the commands the asker can actually run.
Players and chat
Ids are branded: a PlayerId is not an ActorId, and the two cannot be crossed by accident.
api.players(): readonly PlayerInfo[]api.sendChat(playerId: PlayerId, text: string): voidapi.broadcast(text: string): voidReplaces the old player id 0, which a branded id cannot express.
api.kick(playerId: PlayerId, reason: string): voidapi.hasPermission(playerId, permission): booleanFrom data/permissions.json, keyed by identity rather than name. An absent or unreadable file means nobody holds anything, so a fresh server is not accidentally an open one.
Characters
Reading gives a plain object you can write anywhere — a file, a database. Writing puts it back on the player and on every other client, so a character creator or a /loadchar command needs nothing from the mod but these calls.
api.getAppearance(playerId): Appearance | nullapi.setAppearance(playerId, look: Partial<Appearance>): booleanFields left out keep what the player wears now. The server sanitizes and re-slots exactly as it does for a look the client announced.
api.blankAppearance(): AppearanceEvery field at its leave-it-alone value, to build on.
api.getActorVitals(playerId): ActorVitals | nullapi.savedLooks(): readonly SavedLook[]api.savedLook(key) · api.saveLook(key, name, look) · api.forgetLook(key)The server’s own reconnect memory: the last look each identity wore, so a face survives a restart. It is not your character database. What a character means on your server, and where those records live, is yours — the event hands you plain JSON and the mod never asks for it back.
Position and the world
Positions are a Vec3 rather than three loose numbers in an order you have to remember. Position is what the player last reported and the server accepted; teleport is the server telling them where to be.
api.positionOf(playerId): PlayerPosition | nullExtends Vec3 and carries an
agein milliseconds; a large one means they have gone quiet.api.teleport(playerId, at: Vec3, heading?): booleanapi.setTimeOfDay(hour: number): voidThe engine reads the hour every frame, so a write is visible at once on every client — including whoever joins later. Until a resource calls this, the server does not manage the world at all and each client drifts on its own clock.
api.setTimeRatio(ratio) · api.setWeather(setting) · api.world()A night set by hand walks back to morning unless the ratio is zero.
api.setAmbient(patch) · api.ambient()Requested local pedestrian and traffic generation. It does not replicate ambient actors.
Vehicles
Creation names the check that refused it instead of returning null for five different reasons.
api.createVehicle({ model, at, heading, ownerPlayerId }): Created<VehicleId, …>modelis a union of the 122 names the engine defines, so a typo is a compile error and an editor completes the whole game. The result is{ ok: true, id }or{ ok: false, reason }— invalid-model, invalid-position, no-such-owner, invalid-details, registry-full — and reading.idwithout checking.okdoes not compile.isVehicleModel(name: string): name is VehicleModelFor names arriving from chat or the wire, where a union would be a lie.
api.removeVehicle(id: VehicleId): booleanapi.vehicles(): readonly Readonly<VehicleRecord>[]Detached copies, and readonly because mutating one never did anything and the type should say so.
api.trackedVehicles() · api.getVehicleDetails(ownerId, vehicleId)
Server-owned actors
Bounded NPCs, built at runtime through the engine's own actor factory rather than taken from a preallocated pool.
api.createActor(options): Created<ActorId, …>Refusals are named: invalid-name, invalid-position, invalid-appearance, invalid-weapon, invalid-vitals, no-body-slot, registry-full.
api.updateActor(id, patch) · api.removeActor(id)api.actors(): readonly Readonly<ActorRecord>[]api.moveActor(id, target): booleanServer kinematic intent; local native navigation still handles obstacles.
api.stopActor(id): boolean
Storage
One JSON record per resource, scoped so one cannot read or overwrite another's. Written when the resource stops and when the server does; a file that will not parse is reported and treated as empty.
api.store.get<T>(key): T | nullapi.store.set(key, value): voidapi.store.delete(key): booleanapi.store.keys(): readonly string[]This is for configuration and small records. A resource with real volume should bring a database and key it by identity.
Between resources
Resources can depend on each other, publish values, and raise their own events. Declaration merging types all of it: augment ResourceEvents or ResourceExports and your own keys behave like built-ins.
export const manifest = { api: API_VERSION, needs: [...] } satisfies ResourceManifestManifests are read before anything initialises, because they decide the load order. A dependency cycle is reported with the trail that formed it and a missing one is named; either way that resource is skipped. A manifest asking for an API version this server does not implement is refused up front.
api.provide(name, value): voidPublished under "<resource>/<name>" and dropped when the publishing resource stops.
api.use<T>(key): T | nullNull when it is not there — which, if you declared that resource in
needs, cannot happen.api.emit(event, payload): voidYour own events only. A resource cannot raise a server event: forging playerJoined would make every handler's guarantees worthless.
Running a server
The server takes flags, not a config file. Two environment variables exist as well: LANOIREMP_AMBIENT_PEDESTRIANS and LANOIREMP_AMBIENT_TRAFFIC.
| Flag | Default | What it does |
|---|---|---|
| --port <n> | 27015 | UDP port. This is the only port a server needs open. |
| --name <text> | lanoiremp | Shown to a client in its Welcome. |
| --max <n> | 32 | Slots. A default that suits testing — bodies are built at runtime, so this is policy rather than a ceiling. |
| --tick <hz> | 30 | Authoritative tick rate. |
| --dev | off | Unlocks the client's developer commands for everyone on this server. Off means a public server cannot be driven as a cheat console. |
| --ambient-pedestrians <on|off> | engine default | Also settable as LANOIREMP_AMBIENT_PEDESTRIANS. Requested local generation; it does not replicate ambient actors. |
| --ambient-traffic <on|off> | engine default | Also settable as LANOIREMP_AMBIENT_TRAFFIC. |
About player counts
There is no structural ceiling. Player ids are 16-bit, so the wire format addresses tens of thousands, and remote bodies are no longer taken from a preallocated pool — the client builds them at runtime through the engine's own actor factory, which is what removed the old fixed capacity. Spawning thousands of bodies is not the hard part any more. What has not happened is a session with thousands of players actually connected behind them, so treat --max 32 as a default that suits testing rather than as the number the software can carry.