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