@deademx/engine
v4.0.1
Published
Shared Source 2 demo parsing and replay playback engine for Node.js and browsers
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. Ready-to-run packages are listed below.
| Package | Game | Links |
| --- | --- | --- |
| deadem | Deadlock | npm · docs |
| @deademx/cs2 | Counter-Strike 2 | npm · docs |
| @deademx/dota2 | Dota 2 | npm · docs |
[!NOTE]
@deademx/engineis not runnable on its own. It depends on upstream proto files, decoders, and string table parsing instructions. A game package supplies all three.This document shows every example using
deadem(Deadlock). The API is identical across games — only the import and entity class names change.
Contents
- Quick Start Parse a replay, print stats.
- How It Works Packet flow, parser lifecycle, interceptors overview.
- Demo
Structure of Source 2 demo.
- Entities Entity identity, decoded fields, query API.
- User Commands Player input accumulated per slot.
- String Tables Container, entries, subscription events.
- Interceptors
Capturing data in flight.
InterceptorStage.DEMO_PACKETOne call per outer demo packet.InterceptorStage.MESSAGE_PACKETInner message packets — string tables, entities, user messages.InterceptorStage.ENTITY_PACKETPer-entity mutation events — delta reads, operation table.InterceptorStage.USER_COMMANDPer-command player input events.- Interceptor Flow Nesting diagram of all interceptor stages.
- Player
Replay — seeking, playback, state machine.
- Seeking Tick seeking, entity state access.
- State State transitions.
- Navigation Tick methods, seek cost.
- Playback Continuous play, pause/stop, error handling.
- Configuration & Tuning
Packet filtering, break interval, logging.
- Filters
entityClasses,messagePacketTypes— the primary speed lever. - Stream Tuning
breakInterval— macrotask scheduling for UI responsiveness. - Logging Verbosity strategies.
- Filters
- HTTP Broadcast Live Source 2 broadcast parsing.
- Performance
Speedup table and tuning advice.
- Tips Optimization patterns.
- License MIT.
Quick Start
npm install deademimport { createReadStream } from 'node:fs';
import { Parser } from 'deadem';
const parser = new Parser();
await parser.parse(createReadStream('./match.dem'));
const demo = parser.getDemo();
for (const controller of demo.getEntitiesByClassName('CCitadelPlayerController')) {
console.log(controller.getField('m_iszPlayerName'), controller.getField('m_iPlayerKills'));
}
await parser.dispose(); // cleanup state and resourcesHow It Works
Parser reads a byte stream and processes it packet by packet, mutating internal state. Interceptors run before or after each packet. Once parsing ends, Parser holds the game state — dispose() releases it. Player works the same way but buffers the demo in memory, adding seeking and playback.
The engine provides raw parsed data — it does not compute statistics, interpret game state, or make analytical decisions.
Demo
Demo is the state that Parser and Player mutate as they read. Access with parser.getDemo() or player.getDemo().
A demo file is a stream of outer packets, called DemoPacket in this project. Most carry protobuf-decoded data relevant to that packet type. Three outer packet types — DEM_PACKET, DEM_SIGNON_PACKET, DEM_FULL_PACKET — are different: they carry an array of inner packets, called MessagePacket in this project. Each represents a network message — entity deltas, string table operations, user messages — identified by its MessagePacketType.
MessagePacketType.SVC_PACKET_ENTITIES carries packed entity deltas, sent every tick. Once unpacked, they become part of Demo. Demo also holds string tables.
Entities
Demo provides access to current entity 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.getEntityIterator() | Same as getEntities(), without building the array |
| demo.getEntitiesByClassNameIterator(name) | Same as getEntitiesByClassName(), without building the array |
| demo.server | Server — tickRate, tickInterval, maxClients; null until SVC_SERVER_INFO is read |
* — entity fields like m_hOwnerEntity store handles pointing to other entities. demo.getEntityByHandle(handle) resolves references between entities.
Each entity is an instance of Entity. Entity exposes methods for reading data:
| Method | Returns |
| --- | --- |
| entity.getField(name) | Value by flattened name. Dot notation accesses sub-fields, e.g. 'CBodyComponent.m_cellX'. undefined if not present |
| 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() | Plain object keyed by field name |
for (const pawn of demo.getEntitiesByClassName('CCitadelPlayerPawn')) {
const health = pawn.getField('m_iHealth');
const cellX = pawn.getField('CBodyComponent.m_cellX');
const controller = demo.getEntityByHandle(pawn.getField('m_hController'));
console.log(controller.getField('m_iszPlayerName'), health, cellX);
}User Commands
Not every game records player input. Counter-Strike 2 and Deadlock send it as SVC_USER_COMMANDS — buttons, view angles, movement — which the engine accumulates per player slot. Dota 2 does not: its replays carry no user command payload.
| Method | Returns |
| --- | --- |
| demo.getUserCommand(slot) | The slot's UserCommand, or null |
| demo.getUserCommands() | Every accumulator, in slot order |
for (const command of demo.getUserCommands()) {
const base = command.state.base;
console.log(command.slot, base.viewangles, base.forwardmove);
}[!NOTE]
stateis the live accumulator, not a copy at any depth — it keeps mutating as later commands are applied. A snapshot that outlives the current tick must be copied.
String Tables
The engine registers common StringTableTypes:
| StringTableType | Content |
| --- | --- |
| StringTableType.ANIM_ASSET_DATA | Animation asset paths (.vnmgraph/.vnmskel). Values are unparsed binary |
| StringTableType.ANIM_TASK_TYPES | Key-only anim-task class names. Always co-occurs with ANIM_ASSET_DATA |
| StringTableType.DECAL_PRE_CACHE | Decal paths keyed by path. Absent in newer builds for all three games |
| StringTableType.EFFECT_DISPATCH | Key-only names. Fixed per-game set |
| StringTableType.ENTITY_NAMES | Key-only registry of named entities |
| StringTableType.GENERIC_PRE_CACHE | — |
| StringTableType.INFO_PANEL | — |
| StringTableType.INSTANCE_BASE_LINE | Entity class baselines, consumed internally for entity decoding |
| StringTableType.LIGHT_STYLES | — |
| StringTableType.RESPONSE_KEYS | — |
| StringTableType.SCENES | — |
| StringTableType.SERVER_QUERY_INFO | — |
| StringTableType.USER_INFO | Player name, Steam ID, userid — decoded CMsgPlayerInfo per slot |
| StringTableType.V_GUI_SCREEN | — |
[!NOTE]
StringTableType.USER_INFOslots hold values only while a player is connected. Players disconnect after the match ends — by the timeparse()returns, most slots are empty.
Access tables through StringTableContainer:
| Method | Returns |
| --- | --- |
| demo.stringTableContainer.getById(id) | Table by id, or null |
| demo.stringTableContainer.getByType(type) | Table by its StringTableType, or null |
| demo.stringTableContainer.getTables() | All tables |
Table entries:
| Method | Returns |
| --- | --- |
| table.getEntries() | All entries |
| table.getEntriesCount() | Number of entries |
| table.getEntryById(id) | Entry by id, or null |
An entry is a StringTableEntry:
| Property | Returns |
| --- | --- |
| entry.id | Positional id within the table |
| entry.key | String key |
| entry.value | null for key-only entries. Otherwise a decoded value or the raw Uint8Array payload. |
const table = demo.stringTableContainer.getByType(StringTableType.USER_INFO);
for (const entry of table.getEntries()) {
console.log(entry.id, entry.key, entry.value?.name);
}StringTableContainer reports table changes through StringTableEvent:
| Event | Fires when |
| --- | --- |
| TABLE_CREATED | A table is registered |
| TABLE_UPDATED | An existing table's entries change |
| TABLE_CHANGED | Either of the above — fires alongside both |
| TABLE_REMOVED | A table is removed |
Subscribe with demo.stringTableContainer.subscribe(StringTableEvent.TABLE_CHANGED, callback), unsubscribe the same way. The callback receives (StringTableContainer, StringTable, Array<StringTableEntry>|null).
demo.stringTableContainer.subscribe(StringTableEvent.TABLE_CHANGED, (container, table, entries) => {
if (table.type !== StringTableType.USER_INFO) return;
for (const entry of entries) {
console.log(entry.id, entry.value === null ? 'N/A' : entry.value.name);
}
});Interceptors
Interceptors capture data in flight — per packet, before or after it reaches Demo. Parser and Player expose registerPreInterceptor(stage, callback), registerPostInterceptor(stage, callback), unregisterPreInterceptor(stage, callback), and unregisterPostInterceptor(stage, callback). Unregistration uses reference equality — the same function instance must be passed. PRE fires before a stage's data reaches Demo; POST fires after.
There are four InterceptorStage values:
| Stage | Hook signature |
| --- | --- |
| DEMO_PACKET | (DemoPacket) => void |
| MESSAGE_PACKET | (DemoPacket, MessagePacket) => void |
| ENTITY_PACKET | (DemoPacket, MessagePacket, Array<EntityMutationEvent>) => void |
| USER_COMMAND | (DemoPacket, MessagePacket, Array<UserCommandEvent>) => void |
InterceptorStage.DEMO_PACKET
One call per outer DemoPacket:
import { InterceptorStage, Parser } from 'deadem';
const parser = new Parser();
parser.registerPostInterceptor(InterceptorStage.DEMO_PACKET, (demoPacket) => {
console.log(`Tick [ ${demoPacket.tick} ] | Type [ ${demoPacket.type.code} ]`);
});InterceptorStage.MESSAGE_PACKET
One call per inner MessagePacket.
import { MessagePacketType } from 'deadem';
parser.registerPostInterceptor(InterceptorStage.MESSAGE_PACKET, (demoPacket, messagePacket) => {
if (messagePacket.type !== MessagePacketType.NET_TICK) return;
console.log(messagePacket.data);
});messagePacket.data is protobuf-decoded. Two types carry further encoded data:
SVC_CREATE_STRING_TABLEandSVC_UPDATE_STRING_TABLE— encoded entries, decoded internally. For per-entry changes, useStringTableContainer.subscribe.SVC_PACKET_ENTITIES— packed entity changes, decoded internally. For per-entity changes, useInterceptorStage.ENTITY_PACKET.
InterceptorStage.ENTITY_PACKET
One call per SVC_PACKET_ENTITIES, with an array of EntityMutationEvent — one per entity:
import { EntityOperation } from 'deadem';
parser.registerPostInterceptor(InterceptorStage.ENTITY_PACKET, (demoPacket, messagePacket, events) => {
for (const event of events) {
if (event.operation !== EntityOperation.UPDATE) continue;
if (event.entity.class.name !== 'CCitadelPlayerPawn') continue; // Deadlock player pawn entity
const [health] = event.getChanges(['m_iHealth']);
if (health !== undefined) {
console.log(`Tick [ ${demoPacket.tick} ] | Entity [ ${event.entity.class.name}|${event.entity.index} ] | Health [ ${health} ]`);
}
}
});Each event exposes:
| Property | Returns |
| --- | --- |
| event.operation | EntityOperation — CREATE, UPDATE, LEAVE, or DELETE |
| event.entity | Affected Entity |
event.getChanges() — object of all changed fields. event.getChanges(names) — array of values in the same order as names, undefined for fields not in the event.
| Operation | When | Batch |
|---|---|---|
| CREATE | Entity first appears | All initial fields |
| UPDATE | Entity fields changed | Changed fields only |
| LEAVE | Entity deactivated, slot reserved | Empty |
| DELETE | Entity permanently removed | Empty |
InterceptorStage.USER_COMMAND
One call per SVC_USER_COMMANDS, with an array of UserCommandEvent — one per command in that packet:
parser.registerPostInterceptor(InterceptorStage.USER_COMMAND, (demoPacket, messagePacket, events) => {
for (const event of events) {
const buttons = event.userCommand.state.base?.buttonsPb;
if (buttons?.buttonstate1 !== undefined) {
console.log(`Tick [ ${demoPacket.tick} ] | Slot [ ${event.userCommand.slot} ] | Buttons [ ${buttons.buttonstate1} ]`);
}
}
});event.getChanges() — what this command carried, in the same shape as state.
Interceptor Flow
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
│ │ └─ events
│ └─ POST ENTITY_PACKET
├─ POST MESSAGE_PACKET
├─ PRE MESSAGE_PACKET
│ └─ SVC_USER_COMMANDS
│ └─ POST USER_COMMAND
├─ POST MESSAGE_PACKET
└─ ...
POST DEMO_PACKETPlayer
Player steps tick by tick, jumps to any tick, or plays back at a chosen rate. It wraps ParserEngine — the same engine that powers Parser.
load() buffers the entire file and indexes keyframes. Streaming and HTTP broadcast are not supported (yet) — seeking requires the entire file buffered.
Seeking
import { createReadStream } from 'node:fs';
import { Player } from 'deadem';
const player = new Player();
await player.load(createReadStream('./match.dem'));
await player.seekToTick(player.getLastTick());
const controllers = player.getDemo().getEntitiesByClassName('CCitadelPlayerController'); // Deadlock player controllers
for (const controller of controllers) {
const name = controller.getField('m_iszPlayerName');
const netWorth = controller.getField('m_iGoldNetWorth');
console.log(`[ ${name} ] | m_iGoldNetWorth [ ${netWorth} ]`);
}
await player.dispose();State
Player follows a 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() → DISPOSEDA failed seek still returns to LOADED.
Navigation
| Method | Returns |
| --- | --- |
| player.getCurrentTick() | Current tick |
| player.getFirstTick() | First tick in the demo |
| player.getLastTick() | Last tick in the demo |
| player.nextTick() | Advances one tick. false at the last tick |
| player.prevTick() | Moves back one tick. false at the first tick |
| player.seekToTick(tick) | Jumps to the given tick, or the nearest one before it |
nextTick() advances the open session — cheap. seekToTick() closes it and opens a new one from the nearest keyframe — expensive. prevTick() calls seekToTick() internally: the name is symmetric, the cost is not.
Every seek clears Demo internals — entities and tables from a previous tick are stale. The Demo instance remains the same, its contents are rebuilt.
await player.seekToTick(10000);
while (await player.nextTick()) {
if (player.getCurrentTick() % 64 === 0) {
console.log(player.getCurrentTick(), player.getDemo().getStats().entities);
}
}Playback
| Method | Returns |
| --- | --- |
| player.play(rate = 1.0) | Promise that resolves at the last tick |
| player.pause() | Rejects the play() promise. No-op if not playing |
| player.stop() | Rejects the play() promise, then seeks to the first tick. No-op if not playing |
play() also rejects if interrupted by seekToTick(), or by dispose(). Unlike pause() / stop(), calling play() while not LOADED throws — a no‑op otherwise. The rejection is a PlaybackInterruptedError with .reason: 'paused', 'stopped', or 'disposed'.
const playback = player.play(0.5); // half speed
setTimeout(() => player.pause(), 1000);
try {
await playback;
} catch (error) {
console.log(error.reason); // 'paused'
}Interceptors work the same as on Parser (above) and stay registered across seeks.
Configuration & Tuning
ParserConfiguration tunes entity decoding, packet filtering, and stream behavior.
Filters
Filters are the primary lever for parser performance — reducing decoded data cuts both memory and time.
| Option | Type | Default | Filters |
| --- | --- | --- | --- |
| entityClasses | Array<string>\|null | null | Which entity classes get their fields decoded |
| messagePacketTypes | Array<MessagePacketType>\|null | null | Allowlist of message types to process |
| messagePacketTypesExclude | Array<MessagePacketType>\|null | null | Blocklist of message types to process |
Four MessagePacketType values are always processed — they drive internal state: SVC_SERVER_INFO, SVC_CREATE_STRING_TABLE, SVC_UPDATE_STRING_TABLE, SVC_CLEAR_ALL_STRING_TABLES.
import { MessagePacketType, Parser, Player, ParserConfiguration } from 'deadem';
const configA = new ParserConfiguration({
messagePacketTypesExclude: [MessagePacketType.SVC_PACKET_ENTITIES]
});
const configB = new ParserConfiguration({
entityClasses: ['CCitadelPlayerPawn', 'CCitadelPlayerController']
});
new Parser(configA);
new Player(configB);Stream Tuning
breakInterval — every N DemoPackets, the parser schedules and awaits a macrotask to avoid blocking. Lower values mean more breaks (responsive UI, slower parse); higher values mean fewer breaks (faster parse, blocked UI). Default is 1000.
Logging
Logger controls verbosity. Accepted by Parser and Player constructors. Built-in strategies — CONSOLE_TRACE, CONSOLE_DEBUG, CONSOLE_INFO (default), CONSOLE_WARN, NOOP.
import { Logger, Parser, ParserConfiguration } from 'deadem';
new Parser(ParserConfiguration.DEFAULT, Logger.CONSOLE_WARN);HTTP Broadcast
BroadcastAgent exposes a live Source 2 TV broadcast as a readable stream for Parser.
import { BroadcastAgent, BroadcastGateway, DemoSource, Parser } from 'deadem';
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();
await parser.parse(agent.stream(), DemoSource.HTTP_BROADCAST);agent.stream(fromStart = false) polls fragments from the relay and emits them as they arrive. DemoSource.HTTP_BROADCAST is required — parse() defaults to DemoSource.REPLAY.
BroadcastAgent defaults to Logger.CONSOLE_DEBUG — louder than Parser's and Player's CONSOLE_INFO default. A logger can be passed explicitly to quiet it.
Performance
Entity-packet decoding (SVC_PACKET_ENTITIES) accounts for most of the parser's work. SVC_USER_COMMANDS is a distant second. Everything else combined is minor.
| # | Configuration | Speedup |
| --- | --- | --- |
| 1 | No filters | 1× (baseline) |
| 2 | Exclude SVC_PACKET_ENTITIES entirely | ~4–6× |
| 4 | Exclude SVC_USER_COMMANDS entirely | ~1–2× |
| 3 | entityClasses allowlist | ~1–5× |
For concrete numbers see the deadem, @deademx/cs2, or @deademx/dota2 performance sections.
Tips
Filter what gets decoded — the biggest performance gain. Only enable the message types and entity classes the code needs.
breakIntervaldefault (1000) is tuned for Node.js. In a browser, lower it to keep the UI responsive —100is used in Deadem Explorer. Experiment to find the balance for the target environment.Mind the call frequency. A 500 MB Deadlock demo may contain ~150k
InterceptorStage.DEMO_PACKETand ~3MInterceptorStage.MESSAGE_PACKETcalls — one interceptor invocation each. Heavy work in theInterceptorStage.MESSAGE_PACKETinterceptor compounds fast and can tank performance. The same holds for CS2 and Dota 2 demos.Use
InterceptorStage.ENTITY_PACKETonly when per-entity diffs between ticks are needed. To simply read current state — player positions, health, etc. — useDemofrom anInterceptorStage.DEMO_PACKETinterceptor instead.Throttle per-tick logic when frame-level precision isn't required. A UI renderer rarely needs health changes every tick — sampling once per second is usually enough. Depending on server tick rate, that's every 64 ticks (Deadlock), every 30 ticks (Dota 2), or similar for CS2.
Read as much as needed. Prefer point reads via
getField('name')overunpackFlattenedor iterators. A 500 MB Deadlock demo may produce ~10MEntityMutationEvents carrying ~100M individual field updates — entity state stays in TypedArrays internally, keeping memory compact. Values materialize into JS objects on read. SeeEntity.getFieldJSDoc for detailed overhead.
