Counter-Strike: Global Offensive Broadcast

From Valve Developer Community
Jump to: navigation, search
Dead End - Icon.png
This article has no Wikipedia icon links to other VDC articles. Please help improve this article by adding links Wikipedia icon that are relevant to the context within the existing text.
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
  • 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

Warning.pngWarning:DO NOT USE THIS REFERENCE SCRIPT IN PRODUCTION

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"
Warning.pngWarning:DO NOT USE THIS REFERENCE SCRIPT IN PRODUCTION

End of Reference Playcast Webserver Implementation