Guides

Parsing Counter-Strike 2 Demos with .NET in Under 500ms

Author Photo

Nick Stambaugh

Share
Thumbnail

This is a guide for using the DemoFile.Net Github source code.

Counter-Strike 2 ships matches as .dem files.

These are binary snapshots of every tick (or server interval), from player input to entity state changes.

Parsing these used to require picking through C++ source code or relying on incomplete Python implementations.

With DemoFile.Net, you can extract data from competitive matches using C# and .NET.

Why This Is Interesting Beyond CS2

Although this guide uses CS2 as the example, the underlying techniques apply broadly to engineering:

  • Parsing large binary event streams with strict ordering and high data density

  • Entity Component System (ECS) state reconstruction from deltas, rather than full snapshots

  • High-throughput file-level parallelism for batch processing at scale

  • Deterministic replay systems for post-hoc analysis and simulation

The same patterns appear in telemetry pipelines, distributed game servers, and other event driven systems.

The Performance Problem

Processing demo files at scale is generally I/O bound.

A typical competitive match is 1-3MB but contains millions of state updates.

On an M1 MacBook Pro, sequential parsing takes around 1.3 seconds per match.

That’s fine for analysis but it is not fine for a platform processing thousands daily.

DemoFile.Net ships with parallel parsing via ReadAllParallelAsync().

Real-world throughput:

  • 500ms per match.

On a 4-core machine processing 50 demos, you’re looking at sustained 400MB/s parsing.

This is aggregate throughput across cores with demos already resident in the OS page cache.

Installation

dotnet add package DemoFile.Game.Cs

Targets .NET 6+.

Works on Windows, macOS, and Linux.

Basic Event Streaming

The library exposes game events as strongly-typed C# objects.

Here’s subscribing to kills:

using DemoFile;
using DemoFile.Game.Cs;

var demo = new CsDemoParser();

demo.Source1GameEvents.PlayerDeath += e =>
{
    var attacker = e.Attacker?.PlayerName ?? "unknown";
    var victim = e.Player?.PlayerName ?? "unknown";
    var weapon = e.Weapon ?? "unknown";
    
    Console.WriteLine($"{attacker} ({weapon}) -> {victim}");
};

using var fs = File.OpenRead("match.dem");
var reader = DemoFileReader.Create(demo, fs);
await reader.ReadAllAsync();

Run this against a 64-tick demo and you get every kill logged.

Output looks like:

device (awp) -> aimers
aimers (ak47) -> device
frozen (glock18) -> degster

Tracking Spatial Data

CS2 stores player state in entity components.

Each tick update includes position, velocity, and animation state.

You can hook TickEnd to sample this data:

var tickData = new List<(int tick, string player, float x, float y, float z)>();

demo.TickEnd += (_, tick) =>
{
    foreach (var player in demo.Entities.Players)
    {
        if (player.Pawn?.CBodyComponent is { } body)
        {
            var pos = body.Position;
            tickData.Add((
                tick: demo.CurrentTick,
                player: player.PlayerName ?? "bot",
                x: pos.X,
                y: pos.Y,
                z: pos.Z
            ));
        }
    }
};

using var fs = File.OpenRead("match.dem");
var reader = DemoFileReader.Create(demo, fs);
await reader.ReadAllAsync();

Console.WriteLine($"Captured {tickData.Count} position samples");

From here, you can compute heatmaps, detect unusual patterns (players in walls), or measure distance traveled per round.

Grenade Tracking

Utility usage drives rounds. These entities behave like any ECS object in a real-time simulation.

CS2 grenades are entities too:

demo.TickEnd += (_, tick) =>
{
    foreach (var entity in demo.Entities.All.OfType<CBaseGrenade>())
    {
        if (entity.CBodyComponent?.Position is { } pos)
        {
            var type = entity.GetType().Name;
            Console.WriteLine($"[{demo.CurrentTick}] {type} at ({pos.X:F1}, {pos.Y:F1}, {pos.Z:F1})");
        }
    }
};

