Deadem is a JavaScript parser for Deadlock (Valve Source 2 Engine) demo/replay files, compatible with Node.js and modern browsers.
-
Installation
Installing and including the library in your project. -
Examples
Running example scripts and working with demo files. -
Overview
Core concepts and architecture of the parser.-
Understanding Demo
Structure and content of demo files. -
Understanding Parser
Parser internals and state management. -
Understanding Interceptors
Extracting data during parsing.
-
-
Usage
Basic usage example with real game data. -
Compatibility
Supported environments and versions. -
Performance
Benchmark results across platforms. -
Building
Setup and build instructions. -
License
Project licensing information. -
Acknowledgements
Credits to upstream and inspiring projects.
npm install deadem --save
import { Parser } from 'deadem';
<script src="//cdn.jsdelivr.net/npm/deadem@1.X.X/dist/deadem.min.js"></script>
const { Parser } = window.deadem;
The example scripts will, by default, look for a demo file in the /demos
folder.
If no demo file is found locally, they will automatically download one from a public S3 bucket:
https://deadem.s3.us-east-1.amazonaws.com/deadlock/demos/${matchId}-{gameBuild?}.dem
A list of all available demo files can be found in the DemoFile class.
№ | Description | Commands |
---|---|---|
01 | Single demo | node ./examples/runtime-node/01_parse.js |
02 | Multiple demos |
node ./examples/runtime-node/02_parse_multiple.js --matches="36126255,36127043" node ./examples/runtime-node/02_parse_multiple --matches=all
|
10 | Demo duration | node ./examples/runtime-node/10_parse_game_time.js |
11 | Top damage dealer | node ./examples/runtime-node/11_parse_top_damage_dealer.js |
№ | Description | Commands |
---|---|---|
01 | Example page | npm start |
The demo file consists of a sequential stream of outer packets, referred to in this project as DemoPacket. Each packet represents a type defined in DemoPacketType.
Most DemoPacket types, once parsed, become plain JavaScript objects containing structured data. However,
some packet types — such as DemoPacketType.DEM_PACKET
, DemoPacketType.DEM_SIGNON_PACKET
, and
DemoPacketType.DEM_FULL_PACKET
— encapsulate an array of inner packets, referred to in this project as MessagePacket. These inner packets
correspond to a message types defined in MessagePacketType.
Similarly, most MessagePacket types also parse into regular data objects. There are two notable exceptions that require additional parsing:
-
Entities (Developier Wiki) -
MessagePacketType.SVC_PACKET_ENTITIES
: contains granular (or full) updates to existing entities (i.e. game world objects). -
String Tables (Developer Wiki) -
MessagePacketType.SVC_CREATE_STRING_TABLE
,MessagePacketType.SVC_UPDATE_STRING_TABLE
,MessagePacketType.SVC_CLEAR_ALL_STRING_TABLES
: granular (or full) updates to existing string tables (see StringTableType).
⚠️ WarningDemo files contain only the minimal data required for visual playback — not all game state information is preserved or available. Additionally, the parser may skip packets it cannot decode.
You can retrieve detailed statistics about parsed and skipped packets by calling
parser.getStats()
.
The parser accepts a readable stream and incrementally parses individual packets from it. It maintains an internal, mutable instance of Demo, which represents the current state of the game. You can access it by calling:
const demo = parser.getDemo();
Note: The parser overwrites the existing state with each tick and does not store past states.
Interceptors are user-defined functions that hook into the parsing process before or after specific stages (called InterceptorStage). They allow to inspect and extract desired data during parsing. Currently, there are three supported stages:
InterceptorStage.DEMO_PACKET
InterceptorStage.MESSAGE_PACKET
InterceptorStage.ENTITY_PACKET
Use the following methods to register hooks:
-
Before the Demo state is affected:
parser.registerPreInterceptor(InterceptorStage.DEMO_PACKET, hookFn);
-
After the Demo state is affected:
parser.registerPostInterceptor(InterceptorStage.DEMO_PACKET, hookFn);
The diagram below provides an example of the parsing timeline, showing when pre and post interceptors are invoked at each stage:
...
PRE DEMO_PACKET
└─ DEM_FILE_HEADER
POST DEMO_PACKET
...
PRE DEMO_PACKET
└─ DEM_SEND_TABLES
POST DEMO_PACKET
...
PRE DEMO_PACKET
└─ DEM_PACKET
├─ PRE MESSAGE_PACKET
│ └─ NET_TICK
└─ POST MESSAGE_PACKET
├─ PRE MESSAGE_PACKET
│ └─ SVC_ENTITIES
│ ├─ PRE ENTITY_PACKET
│ │ └─ ENTITY_1
│ └─ POST ENTITY_PACKET
│ ├─ PRE ENTITY_PACKET
│ │ └─ ENTITY_2
│ └─ POST ENTITY_PACKET
└─ POST MESSAGE_PACKET
POST DEMO_PACKET
...
Each interceptor receives different arguments depending on the InterceptorStage
:
Interceptor Stage | Hook Type | Hook Signature |
---|---|---|
DEMO_PACKET |
pre / post
|
(demoPacket: DemoPacket) => void |
MESSAGE_PACKET |
pre / post
|
(demoPacket: DemoPacket, messagePacket: MessagePacket) => void |
ENTITY_PACKET |
pre / post
|
(demoPacket: DemoPacket, messagePacket: MessagePacket, events: Array<EntityMutationEvent>) => void |
❗ Important
Interceptors hooks are blocking — the internal packet analyzer waits for hooks to complete before moving forward.
import { InterceptorStage, MessagePacketType, Parser, Printer } from 'deadem';
const parser = new Parser();
const printer = new Printer(parser);
let gameTime = null;
// #1: Extraction of current game time
parser.registerPostInterceptor(InterceptorStage.MESSAGE_PACKET, async (demoPacket, messagePacket) => {
if (messagePacket.type === MessagePacketType.NET_TICK) {
const demo = parser.getDemo();
// Ensure server is initialized
if (demo.server === null) {
return;
}
// Current tick
const tick = messagePacket.data.tick;
// Server tick rate
const tickRate = demo.server.tickRate;
// Translating current tick to seconds
gameTime = tick / tickRate;
}
});
const topDamageDealer = {
player: null,
damage: 0
};
// #2 Getting top hero-damage dealer
parser.registerPostInterceptor(InterceptorStage.ENTITY_PACKET, async (demoPacket, messagePacket, events) => {
events.forEach((event) => {
const entity = event.entity;
if (entity.class.name === 'CCitadelPlayerController') {
const data = entity.unpackFlattened();
if (data.m_iHeroDamage > topDamageDealer.damage) {
topDamageDealer.player = data.m_iszPlayerName;
topDamageDealer.damage = data.m_iHeroDamage;
}
}
});
});
await parser.parse(demoReadableStream);
// Printing final stats to the console
printer.printStats();
console.log(`Game finished in [ ${gameTime} ] seconds; top damage dealer is [ ${topDamageDealer.player} ] with [ ${topDamageDealer.damage} ] damage`);
Tested with Deadlock demo files from game build 5637 and below.
- Node.js: v16.17.0 and above.
- Browsers: All modern browsers, including the latest versions of Chrome, Firefox, Safari, Edge.
By default, entities are parsed but not unpacked. Parser performance may vary depending on the number
ofentity.unpackFlattened()
calls.
The table below shows performance results without calling entity.unpackFlattened()
for MacBook Pro with M3 chip:
# | Runtime | Speed, ticks per second | Speed, game seconds per second (tick rate — 64) | Time to parse a 30-minute game, seconds | Max Memory Usage, mb |
---|---|---|---|---|---|
1 | Node.js v22.14.0 | 14 694 ± 0.91% | 229.59 ± 0.91% | ~7.84 | 266.20 ± 4.31% |
2 | Browser Chrome v133.0 | 12 479 ± 0.59% | 194.98 ± 0.59% | ~9.23 | - |
3 | Node.js v16.20.2 | 10 845 ± 0.64% | 169.45 ± 0.64% | ~10.62 | 242.04 ± 5.49% |
4 | Browser Safari v18.3 | 9 794 ± 0.86% | 153.03 ± 0.86% | ~11.76 | - |
5 | Browser Firefox v139 | 5 546 ± 0.62% | 86.66 ± 0.62% | ~20.77 | - |
npm install
npm run proto:json
npm run build
This project is licensed under the MIT License.
This project was inspired by and built upon the work of the following repositories:
- dotabuff/manta - Dotabuff's Dota 2 replay parser in Go.
- saul/demofile-net - CS2 / Deadlock replay parser in C#.
Huge thanks to their authors and contributors!