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. ServerEngineAPI is an instance that belongs to one server (source/engine/server/ServerEngineAPI.ts), so a server running in a worker hands the game the instance of its own realm; always use the one you were given and do not keep it in a module-level variable shared with the client side. ClientEngineAPI is an instance too (source/engine/client/ClientEngineAPI.ts, built on CommonEngineAPI in source/engine/common/CommonEngineAPI.ts): the page has exactly one, the composition root creates it and the client hands it to the game's Init and constructor. The types are derived from the real classes. |
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.- A game's unit tests (
source/game/<mod>/test/) are.test.tsfiles and are checked by the same command, so a test double that mirrors an outdated signature fails the typecheck. Their typed stand-ins (an edict, an engine API, a game API) are in the engine'stest/support/.
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.
ClientHost.Boot(the page) orDedicatedHost.Init(a dedicated server) callsGameModule.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(); the view is calculated (by the game'scalcView(input, result)when it has one, by the engine otherwise), thenupdateRefDef(refdef);draw()for the HUD (not withnohud), then the optionaldrawOverlay()(also withnohud, for what must stay visible such as a pause sign);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. |
ClientGameInterface.calcView(input, result) |
game | engine | Optional. Calculates the first-person view in place of the engine's: the eye and the weapon go into result. ClientEngineAPI.View.calculateDefault(input, result) is the engine's own, for a game that adjusts it. The engine does the pitch drift before, and the weapon's model and frame, updateRefDef and the chase camera after. |
ClientGameInterface.hudReservedHeight(viewsize) |
game | engine | Optional. Pixels at the bottom of the screen that the HUD keeps, so the 3D view is drawn above them. Without it the view has the whole height. |
Server and client run the same movement code (Pmove) on the same commands and have to get the same result, so a game tunes it in
two places and the engine carries both to the client:
- Behavior switches:
SetPmoveConfiguration(config)on the server API and on the client API (prediction needs it too), with aPmoveConfiguration(QuakeWorld-style, the default) or aPmoveQuake2Configuration, both insource/shared/Pmove.ts. - Tuning values: the
sv_*cvarssv_gravity,sv_stopspeed,sv_maxspeed,sv_spectatormaxspeed,sv_accelerate,sv_airaccelerate,sv_wateraccelerate,sv_friction,sv_waterfrictionandedgefriction. Changing one takes effect on the server and, through the usual cvar sync, in the prediction of every connected client. A game sets them withSetCvar; a Quake 2 game appliesPmoveQuake2MoveVars(same file) for the values ofqcommon/pmove.c.
- 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.- The server keeps only the bounds of a model it precaches, which is all
setModelreads. An alias model's triangles and poses (about 70 times larger in memory than in the file) are only kept when the game says so:ServerEngineAPI.PrecacheModel(name, { meshCollision: true }). Every entity that is solid asSOLID_MESHneeds its model precached that way, from_precacheor fromsetModelwhile loading (setsolidfirst). Without it the entity is traced as its bounding box and the server prints a warning once per model. Precaching a model again with the option reloads it with its geometry, so the order of calls does not matter. Brush and mesh (.obj) models are not affected. PrecacheModel(name, { sidecar: false })says an image sprite has no settings file (<name>.json), which saves the lookup on the server and, as a flags byte after each model name inserverdata(ModelPrecacheFlags, protocol 43), on every client. Leaving it out looks for the file, and precaching the model again without it loads the file after all. See Sprites.GetSpriteSequenceDuration(modelName, frame)andGetSpriteSequenceIndex(modelName, name)exist on bothServerEngineAPIandClientEngineAPI: how long the sequence of a sprite'sframeplays (so an effect can remove itself when it is over), and theframeof a named sequence. The model has to be precached; on the server it also has to have finished loading, so ask while the map runs. See Sprites.ClientEngineAPI.LoadSprite(filename, options?)andDrawSprite(x, y, playback, options?)draw a sprite file on the screen (a HUD element, a screen effect).LoadSpritereturns aSpritePlayback, one handle per element over images that are loaded once per file and kept for the page; the game never constructs one (SpritePlaybackis re-exported fromGameInterfaces.tsas a type).DrawSpritetakesscale,region(a name from the sprite's settings file or Aseprite slices),tintandalpha. See Sprites.
- 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.
- The game's tests (
source/game/<mod>/test/) are type-checked too, so step 2 lists a test double that mirrors an outdated signature.