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

v3.1.1

Published

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

Downloads

786

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.
  • API reference Summary of all public exports and key methods.
  • Usage Common usage patterns for replay parsing, broadcast parsing, and playback.
  • 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_PACKET
  • DEM_SIGNON_PACKET
  • DEM_FULL_PACKET

Two MessagePacketType categories require additional decoding and drive the internal game state:

  • EntitiesSVC_PACKET_ENTITIES contains creates, updates, and deletes.
  • String tablesSVC_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() (or player.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] Player does not support parserThreads > 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_INFO
  • SVC_CREATE_STRING_TABLE
  • SVC_UPDATE_STRING_TABLE
  • SVC_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.