npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@deademx/engine

v4.0.1

Published

Shared Source 2 demo parsing and replay playback engine for Node.js and browsers

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/engine is 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

npm install deadem
import { 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 resources

How 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 | ServertickRate, 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] state is 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_INFO slots hold values only while a player is connected. Players disconnect after the match ends — by the time parse() 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_TABLE and SVC_UPDATE_STRING_TABLE — encoded entries, decoded internally. For per-entry changes, use StringTableContainer.subscribe.
  • SVC_PACKET_ENTITIES — packed entity changes, decoded internally. For per-entity changes, use InterceptorStage.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 | EntityOperationCREATE, 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_PACKET

Player

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()     → DISPOSED

A 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.

  • breakInterval default (1000) is tuned for Node.js. In a browser, lower it to keep the UI responsive — 100 is 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_PACKET and ~3M InterceptorStage.MESSAGE_PACKET calls — one interceptor invocation each. Heavy work in the InterceptorStage.MESSAGE_PACKET interceptor compounds fast and can tank performance. The same holds for CS2 and Dota 2 demos.

  • Use InterceptorStage.ENTITY_PACKET only when per-entity diffs between ticks are needed. To simply read current state — player positions, health, etc. — use Demo from an InterceptorStage.DEMO_PACKET interceptor 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') over unpackFlattened or iterators. A 500 MB Deadlock demo may produce ~10M EntityMutationEvents carrying ~100M individual field updates — entity state stays in TypedArrays internally, keeping memory compact. Values materialize into JS objects on read. See Entity.getField JSDoc for detailed overhead.

License

MIT