Counter-Strike: Global Offensive Broadcast
January 2024
Counter-Strike: Global Offensive Broadcast is an alternative distribution method for GOTV spectators. It is based on HTTP as the transport protocol and implementations must follow a set of defined standards. It is built around 3-second "fragments" that contain GOTV data for the client to consume. Each fragment is identified by a number unique to the broadcast and increments sequentially.
Contents
Enabling Broadcasting in srcds
Enabling broadcasting on the server is as simple as setting a few cvars:
tv_broadcast_url - the base endpoint address to which data will be sent. Example:
tv_broadcast_url "http://gotv-ingest.example.com"
tv_broadcast_origin_auth - arbitrary string that is sent with requests to the broadcast URL in a header named "X-Origin-Auth"
tv_broadcast_origin_auth "SuperSecureStringDoNotShare"
tv_broadcast - enable or disable broadcasting (0 or 1)
tv_broadcast 1
Receiving Gameplay Data from the Server
To receive data from the gameserver, an API will need to listen on the following URLs:
/{token}/{fragment_number}/start /{token}/{fragment_number}/full?tick=1882 /{token}/{fragment_number}/delta?endtick=2144
The token is part of all requests from the gameserver to the URL endpoints and contains both the Steam ID of the gameserver and a "master cookie" to disambiguate requests. It is in the format of:
sSTEAMIDtMASTERCOOKIE
For example:
s845489096165654t8799308478907
START request
When broadcasting is enabled on the server, the gameserver will first send a /start request to the endpoint. The query string will contain information about the broadcast that must be persisted. In addition, the content of the request should be persisted so that it can be made available to clients. The query string for the request has the following parameters:
- tick - the starting tick of the broadcast
- tps - the tickrate of the GOTV broadcast
- map - the name of the map
- protocol - the protocol version (currently 4)
So in our example, the following URL would be hit when the broadcast starts:
http://gotv-ingest.example.com/s845489096165654t8799308478907/1/start
(where 1 is the fragment number).
FULL request
Ex: http://gotv-ingest.example.com/s845489096165654t8799308478907/2/full?tick=1882
Contains a full snapshot for clients. Content is binary data and should be persisted. Tick number and fragment number for the fragment should be persisted. Note: if a /full or /delta request is received for a broadcast that has not received a START request, you MUST respond with HTTP status code 205 (Reset Content). This will cause the game server to retransmit a "start" request. Otherwise, you SHOULD return a HTTP 200.
DELTA request
Ex: http://gotv-ingest.example.com/s845489096165654t8799308478907/2/delta
Contains a delta snapshot for clients. Content is binary data and should be persisted. Note: if a /full or /delta request is received for a broadcast that has not received a START request, you MUST respond with HTTP status code 205 (Reset Content). This will cause the game server to retransmit a "start" request. Otherwise, you SHOULD return a HTTP 200.
Playback in the Client
To start playback clients have to issue the playcast command with the "base URL" for your API.
For example:
playcast "http://gotv-cdn.example.com/match/s845489096165654t8799308478907"
Note: there is no technical limitation requiring the SteamID and Master Cookie to be present in the URL passed to the client. Your implementation may vary.
Once executed, the CS:GO client will issue a series of web requests to initialize and stream the playback of content. To provide GOTV data to CS:GO clients, the following endpoints need to be defined:
/sync /{fragment_number}/start /{fragment_number}/full /{fragment_number}/delta
SYNC
Tells the CS:GO client where to start playback for the broadcast. The endpoint should select a FULL payload to use as the first fragment to send. Endpoint MUST return a JSON document containing the following fields:
- tick - the tick number corresponding to the FULL fragment to be used
- rtdelay - the number of seconds since the select FULL fragment has been received
- rcvage - the number of seconds since the server received the latest FULL fragment
- fragment - the fragment number of the FULL fragment used
- signup_fragment - the fragment number for the START request of the broadcast
- tps - the ticks per second (as specified in the START request)
- protocol - the protocol version (as specified in the START request)
- token_redirect - (optional) a redirect from a generic playcast URL to a match-specific URL to bypass old CDN caches, see below
Any FULL fragment can be used as the starting fragment. If you experience buffering in CS:GO while testing playback, you may want to select a fragment further back in the broadcast as the starting fragment (for example, N-4, where N is the last FULL fragment number received).
If using a CDN, this should only be cached for a short period of time (such as 5 seconds) so that clients that connect later are reasonably in-sync with other viewers.
Example document:
{ tick: 139527, rtdelay: 1, rcvage: 1, fragment: 2, signup_fragment: 0, tps: 128, protocol: 4 }
Optional, when using token_redirect field, for example:
{ tick: 139527, rtdelay: 1, rcvage: 1, fragment: 2, signup_fragment: 0, tps: 128, protocol: 4, token_redirect: 'match13-sharks-vs-eagles' }
Token_redirect allows the organizers to publish a generic URL for playcast, for example "http://cstv.event.com/main_stream", but then redirect every viewer to a specific live match with a unique per-match token, e.g. in the case above if /sync returns "token_redirect = match13-sharks-vs-eagles" then all subsequent client requests will go to "http://cstv.event.com/main_stream/match13-sharks-vs-eagles". This way any CDN content cached for /full and /delta fragments from the previous match will not be served to clients who try to playback the current live match.
START
Ex: http://gotv-cdn.example.com/match/s845489096165654t8799308478907/1/start
This should return the content provided to the START endpoint by the game server for the same fragment number. If using a CDN, this can be cached indefinitely.
FULL
Ex: http://gotv-cdn.example.com/match/s845489096165654t8799308478907/2/full
This should return the content provided to the FULL endpoint by the game server for the same fragment number. If using a CDN, this can be cached indefinitely.
DELTA
Ex: http://gotv-cdn.example.com/match/s845489096165654t8799308478907/2/delta
This should return the content provided to the DELTA endpoint by the game server for the same fragment number. If using a CDN, this can be cached indefinitely.
Reference Playcast Webserver Implementation
playcast_webserver_impl.js
This is a reference implementation of a playcast webserver. It is not suitable for use in production without additional work to tailor the implementation to your production environment, to enforce proper security around match data uploads, CDN integration, proper CDN configuration for HTTP status codes caching rules, proper CDN integration with your origin webserver, and numerous other work items. This implementation is intended to provide a reference vanilla environment that other developers can use either as a starting point for their development, or to experiment with small incremental changes primarily to help recreate and report any issues or feature requests around the playcast systems. Having easy steps to reproduce any issues in an environment close to vanilla game servers, vanilla game clients, and vanilla reference playcast webserver, is the best way to help Valve diagnose and address those issues or get feature requests implemented.
1 //
2 // This is a reference implementation of a playcast webserver.
3 // It is not suitable for use in production without additional work to
4 // tailor the implementation to your production environment, to enforce
5 // proper security around match data uploads, CDN integration, proper
6 // CDN configuration for HTTP status codes caching rules, proper CDN
7 // integration with your origin webserver, and numerous other work items.
8 //
9 // This implementation is intended to provide a reference vanilla environment
10 // that other developers can use either as a starting point for their development,
11 // or to experiment with small incremental changes primarily to help recreate and
12 // report any issues or feature requests around the playcast systems.
13 // Having easy steps to reproduce any issues in an environment close to vanilla game
14 // servers, vanilla game clients, and vanilla reference playcast webserver, is the
15 // best way to help Valve diagnose and address those issues or get feature requests
16 // implemented.
17 //
18
19 var http = require( 'http' );
20 var zlib = require( 'zlib' );
21 var url = require( 'url' );
22
23 "use strict";
24 var port = 8080;
25
26 // In-memory storage of all match broadcast fragments, metadata, etc.
27 // Can easily hold many matches in-memory on modern computers, especially with compression
28 var match_broadcasts = {};
29
30 var stats = { // Various stats that developers might want to track in their production environments
31 post_field: 0, get_field: 0, get_start: 0, get_frag_meta: 0,
32 sync: 0, not_found: 0, new_match_broadcasts: 0,
33 err: [ 0, 0, 0, 0 ],
34 requests: 0, started: Date.now(), version: 1
35 };
36
37
38 function respondSimpleError( uri, response, code, explanation )
39 {
40 // if( uri ) console.log( uri + " => " + code + " " + explanation );
41 response.writeHead( code, { 'X-Reason': explanation } );
42 response.end();
43 }
44
45 function checkFragmentCdnDelayElapsed( fragmentRec )
46 {
47 // Validate that any injected CDN delay has elapsed
48 if ( fragmentRec.cdndelay )
49 {
50 if ( !fragmentRec.timestamp )
51 {
52 console.log( "Refusing to serve cdndelay " + field + " without timestamp" );
53 return false;
54 }
55 else
56 {
57 var iusElapsedLiveMilliseconds = Date.now().valueOf() - ( fragmentRec.cdndelay + fragmentRec.timestamp.valueOf() );
58 if ( iusElapsedLiveMilliseconds < 0 )
59 {
60 console.log( "Refusing to serve cdndelay " + field + " due to " + iusElapsedLiveMilliseconds + " ms of delay remaining" );
61 return false; // refuse to serve the blob due to artificial CDN delay
62 }
63 }
64 }
65 return true;
66 }
67
68 function isSyncReady( f )
69 {
70 return f != null && typeof ( f ) == "object" && f.full != null && f.delta != null && f.tick != null && f.endtick != null
71 && f.timestamp && checkFragmentCdnDelayElapsed( f );
72 }
73
74 function getMatchBroadcastEndTick( broadcasted_match )
75 {
76 for ( var f = broadcasted_match.length - 1; f >= 0; f-- )
77 {
78 if ( broadcasted_match[ f ].endtick )
79 return broadcasted_match[ f ].endtick;
80 }
81 return 0;
82 }
83
84 function respondMatchBroadcastSync( param, response, broadcasted_match )
85 {
86 var nowMs = Date.now();
87 response.setHeader( 'Cache-Control', 'public, max-age=3' );
88 response.setHeader( 'Expires', new Date( nowMs + 3000 ).toUTCString() ); // whatever we find out, this information is going to be stale 3-5 seconds from now
89 // TODO: if you use this reference script in production (which you should not), make sure you set all the necessary headers for your CDN to relay the expiration headers to PoPs and clients
90
91 var match_field_0 = broadcasted_match[ 0 ];
92 if ( match_field_0 != null && match_field_0.start != null )
93 {
94 var fragment = param.query.fragment, frag = null;
95
96 if ( fragment == null )
97 {
98 // skip the last 3-4 fragments, to let the front-running clients get 404, and CDN wait for 3+ seconds, and re-try that fragment again
99 // then go back another 3 fragments that are the buffer size for the client - we want to have the full 3 fragments ahead of whatever the user is streaming for the smooth experience
100 // if we don't, then legit in-sync clients will often hit CDN-cached 404 on buffered fragments
101 fragment = Math.max( 0, broadcasted_match.length - 8 );
102
103 if ( fragment >= 0 && fragment >= match_field_0.signup_fragment )
104 {
105 // can't serve anything before the start fragment
106 var f = broadcasted_match[ fragment ];
107 if ( isSyncReady( f ) )
108 frag = f;
109 }
110 }
111 else
112 {
113 if ( fragment < match_field_0.signup_fragment )
114 fragment = match_field_0.signup_fragment;
115
116 for ( ; fragment < broadcasted_match.length; fragment++ )
117 {
118 var f = broadcasted_match[ fragment ];
119 if ( isSyncReady( f ) )
120 {
121 frag = f;
122 break;
123 }
124 }
125 }
126
127 if ( frag )
128 {
129 console.log( "Sync fragment " + fragment );
130 // found the fragment that we want to send out
131 response.writeHead( 200, { "Content-Type": "application/json" } );
132 if ( match_field_0.protocol == null )
133 match_field_0.protocol = 5; // Source2 protocol: 5
134
135 response.end( JSON.stringify( {
136 tick: frag.tick,
137 endtick: frag.endtick,
138 maxtick: getMatchBroadcastEndTick( broadcasted_match ),
139 rtdelay: ( nowMs - frag.timestamp ) / 1000, // delay of this fragment from real-time, in seconds
140 rcvage: ( nowMs - broadcasted_match[ broadcasted_match.length - 1 ].timestamp ) / 1000, // Receive age: how many seconds since relay last received data from game server
141 fragment: fragment,
142 signup_fragment: match_field_0.signup_fragment,
143 tps: match_field_0.tps,
144 keyframe_interval: match_field_0.keyframe_interval,
145 map: match_field_0.map,
146 protocol: match_field_0.protocol
147 } ) );
148 return; // success!
149 }
150
151 // not found
152 response.writeHead( 405, "Fragment not found, please check back soon" );
153 }
154 else
155 {
156 response.writeHead( 404, "Broadcast has not started yet" );
157 }
158
159 response.end();
160 }
161
162 function postField( request, param, response, broadcasted_match, fragment, field )
163 {
164 // decide on what exactly the response code is - we have enough info now
165 if ( field == "start" )
166 {
167 console.log( "Start tick " + param.query.tick + " in fragment " + fragment );
168 response.writeHead( 200 );
169
170 if ( broadcasted_match[ 0 ] == null )
171 broadcasted_match[ 0 ] = {};
172 if ( broadcasted_match[ 0 ].signup_fragment > fragment )
173 console.log( "UNEXPECTED new start fragment " + fragment + " after " + broadcasted_match[ 0 ].signup_fragment );
174
175 broadcasted_match[ 0 ].signup_fragment = fragment;
176 fragment = 0; // keep the start in the fragment 0
177 }
178 else
179 {
180 if ( broadcasted_match[ 0 ] == null )
181 {
182 console.log( "205 - need start fragment" );
183 response.writeHead( 205 );
184 }
185 else
186 {
187 if ( broadcasted_match[ 0 ].start == null )
188 {
189 console.log( "205 - need start data" );
190 response.writeHead( 205 );
191 }
192 else
193 {
194 response.writeHead( 200 );
195 }
196 }
197 if ( broadcasted_match[ fragment ] == null )
198 {
199 //console.log("Creating fragment " + fragment + " in match_broadcast " + path[1]);
200 broadcasted_match[ fragment ] = {};
201 }
202 }
203
204 for ( q in param.query )
205 {
206 var v = param.query[ q ], n = parseInt( v );
207 broadcasted_match[ fragment ][ q ] = ( v == n ? n : v );
208 }
209
210 var body = [];
211 request.on( 'data', function ( data ) { body.push( data ); } );
212 request.on( 'end', function ()
213 {
214 var totalBuffer = Buffer.concat( body );
215 if ( field == "start" )
216 console.log( "Received [" + fragment + "]." + field + ", " + totalBuffer.length + " bytes in " + body.length + " pieces" );
217 response.end(); // we can end the response before gzipping the received data
218
219 var originCdnDelay = request.headers[ 'x-origin-delay' ];
220 if ( originCdnDelay && parseInt( originCdnDelay ) > 0 )
221 { // CDN delay must match for both fragments, overwrite is ok
222 broadcasted_match[ fragment ].cdndelay = parseInt( originCdnDelay );
223 }
224
225 zlib.gzip( totalBuffer, function ( error, compressedBlob )
226 {
227 if ( error )
228 {
229 console.log( "Cannot gzip " + totalBuffer.length + " bytes: " + error );
230 broadcasted_match[ fragment ][ field ] = totalBuffer;
231 }
232 else
233 {
234 //console.log(fragment + "/" + field + " " + totalBuffer.length + " bytes, compressed " + compressedBlob.length + " to " + ( 100 * compressedBlob.length / totalBuffer.length ).toFixed(1) + "%" );
235 broadcasted_match[ fragment ][ field + "_ungzlen" ] = totalBuffer.length;
236 broadcasted_match[ fragment ][ field ] = compressedBlob;
237 }
238
239 // flag the fragment as received and ready for ingestion by CDN (provided "originCdnDelay" is satisfied)
240 broadcasted_match[ fragment ].timestamp = Date.now();
241 } );
242 } );
243 }
244
245 function serveBlob( request, response, fragmentRec, field )
246 {
247 var blob = fragmentRec[ field ];
248 var ungzipped_length = fragmentRec[ field + "_ungzlen" ];
249
250 // Validate that any injected CDN delay has elapsed
251 if ( !checkFragmentCdnDelayElapsed( fragmentRec ) )
252 {
253 blob = null; // refuse to serve the blob due to artificial CDN delay
254 }
255
256 if ( blob == null )
257 {
258 response.writeHead( 404, "Field not found" );
259 response.end();
260 }
261 else
262 {
263 // we have data to serve
264 if ( Buffer.isBuffer( blob ) )
265 {
266 // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.11
267 headers = { 'Content-Type': 'application/octet-stream' };
268 if ( ungzipped_length )
269 {
270 headers[ 'Content-Encoding' ] = 'gzip';
271 }
272 response.writeHead( 200, headers );
273 response.end( blob );
274 }
275 else
276 {
277 response.writeHead( 404, "Unexpected field type " + typeof ( blob ) ); // we only serve strings
278 console.log( "Unexpected Field type " + typeof ( blob ) ); // we only serve strings
279 response.end();
280 }
281 }
282 }
283
284 function getStart( request, response, broadcasted_match, fragment, field )
285 {
286 if ( broadcasted_match[ 0 ] == null || broadcasted_match[ 0 ].signup_fragment != fragment )
287 {
288 respondSimpleError( request.url, response, 404, "Invalid or expired start fragment, please re-sync" );
289 }
290 else
291 {
292 // always take start data from the 0th fragment
293 serveBlob( request, response, broadcasted_match[ 0 ], field );
294 }
295 }
296
297 function getField( request, response, broadcasted_match, fragment, field )
298 {
299 serveBlob( request, response, broadcasted_match[ fragment ], field );
300 }
301
302 function getFragmentMetadata( response, broadcasted_match, fragment )
303 {
304 var res = {};
305 for ( var field in broadcasted_match[ fragment ] )
306 {
307 var f = broadcasted_match[ fragment ][ field ];
308 if ( typeof ( f ) == 'number' ) res[ field ] = f;
309 else if ( Buffer.isBuffer( f ) ) res[ field ] = f.length;
310 }
311 response.writeHead( 200, { "Content-Type": "application/json" } );
312 response.end( JSON.stringify( res ) );
313 }
314
315 function processRequestUnprotected( request, response )
316 {
317 // https://nodejs.org/api/http.html#http_class_http_incomingmessage
318 var uri = decodeURI( request.url );
319
320 var param = url.parse( uri, true );
321 var path = param.pathname.split( "/" );
322 path.shift(); // the first element is always empty, because the path starts with /
323 response.httpVersion = '1.0';
324
325 var prime = path.shift();
326
327 if ( prime == null || prime == '' || prime == 'index.html' )
328 {
329 respondSimpleError( uri, response, 401, 'Unauthorized' );
330 return;
331 }
332
333 var isPost;
334 if ( request.method == 'POST' )
335 {
336 isPost = true;
337 // TODO: if you use this reference script in production (which you should not), make sure you check "originAuth" header - it must match your game server private setting!
338 // if ( !verify request.headers['x-origin-auth'] equals "SuPeRsEcUrEsErVeR" ) {
339 // console.log("Unauthorized POST to " + request.url + ", origin auth " + originAuth);
340 // respondSimpleError(uri, response, 403, "Not Authorized");
341 // return;
342 // }
343 }
344 else if ( request.method == 'GET' )
345 {
346 isPost = false;
347 // TODO: if you use this reference script in production (which you should not), make sure you check "originAuth" header - it must match your CDN authorization setting!
348 // if ( !verify request.headers['x-origin-auth'] equals "SuPeRsEcUrE_CDN_AuTh" ) {
349 // respondSimpleError(uri, response, 403, "Not Authorized");
350 // return;
351 // }
352 }
353 else
354 {
355 respondSimpleError( uri, response, 404, "Only POST or GET in this API" );
356 return;
357 }
358
359 var broadcasted_match = match_broadcasts[ prime ];
360 if ( broadcasted_match == null )
361 {
362 // the match_broadcast does not exist
363 if ( isPost )
364 {
365 // TODO: if you use this reference script in production (which you should not), make sure that your intent is to create a new match_broadcast on any POST request
366 console.log( "Creating match_broadcast '" + prime + "'" );
367 match_broadcasts[ prime ] = broadcasted_match = [];
368 stats.new_match_broadcasts++;
369 }
370 else
371 {
372 // GET requests cannot create new match_broadcasts in this reference implementation
373 respondSimpleError( uri, response, 404, "match_broadcast " + prime + " not found" ); // invalid match_broadcast
374 stats.err[ 0 ]++;
375 return;
376 }
377 }
378
379 var requestFragmentOrKey = path.shift();
380 if ( requestFragmentOrKey == null || requestFragmentOrKey == '' )
381 {
382 if ( isPost )
383 {
384 respondSimpleError( uri, response, 405, "Invalid POST: no fragment or field" );
385 stats.err[ 1 ]++;
386 }
387 else
388 {
389 respondSimpleError( uri, response, 401, "Unauthorized" );
390 }
391 return;
392 }
393
394 stats.requests++;
395
396 var fragment = parseInt( requestFragmentOrKey );
397
398 if ( fragment != requestFragmentOrKey )
399 {
400 if ( requestFragmentOrKey == "sync" )
401 {
402 //setTimeout(() => {
403 respondMatchBroadcastSync( param, response, broadcasted_match );
404 //}, 2000); // can be useful for your debugging additional latency on the /sync response
405 stats.sync++;
406 }
407 else
408 {
409 respondSimpleError( uri, response, 405, "Fragment is not an int or sync" );
410 stats.err[ 2 ]++;
411 }
412 return;
413 }
414
415 var field = path.shift();
416 if ( isPost )
417 {
418 stats.post_field++;
419 if ( field != null )
420 {
421 postField( request, param, response, broadcasted_match, fragment, field );
422 }
423 else
424 {
425 respondSimpleError( uri, response, 405, "Cannot post fragment without field name" );
426 stats.err[ 3 ]++;
427 }
428 }
429 else
430 {
431 if ( field == 'start' )
432 {
433 getStart( request, response, broadcasted_match, fragment, field );
434 stats.get_start++;
435 }
436 else if ( broadcasted_match[ fragment ] == null )
437 {
438 stats.err[ 4 ]++;
439 response.writeHead( 404, "Fragment " + fragment + " not found" );
440 response.end();
441 }
442 else if ( field == null || field == '' )
443 {
444 getFragmentMetadata( response, broadcasted_match, fragment );
445 stats.get_frag_meta++;
446 }
447 else
448 {
449 getField( request, response, broadcasted_match, fragment, field );
450 stats.get_field++;
451 }
452 }
453 }
454
455 function processRequest( request, response )
456 {
457 try
458 {
459 processRequestUnprotected( request, response );
460 }
461 catch ( err )
462 {
463 console.log( ( new Date ).toUTCString() + " Exception when processing request " + request.url );
464 console.log( err );
465 console.log( err.stack );
466 }
467 }
468
469 var newServer = http.createServer( processRequest ).listen( port );
470 if ( newServer )
471 console.log( ( new Date() ).toUTCString() + " Started in " + __dirname + " on port " + port );
472 else
473 console.log( ( new Date() ).toUTCString() + " Failed to start on port " + port );
474
475
Testing
- Install nodejs
- Save the reference implementation script above as playcast_webserver_impl.js
- Run cmd.exe on Windows or your favorite terminal on another operating system
- Navigate to the location of the .js script file and launch it under nodejs interpreter, e.g.:
node playcast_webserver_impl.js
- Consult a professional if you need assistance with performing the necessary firewall adjustments for the script to work properly
- Launch Counter-Strike 2
- In dev console enable TV:
tv_enable 1
- Select Practice > Competitive > Your Favorite Map and GO to get loaded into the map of your choice with competitive settings
- Optionally adjust the tv_delay after loading into the map using dev console and setting a smaller "tv_delay" value
- Begin broadcasting to your playcast webserver (if the playcast_webserver_impl.js is running on the same computer you can use "localhost" address, otherwise you need to use a fully qualified computer name instead of "localhost"):
tv_broadcast_url "http://localhost:8080" tv_broadcast true
- Watch the output of the .js script in the terminal and find the line looking like:
Creating match_broadcast 's162129646688732160t1732161052'
- On a different computer wait for at least the "tv_delay" number of seconds
- Launch Counter-Strike 2
- Type the following dev console command to begin watching the playcast (make sure you specify the correct fully qualified computer name instead of "your_playcast_server", and supply the match token string observed in the .js script terminal output in the step above), e.g.:
playcast "http://your_playcast_server:8080/s162129646688732160t1732161052"