A game module is a directory under source/game/<name>/ whose main.ts hands the engine three things: an identification, a ServerGameAPI class and a ClientGameAPI class. This page describes what the engine calls on them, in which order, and how the compiler keeps a game and the engine in agreement.
Game modules never import engine files. Everything they need from the engine arrives through the API objects below, and the shared types live in source/shared/GameInterfaces.ts. That file is the source of truth: every member there documents who calls or writes it and when.
| Types | Direction | What it is |
|---|---|---|
ServerEngineAPI, ClientEngineAPI |
game → engine | Objects the engine passes to a game's constructor and Init. Everything a game may use from the engine hangs off them. Derived from the real classes in source/engine/common/GameAPIs.ts. |
ServerGameInterface, ClientGameInterface |
engine → game | What the engine calls, reads and writes on a running game instance. |
ServerGameConstructor, ClientGameConstructor |
engine → game | The class itself: the constructor plus the static hooks (Init, ...). |
GameModuleInterface |
engine → game | The whole module: identification plus both constructors. This is what main.ts exports. |
A game declares that its classes implement the instance interfaces, and its main.ts asserts the whole module against the engine's contract:
// source/game/<name>/main.ts
import type { GameModuleIdentification, GameModuleInterface } from '../../shared/GameInterfaces.ts';
import { ServerGameAPI } from './GameAPI.ts';
import { ClientGameAPI } from './client/ClientAPI.ts';
export const identification = {
name: 'My Game',
author: 'me',
version: [1, 0, 0],
capabilities: [],
} satisfies GameModuleIdentification;
export {
ClientGameAPI,
ServerGameAPI,
};
// Compile-time check only: fails the typecheck when this module drifts from the engine's contract.
({ identification, ServerGameAPI, ClientGameAPI }) satisfies GameModuleInterface;export class ServerGameAPI implements ServerGameInterface { /* ... */ }
export class ClientGameAPI implements ClientGameInterface { /* ... */ }implementsreports instance-side drift on the class member. Thesatisfiesline covers whatimplementscannot: the constructor signature, the static hooks and the identification.- A game that extends another game's API class inherits the
implementsbut still assertssatisfiesin its ownmain.ts, because its statics and constructor can differ. npm run typecheckruns these checks. It gates the Cloudflare build (npm run build:wrangler) and the Dockerteststage, because Vite and esbuild strip types without checking them.- Unit tests are
.mjsfiles and are not type-checked. A test double that mirrors an outdated signature keeps passing, so grep the tests when you change a member.
nameandauthormust be identical on client and server. The server announces them, and the client refuses to connect on a mismatch.versionis[major, minor, patch]. The client hands the server's version toClientGameAPI.IsServerCompatible.capabilitieslists optional engine behaviors the game opts into, from thegameCapabilitiesenum insource/shared/Defs.ts. Unknown values are rejected when the module loads.
Host.InitcallsGameModule.Init(). It picks the game directory (-game <dir>,?game=<dir>in the browser, or the build-time default), loads itsmain.ts, and checks the runtime shape: identification, supported capabilities, both classes present.ServerGameAPI.Init(ServerEngineAPI)runs. It is static, and the place to register cvars.- On a client,
ClientGameAPI.Init(ClientEngineAPI)runs next (for example to register menu pages), followed byClientGameAPI.GetStartGameInterface(ClientEngineAPI). Returnnullto keep the engine's default way of starting a game.
SV.SpawnServer runs for map, restart, changelevel and loading a savegame:
new ServerGameAPI(ServerEngineAPI)creates a fresh instance. Nothing carries over exceptserverflagsand the players' spawn parameters (see below).- The engine calls
prepareEntity(edict, 'player')for every player slot and asksgetClientEntityFields(). init(mapname, serverflags)runs.- The worldspawn entity is created with
prepareEntityfollowed byspawnPreparedEntity. - Every entity in the map's entity lump gets the same two calls.
prepareEntityreturnsfalseto skip an entity.ServerEngineAPI.SpawnEntityuses the same pair when the game spawns entities at runtime.
frametimeis written once per frame.timeis written right before every call into the game.startFrame()runs first. Then, for each entity, the engine runs physics and calls the entity's own methods (think,touch, ...), and for each connected client it callsPlayerPreThink, runs the movement physics, then callsPlayerPostThink.- While
force_retouchis non-zero the engine re-links every entity so stationary triggers re-check their contacts, and decrements it once per frame.
- When a client asks to spawn, the engine calls
prepareEntity(edict, 'player', { netname, colormap, team }), then the player entity'srestoreSpawnParameters(data), thenClientConnect(edict)andPutClientInServer(edict). While a savegame is being restored, the last two are skipped. - When the client reports that it finished loading, the optional
ClientBegin(edict)runs. - The
killcommand callsClientKill(edict). - When a spawned client leaves and the engine can still talk to it (it disconnected, was kicked, or the server shut down),
ClientDisconnect(edict)runs. It is not called for a client dropped because its connection failed.
- On
changelevelthe engine first readsserverflagsand asks every connected player entity forsaveSpawnParameters(), then runs the per-map steps above. The old game instance is replaced, andshutdownis not called on it. shutdown(isCrashShutdown)runs when the server is shut down: quitting,mapandrestart, a local player leaving their own listen server (loading a savegame during a local game does this too), or a failed map load.
serialize() and deserialize(data) carry game-wide state. Entities are saved separately through their own serialize()/deserialize(). On the client, saveGame() and loadGame(data) do the same for client state.
Every serverdata message starts this sequence, on connect and after every changelevel:
- The server announces its game
name,authorandversion. The client rejects a different name or author, then asksClientGameAPI.IsServerCompatible(version). new ClientGameAPI(ClientEngineAPI)creates a new instance. The previous one is replaced withoutshutdown().- The client loads the map's models and sounds, then calls
init(), followed byloadGame(data)when a savegame is being restored. - While connected, the engine writes the server's
clientdataupdates intoclientdataand forwards client events tohandleClientEvent(code, ...args). - Every frame:
startFrame(); after the view is calculated,updateRefDef(refdef);draw()for the HUD;drawLoading()while connecting or changing level. The engine readsviewmodelto draw the first-person weapon. GetClientEdictHandler(classname)is asked when a client entity is assigned a classname. That covers both entities mirrored from the server and client-only ones a game spawns itself withClientEngineAPI.SpawnClientEntity(classname, { persistent }): a client-only classname has no server entity class, so the game resolves it from its own table (for example a registry of handler classes keyed by classname). A persistent client-only entity is captured by savegames, including its model name and whatever its handler'sserialize()returns. See Client Entities.- When the client disconnects,
shutdown()runs.
| Member | Written by | Read by | Notes |
|---|---|---|---|
ServerGameInterface.time |
engine | game | Right before every call into the game. |
ServerGameInterface.frametime |
engine | game | Once per frame. |
ServerGameInterface.force_retouch |
game sets, engine decrements | engine | While non-zero, every entity is re-linked each frame. |
ServerGameInterface.serverflags |
game | engine | Game-defined bits that survive a changelevel (for example Quake's episode runes). Passed to init. |
ClientGameInterface.clientdata |
engine writes its contents | game | Must be non-null before the first server update arrives. |
ClientGameInterface.viewmodel |
game | engine | A null model draws nothing. |
- Static helpers a game adds for its own use, such as lists of maps or start-server entries for its menus. The engine never calls them, so they can be named and shaped freely.
- The static
Shutdownhooks. Both constructors declare them, but the engine has no module-unload path yet and never calls them. - The entity side. The engine also calls methods on the entity objects attached to edicts (
think,touch,use, ...). That boundary is a hand-written interface insource/engine/server/Edict.tsand is not yet checked against a game's entity classes. Seeplans/game-entity-contract.md.
- A changelevel replaces the game instance on both sides without calling
shutdown. Do not rely onshutdownfor per-map cleanup. isCrashShutdownis nevertruetoday: no engine path raises it.ClientDisconnectis skipped for clients whose connection failed, so a game cannot rely on it to clean up after every departing player.
- Add, change or remove the member in
GameInterfaces.ts, with a JSDoc that says who calls or writes it and when. Check that statement against the call sites: the compiler cannot verify lifecycle claims. - Run
npm run typecheck. It lists every game that needs updating. - Update this page if the call order or the ownership changed.
- Search the tests (
.mjs) for the member: they are not type-checked.