@deademx/engine
v3.1.1
Published
Shared Source 2 demo parsing and replay playback engine for Node.js and browsers
Downloads
786
Maintainers
Readme
@deademx/engine is the shared, game-agnostic Source 2 parsing and replay playback engine that powers deadem (Deadlock), @deademx/cs2 (Counter-Strike 2), and @deademx/dota2 (Dota 2).
It provides the packet pipeline, mutable demo state, replay player, interceptor lifecycle, broadcast client, and configuration primitives. The engine itself carries no game-specific protobuf schemas or message types — using it directly requires a prepared SchemaRegistry. For a ready-to-run package, install one of the implementations below.
Contents
- Implementations Game-specific packages built on top of the shared engine.
- Concepts
Core concepts of the parsing and playback engine.
- Demo Structure and content of Source 2 demo packets.
- Parser Parser lifecycle and stream processing model.
- Player Replay playback, seeking, and player state transitions.
- Interceptors Hook points for inspecting and extracting data during parsing.
- Configuration
Parser configuration options and logging strategies.
- Parser options Packet filtering, worker settings, and parser tuning.
- Logging Built-in logger strategies.
- API reference Summary of all public exports and key methods.
- Usage
Common usage patterns for replay parsing, broadcast parsing, and playback.
- Replay file
Parse a replay from a
.demfile. - HTTP broadcast Parse a live Source 2 HTTP broadcast stream.
- Data extraction Capture data during parsing or query the final state.
- Playback and seeking Seek through buffered state and run continuous playback.
- Replay file
Parse a replay from a
- Performance How configuration choices affect parser throughput.
- License Project licensing information.
Implementations
| Package | Game | Links |
| --- | --- | --- |
| deadem | Deadlock (original) | npm · docs |
| @deademx/cs2 | Counter-Strike 2 | npm · docs |
| @deademx/dota2 | Dota 2 | npm · docs |
Concepts
Demo
A Source 2 demo is a sequential stream of outer packets, called DemoPacket in this project. Each has a type from DemoPacketType.
Most DemoPacket types parse into plain objects. Three types carry an array of inner MessagePacket values:
DEM_PACKETDEM_SIGNON_PACKETDEM_FULL_PACKET
Two MessagePacketType categories require additional decoding and drive the internal game state:
- Entities —
SVC_PACKET_ENTITIEScontains creates, updates, and deletes. - String tables —
SVC_CREATE_STRING_TABLE,SVC_UPDATE_STRING_TABLE,SVC_CLEAR_ALL_STRING_TABLES.
The engine maintains a mutable Demo object that is updated tick by tick. Query it for the current state:
| Method | Returns |
| --- | --- |
| demo.getEntities() | All live entities |
| demo.getEntitiesByClassName(name) | Entities filtered by class name |
| demo.getEntity(index) | Entity by index |
| demo.getEntityByHandle(handle) | Entity by handle |
| demo.getClasses() | All registered entity classes |
| demo.getClassByName(name) | Entity class by name |
| demo.stringTableContainer.getByName(name) | String table by name |
| demo.server | Server metadata (tickInterval, tickRate, maxClients, maxClasses) |
Each Entity exposes its decoded fields:
| Method | Returns |
| --- | --- |
| entity.getField(name) | Field value by flattened name (e.g. 'CBodyComponent.m_cellX'), or undefined |
| entity.hasField(name) | Whether the named field is currently set |
| entity.getFieldCount() | Number of fields currently set |
| entity.fieldEntries() | Iterator of [ name, value ] pairs for present fields |
| entity.fieldNames() | Iterator of present field names |
| entity.unpackFlattened() | Cached plain object keyed by field name, lazily materialized and reused between ticks |
[!WARNING] Demos carry only the minimal data required for visual playback. Not all game state is preserved, and the parser may skip packets it cannot decode. Call
parser.getStats()(orplayer.getStats()) for detailed per-packet statistics.
Parser
Parser consumes a readable stream and incrementally parses packets into an internal Demo instance. It overwrites past state as ticks advance and does not buffer snapshots.
const parser = new Parser(registry);
await parser.parse(readable);
await parser.dispose();Parsing can be paused and resumed mid-stream:
parser.pause();
parser.resume();Player
Player is a higher-level class built on top of the same engine. Unlike Parser, it buffers the entire demo on load() and builds a packet index, enabling forward and backward seeks.
const player = new Player(registry);
await player.load(readable);
await player.seekToTick(50000);
await player.nextTick();
await player.prevTick();
await player.play(2.0).catch((err) => {
if (!(err instanceof PlaybackInterruptedError)) throw err;
});
player.pause();
await player.stop();
await player.dispose();The player follows a strict state machine:
IDLE
├── load() → LOADED
└── dispose() → DISPOSED
LOADED
├── play() → PLAYING
├── seekToTick() → SEEKING → LOADED
└── dispose() → DISPOSED
PLAYING
├── pause() / end → LOADED
└── dispose() → DISPOSED
SEEKING
├── (completes) → LOADED
└── dispose() → DISPOSED| State | Description |
| --- | --- |
| IDLE | Initial state. Only load() or dispose() is allowed. |
| LOADED | Demo is buffered and indexed. Ready for seek and playback. |
| PLAYING | Continuous playback is running. |
| SEEKING | A seekToTick() call is in progress. |
| DISPOSED | Resources released. The player cannot be used further. |
The current state is exposed via player.state.
[!NOTE]
Playerdoes not supportparserThreads > 0. Constructing a player with parallel parsing throws immediately.
Interceptors
Interceptors are user-defined hooks that run before or after specific parsing stages. They are the primary way to extract data during parsing.
Parser and Player expose the same registration API:
registerPreInterceptor(stage, fn)registerPostInterceptor(stage, fn)unregisterPreInterceptor(stage, fn)unregisterPostInterceptor(stage, fn)
Three stages are supported via InterceptorStage:
| Stage | Hook signature |
| --- | --- |
| DEMO_PACKET | (demoPacket) => void |
| MESSAGE_PACKET | (demoPacket, messagePacket) => void |
| ENTITY_PACKET | (demoPacket, messagePacket, events) => void |
For ENTITY_PACKET, events is an array of EntityMutationEvent values with { operation, entity, mutations }, where operation is an EntityOperation (CREATE, UPDATE, LEAVE, DELETE).
import { EntityOperation, InterceptorStage, Parser } from '@deademx/engine';
parser.registerPostInterceptor(InterceptorStage.ENTITY_PACKET, (demoPacket, messagePacket, events) => {
for (const event of events) {
if (event.operation !== EntityOperation.UPDATE) continue;
const changes = event.getChanges();
if ('m_iHealth' in changes) {
console.log(event.entity.class.name, changes.m_iHealth);
}
}
});The parse timeline:
PRE DEMO_PACKET
└─ DEM_FILE_HEADER
POST DEMO_PACKET
...
PRE DEMO_PACKET
└─ DEM_PACKET
├─ PRE MESSAGE_PACKET
│ └─ NET_TICK
└─ POST MESSAGE_PACKET
├─ PRE MESSAGE_PACKET
│ └─ SVC_PACKET_ENTITIES
│ ├─ PRE ENTITY_PACKET
│ │ └─ ENTITY_1
│ └─ POST ENTITY_PACKET
│ └─ ...
└─ POST MESSAGE_PACKET
POST DEMO_PACKET[!IMPORTANT] Interceptor callbacks are not awaited. Use synchronous callbacks when data must be captured before the parser advances.
Configuration
Parser options
ParserConfiguration controls packet filtering and parser tuning:
| Option | Description | Type | Default |
| --- | --- | --- | --- |
| breakInterval | How often, in packets, to yield to the event loop. Lower values improve responsiveness, higher values improve throughput. | number | 1000 |
| entityClasses | Allowlist of entity class names to decode from SVC_PACKET_ENTITIES. | Array<string> \| null | null |
| messagePacketTypes | Allowlist of MessagePacketType values. Mutually exclusive with messagePacketTypesExclude. | Array<MessagePacketType> \| null | null |
| messagePacketTypesExclude | Blocklist of MessagePacketType values. Mutually exclusive with messagePacketTypes. | Array<MessagePacketType> \| null | null |
| parserThreads | Number of additional worker threads. Not supported by Player. | number | 0 |
The engine always processes the following message types regardless of filters, because they drive internal state:
SVC_SERVER_INFOSVC_CREATE_STRING_TABLESVC_UPDATE_STRING_TABLESVC_CLEAR_ALL_STRING_TABLES
Example — skip entity packets when entity data is not needed:
import { MessagePacketType, Parser, ParserConfiguration } from '@deademx/engine';
const parser = new Parser(registry, new ParserConfiguration({
messagePacketTypesExclude: [ MessagePacketType.SVC_PACKET_ENTITIES ]
}));Example - decoding only a subset of entity classes:
import { MessagePacketType, Parser, ParserConfiguration } from '@deademx/engine';
const parser = new Parser(registry, new ParserConfiguration({
messagePacketTypes: [ MessagePacketType.SVC_PACKET_ENTITIES ],
entityClasses: [ 'CExampleEntityA', 'CExampleEntityB' ]
}));Logging
Logger ships with predefined strategies:
| Strategy | Levels emitted |
| --- | --- |
| Logger.CONSOLE_TRACE | trace, debug, info, warn, error |
| Logger.CONSOLE_DEBUG | debug, info, warn, error |
| Logger.CONSOLE_INFO (default) | info, warn, error |
| Logger.CONSOLE_WARN | warn, error |
| Logger.NOOP | (nothing) |
import { Logger, Parser, ParserConfiguration } from '@deademx/engine';
const parser = new Parser(registry, ParserConfiguration.DEFAULT, Logger.CONSOLE_WARN);API reference
| Export | Purpose |
| --- | --- |
| Parser | Streaming parser over a Readable. |
| Player | Buffered replay player with seek and playback. |
| ParserConfiguration | Parser options (filters, threads, break interval). |
| SchemaRegistry | Registry of protobuf types. Required by Parser and Player. |
| Bootstrap | Populates a SchemaRegistry with engine-level types. |
| ProtoProvider | Base protobuf schema provider for game-specific packages. |
| FieldDecoderDescriptor | Field decoder descriptors used by game-specific bootstrap rules. |
| FieldDecoderType | Enum of supported field decoder descriptor types. |
| BroadcastAgent | Polls Source 2 HTTP broadcast fragments. |
| BroadcastGateway | Low-level HTTP client for broadcast endpoints. |
| Printer | Prints parser stats (memory, packets, performance). |
| Logger | Logging strategy (see above). |
| PlaybackInterruptedError | Raised when Player.play() is interrupted. Exposes reason. |
| DemoPacketType | Enum of outer packet types (DEM_PACKET, DEM_FULL_PACKET, …). |
| MessagePacketType | Enum of inner message types (NET_TICK, SVC_PACKET_ENTITIES, …). |
| StringTableType | Enum of string tables (USER_INFO, INSTANCE_BASE_LINE, …). |
| EntityOperation | CREATE, UPDATE, LEAVE, DELETE. |
| InterceptorStage | DEMO_PACKET, MESSAGE_PACKET, ENTITY_PACKET. |
| PlayerState | IDLE, LOADED, PLAYING, SEEKING, DISPOSED. |
| DemoSource | REPLAY, HTTP_BROADCAST. |
| Protocol | HTTP, HTTPS. |
Key methods on Parser: parse(reader, source?, objectMode?), extract(reader, source?), pause(), resume(), abort(), dispose(), getDemo(), getStats(), getIsStarted(), getIsPaused(), getIsFinished(), getIsDisposed(), plus the interceptor registration API.
Key methods on Player: load(reader, source?), seekToTick(tick), nextTick(), prevTick(), play(rate?), pause(), stop(), dispose(), getDemo(), getCurrentTick(), getFirstTick(), getLastTick(), getStats(), state, plus the interceptor registration API.
Usage
All examples assume registry is a populated SchemaRegistry. When using a game-specific package (deadem, @deademx/cs2, @deademx/dota2), the registry is built automatically inside the subclassed Parser and Player — there is nothing to wire up. When consuming @deademx/engine directly, you must supply both a ProtoProvider and a bootstrap routine that layers game-specific types on top of Bootstrap.run(registry).
Replay file
import { createReadStream } from 'node:fs';
import { Parser, Printer } from '@deademx/engine';
const parser = new Parser(registry);
const printer = new Printer(parser);
const readable = createReadStream(PATH_TO_DEMO_FILE);
await parser.parse(readable);
await parser.dispose();
printer.printStats();HTTP broadcast
import { BroadcastAgent, BroadcastGateway, DemoSource, Parser } from '@deademx/engine';
const FROM_BEGINNING = false;
const MATCH_ID = 'MATCH_IDENTIFIER';
const gateway = new BroadcastGateway('dist1-ord1.steamcontent.com/tv');
const agent = new BroadcastAgent(gateway, MATCH_ID);
const parser = new Parser(registry);
await parser.parse(agent.stream(FROM_BEGINNING), DemoSource.HTTP_BROADCAST);
await parser.dispose();Data extraction
Use interceptors when data must be captured during parsing:
import { InterceptorStage, Parser } from '@deademx/engine';
const parser = new Parser(registry);
parser.registerPostInterceptor(InterceptorStage.MESSAGE_PACKET, (demoPacket, messagePacket) => {
console.log(messagePacket.type.code, messagePacket.data);
});
await parser.parse(readable);Use post-parse queries when only the final state is needed:
await parser.parse(readable);
const demo = parser.getDemo();
const entities = demo.getEntities();Playback and seeking
Inspect state at a specific tick:
import { createReadStream } from 'node:fs';
import { Player } from '@deademx/engine';
const player = new Player(registry);
const readable = createReadStream(PATH_TO_DEMO_FILE);
await player.load(readable);
await player.seekToTick(player.getLastTick());
const demo = player.getDemo();
const entities = demo.getEntities();
await player.dispose();Continuous playback at 2× speed, paused after 3 seconds:
import { PlaybackInterruptedError, Player } from '@deademx/engine';
const player = new Player(registry);
await player.load(readable);
const playback = player.play(2.0).catch((err) => {
if (!(err instanceof PlaybackInterruptedError)) throw err;
});
setTimeout(() => player.pause(), 3000);
await playback;
await player.dispose();Performance
Entity-packet decoding (MessagePacketType.SVC_PACKET_ENTITIES) accounts for most of the parser's work — everything else combined is under ~20%. Three configurations cover typical use cases:
| # | Configuration | Speedup vs default | Use case |
| - | --- | --- | --- |
| 1 | No filters (ParserConfiguration.DEFAULT) | 1× (baseline) | Full replay state. |
| 2 | messagePacketTypes allowlist excluding SVC_PACKET_ENTITIES | ~6–8× | Non-entity packets only. |
| 3 | entityClasses allowlist | ~4–6× | Entity consumers with a known set of classes. |
The engine itself is game-agnostic. For concrete numbers see the deadem, @deademx/cs2, or @deademx/dota2 performance sections.
License
This project is licensed under the MIT License.