Pair this with player positions to see utility deployment patterns or detect smokes that landed in unexpected areas.

Parallel Parsing for Batch Processing

When you need to process 100+ demos, parallelize at the file level, not within a single demo:

var demoFiles = Directory.GetFiles("./demos", "*.dem");

var options = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount };
var results = new ConcurrentBag<DemoStats>();

Parallel.ForEach(demoFiles, options, demoPath =>
{
    try
    {
        var demo = new CsDemoParser();
        var kills = 0;
        var deaths = 0;

        demo.Source1GameEvents.PlayerDeath += e =>
        {
            if (e.Attacker?.SteamId == 0) deaths++;
            else kills++;
        };

        using var fs = File.OpenRead(demoPath);
        var reader = DemoFileReader.Create(demo, fs);
        reader.ReadAllAsync().Wait();

        results.Add(new DemoStats
        {
            File = Path.GetFileName(demoPath),
            Kills = kills,
            Deaths = deaths,
            Ticks = demo.CurrentTick
        });
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error parsing {demoPath}: {ex.Message}");
    }
});

foreach (var result in results)
{
    Console.WriteLine($"{result.File}: {result.Kills} kills, {result.Deaths} deaths");
}

record DemoStats(string File, int Kills, int Deaths, int Ticks);

On a 4-core machine, expect 2000+ demos/hour throughput.

Measuring Mechanical Changes

Valve updates CS2 regularly through movement changes, spray patterns, utility costs.

These are all visible in demo data.

After the January 2025 velocity update, you could measure jump stamina decay:

var velocityByHeight = new Dictionary<int, List<float>>();

demo.TickEnd += (_, tick) =>
{
    foreach (var player in demo.Entities.Players)
    {
        if (player.Pawn?.CBodyComponent is { } body)
        {
            var zPos = (int)body.Position.Z;
            var zVel = body.AbsVelocity.Z;

            if (!velocityByHeight.ContainsKey(zPos))
                velocityByHeight[zPos] = new();
            
            velocityByHeight[zPos].Add(zVel);
        }
    }
};

using var fs = File.OpenRead("match.dem");
var reader = DemoFileReader.Create(demo, fs);
await reader.ReadAllAsync();

// Calculate average downward velocity per height
foreach (var (height, velocities) in velocityByHeight.OrderBy(x => x.Key))
{
    var avgVel = velocities.Average(v => Math.Abs(v));
    Console.WriteLine($"Height {height}m: avg velocity {avgVel:F2} u/s");
}

This gives you empirical proof of how movement mechanics work.

Common Pitfalls

Null safety.

Entities are created and destroyed mid-demo. Always check for null:

if (player.Pawn?.CBodyComponent?.Position is { } pos)
{
    // Safe to use pos
}

Tick timing.

Not all ticks fire all events. TickEnd fires for most ticks, but some events happen between ticks. If you need precise timing, cross-reference with the timestamp in the event itself.

Memory in loops.

Allocating strings or lists inside TickEnd will cause GC pressure. Use object pools or ValueTuple where possible:

// Bad
demo.TickEnd += _ =>
{
    var names = demo.Entities.Players.Select(p => p.PlayerName).ToList();
};

// Good
demo.TickEnd += _ =>
{
    foreach (var player in demo.Entities.Players)
    {
        var name = player.PlayerName;
        // Use name
    }
};

What You Get

  • Tick-by-tick state reconstruction
  • Kill/death/bomb events with weapons and positions
  • Grenade trajectories
  • Player equipment and economy
  • Map-specific entity data
  • Sub-millisecond parsing per demo at scale

This is enough to build ranking systems, coaching tools, cheat detection pipelines, or just understand how pros play the game.

#cs2#dotnet#performance#game-analysis#demofile
Author Photo

About Nick Stambaugh

Nick Stambaugh has been following pro Counter-Strike since 2009. He witnessed the franchise transform from a grassroots scene to a global esports phenomenon. Nick provides high-level analysis on tactics and trends.