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.
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
- keyframe_interval - seconds between keyframe (tv_broadcast_keyframe_interval, by default 3 seconds)
- protocol - the protocol version (currently 5 in Source2)
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)
- keyframe_interval - the keyframe interval (as specified in the START request, client will assume 3 seconds if the field is missing)
- 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: 64, keyframe_interval: 3, protocol: 5 }
Optional, when using token_redirect field, for example:
{ tick: 139527, rtdelay: 1, rcvage: 1, fragment: 2, signup_fragment: 0, tps: 64, keyframe_interval: 3, protocol: 5, 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.
//
// 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.
//
var http = require( 'http' );
var zlib = require( 'zlib' );
var url = require( 'url' );
"use strict";
var port = 8080;
// In-memory storage of all match broadcast fragments, metadata, etc.
// Can easily hold many matches in-memory on modern computers, especially with compression
var match_broadcasts = {};
// Example of how to support token_redirect (for CDN, unified playcast URL for the whole event, etc.)
var token_redirect_for_example = null;
var stats = { // Various stats that developers might want to track in their production environments
post_field: 0, get_field: 0, get_start: 0, get_frag_meta: 0,
sync: 0, not_found: 0, new_match_broadcasts: 0,
err: [ 0, 0, 0, 0 ],
requests: 0, started: Date.now(), version: 1
};
function respondSimpleError( uri, response, code, explanation )
{
// if( uri ) console.log( uri + " => " + code + " " + explanation );
response.writeHead( code, { 'X-Reason': explanation } );
response.end();
}
function checkFragmentCdnDelayElapsed( fragmentRec )
{
// Validate that any injected CDN delay has elapsed
if ( fragmentRec.cdndelay )
{
if ( !fragmentRec.timestamp )
{
console.log( "Refusing to serve cdndelay " + field + " without timestamp" );
return false;
}
else
{
var iusElapsedLiveMilliseconds = Date.now().valueOf() - ( fragmentRec.cdndelay + fragmentRec.timestamp.valueOf() );
if ( iusElapsedLiveMilliseconds < 0 )
{
console.log( "Refusing to serve cdndelay " + field + " due to " + iusElapsedLiveMilliseconds + " ms of delay remaining" );
return false; // refuse to serve the blob due to artificial CDN delay
}
}
}
return true;
}
function isSyncReady( f )
{
return f != null && typeof ( f ) == "object" && f.full != null && f.delta != null && f.tick != null && f.endtick != null
&& f.timestamp && checkFragmentCdnDelayElapsed( f );
}
function getMatchBroadcastEndTick( broadcasted_match )
{
for ( var f = broadcasted_match.length - 1; f >= 0; f-- )
{
if ( broadcasted_match[ f ].endtick )
return broadcasted_match[ f ].endtick;
}
return 0;
}
function respondMatchBroadcastSync( param, response, broadcasted_match, token_redirect )
{
var nowMs = Date.now();
response.setHeader( 'Cache-Control', 'public, max-age=3' );
response.setHeader( 'Expires', new Date( nowMs + 3000 ).toUTCString() ); // whatever we find out, this information is going to be stale 3-5 seconds from now
// 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
var match_field_0 = broadcasted_match[ 0 ];
if ( match_field_0 != null && match_field_0.start != null )
{
var fragment = param.query.fragment, frag = null;
if ( fragment == null )
{
// 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
// 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
// if we don't, then legit in-sync clients will often hit CDN-cached 404 on buffered fragments
fragment = Math.max( 0, broadcasted_match.length - 8 );
if ( fragment >= 0 && fragment >= match_field_0.signup_fragment )
{
// can't serve anything before the start fragment
var f = broadcasted_match[ fragment ];
if ( isSyncReady( f ) )
frag = f;
}
}
else
{
if ( fragment < match_field_0.signup_fragment )
fragment = match_field_0.signup_fragment;
for ( ; fragment < broadcasted_match.length; fragment++ )
{
var f = broadcasted_match[ fragment ];
if ( isSyncReady( f ) )
{
frag = f;
break;
}
}
}
if ( frag )
{
console.log( "Sync fragment " + fragment );
// found the fragment that we want to send out
response.writeHead( 200, { "Content-Type": "application/json" } );
if ( match_field_0.protocol == null )
match_field_0.protocol = 5; // Source2 protocol: 5
var jso = {
tick: frag.tick,
endtick: frag.endtick,
maxtick: getMatchBroadcastEndTick( broadcasted_match ),
rtdelay: ( nowMs - frag.timestamp ) / 1000, // delay of this fragment from real-time, in seconds
rcvage: ( nowMs - broadcasted_match[ broadcasted_match.length - 1 ].timestamp ) / 1000, // Receive age: how many seconds since relay last received data from game server
fragment: fragment,
signup_fragment: match_field_0.signup_fragment,
tps: match_field_0.tps,
keyframe_interval: match_field_0.keyframe_interval,
map: match_field_0.map,
protocol: match_field_0.protocol
};
if ( token_redirect )
jso.token_redirect = token_redirect;
response.end( JSON.stringify( jso ) );
return; // success!
}
// not found
response.writeHead( 405, "Fragment not found, please check back soon" );
}
else
{
response.writeHead( 404, "Broadcast has not started yet" );
}
response.end();
}
function postField( request, param, response, broadcasted_match, fragment, field )
{
// decide on what exactly the response code is - we have enough info now
if ( field == "start" )
{
console.log( "Start tick " + param.query.tick + " in fragment " + fragment );
response.writeHead( 200 );
if ( broadcasted_match[ 0 ] == null )
broadcasted_match[ 0 ] = {};
if ( broadcasted_match[ 0 ].signup_fragment > fragment )
console.log( "UNEXPECTED new start fragment " + fragment + " after " + broadcasted_match[ 0 ].signup_fragment );
broadcasted_match[ 0 ].signup_fragment = fragment;
fragment = 0; // keep the start in the fragment 0
}
else
{
if ( broadcasted_match[ 0 ] == null )
{
console.log( "205 - need start fragment" );
response.writeHead( 205 );
}
else
{
if ( broadcasted_match[ 0 ].start == null )
{
console.log( "205 - need start data" );
response.writeHead( 205 );
}
else
{
response.writeHead( 200 );
}
}
if ( broadcasted_match[ fragment ] == null )
{
//console.log("Creating fragment " + fragment + " in match_broadcast " + path[1]);
broadcasted_match[ fragment ] = {};
}
}
for ( q in param.query )
{
var v = param.query[ q ], n = parseInt( v );
broadcasted_match[ fragment ][ q ] = ( v == n ? n : v );
}
var body = [];
request.on( 'data', function ( data ) { body.push( data ); } );
request.on( 'end', function ()
{
var totalBuffer = Buffer.concat( body );
if ( field == "start" )
console.log( "Received [" + fragment + "]." + field + ", " + totalBuffer.length + " bytes in " + body.length + " pieces" );
response.end(); // we can end the response before gzipping the received data
var originCdnDelay = request.headers[ 'x-origin-delay' ];
if ( originCdnDelay && parseInt( originCdnDelay ) > 0 )
{ // CDN delay must match for both fragments, overwrite is ok
broadcasted_match[ fragment ].cdndelay = parseInt( originCdnDelay );
}
zlib.gzip( totalBuffer, function ( error, compressedBlob )
{
if ( error )
{
console.log( "Cannot gzip " + totalBuffer.length + " bytes: " + error );
broadcasted_match[ fragment ][ field ] = totalBuffer;
}
else
{
//console.log(fragment + "/" + field + " " + totalBuffer.length + " bytes, compressed " + compressedBlob.length + " to " + ( 100 * compressedBlob.length / totalBuffer.length ).toFixed(1) + "%" );
broadcasted_match[ fragment ][ field + "_ungzlen" ] = totalBuffer.length;
broadcasted_match[ fragment ][ field ] = compressedBlob;
}
// flag the fragment as received and ready for ingestion by CDN (provided "originCdnDelay" is satisfied)
broadcasted_match[ fragment ].timestamp = Date.now();
} );
} );
}
function serveBlob( request, response, fragmentRec, field )
{
var blob = fragmentRec[ field ];
var ungzipped_length = fragmentRec[ field + "_ungzlen" ];
// Validate that any injected CDN delay has elapsed
if ( !checkFragmentCdnDelayElapsed( fragmentRec ) )
{
blob = null; // refuse to serve the blob due to artificial CDN delay
}
if ( blob == null )
{
response.writeHead( 404, "Field not found" );
response.end();
}
else
{
// we have data to serve
if ( Buffer.isBuffer( blob ) )
{
// https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.11
headers = { 'Content-Type': 'application/octet-stream' };
if ( ungzipped_length )
{
headers[ 'Content-Encoding' ] = 'gzip';
}
response.writeHead( 200, headers );
response.end( blob );
}
else
{
response.writeHead( 404, "Unexpected field type " + typeof ( blob ) ); // we only serve strings
console.log( "Unexpected Field type " + typeof ( blob ) ); // we only serve strings
response.end();
}
}
}
function getStart( request, response, broadcasted_match, fragment, field )
{
if ( broadcasted_match[ 0 ] == null || broadcasted_match[ 0 ].signup_fragment != fragment )
{
respondSimpleError( request.url, response, 404, "Invalid or expired start fragment, please re-sync" );
}
else
{
// always take start data from the 0th fragment
serveBlob( request, response, broadcasted_match[ 0 ], field );
}
}
function getField( request, response, broadcasted_match, fragment, field )
{
serveBlob( request, response, broadcasted_match[ fragment ], field );
}
function getFragmentMetadata( response, broadcasted_match, fragment )
{
var res = {};
for ( var field in broadcasted_match[ fragment ] )
{
var f = broadcasted_match[ fragment ][ field ];
if ( typeof ( f ) == 'number' ) res[ field ] = f;
else if ( Buffer.isBuffer( f ) ) res[ field ] = f.length;
}
response.writeHead( 200, { "Content-Type": "application/json" } );
response.end( JSON.stringify( res ) );
}
function processRequestUnprotected( request, response )
{
// https://nodejs.org/api/http.html#http_class_http_incomingmessage
var uri = decodeURI( request.url );
var param = url.parse( uri, true );
var path = param.pathname.split( "/" );
path.shift(); // the first element is always empty, because the path starts with /
response.httpVersion = '1.0';
var prime = path.shift();
if ( prime == null || prime == '' || prime == 'index.html' )
{
respondSimpleError( uri, response, 401, 'Unauthorized' );
return;
}
var isPost;
if ( request.method == 'POST' )
{
isPost = true;
// 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!
// if ( !verify request.headers['x-origin-auth'] equals "SuPeRsEcUrEsErVeR" ) {
// console.log("Unauthorized POST to " + request.url + ", origin auth " + originAuth);
// respondSimpleError(uri, response, 403, "Not Authorized");
// return;
// }
}
else if ( request.method == 'GET' )
{
isPost = false;
// 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!
// if ( !verify request.headers['x-origin-auth'] equals "SuPeRsEcUrE_CDN_AuTh" ) {
// respondSimpleError(uri, response, 403, "Not Authorized");
// return;
// }
}
else
{
respondSimpleError( uri, response, 404, "Only POST or GET in this API" );
return;
}
var broadcasted_match = match_broadcasts[ prime ];
if ( broadcasted_match == null )
{
// the match_broadcast does not exist
if ( isPost )
{
// 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
console.log( "Creating match_broadcast '" + prime + "'" );
token_redirect_for_example = prime; // TODO: implement your own logic here or somewhere else that decides which token_redirect to use for unified playcast URL/CDN/etc.
match_broadcasts[ prime ] = broadcasted_match = [];
stats.new_match_broadcasts++;
}
else
{
if ( prime == 'sync' )
{
// TODO: implement your own logic here or somewhere else that decides which token_redirect to use for unified playcast URL/CDN/etc.
// This reference implementation (which you should not use in production) will try to redirect to whatever "token_redirect_for_example"
if ( token_redirect_for_example && match_broadcasts[ token_redirect_for_example ] )
{
respondMatchBroadcastSync( param, response, match_broadcasts[ token_redirect_for_example ], token_redirect_for_example );
stats.sync++;
}
else
{
respondSimpleError( uri, response, 404, "match_broadcast " + prime + " not found and no valid token_redirect" );
stats.err[ 0 ]++;
}
}
else
{
// GET requests cannot create new match_broadcasts in this reference implementation
respondSimpleError( uri, response, 404, "match_broadcast " + prime + " not found" ); // invalid match_broadcast
stats.err[ 0 ]++;
}
return;
}
}
var requestFragmentOrKey = path.shift();
if ( requestFragmentOrKey == null || requestFragmentOrKey == '' )
{
if ( isPost )
{
respondSimpleError( uri, response, 405, "Invalid POST: no fragment or field" );
stats.err[ 1 ]++;
}
else
{
respondSimpleError( uri, response, 401, "Unauthorized" );
}
return;
}
stats.requests++;
var fragment = parseInt( requestFragmentOrKey );
if ( fragment != requestFragmentOrKey )
{
if ( requestFragmentOrKey == "sync" )
{
//setTimeout(() => {
respondMatchBroadcastSync( param, response, broadcasted_match );
//}, 2000); // can be useful for your debugging additional latency on the /sync response
stats.sync++;
}
else
{
respondSimpleError( uri, response, 405, "Fragment is not an int or sync" );
stats.err[ 2 ]++;
}
return;
}
var field = path.shift();
if ( isPost )
{
stats.post_field++;
if ( field != null )
{
postField( request, param, response, broadcasted_match, fragment, field );
}
else
{
respondSimpleError( uri, response, 405, "Cannot post fragment without field name" );
stats.err[ 3 ]++;
}
}
else
{
if ( field == 'start' )
{
getStart( request, response, broadcasted_match, fragment, field );
stats.get_start++;
}
else if ( broadcasted_match[ fragment ] == null )
{
stats.err[ 4 ]++;
response.writeHead( 404, "Fragment " + fragment + " not found" );
response.end();
}
else if ( field == null || field == '' )
{
getFragmentMetadata( response, broadcasted_match, fragment );
stats.get_frag_meta++;
}
else
{
getField( request, response, broadcasted_match, fragment, field );
stats.get_field++;
}
}
}
function processRequest( request, response )
{
try
{
processRequestUnprotected( request, response );
}
catch ( err )
{
console.log( ( new Date ).toUTCString() + " Exception when processing request " + request.url );
console.log( err );
console.log( err.stack );
}
}
var newServer = http.createServer( processRequest ).listen( port );
if ( newServer )
console.log( ( new Date() ).toUTCString() + " Started in " + __dirname + " on port " + port );
else
console.log( ( new Date() ).toUTCString() + " Failed to start on port " + port );
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"
- Alternatively, the reference playcast webserver implementation supports token redirect to whatever match started last. Redirect approach must be used when you want to provide a consistent URI for the entire event, match day, or stream A / stream B, etc. (e.g. playcast "http://cstv.main_stream.tournament.com/"). You can try using:
playcast "http://your_playcast_server:8080"
- The game client should print the following line in dev console acknowledging the token redirect:
[HLTV Broadcast] ... Broadcast sync redirect >> 's162129646688732160t1732161052' = 'http://your_playcast_server:8080/s162129646688732160t1732161052' ... so effectively the game client will behave exactly the same after getting redirected ... advantage is that the game client doesn't even need to know the current match token ... the playcast reference webserver implementation automatically redirects to the match