Skip to content

Latest commit

 

History

History
157 lines (114 loc) · 14.9 KB

game-module-contract.md

File metadata and controls

157 lines (114 loc) · 14.9 KB

Game Module Contract

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.

Two directions

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.

Making the compiler check a module

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 { /* ... */ }
  • implements reports instance-side drift on the class member. The satisfies line covers what implements cannot: the constructor signature, the static hooks and the identification.
  • A game that extends another game's API class inherits the implements but still asserts satisfies in its own main.ts, because its statics and constructor can differ.
  • npm run typecheck runs these checks. It gates the Cloudflare build (npm run build:wrangler) and the Docker test stage, because Vite and esbuild strip types without checking them.
  • A game's unit tests (source/game/<mod>/test/) are .test.ts files 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's test/support/.

Identification and capabilities

  • name and author must be identical on client and server. The server announces them, and the client refuses to connect on a mismatch.
  • version is [major, minor, patch]. The client hands the server's version to ClientGameAPI.IsServerCompatible.
  • capabilities lists optional engine behaviors the game opts into, from the gameCapabilities enum in source/shared/Defs.ts. Unknown values are rejected when the module loads.

Lifecycle

Loading, once per process

  1. ClientHost.Boot (the page) or DedicatedHost.Init (a dedicated server) calls GameModule.Init(). It picks the game directory (-game <dir>, ?game=<dir> in the browser, or the build-time default), loads its main.ts, and checks the runtime shape: identification, supported capabilities, both classes present.
  2. ServerGameAPI.Init(ServerEngineAPI) runs. It is static, and the place to register cvars.
  3. On a client, ClientGameAPI.Init(ClientEngineAPI) runs next (for example to register menu pages), followed by ClientGameAPI.GetStartGameInterface(ClientEngineAPI). Return null to keep the engine's default way of starting a game.

Server, per map

SV.SpawnServer runs for map, restart, changelevel and loading a savegame:

  1. new ServerGameAPI(ServerEngineAPI) creates a fresh instance. Nothing carries over except serverflags and the players' spawn parameters (see below).
  2. The engine calls prepareEntity(edict, 'player') for every player slot and asks getClientEntityFields().
  3. init(mapname, serverflags) runs.
  4. The worldspawn entity is created with prepareEntity followed by spawnPreparedEntity.
  5. Every entity in the map's entity lump gets the same two calls. prepareEntity returns false to skip an entity. ServerEngineAPI.SpawnEntity uses the same pair when the game spawns entities at runtime.

Server, every frame

  • frametime is written once per frame. time is 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 calls PlayerPreThink, runs the movement physics, then calls PlayerPostThink.
  • While force_retouch is non-zero the engine re-links every entity so stationary triggers re-check their contacts, and decrements it once per frame.

Server, per player

  1. When a client asks to spawn, the engine calls prepareEntity(edict, 'player', { netname, colormap, team }), then the player entity's restoreSpawnParameters(data), then ClientConnect(edict) and PutClientInServer(edict). While a savegame is being restored, the last two are skipped.
  2. When the client reports that it finished loading, the optional ClientBegin(edict) runs.
  3. The kill command calls ClientKill(edict).
  4. 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.

Changing level and shutting down

  • On changelevel the engine first reads serverflags and asks every connected player entity for saveSpawnParameters(), then runs the per-map steps above. The old game instance is replaced, and shutdown is not called on it.
  • shutdown(isCrashShutdown) runs when the server is shut down: quitting, map and restart, a local player leaving their own listen server (loading a savegame during a local game does this too), or a failed map load.

Savegames

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.

Client, per map

Every serverdata message starts this sequence, on connect and after every changelevel:

  1. The server announces its game name, author and version. The client rejects a different name or author, then asks ClientGameAPI.IsServerCompatible(version).
  2. new ClientGameAPI(ClientEngineAPI) creates a new instance. The previous one is replaced without shutdown().
  3. The client loads the map's models and sounds, then calls init(), followed by loadGame(data) when a savegame is being restored.
  4. While connected, the engine writes the server's clientdata updates into clientdata and forwards client events to handleClientEvent(code, ...args).
  5. Every frame: startFrame(); the view is calculated (by the game's calcView(input, result) when it has one, by the engine otherwise), then updateRefDef(refdef); draw() for the HUD (not with nohud), then the optional drawOverlay() (also with nohud, for what must stay visible such as a pause sign); drawLoading() while connecting or changing level. The engine reads viewmodel to draw the first-person weapon.
  6. 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 with ClientEngineAPI.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's serialize() returns. See Client Entities.
  7. When the client disconnects, shutdown() runs.

Who writes what

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.

Player movement

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 a PmoveConfiguration (QuakeWorld-style, the default) or a PmoveQuake2Configuration, both in source/shared/Pmove.ts.
  • Tuning values: the sv_* cvars sv_gravity, sv_stopspeed, sv_maxspeed, sv_spectatormaxspeed, sv_accelerate, sv_airaccelerate, sv_wateraccelerate, sv_friction, sv_waterfriction and edgefriction. Changing one takes effect on the server and, through the usual cvar sync, in the prediction of every connected client. A game sets them with SetCvar; a Quake 2 game applies PmoveQuake2MoveVars (same file) for the values of qcommon/pmove.c.

What is not part of the contract

  • 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 Shutdown hooks. 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 in source/engine/server/Edict.ts and is not yet checked against a game's entity classes. See plans/game-entity-contract.md.

Current behavior worth knowing

  • A changelevel replaces the game instance on both sides without calling shutdown. Do not rely on shutdown for per-map cleanup.
  • isCrashShutdown is never true today: no engine path raises it.
  • ClientDisconnect is 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 setModel reads. 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 as SOLID_MESH needs its model precached that way, from _precache or from setModel while loading (set solid first). 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 in serverdata (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) and GetSpriteSequenceIndex(modelName, name) exist on both ServerEngineAPI and ClientEngineAPI: how long the sequence of a sprite's frame plays (so an effect can remove itself when it is over), and the frame of 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?) and DrawSprite(x, y, playback, options?) draw a sprite file on the screen (a HUD element, a screen effect). LoadSprite returns a SpritePlayback, one handle per element over images that are loaded once per file and kept for the page; the game never constructs one (SpritePlayback is re-exported from GameInterfaces.ts as a type). DrawSprite takes scale, region (a name from the sprite's settings file or Aseprite slices), tint and alpha. See Sprites.

Changing the contract

  1. 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.
  2. Run npm run typecheck. It lists every game that needs updating.
  3. Update this page if the call order or the ownership changed.
  4. The game's tests (source/game/<mod>/test/) are type-checked too, so step 2 lists a test double that mirrors an outdated signature.