Skip to content

Commit 9542c6d

Browse files
authored
Merge pull request #39 from healeycodes/post/demo-player
Add demo player post
2 parents 0e6bcce + 541a570 commit 9542c6d

15 files changed

Lines changed: 301 additions & 6 deletions

β€Žcomponents/image.tsxβ€Ž

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ export default function SpacedImage(props) {
1919
let { width, height } = imageResize(originalWidth, originalHeight);
2020

2121
return (
22-
<div className="spacer">
22+
<span className="spacer">
2323
<Image {...rest} width={width} height={height} />
2424
<style jsx>{`
2525
.spacer {
2626
padding-top: 16px;
2727
padding-bottom: 16px;
2828
}
2929
`}</style>
30-
</div>
30+
</span>
3131
);
3232
}

β€Ždata/posts.tsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const popularPosts = [
77

88
// Good posts/highly viewed posts (not in any specific order)
99
export const postStars = [
10+
"rendering-counter-strike-demos-in-the-browser",
1011
"running-untrusted-python-code",
1112
"my-own-python-web-framework",
1213
"sandboxing-javascript-code",

β€Žpackage-lock.jsonβ€Ž

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žpackage.jsonβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"image-size": "^1.0.2",
2222
"js-cookie": "^3.0.1",
2323
"markdown-to-jsx": "^7.1.8",
24+
"mp4box": "^0.5.2",
2425
"ms": "^2.1.3",
2526
"next": "^13.1.3",
2627
"prism-react-renderer": "^1.3.5",

β€Žpages/[id].tsxβ€Ž

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from "fs";
22
import path from "path";
33
import imageSize from "image-size";
4+
import mp4box from 'mp4box';
45

56
import React from "react";
67
import Link from "next/link";
@@ -14,6 +15,8 @@ import Code from "../components/code";
1415
import Date from "../components/date";
1516
import Newsletter from "../components/newsletter";
1617

18+
const isVideo = /\.mp4$/;
19+
1720
export async function getStaticPaths() {
1821
const paths = getAllPostIds();
1922
return {
@@ -27,12 +30,36 @@ export async function getStaticProps({ params }) {
2730

2831
const postImages = path.join(process.cwd(), "public", "posts", params.id);
2932
const imageMetadata = {};
33+
const videoMetadata = {};
3034

31-
if (fs.existsSync(postImages)) {
32-
const imageFiles = fs.readdirSync(postImages);
33-
imageFiles.forEach((image) => {
34-
imageMetadata[image] = { ...imageSize(path.join(postImages, image)) };
35+
const getVideoDimensions = async (filePath): Promise<{ width: number, height: number }> => {
36+
return new Promise((resolve, reject) => {
37+
const mp4boxFile = mp4box.createFile();
38+
mp4boxFile.onReady = (info) => {
39+
resolve({ width: info.tracks[0].video.width, height: info.tracks[0].video.height });
40+
};
41+
fs.readFile(filePath, (err, data) => {
42+
if (err) { throw err };
43+
const buf = new Uint8Array(data).buffer
44+
// @ts-ignore
45+
buf.fileStart = 0
46+
mp4boxFile.appendBuffer(buf);
47+
mp4boxFile.flush();
48+
});
3549
});
50+
};
51+
52+
if (fs.existsSync(postImages)) {
53+
const files = fs.readdirSync(postImages);
54+
files
55+
.filter(fp => !fp.match(isVideo))
56+
.forEach((image) => {
57+
imageMetadata[image] = { ...imageSize(path.join(postImages, image)) };
58+
});
59+
60+
for (const fp of files.filter(fp => fp.match(isVideo))) {
61+
videoMetadata[fp] = { ...(await getVideoDimensions(path.join(postImages, fp))) }
62+
}
3663
}
3764

3865
let prevPost = null;
@@ -56,6 +83,7 @@ export async function getStaticProps({ params }) {
5683
id: params.id,
5784
source: postData.content,
5885
imageMetadata,
86+
videoMetadata,
5987
...postData,
6088
prevPost,
6189
nextPost,
@@ -71,6 +99,7 @@ export default function Post({
7199
date,
72100
outdated,
73101
imageMetadata,
102+
videoMetadata,
74103
source,
75104
prevPost,
76105
nextPost,
@@ -93,6 +122,18 @@ export default function Post({
93122
options={{
94123
createElement(type, props, children) {
95124
if (type === "img") {
125+
if (props.src.match(isVideo)) {
126+
return <video style={{ display: 'block', margin: '0 auto' }}
127+
key={props.src}
128+
controls
129+
autoPlay
130+
loop
131+
muted
132+
width={videoMetadata[props.src].width}
133+
height={videoMetadata[props.src].height}>
134+
<source src={`/posts/${id}/${props.src}`} type="video/mp4"></source>
135+
</video>
136+
}
96137
return (
97138
<SpacedImage
98139
{...props} // @ts-ignore
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
---
2+
title: "Rendering Counter-Strike Demos in the Browser"
3+
date: "2023-11-20"
4+
tags: ["javascript"]
5+
description: "Building a demo player for coaches and analysts to visualize CS2 gameplay."
6+
---
7+
8+
Counter-Strike 2 (CS2) demos are recorded gameplay files that capture every player's movement, actions, and the overall game state. They're the [flight recorder](https://en.wikipedia.org/wiki/Flight_recorder) of a match, storing the raw data so that it can be watched later on.
9+
10+
Professional players and analysts use these demos to review their team's performance β€” and they also spend time watching upcoming opponents to counter strategize. Demos can be watched by loading them in CS2. They can also be parsed using open source libraries like [demoinfocs-golang](https://github.com/markus-wa/demoinfocs-golang) or [awpy](https://github.com/pnxenopoulos/awpy). There is a range of tools that can be used to watch demos in the browser from a top-down 2D view.
11+
12+
Here's Starcaller's in-browser demo player:
13+
14+
![Starcaller's user interface during playback. Player positions, in-flight grenades, heath, weapons, etc.](starcaller.png)
15+
16+
Raw demo data is used to render player equipment and positions, smoke grenades paths and effects, gun shots, and other strategically relevant data. There is also an option to overlay multiple rounds into a single view. This can be used to view the aggregate behavior of certain teams or players during specific types of rounds (e.g. buy rounds).
17+
18+
Using Chrome's network tools, I saw that each round is a ~5MB file containing pre-processed demo data. Starcaller's player is written in a JavaScript game engine called Phaser which consumes this data.
19+
20+
![The keys for Starcaller's round object; frames, positions, weapons, etc.](starcaller-json.png)
21+
22+
I learned about Starcaller when my friend, a professional CS2 coach, was showing me his workflow as he prepared for an upcoming match. He clicked through UI very quickly, filtering his next opponent's matches (official demo files are public) and scanned through different rounds to point out player behaviors and how they might become part of his strategy for an upcoming match. He showed me how he distils this research into a document to be discussed with the rest of his team, with notes and links to certain game rounds in Starcaller to highlight his ideas.
23+
24+
These demo analysis tools are a tactical edge that top gaming organizations pay thousands-per-month for. They are not the most polished pieces of software but that's not really the point. The userbase puts up with a clunky UI because they're functional tools that produce real insights.
25+
26+
As a CS2 fan, I was curious how demos worked on a technical level, and the steps required to process and visualize the data. I set out to build a prototype demo player to feature-match Starcaller's (not including the aggregate views, demo library, filter options, etc.).
27+
28+
## Parsing a Demo
29+
30+
Uncompressed demo files are quite large and depending on match length can be up to 500MB. These files contain *far* more data than is required to render a top-down 2D view of match. With 64 ticks per second and a range of game entities (players, weapons, world objects), it's not a file format that's designed for compactness.
31+
32+
While there are experimental tools that use WebAssembly to parse demo files within a browser, the download time of a demo file (and the long processing time due to the WebAssembly overhead) excludes this method as a viable option.
33+
34+
Demos must be pre-processed into a schema that can be consumed by an in-browser player β€” consisting of a mixture of granular position data and other game events and metadata. World positions need to be scaled to a map image β€” demoinfocs-golang has a [code example](https://github.com/markus-wa/demoinfocs-golang/blob/fd1a1f6d5f8ae7c27d5a200b344ec4272e38d3eb/examples/map_metadata.go) for using map metadata to scale a world position. That example hasn't been updated for CS2 yet so I had to use a program called Source 2 Viewer to decompile map images and map metadata from CS2's game files.
35+
36+
![A screenshot of Source 2 Viewer. Showing a list of maps and their metadata files.](source-2-viewer.png)
37+
38+
Software libraries like demoinfocs-golang allow you to parse a demo frame-by-frame and register callbacks on game events. I had to use both methods. By reading each frame, you can get player positions and other status information (health, currently equipped weapon, etc.). For key game events like nade throws, gunshots, bomb plants, etc., you can use callbacks like this:
39+
40+
```go
41+
p.RegisterEventHandler(func(e events.GrenadeProjectileDestroy) {
42+
id := e.Projectile.UniqueID()
43+
44+
var team common.Team
45+
if e.Projectile.Thrower != nil {
46+
team = e.Projectile.Thrower.Team
47+
}
48+
49+
if nadeTrajectories[id] == nil {
50+
nadeTrajectories[id] = &nadePath{
51+
wep: e.Projectile.WeaponInstance.Type,
52+
team: team,
53+
}
54+
}
55+
56+
nadeTrajectories[id].path = e.Projectile.Trajectory
57+
})
58+
```
59+
60+
I reverse engineered a few demo players and copied their data model; a game round object has a list of position data, and other game events. Visual playback is produced as a timer increments and a different slice of position and status data is read. The number of other game events is bounded so they can be looped and checked to see if they should also be rendered.
61+
62+
My demo parser is Go program that takes a demo file path (processing a demo file takes ~15sec on my desktop PC) and produces JSON files for a Next.js app. Information needs to be thrown away in order to keep the JSON files from being too large. Instead of using every available position update (64 per seccond), I read a slice of player data every ~200ms.
63+
64+
This time granularity of ~200ms is enough to visualize the required strategic information about player positions and behavior. However, rendering player movement only five times per second is quite stuttery.
65+
66+
![Player positions as they leave spawn on de_inferno.](position_stutter.mp4)
67+
68+
This can be fixed with [linear interpolation](https://en.wikipedia.org/wiki/Linear_interpolation) aka lerp. In my demo player, SVGs are drawn over an image of a decompiled radar map. A player is made up of a few elements:
69+
70+
```jsx
71+
<svg key={player.SteamID64}>
72+
{/* Not included here: bullet tracers, player name, player-is-flashed effect */}
73+
74+
{/* Place them over the map in their current position */}
75+
<g transform={`translate(${x}, ${y}) rotate(${dir}, 0, 0)`}>
76+
{/* Use the corner of a reactangle to indicate the player's direction */}
77+
<rect transform={`rotate(225)`} width={0.9} height={0.9} fill='white' />
78+
<circle r="0.9" fill={sideColor(player.side)} />
79+
</g>
80+
</svg>
81+
```
82+
83+
The `x`, `y`, and `dir` values here are *lerped* between the previous player position and the next position. A time value `t`(in the range `0` to `1`) is used to move between a previous value and a future value.
84+
85+
```tsx
86+
const x = lerp(prevPlayer.pos.x, player.pos.x, t)
87+
const y = lerp(prevPlayer.pos.y, player.pos.y, t)
88+
const dir = angleLerp(prevPlayer.dir, player.dir, t)
89+
```
90+
91+
There is a little more complexity involved when lerping between two angles because it's important to show the shortest path between two rotation values (otherwise the player will rotate 359 degrees when they pass over zero instead of the most-likely scenario that they rotated two degrees).
92+
93+
```tsx
94+
function lerp(from: number, to: number, t: number) {
95+
t = Math.max(0, Math.min(1, t));
96+
return from + (to - from) * t;
97+
}
98+
99+
function angleLerp(a: number, b: number, t: number): number {
100+
a = normalizeAngle(a);
101+
b = normalizeAngle(b);
102+
const delta = ((b - a + 540) % 360) - 180;
103+
const interpolatedAngle = a + delta * t;
104+
return normalizeAngle(interpolatedAngle);
105+
}
106+
```
107+
108+
Armed with a few lerp functions, player movement becomes as smooth as the user's screen's refresh rate.
109+
110+
![Player positions as they leave spawn on de_inferno (but smoother).](position_smooth.mp4)
111+
112+
## Rendering Grenades
113+
114+
A critical aspect of CS2 strategy is the timing and placement of grenades. Both your team's grenades and the opposing team's grenades. Knowing where to hide to counteract the opponents flashes, when to throw molotovs to stop a bombsite rush, what certain smoke timings mean, etc.
115+
116+
Demoinfocs-golang provides a `GrenadeProjectile` which has a `TrajectoryEntry` list containing the world positions, frame ids, and timing of a grenade's trajectory entries. Like player positions, these coordinates must be scaled to the map image.
117+
118+
In my demo player, as the playback clock passes each trajectory entry's time, a line is drawn from the nade's origin to the next point (and from the next point to the next, and so on). Additionally, an SVG of the grenade (decompiled from CS2's source files) is placed at the furthest point.
119+
120+
However, much like the 200ms stuttering problem of rendering player positions, there is an additional step required. A naive implementation will make it look like the grenades are teleporting between each trajectory entry (recorded at throw, bounce, and destroy events).
121+
122+
![A cluster of grenades landing at mid on de_inferno.](nade_path_stutter.mp4)
123+
124+
It needs to be lerped! By working out the progress of time between two trajectory entries, we can draw a partial line that smoothly grows to roughly match the grenade's flight. The parabolic nature of CS2 grenades means that the speed between each point is not exactly linear but from a strategic POV this is irrelevant.
125+
126+
```tsx
127+
// Get the progress between start and end in the range 0-1
128+
function progress(start: number, end: number, current: number) {
129+
const percent = (current - start) / (end - start);
130+
return Math.min(Math.max(percent, 0), 1);
131+
}
132+
133+
const t = progress(lastTrajectory.time, nextTrajectory.time, currentTime);
134+
const x = lerp(lastTrajectory.pos.x, nextTrajectory.pos.x, t)
135+
const y = lerp(lastTrajectory.pos.y, nextTrajectory.pos.y, t)
136+
137+
// And then, roughly, do this
138+
// for each nade trajectory:
139+
<svg key={`${nade.id}-${i}`} >
140+
<g>
141+
{/* A line between each trajectory aka every throw/bounce/destroy event */}
142+
<line
143+
x1={lastTrajectory.pos.x}
144+
y1={lastTrajectory.pos.y}
145+
x2={x}
146+
y2={y}
147+
style={{ stroke: nadePathColorForSide(nade.side), strokeWidth: 0.18, strokeDasharray: 0.5 }}
148+
/>
149+
150+
{/* Icons are consistently sized so these offsets are fixed */}
151+
{weaponIcon(icon, `translate(${x - 0.60}, ${y - 1}) scale(0.06)`, 'rgba(255, 255, 255, 0.9)')}
152+
</g>
153+
</svg>
154+
155+
// When a nade is destroyed render the flash, smoke, fire effects
156+
// ..
157+
```
158+
159+
The lines now more accurately visualize the realtime grenade paths.
160+
161+
![A cluster of grenades landing at mid on de_inferno (but smoother).](nade_path_smooth.mp4)
162+
163+
164+
## Framerate
165+
166+
Tools for professional gamers need to be fast. In fact, this is the first feedback I received when I shared my prototype. My demo player had smooth playback ... but they didn't "play" it. They scanned through gameplay rounds by dragging the slider β€” which caused my player to stutter because I hadn't optimized for that use case yet!
167+
168+
Through trial and error, I ended up with this general structure in my highest level component. A `setInterval` every 200ms advances through frames containing position information, and another `setInterval` every 2ms applies lerp and other smoothing effects to this position data, and handles other current game events (like nades in flight). React makes draw calls as often as this second interval.
169+
170+
```tsx
171+
// Increment through player data slices (aka frames)
172+
useEffect(() => {
173+
// Calculate round time for display, set it
174+
// ..
175+
// Set player status for non-map UI
176+
// ..
177+
178+
const intervalId = setInterval(() => {
179+
// Increment frame index, set it
180+
setFrameIndex(frameIndex => frameIndex + 1)
181+
}, FRAME_TIME) // 200ms
182+
183+
return () => clearInterval(intervalId);
184+
}, []);
185+
186+
// Increment render time
187+
useEffect(() => {
188+
const intervalId = setInterval(() => {
189+
// The render logic section further down takes ~1ms
190+
// so this interval can be set to run _very_ often
191+
// to support a fast framerate during slider scanning
192+
setRenderTime(Date.now())
193+
}, RENDER_TIME) // 2ms
194+
195+
return () => clearInterval(intervalId);
196+
}, []);
197+
198+
// Render logic
199+
useEffect(() => {
200+
const curFrame = curRound.Frames[frameIndex]
201+
const players: React.JSX.Element[] = []
202+
203+
// Our time value, used for all the lerping
204+
const t = progress(lastTick, lastTick + FRAME_TIME, Date.now())
205+
206+
// Loop player positions, push groups of SVGs onto `players`
207+
// ..
208+
209+
// Generate non-player objects like nades/nade paths/etc.
210+
// by looping over all of that data (remember, it's bounded in size)
211+
// ..
212+
213+
setSVGItems(players)
214+
}, [renderTime]);
215+
216+
return (
217+
<div>
218+
{/* Draw map image */}
219+
{SVGItems}
220+
{/* Draw other UI */}
221+
</div>
222+
)
223+
```
224+
225+
## UI
226+
227+
The user interface of my player is functional and received rave reviews like "that works" and "not easy on the eyes but contains all the information I need" β€” which is exactly the kind of thing I was hoping for my prototype.
228+
229+
Player sprites have a team color, a direction, a current weapon, and whether they are the bomb carrier. When they are flashed, their circle is shaded white and then fades as they become unflashed. The icons are more decompiled game files. I had to create the mapping from demoinfocs-golang entity names to CS2 internal names by hand (this was a little tedious).
230+
231+
![My demo player's full UI.](player-ui.png)
232+
233+
You can try out [the demo player](https://demo-player-prototype-q21yqpl5t-healeycodes.vercel.app/) but be warned that it doesn't really work on mobile yet (I haven't found a customer of these kinds of tools who uses them on mobile yet, and my demo player is just a prototype for now).
234+
235+
A while ago, I traded serious amounts of gaming for serious amounts of coding. There's always room for gaming in my heart and this project has been fun to iterate on, it took me an average of ~1.5hrs an evening for two weeks to go from drawing simple player paths on an image to a feature-complete demo player (albeit missing the capability to upload your own demos, etc.)
236+
237+
I'll repeat my kudos for [markus-wa](https://github.com/markus-wa) that I posted in demoinfocs-golang's Discord. It's a delightful open source library.
238+
239+
![Thanking Markus for maintaining the library in Discord, and a screenshot of my early line-drawing experiment.](discord.png)
240+
241+
Are you a software engineer working on game analysis tools? I'd love to talk shop!
67.9 KB
Loading
Binary file not shown.
Binary file not shown.
180 KB
Loading

0 commit comments

Comments
Β (0)