In general the game code is completely object-oriented and it has no global state, therefore we need to carry around both engine interface as well as game interface. This allows the dedicated server to handle multiple servers at the same time enabling dynamic game lobbies etc. The most global state must be the variables on the ServerGameAPI object or on the WorldspawnEntity.
This repository provides a clean, modern framework to build Quake mods using JavaScript/ES6 modules.
Want to create a mod? Here's what you need to know:
- Everything is an Entity - Players, monsters, items, doors, triggers - all extend
BaseEntity - No Global State - All game state lives in
ServerGameAPIor individual entities - Object-Oriented - Use classes, inheritance, and composition (helper classes)
- Type-Safe - TypeScript with TC39 decorators for serialization
- Modern TypeScript - ES modules, classes, decorators, compiled through esbuild
Common modding tasks:
- Create a new monster → Extend
BaseMonster(seeentity/monster/for examples) - Create a new weapon → Add to
entity/Weapons.tsandweaponConfig - Create a new item → Extend
BaseItemEntity(seeentity/Items.ts) - Create a new trigger → Extend
BaseTriggerEntity(seeentity/Triggers.ts) - Create a custom entity → Just pick one of the misc entities, they are an easy start.
If a mod repository is vendored into the engine under source/game/<mod>, it should ship a module-root tsconfig.json when it wants typed linting for its own tests. The engine lint config intentionally does not try to predict arbitrary mod test folder layouts.
Use source/game/tsconfig.vendored.json as the base config and keep the module's include list local to that repo. A minimal setup looks like this:
{
"extends": "../tsconfig.vendored.json",
"include": [
"./**/*.mjs",
"./**/*.cjs",
"./**/*.ts",
"./**/*.mts",
"./**/*.cts",
"./**/*.d.ts"
]
}If the mod depends on another vendored game repo, add that sibling repo to include as well. For example, hellwave includes ../id1/** because its tests and game code build on top of the id1 implementation.
File structure:
source/game/id1/
├── entity/ # All entity classes
│ ├── monster/ # Monster AI and behaviors
│ ├── props/ # Doors, platforms, buttons
│ ├── BaseEntity.ts # Root entity class (decorators)
│ ├── Items.ts
│ ├── Weapons.ts
│ ├── Triggers.ts
│ └── ...
├── helper/ # Helper classes (AI, utilities)
│ └── MiscHelpers.ts # Serializer, decorators, EntityWrapper, plain-object save/load helpers
├── client/ # Client-side code (HUD, effects)
├── GameAPI.ts # Server game state and entity registry
└── Defs.ts # Constants and enums
Right now the id1 GameModule is a clean reimplementation of the Quake game logic. It might not be perfect though, some idiosyncrasis will be sorted out as part of the code restructuring. Some idiosyncrasis will remain due to compatibility.
During the reimplementation I noticed some bugs/issues within the original Quake game logic that I sorted out. Always trying to keep the actual game play unaffected.
Beyond bugfixes and modernizing the architecture, this port introduces several new gameplay capabilities and modding enhancements out of the box, pushing beyond the limits of vanilla QuakeC:
- Player Interaction (
+use): Built-in support for a dedicated+use(interact) button. Entities can be flagged withFL_USEABLE, enabling a Half-Life-style direct player interaction mechanism instead of just relying on proximity triggers (touch) or shooting. - Custom Blood Colors: Entities that take damage (
takedamage) can define custom color indices for their "blood" particles or spray via thebloodcolorfield (e.g., buttons and doors usecolors.DUSTinstead of red blood). - Client-Side Game Code Capabilities: Unlike QuakeC, this port has an entire client-side framework (
ClientGameAPI) that handles logic like drawing dynamic HUD elements, managing intermission screens, and rendering effects (e.g., screen flashes, decals, or gibbing models) independently of the server. - Complex Serialization (
Serializer+ Decorators): The game state management supports detailed object serialization via@serializableObject/@serializableTC39 decorators. The serializer is not tied toServerEngineAPI; it can be attached to entities, helper objects, or plain state bags, and only needs an engine reference when it has to resolve edict-backed entity references during load. This goes far beyond QuakeC's simpleparm0...15spawn parameters. - Feature Flags: Built-in toggles (
featureFlagsarray inGameAPI.ts) to enable modernized physics and gameplay behaviors that alter standard Quake conventions:improved-gib-physics: Instead of a simple upward throw, gibs and player heads properly calculate momentum from the incoming impact, resulting in realistic physical forces applied correctly during explosions or deaths. Additionally applies blast momentum realistically to all entities (not just those walking).correct-ballistic-grenades: Replaces hard-coded trajectories for Ogre grenades and Zombie gibs. It uses actual physics equations, gravity settings, and travel-time formulas to calculate perfect parabolic arcs towards the target limits.draw-bullet-hole-decals: Enables a robust client-side event listener that automatically maps decal sprites (likegfx/bhole1.png) to surfaces hit by player bullet/hitscan attacks.
TypeScript classes use TC39 decorators to declare which fields are part of the serialized game state (save/load, spawn parameters). Two decorators from helper/MiscHelpers.ts work together:
| Decorator | Target | Purpose |
|---|---|---|
@serializable |
Field | Marks a class field for serialization |
@serializableObject |
Class | Finalizes all @serializable fields into a frozen static serializableFields array |
Usage:
import { serializableObject, serializable, Serializer } from '../helper/MiscHelpers.ts';
@serializableObject
class MyMonster extends BaseEntity {
@serializable health = 100;
@serializable enemy: BaseEntity | null = null;
@serializable pausetime = 0;
// Not decorated → not serialized
protected readonly _damageHandler = new DamageHandler(this);
}How it works:
@serializablefield decorators accumulate field names during class definition.@serializableObjectclass decorator freezes them intostatic serializableFieldson the class.- At runtime,
collectSerializableFields()walks the prototype chain and merges fields from every class in the hierarchy — parent fields are included automatically.
Backward compatibility: Legacy .mjs subclasses can still use static serializableFields arrays or the startFields()/endFields() pattern. A decorated parent and a legacy child work correctly together.
The same serializer can also be attached to plain objects with Serializer.makeSerializable() or Serializer.makeSerializableObject(). Use a server engine only when the object needs to resolve entity references during deserialization.
Originally, Quake did not support client-side game code. In this project we also move game related logic from the engine to the game code. However, this APIs are not fully specified yet and change as the client-side game code is being ported over from the engine.
A couple of things I spotted or I’m unhappy with
- applyBackpack: currentammo not updated --> fixed by the new client code
- cvars: move game related cvars fully into the GameModule, less game duties on the engine
- BaseEntity: make state definitions static, right now it’s bloating up the memory footprint
- fix bobbing and moving around when boss monster is killed
- implement a more lean Sbar/HUD
- implement intermission, finale etc. screens
- move more of the effect handling from the engine to the game code
- implement damage effects (red flash)
- implement powerup effects (quad, invis etc.)
- handle things like gibbing, bubbles etc. on the client-side only
- air_bubbles (implemented as
StaticBubbleSpawnerEntity) - GibEntity (implemented in
Player.ts) - MeatSprayEntity (implemented in
monster/BaseMonster.ts)
- air_bubbles (implemented as
- handle screen flashes like bonus flash (
bf) through events
Note: Most client-side entities are implemented. Consider moving more visual-only effects to client-side code.
RFC 2119 applies.
- Every entity must have a unique classname.
- Every entity class should end with “Entity” in their name.
- Every entity class must not change their
classnameduring their lifetime. - Every entity class must have a
QUAKEDjsdoc. - The game code must not spawn “naked” entities using
spawn()and simply setting fields during runtime. - The game code must not assume internal structures of the engine.
- The game code must not use global variables.
- The game code should not hardcode
classnamewhen used for spawning entities, the game code should useExampleEntity.classnameinstead of'misc_example'. - Entity properties starting with
_are considered protected and must and will not be set by the map loading code. If you intend to modify these properties outside of the class defining it, you must mark with with jsdoc’s@publicannotation. - Entity properties intended to be read-only must be annotated with jsdoc’s
@readonlyannotation and should be declared throw a getter without a setter. - TypeScript entities must declare serializable properties with the
@serializablefield decorator and@serializableObjectclass decorator. - Legacy JS entities may still declare properties in the
_declareFields()method. - Entities must declare assets to be precached in the
_precache()method only. - Entities must declare states in the
_initStates()method only. - Assets required during run-time must be precached by the
WorldspawnEntity. - Numbers related to map units should be formated like this:
1234.5. - Do not use private methods. Allow mods to extend and reuse entities by extending the classes.
- When porting over QuakeC almost verbatim, comments must be copied over as well in order to give context.
- Settings and/or properties that are considered extensions to the original should be prefixed with
qs_.
The server keeps a list of things in the world in a structure called an Edict.
Edicts will hold information only relevant to the engine such as position in the world data tree.
Furthermore, an Edict provides many methods to interact with the world and the game engine related to that Edict. See ServerEdict in the engine code.
An Entity is sitting on top of an Edict. The Entity class will provide logic and keeps track of states. There are also client entities which are not related to these Entity structures.
Entities have a classname apart from the JavaScript class name. This classname will be used by the editor to place entities into the world.
However, the engine reads from a set of must be defined properties. BaseEntity is defining all of them.
| Class | Purpose |
|---|---|
ServerGameAPI |
Holds the whole server game state. It will be instantiated by the engine’s spawn server code and only lasts exactly one level. The class holds information such as the skill level and exposes methods for engine game updates. Also the engine asks the ServerGameAPI to spawn map objects. |
ClientGameAPI |
Not completely designed yet. It is supposed to handle anything supposed to run on the client side such as HUD, temporary entities, etc. |
BaseEntity |
Every entity derives from this class. It provides all necessary information for the engine to place objects in the world. Also the engine will write back certain information directly into an entity. This class provides lots of helpers such as the state machine, thinking scheduler and also provides core concepts of for instance damage handling. Uses @serializableObject/@serializable decorators for field serialization. |
PlayerEntity |
The player entity not just represents a player in the world, but it also handles impulse commands, weapon interaction, jumping, partially swimming, effects of having certain items. Some logic is outsourced to helper classes such as the PlayerWeapons class. |
WorldspawnEntity |
Defines the world, but is mainly used to precache resources that can be used from anywhere. |
Helper classes extend EntityWrapper and are found in entity/Weapons.ts and entity/Subs.ts.
| Class | Purpose | Location |
|---|---|---|
EntityWrapper |
Base wrapper for a BaseEntity. Adds shortcuts for engine API and game API instances. All helpers below extend this. |
helper/MiscHelpers.ts |
Sub |
Brings all the target/killtarget/targetname handling to an entity. Also provides movement related helpers. The name is based on the SUB_CalcMove, SUB_UseTargets etc. prefix from QuakeC. |
entity/Subs.ts |
DamageHandler |
Brings all logic related to receiving and handling damage to an entity. Used by monsters, players, and breakable objects. | entity/Weapons.ts |
DamageInflictor |
Brings more complex logic related to giving out damage. This is optional - every entity will expose damage() to inflict basic damage to another entity. |
entity/Weapons.ts |
Explosions |
A streamlined way to turn any entity into an explosion with proper effects and damage radius. | entity/Weapons.ts |
These base classes make it easy to create new entities with common behaviors:
| Class | Purpose | Location |
|---|---|---|
BaseItemEntity |
Allows easily creating entities containing an item or ammo. This base class provides all logic connected to target handling, respawning (multiplayer games), sound effects etc. | entity/Items.ts |
BaseKeyEntity |
Base for keys. Main differences from items are sounds, regeneration behavior, and keys not being removed after pickup. | entity/Items.ts |
BaseWeaponEntity |
Weapons are based on items, only the sound is different. | entity/Items.ts |
BaseAmmoEntity |
Base class for ammunition pickups (shells, nails, rockets, cells). | entity/Items.ts |
BaseProjectile |
A moving object that will cause something upon impact. Used for spikes, rockets, grenades. | entity/Weapons.ts |
BaseTriggerEntity |
Convenient base class to make any kind of triggers. | entity/Triggers.ts |
BaseLightEntity |
Handles anything related to light entities (torches, globes, fluorescent lights, etc.). | entity/Misc.ts |
BasePropEntity |
Base class to support platforms, doors, trains etc. Provides movement state machine. | entity/props/BasePropEntity.ts |
BaseDoorEntity |
Base class to handle doors and secret doors with key support and linking. | entity/props/Doors.ts |
BaseMonster |
Base class for all monsters. Provides AI, damage handling, gibbing, and common monster behaviors. | entity/monster/BaseMonster.ts |
- Access through properties
- Engine may write to things like
groundentity,effectsetc. - Engine will read from things like
origin,anglesetc.
- Engine may write to things like
- Access through methods
- Engine will communicate with the game through
ServerGameAPIcalling methods likeClientConnectandClientDisconnect, but also with entities directly through methods such astouchandthink. - Game will communicate mainly through the
ServerEngineAPIobject which is augmented by lots of methods declared onBaseEntity.
- Engine will communicate with the game through
Server-side initialization:
GameModule.Initimports the active server game moduleServerGameAPI.Init()is called (static) - register console variables here- When server spawns,
new ServerGameAPI(engineAPI)is instantiated - Map loads, entities spawn via
entityRegistry
Client-side initialization:
CL.Initimports the client game codeClientGameAPI.Init()is called (static) - client-side setup- When connecting,
new ClientGameAPI(engineAPI)is instantiated - HUD and effects are initialized
When porting monsters from QuakeC to TypeScript, follow these patterns:
Most monsters extend WalkMonster, FlyMonster, or SwimMonster (which all extend BaseMonster):
import { WalkMonster } from './BaseMonster.ts';
import { serializableObject, serializable } from '../../helper/MiscHelpers.ts';
@serializableObject
export class MyMonster extends WalkMonster {
static classname = 'monster_mymonster';
static _health = 100;
static _size = [new Vector(-16, -16, -24), new Vector(16, 16, 40)];
static _modelDefault = 'progs/mymonster.mdl';
@serializable customField = 0;
}Key requirements:
- Use
@serializableObjecton the class and@serializableon fields that need to survive save/load - Use
_defineState()instatic _initStates()to define animation states - Use
_runState('statename')to transition between states
Bosses like Chthon and Shub-Niggurath don't use the standard AI system. They are purely state-machine driven:
import BaseEntity from '../BaseEntity.ts';
import BaseMonster from './BaseMonster.ts';
import { serializableObject, serializable } from '../../helper/MiscHelpers.ts';
@serializableObject
export class MyBoss extends BaseMonster {
static classname = 'monster_myboss';
@serializable bossPhase = 0;
// Disable the AI system
_newEntityAI() {
return null;
}
// Skip AI think but still process scheduled thinks for state machine
think() {
BaseEntity.prototype.think.call(this);
}
// Custom spawn - don't call _postSpawn() which sets up AI
spawn() {
if (this.game.deathmatch) {
this.remove();
return;
}
this.engine.eventBus.publish('game.monster.spawned', this);
// Boss starts inactive until triggered via use()
}
}Important: The think() override must call BaseEntity.prototype.think.call(this) directly (not super.think()) to:
- Skip
BaseMonster.think()which callsthis._ai.think() - Still process
_scheduledThinkswhich the state machine relies on
Define states using _defineState(stateName, frameId, nextState, callback):
static _initStates() {
this._states = {};
// Simple state
this._defineState('boss_idle1', 'walk1', 'boss_idle2', function () {});
// State with callback
this._defineState('boss_attack1', 'attack1', 'boss_attack2', function () {
this._bossFace(); // 'this' is the entity instance
});
// Looping state (nextState points to itself)
this._defineState('boss_wait', 'idle1', 'boss_wait', function () {});
}Add your entity class to GameAPI.ts:
import { MyBoss } from './entity/monster/MyBoss.ts';
const entityClasses = [
// ... existing entities
MyBoss,
];There’s a way to store information across maps. This is done by Spawn Parameters. Classic Quake uses SetSpawnParms, SetNewParms, SetChangeParms, parm0..15`.
QuakeShack uses a modern spawn-parameter API instead of the classic flow:
- Engine can call:
saveSpawnParameters(): stringfor clientsrestoreSpawnParameters(data: string)for clients
That API will allow for more complex serialization/deserialization of spawn parameters.
A game is limited by a map. Every map starts a new game. The engine may restore saved spawn parameters for the connecting player before the normal client lifecycle continues.
The server has to run every edict and it will run every edict, when certain conditions are met (e.g. nextthink is due).
ServerGameAPI.StartFrame- Server goes over all active entities, for each of them:
- if it’s a player, it will go the Player Think route instead
- it will execute physics engine code
- invoke
entity.think()afterwards.
ServerGameAPI.PlayerPreThink- Server executes the physics engine code.
ServerGameAPI.PlayerPostThink
ClientEntities.thinkexecute client-side thinkingClientEntities.emitentity is staged for rendering in this frame
-
Whenever a client connects, the server is calling:
- Set a new
playerentity, settingnetname(player name),colormap,team. (Subject to change) Spawn parameters (parm0..15) are copied from client to the game object. (Subject to change)- Spawn parameters are restored by invoking
restoreSpawnParameters. ServerGameAPI.ClientConnectServerGameAPI.PutClientInServer
- Set a new
-
When a client disconnects or drops, the server is calling:
ServerGameAPI.ClientDisconnect