diff --git a/src/framework/Session.cpp b/src/framework/Session.cpp
index 2c51894..a80d448 100644
--- a/src/framework/Session.cpp
+++ b/src/framework/Session.cpp
@@ -4675,6 +4675,11 @@ void idSessionLocal::ExecuteMapChange( bool noFadeWipe ) {
 		game->SetPersistentPlayerInfo( i, mapSpawnData.persistentPlayerInfo[i] );
 	}
 
+	// A replay instance owns a server port but consumes recorded client state.
+	const bool serverDemoPlayback = idAsyncNetwork::server.IsServerDemoPlaying();
+	const bool gameIsServer = idAsyncNetwork::server.IsActive() && !serverDemoPlayback;
+	const bool gameIsClient = idAsyncNetwork::client.IsActive() || serverDemoPlayback;
+
 	// load and spawn all other entities ( from a savegame possibly )
 	if ( loadingSaveGame && savegameFile ) {
 		if ( game->InitFromSaveGame( fullMapName, rw, savegameFile ) == false ) {
@@ -4684,11 +4689,11 @@ void idSessionLocal::ExecuteMapChange( bool noFadeWipe ) {
 			savegameFile = NULL;
 
 			game->SetServerInfo( mapSpawnData.serverInfo );
-			game->InitFromNewMap( fullMapName, rw, idAsyncNetwork::server.IsActive(), idAsyncNetwork::client.IsActive(), Sys_Milliseconds() );
+			game->InitFromNewMap( fullMapName, rw, gameIsServer, gameIsClient, Sys_Milliseconds() );
 		}
 	} else {
 		game->SetServerInfo( mapSpawnData.serverInfo );
-		game->InitFromNewMap( fullMapName, rw, idAsyncNetwork::server.IsActive(), idAsyncNetwork::client.IsActive(), Sys_Milliseconds() );
+		game->InitFromNewMap( fullMapName, rw, gameIsServer, gameIsClient, Sys_Milliseconds() );
 	}
 	gameInitMsec = Sys_Milliseconds() - phaseStart;
 	phaseStart = Sys_Milliseconds();
diff --git a/src/framework/async/AsyncClient.cpp b/src/framework/async/AsyncClient.cpp
index c71410b..687f1a8 100644
--- a/src/framework/async/AsyncClient.cpp
+++ b/src/framework/async/AsyncClient.cpp
@@ -49,6 +49,13 @@ const int SETUP_CONNECTION_RESEND_TIME	= 1000;
 const int EMPTY_RESEND_TIME				= 500;
 const int PREDICTION_FAST_ADJUST		= 4;
 
+static const int SERVER_DEMO_MAGIC = 'Q' | ( '4' << 8 ) | ( 'S' << 16 ) | ( 'D' << 24 );
+static const int SERVER_DEMO_FORMAT = 1;
+static const int SERVER_DEMO_RECORD_RELIABLE = 1;
+static const int SERVER_DEMO_RECORD_SNAPSHOT = 2;
+static const int SERVER_DEMO_RECORD_END = 3;
+static const int SERVER_DEMO_MAX_RECORD = 4 * 1024 * 1024;
+
 
 /*
 ==================
@@ -67,6 +74,14 @@ idAsyncClient::Clear
 ==================
 */
 void idAsyncClient::Clear( void ) {
+	serverDemoFile = NULL;
+	serverDemoPath.Clear();
+	serverDemoInitialGameTime = 0;
+	serverDemoStartRealTime = 0;
+	serverDemoPredictedGameFrame = 0;
+	serverDemoPredictedGameTime = 0;
+	serverIsRepeater = false;
+	repeaterUserInfo.Clear();
 	active = false;
 	realTime = 0;
 	clientTime = 0;
@@ -109,6 +124,7 @@ idAsyncClient::Shutdown
 ==================
 */
 void idAsyncClient::Shutdown( void ) {
+	StopServerDemoPlayback();
 	guiNetMenu = NULL;
 	updateMSG.Clear();
 	updateURL.Clear();
@@ -276,6 +292,11 @@ void idAsyncClient::DisconnectFromServer( void ) {
 	idBitMsg	msg;
 	byte		msgBuf[MAX_MESSAGE_SIZE];
 
+	if ( serverDemoFile != NULL || clientNum == MAX_ASYNC_CLIENTS ) {
+		StopServerDemoPlayback();
+		return;
+	}
+
 	if ( clientState >= CS_CONNECTED ) {
 		// if we were actually connected, clear the pure list
 		fileSystem->ClearPureChecksums();
@@ -298,6 +319,12 @@ void idAsyncClient::DisconnectFromServer( void ) {
 		channel.Shutdown();
 		clientState = CS_DISCONNECTED;
 	}
+	if ( serverIsRepeater && game != NULL ) {
+		game->SetRepeaterState( false, false );
+		game->SetDemoState( DEMO_NONE, false, false );
+	}
+	serverIsRepeater = false;
+	repeaterUserInfo.Clear();
 
 	active = false;
 }
@@ -602,13 +629,17 @@ void idAsyncClient::SendUserInfoToServer( void ) {
 	// send reliable client info to server
 	msg.Init( msgBuf, sizeof( msgBuf ) );
 	msg.WriteByte( CLIENT_RELIABLE_MESSAGE_CLIENTINFO );
-	msg.WriteDeltaDict( info, &sessLocal.mapSpawnData.userInfo[ clientNum ] );
+	msg.WriteDeltaDict( info, serverIsRepeater ? &repeaterUserInfo : &sessLocal.mapSpawnData.userInfo[ clientNum ] );
 
 	if ( !channel.SendReliableMessage( msg ) ) {
 		common->Error( "client->server reliable messages overflow\n" );
 	}
 
-	sessLocal.mapSpawnData.userInfo[clientNum] = info;
+	if ( serverIsRepeater ) {
+		repeaterUserInfo = info;
+	} else {
+		sessLocal.mapSpawnData.userInfo[clientNum] = info;
+	}
 }
 
 /*
@@ -736,6 +767,20 @@ void idAsyncClient::InitGame( int serverGameInitId, int serverGameFrame, int ser
 	}
 
 	sessLocal.mapSpawnData.serverInfo = serverSI;
+	serverIsRepeater = serverSI.GetBool( "si_tv" );
+}
+
+/*
+==================
+idAsyncClient::ConfigureRepeaterGame
+==================
+*/
+void idAsyncClient::ConfigureRepeaterGame( void ) {
+	if ( !serverIsRepeater || game == NULL ) {
+		return;
+	}
+	game->SetDemoState( DEMO_PLAYING, false, false );
+	game->SetRepeaterState( false, true );
 }
 
 /*
@@ -795,6 +840,7 @@ void idAsyncClient::ProcessUnreliableServerMessage( const idBitMsg &msg ) {
 				// load map
 				session->SetGUI( NULL, NULL );
 				sessLocal.ExecuteMapChange();
+				ConfigureRepeaterGame();
 			}
 
 			break;
@@ -815,7 +861,11 @@ void idAsyncClient::ProcessUnreliableServerMessage( const idBitMsg &msg ) {
 			aheadOfServer = msg.ReadShort();
 
 			// read the game snapshot
-			game->ClientReadSnapshot( clientNum, snapshotSequence, snapshotGameFrame, snapshotGameTime, numDuplicatedUsercmds, aheadOfServer, msg );
+			if ( serverIsRepeater ) {
+				game->ClientReadRepeaterSnapshot( snapshotSequence, snapshotGameFrame, snapshotGameTime, aheadOfServer, msg );
+			} else {
+				game->ClientReadSnapshot( clientNum, snapshotSequence, snapshotGameFrame, snapshotGameTime, numDuplicatedUsercmds, aheadOfServer, msg );
+			}
 
 			// read user commands of other clients from the snapshot
 			for ( last = NULL, i = msg.ReadByte(); i < MAX_ASYNC_CLIENTS; i = msg.ReadByte() ) {
@@ -990,7 +1040,7 @@ void idAsyncClient::ProcessReliableServerMessages( void ) {
 				}
 
 				// server forces us to a different userinfo
-				if ( clientNum == idAsyncClient::clientNum ) {
+				if ( !serverIsRepeater && clientNum == idAsyncClient::clientNum ) {
 					common->DPrintf( "local user info modified by server\n" );
 					cvarSystem->SetCVarsFromDict( info );
 					cvarSystem->ClearModifiedFlags( CVAR_USERINFO ); // don't emit back
@@ -1032,7 +1082,7 @@ void idAsyncClient::ProcessReliableServerMessages( void ) {
 			case SERVER_RELIABLE_MESSAGE_APPLYSNAPSHOT: {
 				int sequence;
 				sequence = msg.ReadLong();
-				if ( !game->ClientApplySnapshot( clientNum, sequence ) ) {
+				if ( !game->ClientApplySnapshot( serverIsRepeater ? MAX_ASYNC_CLIENTS : clientNum, sequence ) ) {
 					session->Stop();
 					common->Error( "couldn't apply snapshot %d", sequence );
 				}
@@ -1052,13 +1102,15 @@ void idAsyncClient::ProcessReliableServerMessages( void ) {
 			}
 			case SERVER_RELIABLE_MESSAGE_ENTERGAME: {
 				SendUserInfoToServer();
-				game->SetUserInfo( clientNum, sessLocal.mapSpawnData.userInfo[ clientNum ], true );
+				if ( !serverIsRepeater ) {
+					game->SetUserInfo( clientNum, sessLocal.mapSpawnData.userInfo[ clientNum ], true );
+				}
 				cvarSystem->ClearModifiedFlags( CVAR_USERINFO );
 				break;
 			}
 			default: {
 				// pass reliable message on to game code
-				game->ClientProcessReliableMessage( clientNum, msg );
+				game->ClientProcessReliableMessage( serverIsRepeater ? MAX_ASYNC_CLIENTS : clientNum, msg );
 				break;
 			}
 		}
@@ -1172,6 +1224,7 @@ void idAsyncClient::ProcessConnectResponseMessage( const netadr_t from, const id
 	// load map
 	session->SetGUI( NULL, NULL );
 	sessLocal.ExecuteMapChange();
+	ConfigureRepeaterGame();
 
 	clientPredictTime = clientPrediction = idMath::ClampInt( 0, idAsyncNetwork::clientMaxPrediction.GetInteger(), clientTime - lastConnectTime );
 }
@@ -1699,6 +1752,230 @@ int idAsyncClient::UpdateTime( int clamp ) {
 	return msec;
 }
 
+/*
+==================
+idAsyncClient::StartServerDemoPlayback
+==================
+*/
+bool idAsyncClient::StartServerDemoPlayback( const char *path ) {
+#ifdef ID_DEDICATED
+	common->Printf( "server demo playback is unavailable in a dedicated executable\n" );
+	return false;
+#else
+	if ( path == NULL || path[ 0 ] == '\0' ) {
+		common->Printf( "StartServerDemoPlayback: no file specified\n" );
+		return false;
+	}
+
+	// Stop first: this also closes a previously running server demo playback.
+	session->Stop();
+	idFile *file = fileSystem->OpenExplicitFileRead( path );
+	if ( file == NULL ) {
+		file = fileSystem->OpenFileRead( path );
+	}
+	if ( file == NULL ) {
+		common->Printf( "could not open server demo %s\n", path );
+		return false;
+	}
+
+	int magic = 0;
+	int format = 0;
+	int protocol = 0;
+	int initialGameInitId = 0;
+	int initialGameFrame = 0;
+	int initialGameTime = 0;
+	if ( file->ReadInt( magic ) != sizeof( magic ) ||
+		file->ReadInt( format ) != sizeof( format ) ||
+		file->ReadInt( protocol ) != sizeof( protocol ) ||
+		file->ReadInt( initialGameInitId ) != sizeof( initialGameInitId ) ||
+		file->ReadInt( initialGameFrame ) != sizeof( initialGameFrame ) ||
+		file->ReadInt( initialGameTime ) != sizeof( initialGameTime ) ) {
+		common->Warning( "truncated server demo header: %s", path );
+		fileSystem->CloseFile( file );
+		return false;
+	}
+	if ( magic != SERVER_DEMO_MAGIC || format != SERVER_DEMO_FORMAT ) {
+		common->Warning( "unsupported server demo format in %s", path );
+		fileSystem->CloseFile( file );
+		return false;
+	}
+	if ( MAJOR_VERSION( protocol ) != ASYNC_PROTOCOL_MAJOR ||
+		!game->ValidateDemoProtocol( ASYNC_PROTOCOL_MINOR, protocol & 0xffff ) ) {
+		common->Warning( "server demo protocol %d.%d is incompatible with %d.%d",
+			MAJOR_VERSION( protocol ), protocol & 0xffff, ASYNC_PROTOCOL_MAJOR, ASYNC_PROTOCOL_MINOR );
+		fileSystem->CloseFile( file );
+		return false;
+	}
+
+	idStr recordedAt;
+	file->ReadString( recordedAt );
+	idDict serverInfo;
+	serverInfo.ReadFromFileHandle( file );
+	idDict userInfo[ MAX_ASYNC_CLIENTS ];
+	for ( int i = 0; i < MAX_ASYNC_CLIENTS; ++i ) {
+		userInfo[ i ].ReadFromFileHandle( file );
+	}
+	idDict syncedCVars;
+	syncedCVars.ReadFromFileHandle( file );
+
+	Clear();
+	serverDemoFile = file;
+	serverDemoPath = path;
+	serverDemoInitialGameTime = initialGameTime;
+	serverDemoPredictedGameFrame = initialGameFrame;
+	serverDemoPredictedGameTime = initialGameTime;
+	clientNum = MAX_ASYNC_CLIENTS;
+	clientState = CS_INGAME;
+	active = true;
+	InitGame( initialGameInitId, initialGameFrame, initialGameTime, serverInfo );
+	for ( int i = 0; i < MAX_ASYNC_CLIENTS; ++i ) {
+		sessLocal.mapSpawnData.userInfo[ i ] = userInfo[ i ];
+	}
+	sessLocal.mapSpawnData.syncedCVars = syncedCVars;
+
+	game->SetDemoState( DEMO_PLAYING, true, false );
+	session->SetGUI( NULL, NULL );
+	sessLocal.ExecuteMapChange();
+	for ( int i = 0; i < MAX_ASYNC_CLIENTS; ++i ) {
+		if ( userInfo[ i ].GetNumKeyVals() > 0 ) {
+			game->SetUserInfo( i, userInfo[ i ], true );
+		}
+	}
+	game->SetDemoState( DEMO_PLAYING, true, false );
+	game->ReadNetworkInfo( initialGameTime, serverDemoFile, MAX_ASYNC_CLIENTS );
+	serverDemoStartRealTime = Sys_Milliseconds();
+	common->Printf( "playing full-state server demo %s (recorded %s)\n", path, recordedAt.c_str() );
+	return true;
+#endif
+}
+
+/*
+==================
+idAsyncClient::StopServerDemoPlayback
+==================
+*/
+void idAsyncClient::StopServerDemoPlayback( void ) {
+	if ( serverDemoFile != NULL ) {
+		fileSystem->CloseFile( serverDemoFile );
+		serverDemoFile = NULL;
+	}
+	if ( clientNum == MAX_ASYNC_CLIENTS && game != NULL ) {
+		game->SetDemoState( DEMO_NONE, false, false );
+	}
+	serverDemoPath.Clear();
+	clientState = CS_DISCONNECTED;
+	clientNum = 0;
+	active = false;
+}
+
+/*
+==================
+idAsyncClient::RunServerDemoFrame
+==================
+*/
+void idAsyncClient::RunServerDemoFrame( void ) {
+#ifdef ID_DEDICATED
+	StopServerDemoPlayback();
+#else
+	if ( serverDemoFile == NULL ) {
+		return;
+	}
+
+	const int targetGameTime = serverDemoInitialGameTime + Max( 0, Sys_Milliseconds() - serverDemoStartRealTime );
+	bool reachedEnd = false;
+	while ( serverDemoFile != NULL ) {
+		const int recordOffset = serverDemoFile->Tell();
+		int recordType = 0;
+		int recordTime = 0;
+		int recordSize = 0;
+		if ( serverDemoFile->ReadInt( recordType ) != sizeof( recordType ) ||
+			serverDemoFile->ReadInt( recordTime ) != sizeof( recordTime ) ||
+			serverDemoFile->ReadInt( recordSize ) != sizeof( recordSize ) ) {
+			common->Warning( "truncated server demo record in %s", serverDemoPath.c_str() );
+			reachedEnd = true;
+			break;
+		}
+		if ( recordSize < 0 || recordSize > SERVER_DEMO_MAX_RECORD ) {
+			common->Warning( "invalid server demo record size %d in %s", recordSize, serverDemoPath.c_str() );
+			reachedEnd = true;
+			break;
+		}
+		if ( recordTime > targetGameTime ) {
+			serverDemoFile->Seek( recordOffset, FS_SEEK_SET );
+			break;
+		}
+
+		if ( recordType == SERVER_DEMO_RECORD_END ) {
+			reachedEnd = true;
+			break;
+		}
+
+		int snapshotSequence = 0;
+		int snapshotFrame = 0;
+		int snapshotTime = 0;
+		if ( recordType == SERVER_DEMO_RECORD_SNAPSHOT ) {
+			if ( serverDemoFile->ReadInt( snapshotSequence ) != sizeof( snapshotSequence ) ||
+				serverDemoFile->ReadInt( snapshotFrame ) != sizeof( snapshotFrame ) ||
+				serverDemoFile->ReadInt( snapshotTime ) != sizeof( snapshotTime ) ) {
+				common->Warning( "truncated server demo snapshot header in %s", serverDemoPath.c_str() );
+				reachedEnd = true;
+				break;
+			}
+		} else if ( recordType != SERVER_DEMO_RECORD_RELIABLE ) {
+			common->Warning( "unknown server demo record %d in %s", recordType, serverDemoPath.c_str() );
+			reachedEnd = true;
+			break;
+		}
+
+		byte *payload = static_cast<byte *>( Mem_Alloc( Max( 1, recordSize ) ) );
+		if ( recordSize > 0 && serverDemoFile->Read( payload, recordSize ) != recordSize ) {
+			Mem_Free( payload );
+			common->Warning( "truncated server demo payload in %s", serverDemoPath.c_str() );
+			reachedEnd = true;
+			break;
+		}
+		idBitMsg msg;
+		msg.Init( payload, recordSize );
+		msg.SetSize( recordSize );
+		msg.BeginReading();
+		if ( recordType == SERVER_DEMO_RECORD_RELIABLE ) {
+			game->ClientProcessReliableMessage( MAX_ASYNC_CLIENTS, msg );
+		} else {
+			game->ClientReadServerDemoSnapshot( snapshotSequence, snapshotFrame, snapshotTime, msg );
+			serverDemoPredictedGameFrame = snapshotFrame;
+			serverDemoPredictedGameTime = snapshotTime;
+		}
+		Mem_Free( payload );
+	}
+
+	if ( reachedEnd ) {
+		const idStr finishedPath = serverDemoPath;
+		StopServerDemoPlayback();
+		common->Printf( "finished server demo %s\n", finishedPath.c_str() );
+		cmdSystem->BufferCommandText( CMD_EXEC_APPEND, "disconnect\n" );
+		return;
+	}
+
+	int predictionFrames = 0;
+	while ( predictionFrames < 8 ) {
+		const int nextFrameMsec = common->GetUserCmdDeltaMsec( serverDemoPredictedGameFrame + 1 );
+		if ( serverDemoPredictedGameTime + nextFrameMsec > targetGameTime ) {
+			break;
+		}
+		usercmd_t demoCmds[ MAX_ASYNC_CLIENTS + 1 ];
+		memset( demoCmds, 0, sizeof( demoCmds ) );
+		demoCmds[ MAX_ASYNC_CLIENTS ] = usercmdGen->GetDirectUsercmd();
+		demoCmds[ MAX_ASYNC_CLIENTS ].gameFrame = serverDemoPredictedGameFrame;
+		demoCmds[ MAX_ASYNC_CLIENTS ].gameTime = serverDemoPredictedGameTime;
+		gameReturn_t ret = game->ClientPrediction( MAX_ASYNC_CLIENTS, demoCmds, true );
+		idAsyncNetwork::ExecuteSessionCommand( ret.sessionCommand );
+		serverDemoPredictedGameFrame++;
+		serverDemoPredictedGameTime += nextFrameMsec;
+		predictionFrames++;
+	}
+#endif
+}
+
 /*
 ==================
 idAsyncClient::RunFrame
@@ -1711,6 +1988,11 @@ void idAsyncClient::RunFrame( bool allowBlocking ) {
 	byte		msgBuf[MAX_MESSAGE_SIZE];
 	netadr_t	from;
 
+	if ( serverDemoFile != NULL ) {
+		RunServerDemoFrame();
+		return;
+	}
+
 	msec = UpdateTime( 100 );
 
 	if ( !clientPort.GetPort() ) {
@@ -1793,7 +2075,9 @@ void idAsyncClient::RunFrame( bool allowBlocking ) {
 	if ( cvarSystem->GetModifiedFlags() & CVAR_USERINFO ) {
 		game->ThrottleUserInfo( );
 		SendUserInfoToServer( );
-		game->SetUserInfo( clientNum, sessLocal.mapSpawnData.userInfo[ clientNum ], true );
+		if ( !serverIsRepeater ) {
+			game->SetUserInfo( clientNum, sessLocal.mapSpawnData.userInfo[ clientNum ], true );
+		}
 		cvarSystem->ClearModifiedFlags( CVAR_USERINFO );
 	}
 
@@ -1825,7 +2109,15 @@ void idAsyncClient::RunFrame( bool allowBlocking ) {
 			bool lastPredictFrame = ( snapshotGameFrame + 1 >= gameFrame && gameTimeResidual + clientPredictTime < AsyncClient_NextGameFrameMsec( gameFrame ) );
 
 			// run client prediction
-			gameReturn_t ret = game->ClientPrediction( clientNum, userCmds[ snapshotGameFrame & ( MAX_USERCMD_BACKUP - 1 ) ], lastPredictFrame );
+			gameReturn_t ret;
+			if ( serverIsRepeater ) {
+				usercmd_t repeaterCmds[MAX_ASYNC_CLIENTS + 1];
+				memcpy( repeaterCmds, userCmds[ snapshotGameFrame & ( MAX_USERCMD_BACKUP - 1 ) ], sizeof( usercmd_t ) * MAX_ASYNC_CLIENTS );
+				repeaterCmds[MAX_ASYNC_CLIENTS] = repeaterCmds[clientNum];
+				ret = game->ClientPrediction( MAX_ASYNC_CLIENTS, repeaterCmds, lastPredictFrame );
+			} else {
+				ret = game->ClientPrediction( clientNum, userCmds[ snapshotGameFrame & ( MAX_USERCMD_BACKUP - 1 ) ], lastPredictFrame );
+			}
 
 			idAsyncNetwork::ExecuteSessionCommand( ret.sessionCommand );
 
@@ -1841,7 +2133,7 @@ idAsyncClient::PacifierUpdate
 ==================
 */
 void idAsyncClient::PacifierUpdate( void ) {
-	if ( !IsActive() ) {
+	if ( !IsActive() || serverDemoFile != NULL ) {
 		return;
 	}
 	realTime = Sys_Milliseconds();
diff --git a/src/framework/async/AsyncClient.h b/src/framework/async/AsyncClient.h
index 6300836..d58cad2 100644
--- a/src/framework/async/AsyncClient.h
+++ b/src/framework/async/AsyncClient.h
@@ -94,7 +94,7 @@ public:
 	bool				IsPortInitialized() { return clientPort.GetPort() != 0; }
 
 	bool				IsActive( void ) const { return active; }
-	int					GetLocalClientNum( void ) const { return clientNum; }
+	int					GetLocalClientNum( void ) const { return serverIsRepeater ? MAX_ASYNC_CLIENTS : clientNum; }
 	const netadr_t&		GetServerAddress( void ) const { return serverAddress; }
 	int					GetPrediction( void ) const;
 	int					GetTimeSinceLastPacket( void ) const;
@@ -107,6 +107,9 @@ public:
 
 	void				RunFrame( bool allowBlocking = true );
 	void				SendReliableGameMessage( const idBitMsg &msg );
+	bool				StartServerDemoPlayback( const char *path );
+	void				StopServerDemoPlayback( void );
+	bool				IsServerDemoPlaying( void ) const { return serverDemoFile != NULL; }
 
 	void				SendVersionCheck( bool fromMenu = false );
 	// Legacy no-op in openQ4 (CD key auth removed).
@@ -172,6 +175,15 @@ private:
 
 	int					lastFrameDelta;
 
+	idFile *			serverDemoFile;
+	idStr				serverDemoPath;
+	int					serverDemoInitialGameTime;
+	int					serverDemoStartRealTime;
+	int					serverDemoPredictedGameFrame;
+	int					serverDemoPredictedGameTime;
+	bool				serverIsRepeater;
+	idDict				repeaterUserInfo;
+
 	int					dlRequest;		// randomized number to keep track of the requests
 	int					dlChecksums[ MAX_PURE_PAKS ]; // 0-terminated, first element is the game pak checksum or 0
 	int					dlCount;		// download-info slots expected: game pak slot plus asset paks
@@ -187,6 +199,7 @@ private:
 	void				SendPingResponseToServer( int time );
 	void				SendUsercmdsToServer( void );
 	void				InitGame( int serverGameInitId, int serverGameFrame, int serverGameTime, const idDict &serverSI );
+	void				ConfigureRepeaterGame( void );
 	void				ProcessUnreliableServerMessage( const idBitMsg &msg );
 	void				ProcessReliableServerMessages( void );
 	void				ProcessChallengeResponseMessage( const netadr_t from, const idBitMsg &msg );
@@ -210,6 +223,7 @@ private:
 	void				Idle( void );
 	int					UpdateTime( int clamp );
 	void				ReadLocalizedServerString( const idBitMsg &msg, char* out, int maxLen );
+	void				RunServerDemoFrame( void );
 	bool				CheckTimeout( void );
 	void				ProcessDownloadInfoMessage( const netadr_t from, const idBitMsg &msg );
 	int					GetDownloadRequest( const int checksums[ MAX_PURE_PAKS ], int count, int gamePakChecksum );
diff --git a/src/framework/async/AsyncNetwork.cpp b/src/framework/async/AsyncNetwork.cpp
index 8763d77..cf48a12 100644
--- a/src/framework/async/AsyncNetwork.cpp
+++ b/src/framework/async/AsyncNetwork.cpp
@@ -67,6 +67,10 @@ idCVar				idAsyncNetwork::serverReloadEngine( "net_serverReloadEngine", "0", CVA
 idCVar				idAsyncNetwork::serverAllowServerMod( "net_serverAllowServerMod", "0", CVAR_SYSTEM | CVAR_BOOL | CVAR_NOCHEAT, "allow server-side mods" );
 idCVar				idAsyncNetwork::idleServer( "si_idleServer", "0", CVAR_SYSTEM | CVAR_BOOL | CVAR_INIT | CVAR_SERVERINFO, "game clients are idle" );
 idCVar				idAsyncNetwork::clientDownload( "net_clientDownload", "1", CVAR_SYSTEM | CVAR_INTEGER | CVAR_ARCHIVE, "client pk4 downloads policy: 0 - never, 1 - ask, 2 - always (will still prompt for binary code)" );
+idCVar				idAsyncNetwork::serverDemo( "net_serverDemo", "1", CVAR_SYSTEM | CVAR_BOOL | CVAR_ARCHIVE, "automatically record complete multiplayer server demos" );
+idCVar				idAsyncNetwork::serverDemoPath( "net_serverDemoPath", "", CVAR_SYSTEM | CVAR_ARCHIVE, "absolute output directory for server demos; empty uses fs_savepath/baseoq4/replays" );
+idCVar				idAsyncNetwork::repeaterMaxViewers( "ri_maxViewers", "16", CVAR_SYSTEM | CVAR_INTEGER | CVAR_ARCHIVE, "maximum viewers on a Q4TV/replay instance", 1, MAX_ASYNC_CLIENTS );
+idCVar				idAsyncNetwork::repeaterWaitForViewer( "ri_waitForViewer", "1", CVAR_SYSTEM | CVAR_BOOL, "wait for the first in-game viewer before advancing replay time" );
 
 int					idAsyncNetwork::realTime;
 master_t			idAsyncNetwork::masters[ MAX_MASTER_SERVERS ];
@@ -108,9 +112,94 @@ void idAsyncNetwork::Init( void ) {
 	cmdSystem->AddCommand( "kick", Kick_f, CMD_FL_SYSTEM, "kick a client by connection number" );
 	cmdSystem->AddCommand( "checkNewVersion", CheckNewVersion_f, CMD_FL_SYSTEM, "check if a new version of the game is available" );
 	cmdSystem->AddCommand( "updateUI", UpdateUI_f, CMD_FL_SYSTEM, "internal - cause a sync down of game-modified userinfo" );
+	cmdSystem->AddCommand( "recordServerDemo", RecordServerDemo_f, CMD_FL_SYSTEM, "starts a full-state multiplayer server demo" );
+	cmdSystem->AddCommand( "stopServerDemo", StopServerDemo_f, CMD_FL_SYSTEM, "finishes the current multiplayer server demo" );
+	cmdSystem->AddCommand( "playServerDemo", PlayServerDemo_f, CMD_FL_SYSTEM, "plays a full-state multiplayer server demo" );
+	cmdSystem->AddCommand( "playServerDemoServer", PlayServerDemoServer_f, CMD_FL_SYSTEM, "serves a full-state server demo to live viewers" );
 #endif
 }
 
+/*
+==================
+idAsyncNetwork::RecordServerDemo_f
+==================
+*/
+void idAsyncNetwork::RecordServerDemo_f( const idCmdArgs &args ) {
+	if ( args.Argc() != 1 ) {
+		common->Printf( "USAGE: recordServerDemo\n" );
+		return;
+	}
+	if ( !server.IsActive() ) {
+		common->Printf( "recordServerDemo: no active server\n" );
+		return;
+	}
+	server.StartServerDemo();
+}
+
+/*
+==================
+idAsyncNetwork::StopServerDemo_f
+==================
+*/
+void idAsyncNetwork::StopServerDemo_f( const idCmdArgs &args ) {
+	if ( args.Argc() != 1 ) {
+		common->Printf( "USAGE: stopServerDemo\n" );
+		return;
+	}
+	server.StopServerDemo();
+}
+
+/*
+==================
+idAsyncNetwork::PlayServerDemo_f
+==================
+*/
+void idAsyncNetwork::PlayServerDemo_f( const idCmdArgs &args ) {
+	if ( args.Argc() != 2 ) {
+		common->Printf( "USAGE: playServerDemo <file.q4sd>\n" );
+		return;
+	}
+#ifdef ID_DEDICATED
+	common->Printf( "playServerDemo is only available in the client executable\n" );
+#else
+	const char *activeModule = cvarSystem->GetCVarString( "com_activeGameModule" );
+	if ( idStr::Icmp( activeModule, "game_mp" ) != 0 ) {
+		cvarSystem->SetCVarString( "si_gameType", "dm" );
+		cvarSystem->SetCVarString( "com_nextGameModule", "game_mp" );
+		idCmdArgs reloadArgs;
+		reloadArgs.AppendArg( "playServerDemo" );
+		reloadArgs.AppendArg( args.Argv( 1 ) );
+		cmdSystem->SetupReloadGameModule( reloadArgs );
+		return;
+	}
+	com_asyncInput.SetBool( false );
+	client.StartServerDemoPlayback( args.Argv( 1 ) );
+#endif
+}
+
+/*
+==================
+idAsyncNetwork::PlayServerDemoServer_f
+==================
+*/
+void idAsyncNetwork::PlayServerDemoServer_f( const idCmdArgs &args ) {
+	if ( args.Argc() != 2 ) {
+		common->Printf( "USAGE: playServerDemoServer <file.q4sd>\n" );
+		return;
+	}
+	const char *activeModule = cvarSystem->GetCVarString( "com_activeGameModule" );
+	if ( idStr::Icmp( activeModule, "game_mp" ) != 0 ) {
+		cvarSystem->SetCVarString( "si_gameType", "dm" );
+		cvarSystem->SetCVarString( "com_nextGameModule", "game_mp" );
+		idCmdArgs reloadArgs;
+		reloadArgs.AppendArg( "playServerDemoServer" );
+		reloadArgs.AppendArg( args.Argv( 1 ) );
+		cmdSystem->SetupReloadGameModule( reloadArgs );
+		return;
+	}
+	server.StartServerDemoPlayback( args.Argv( 1 ) );
+}
+
 /*
 ==================
 idAsyncNetwork::GetMasterAddress
@@ -574,4 +663,3 @@ void idAsyncNetwork::BuildInvalidKeyMsg( idStr &msg, bool valid[ 2 ] ) {
 	msg += "\n";
 	msg += common->GetLanguageDict()->GetString( "#str_04304" );	
 }
-
diff --git a/src/framework/async/AsyncNetwork.h b/src/framework/async/AsyncNetwork.h
index 026d6e8..c488349 100644
--- a/src/framework/async/AsyncNetwork.h
+++ b/src/framework/async/AsyncNetwork.h
@@ -197,6 +197,10 @@ public:
 	static idCVar			serverAllowServerMod;			// let a pure server start with a different game code than what is referenced in game code
 	static idCVar			idleServer;						// serverinfo reply, indicates all clients are idle
 	static idCVar			clientDownload;					// preferred download policy
+	static idCVar			serverDemo;						// automatically record server demos
+	static idCVar			serverDemoPath;					// output directory for server demos
+	static idCVar			repeaterMaxViewers;				// maximum viewers on replay/Q4TV instances
+	static idCVar			repeaterWaitForViewer;				// hold replay time until the first viewer is in game
 
 	// same message used for offline check and network reply
 	static void				BuildInvalidKeyMsg( idStr &msg, bool valid[ 2 ] );
@@ -217,6 +221,10 @@ private:
 	static void				Kick_f( const idCmdArgs &args );
 	static void				CheckNewVersion_f( const idCmdArgs &args );
 	static void				UpdateUI_f( const idCmdArgs &args );
+	static void				RecordServerDemo_f( const idCmdArgs &args );
+	static void				StopServerDemo_f( const idCmdArgs &args );
+	static void				PlayServerDemo_f( const idCmdArgs &args );
+	static void				PlayServerDemoServer_f( const idCmdArgs &args );
 };
 
 #endif /* !__ASYNCNETWORK_H__ */
diff --git a/src/framework/async/AsyncServer.cpp b/src/framework/async/AsyncServer.cpp
index 4fe2335..2c0ad4b 100644
--- a/src/framework/async/AsyncServer.cpp
+++ b/src/framework/async/AsyncServer.cpp
@@ -44,6 +44,60 @@ const int NOINPUT_IDLE_TIME				= 30000;
 
 const int HEARTBEAT_MSEC				= 5*60*1000;
 
+static const int SERVER_DEMO_MAGIC = 'Q' | ( '4' << 8 ) | ( 'S' << 16 ) | ( 'D' << 24 );
+static const int SERVER_DEMO_FORMAT = 1;
+static const int SERVER_DEMO_RECORD_RELIABLE = 1;
+static const int SERVER_DEMO_RECORD_SNAPSHOT = 2;
+static const int SERVER_DEMO_RECORD_END = 3;
+static const int SERVER_DEMO_MAX_SNAPSHOT = 4 * 1024 * 1024;
+
+static idStr ServerDemoUtcStamp( const char *format ) {
+	char stamp[ 64 ];
+	time_t now = time( NULL );
+	tm *utc = gmtime( &now );
+	if ( utc == NULL || strftime( stamp, sizeof( stamp ), format, utc ) == 0 ) {
+		idStr::snPrintf( stamp, sizeof( stamp ), "unknown-%d", Sys_Milliseconds() );
+	}
+	return stamp;
+}
+
+static idStr ServerDemoSafeName( const char *value ) {
+	idStr safe;
+	for ( const char *p = value; p != NULL && *p != '\0'; ++p ) {
+		const char c = *p;
+		if ( ( c >= 'a' && c <= 'z' ) || ( c >= 'A' && c <= 'Z' ) ||
+			( c >= '0' && c <= '9' ) || c == '-' || c == '_' ) {
+			safe += c;
+		} else {
+			safe += '_';
+		}
+	}
+	if ( safe.IsEmpty() ) {
+		safe = "unknown";
+	}
+	return safe;
+}
+
+static idStr ServerDemoJsonString( const char *value ) {
+	idStr escaped = "\"";
+	for ( const char *p = value; p != NULL && *p != '\0'; ++p ) {
+		switch ( *p ) {
+		case '\\': escaped += "\\\\"; break;
+		case '\"': escaped += "\\\""; break;
+		case '\n': escaped += "\\n"; break;
+		case '\r': escaped += "\\r"; break;
+		case '\t': escaped += "\\t"; break;
+		default:
+			if ( static_cast<unsigned char>( *p ) >= 0x20 ) {
+				escaped += *p;
+			}
+			break;
+		}
+	}
+	escaped += "\"";
+	return escaped;
+}
+
 // must be kept in sync with authReplyMsg_t
 const char* authReplyMsg[] = {
 	//	"Waiting for authorization",
@@ -83,6 +137,20 @@ idAsyncServer::idAsyncServer( void ) {
 	gameFrame = 0;
 	gameTime = 0;
 	gameTimeResidual = 0;
+	serverDemoFile = NULL;
+	serverDemoSequence = 0;
+	serverDemoLastSnapshotFrame = 0;
+	serverDemoLastSnapshotTime = 0;
+	serverDemoStartTime = 0;
+	serverDemoReliableCount = 0;
+	serverDemoPlaybackActive = false;
+	serverDemoPlaybackEnded = false;
+	serverDemoPlaybackLoading = false;
+	serverDemoPlaybackStarted = false;
+	serverDemoPlaybackFile = NULL;
+	serverDemoPlaybackInitialGameTime = 0;
+	serverDemoPlaybackStartTime = 0;
+	memset( serverDemoViewerOrigins, 0, sizeof( serverDemoViewerOrigins ) );
 	memset( challenges, 0, sizeof( challenges ) );
 	memset( userCmds, 0, sizeof( userCmds ) );
 	for ( i = 0; i < MAX_ASYNC_CLIENTS; i++ ) {
@@ -208,9 +276,14 @@ void idAsyncServer::Kill( void ) {
 	int i, j;
 
 	if ( !active ) {
+		if ( serverDemoPlaybackActive && !serverDemoPlaybackLoading ) {
+			StopServerDemoPlayback();
+		}
 		return;
 	}
 
+	StopServerDemo();
+
 	// drop all clients
 	for ( i = 0; i < MAX_ASYNC_CLIENTS; i++ ) {
 		DropClient( i, "#str_07135" );
@@ -234,6 +307,7 @@ void idAsyncServer::Kill( void ) {
 	fileSystem->ClearPureChecksums();
 
 	active = false;
+	StopServerDemoPlayback();
 
 	// shutdown any current game
 	session->Stop();
@@ -255,6 +329,9 @@ void idAsyncServer::ExecuteMapChange( void ) {
 
 	assert( active );
 
+	// A server demo is one map/match. Close it before tearing down game state.
+	StopServerDemo();
+
 	// reset any pureness
 	fileSystem->ClearPureChecksums();
 
@@ -384,6 +461,429 @@ void idAsyncServer::ExecuteMapChange( void ) {
 
 	// serverTime gets reset, force a heartbeat so timings restart
 	MasterHeartbeat( true );
+
+	if ( idAsyncNetwork::serverDemo.GetBool() && !IsServerDemoPlaying() ) {
+		StartServerDemo();
+	}
+}
+
+/*
+==================
+idAsyncServer::StartServerDemo
+==================
+*/
+void idAsyncServer::StartServerDemo( void ) {
+	if ( serverDemoFile != NULL ) {
+		common->Printf( "server demo is already recording to %s\n", serverDemoFinalPath.c_str() );
+		return;
+	}
+	if ( !active || game == NULL || !sessLocal.mapSpawned ) {
+		common->Printf( "StartServerDemo: no active multiplayer map\n" );
+		return;
+	}
+
+	serverDemoMap = sessLocal.mapSpawnData.serverInfo.GetString( "si_map", "unknown" );
+	serverDemoGameType = sessLocal.mapSpawnData.serverInfo.GetString( "si_gameType", "unknown" );
+	serverDemoStartedAt = ServerDemoUtcStamp( "%Y-%m-%dT%H:%M:%SZ" );
+	const idStr fileStamp = ServerDemoUtcStamp( "%Y%m%d-%H%M%S" );
+	const idStr fileName = va( "q4-%s-%s-%08x.q4sd", fileStamp.c_str(),
+		ServerDemoSafeName( serverDemoMap.c_str() ).c_str(), static_cast<unsigned int>( gameInitId ) );
+
+	const char *configuredPath = idAsyncNetwork::serverDemoPath.GetString();
+	if ( configuredPath != NULL && configuredPath[ 0 ] != '\0' ) {
+		serverDemoFinalPath = configuredPath;
+		serverDemoFinalPath.AppendPath( fileName );
+	} else {
+		serverDemoFinalPath = fileSystem->RelativePathToOSPath( va( "replays/%s", fileName.c_str() ), "fs_savepath" );
+	}
+	serverDemoPartPath = serverDemoFinalPath;
+	serverDemoPartPath += ".part";
+	serverDemoFile = fileSystem->OpenExplicitFileWrite( serverDemoPartPath.c_str() );
+	if ( serverDemoFile == NULL ) {
+		common->Warning( "StartServerDemo: cannot open %s", serverDemoPartPath.c_str() );
+		serverDemoPartPath.Clear();
+		serverDemoFinalPath.Clear();
+		return;
+	}
+
+	serverDemoSequence = 1;
+	serverDemoLastSnapshotFrame = gameFrame;
+	serverDemoLastSnapshotTime = -999999;
+	serverDemoStartTime = serverTime;
+	serverDemoReliableCount = 0;
+
+	serverDemoFile->WriteInt( SERVER_DEMO_MAGIC );
+	serverDemoFile->WriteInt( SERVER_DEMO_FORMAT );
+	serverDemoFile->WriteInt( ASYNC_PROTOCOL_VERSION );
+	serverDemoFile->WriteInt( gameInitId );
+	serverDemoFile->WriteInt( gameFrame );
+	serverDemoFile->WriteInt( gameTime );
+	serverDemoFile->WriteString( serverDemoStartedAt.c_str() );
+	sessLocal.mapSpawnData.serverInfo.WriteToFileHandle( serverDemoFile );
+	for ( int i = 0; i < MAX_ASYNC_CLIENTS; ++i ) {
+		sessLocal.mapSpawnData.userInfo[ i ].WriteToFileHandle( serverDemoFile );
+	}
+	sessLocal.mapSpawnData.syncedCVars.WriteToFileHandle( serverDemoFile );
+
+	game->SetDemoState( DEMO_RECORDING, true, false );
+	game->WriteNetworkInfo( serverDemoFile, MAX_ASYNC_CLIENTS );
+	serverDemoFile->ForceFlush();
+	common->Printf( "recording full-state server demo to %s\n", serverDemoFinalPath.c_str() );
+}
+
+/*
+==================
+idAsyncServer::StopServerDemo
+==================
+*/
+void idAsyncServer::StopServerDemo( void ) {
+	if ( serverDemoFile == NULL ) {
+		return;
+	}
+
+	serverDemoFile->WriteInt( SERVER_DEMO_RECORD_END );
+	serverDemoFile->WriteInt( gameTime );
+	serverDemoFile->WriteInt( 0 );
+	serverDemoFile->ForceFlush();
+	fileSystem->CloseFile( serverDemoFile );
+	serverDemoFile = NULL;
+	game->SetDemoState( DEMO_NONE, false, false );
+
+	if ( rename( serverDemoPartPath.c_str(), serverDemoFinalPath.c_str() ) != 0 ) {
+		common->Warning( "StopServerDemo: cannot finalize %s", serverDemoFinalPath.c_str() );
+		serverDemoPartPath.Clear();
+		serverDemoFinalPath.Clear();
+		return;
+	}
+
+	const idStr endedAt = ServerDemoUtcStamp( "%Y-%m-%dT%H:%M:%SZ" );
+	idStr metadataPath = serverDemoFinalPath;
+	metadataPath += ".meta.json";
+	idFile *metadata = fileSystem->OpenExplicitFileWrite( metadataPath.c_str() );
+	if ( metadata != NULL ) {
+		metadata->Printf(
+			"{\n"
+			"  \"format\": \"openq4-server-demo\",\n"
+			"  \"version\": %d,\n"
+			"  \"complete\": true,\n"
+			"  \"map\": %s,\n"
+			"  \"gameType\": %s,\n"
+			"  \"startedAt\": %s,\n"
+			"  \"endedAt\": %s,\n"
+			"  \"durationMs\": %d,\n"
+			"  \"snapshots\": %d,\n"
+			"  \"reliableMessages\": %d,\n"
+			"  \"playCommand\": %s\n"
+			"}\n",
+			SERVER_DEMO_FORMAT,
+			ServerDemoJsonString( serverDemoMap.c_str() ).c_str(),
+			ServerDemoJsonString( serverDemoGameType.c_str() ).c_str(),
+			ServerDemoJsonString( serverDemoStartedAt.c_str() ).c_str(),
+			ServerDemoJsonString( endedAt.c_str() ).c_str(),
+			Max( 0, serverTime - serverDemoStartTime ),
+			Max( 0, serverDemoSequence - 1 ),
+			serverDemoReliableCount,
+			ServerDemoJsonString( va( "playServerDemo %s", serverDemoFinalPath.c_str() ) ).c_str() );
+		fileSystem->CloseFile( metadata );
+	}
+
+	common->Printf( "finished server demo %s (%d snapshots, %d reliable messages)\n",
+		serverDemoFinalPath.c_str(), Max( 0, serverDemoSequence - 1 ), serverDemoReliableCount );
+	serverDemoPartPath.Clear();
+	serverDemoFinalPath.Clear();
+}
+
+/*
+==================
+idAsyncServer::StartServerDemoPlayback
+==================
+*/
+bool idAsyncServer::StartServerDemoPlayback( const char *path ) {
+	if ( path == NULL || path[0] == '\0' ) {
+		return false;
+	}
+	if ( active ) {
+		Kill();
+	}
+
+	idFile *file = fileSystem->OpenExplicitFileRead( path );
+	if ( file == NULL ) {
+		file = fileSystem->OpenFileRead( path );
+	}
+	if ( file == NULL ) {
+		common->Printf( "could not open server demo %s\n", path );
+		return false;
+	}
+
+	int magic = 0;
+	int format = 0;
+	int protocol = 0;
+	int initialGameInitId = 0;
+	int initialGameFrame = 0;
+	int initialGameTime = 0;
+	if ( file->ReadInt( magic ) != sizeof( magic ) ||
+		file->ReadInt( format ) != sizeof( format ) ||
+		file->ReadInt( protocol ) != sizeof( protocol ) ||
+		file->ReadInt( initialGameInitId ) != sizeof( initialGameInitId ) ||
+		file->ReadInt( initialGameFrame ) != sizeof( initialGameFrame ) ||
+		file->ReadInt( initialGameTime ) != sizeof( initialGameTime ) ) {
+		common->Warning( "truncated server demo header: %s", path );
+		fileSystem->CloseFile( file );
+		return false;
+	}
+	if ( magic != SERVER_DEMO_MAGIC || format != SERVER_DEMO_FORMAT ) {
+		common->Warning( "unsupported server demo format in %s", path );
+		fileSystem->CloseFile( file );
+		return false;
+	}
+	if ( MAJOR_VERSION( protocol ) != ASYNC_PROTOCOL_MAJOR ||
+		!game->ValidateDemoProtocol( ASYNC_PROTOCOL_MINOR, protocol & 0xffff ) ) {
+		common->Warning( "server demo protocol %d.%d is incompatible with %d.%d",
+			MAJOR_VERSION( protocol ), protocol & 0xffff, ASYNC_PROTOCOL_MAJOR, ASYNC_PROTOCOL_MINOR );
+		fileSystem->CloseFile( file );
+		return false;
+	}
+
+	idStr recordedAt;
+	file->ReadString( recordedAt );
+	idDict serverInfo;
+	serverInfo.ReadFromFileHandle( file );
+	idDict userInfo[MAX_ASYNC_CLIENTS];
+	for ( int i = 0; i < MAX_ASYNC_CLIENTS; ++i ) {
+		userInfo[i].ReadFromFileHandle( file );
+	}
+	idDict syncedCVars;
+	syncedCVars.ReadFromFileHandle( file );
+
+	serverInfo.SetBool( "si_tv", true );
+	serverInfo.SetBool( "si_pure", false );
+	serverInfo.SetInt( "si_maxPlayers", idAsyncNetwork::repeaterMaxViewers.GetInteger() );
+	cvarSystem->SetCVarString( "si_map", serverInfo.GetString( "si_map" ) );
+	cvarSystem->SetCVarString( "si_gameType", serverInfo.GetString( "si_gameType", "DM" ) );
+	cvarSystem->SetCVarBool( "si_pure", false );
+
+	serverDemoPlaybackActive = true;
+	serverDemoPlaybackEnded = false;
+	serverDemoPlaybackLoading = true;
+	serverDemoPlaybackStarted = !idAsyncNetwork::repeaterWaitForViewer.GetBool();
+	serverDemoPlaybackFile = file;
+	serverDemoPlaybackPath = path;
+	serverDemoPlaybackInitialGameTime = initialGameTime;
+	sessLocal.mapSpawnData.serverInfo = serverInfo;
+	for ( int i = 0; i < MAX_ASYNC_CLIENTS; ++i ) {
+		sessLocal.mapSpawnData.userInfo[i] = userInfo[i];
+		serverDemoViewerInfo[i].Clear();
+		serverDemoViewerOrigins[i].origin.Zero();
+		serverDemoViewerOrigins[i].followClient = -1;
+	}
+	sessLocal.mapSpawnData.syncedCVars = syncedCVars;
+
+	Spawn();
+	if ( !active || !sessLocal.mapSpawned ) {
+		StopServerDemoPlayback();
+		return false;
+	}
+
+	gameInitId = initialGameInitId;
+	gameFrame = initialGameFrame;
+	gameTime = initialGameTime;
+	gameTimeResidual = 0;
+	sessLocal.mapSpawnData.serverInfo = serverInfo;
+	for ( int i = 0; i < MAX_ASYNC_CLIENTS; ++i ) {
+		sessLocal.mapSpawnData.userInfo[i] = userInfo[i];
+		if ( userInfo[i].GetNumKeyVals() > 0 ) {
+			game->SetUserInfo( i, userInfo[i], true );
+		}
+	}
+	sessLocal.mapSpawnData.syncedCVars = syncedCVars;
+	game->SetDemoState( DEMO_PLAYING, true, false );
+	game->SetRepeaterState( true, false );
+	game->SetServerInfo( serverInfo );
+	game->ReadNetworkInfo( initialGameTime, serverDemoPlaybackFile, MAX_ASYNC_CLIENTS );
+	serverDemoPlaybackStartTime = serverTime;
+	serverDemoPlaybackLoading = false;
+	MasterHeartbeat( true );
+	common->Printf( "serving full-state server demo %s on port %d (recorded %s)\n",
+		path, serverPort.GetPort(), recordedAt.c_str() );
+	return true;
+}
+
+/*
+==================
+idAsyncServer::StopServerDemoPlayback
+==================
+*/
+void idAsyncServer::StopServerDemoPlayback( void ) {
+	if ( serverDemoPlaybackFile != NULL ) {
+		fileSystem->CloseFile( serverDemoPlaybackFile );
+		serverDemoPlaybackFile = NULL;
+	}
+	if ( serverDemoPlaybackActive && game != NULL ) {
+		game->SetRepeaterState( false, false );
+		game->SetDemoState( DEMO_NONE, false, false );
+	}
+	serverDemoPlaybackActive = false;
+	serverDemoPlaybackEnded = false;
+	serverDemoPlaybackLoading = false;
+	serverDemoPlaybackStarted = false;
+	serverDemoPlaybackPath.Clear();
+}
+
+/*
+==================
+idAsyncServer::RunServerDemoPlaybackFrame
+==================
+*/
+void idAsyncServer::RunServerDemoPlaybackFrame( void ) {
+	if ( !serverDemoPlaybackActive || serverDemoPlaybackLoading || serverDemoPlaybackEnded || serverDemoPlaybackFile == NULL ) {
+		return;
+	}
+	if ( !serverDemoPlaybackStarted ) {
+		for ( int i = 0; i < MAX_ASYNC_CLIENTS; ++i ) {
+			if ( clients[i].clientState == SCS_INGAME ) {
+				serverDemoPlaybackStarted = true;
+				serverDemoPlaybackStartTime = serverTime;
+				common->Printf( "starting network server demo %s for first viewer\n", serverDemoPlaybackPath.c_str() );
+				break;
+			}
+		}
+		if ( !serverDemoPlaybackStarted ) {
+			game->RepeaterFrame( serverDemoViewerOrigins, true, gameFrame );
+			return;
+		}
+	}
+
+	const int targetGameTime = serverDemoPlaybackInitialGameTime + Max( 0, serverTime - serverDemoPlaybackStartTime );
+	bool reachedEnd = false;
+	while ( !reachedEnd ) {
+		const int recordOffset = serverDemoPlaybackFile->Tell();
+		int recordType = 0;
+		int recordTime = 0;
+		int recordSize = 0;
+		if ( serverDemoPlaybackFile->ReadInt( recordType ) != sizeof( recordType ) ||
+			serverDemoPlaybackFile->ReadInt( recordTime ) != sizeof( recordTime ) ||
+			serverDemoPlaybackFile->ReadInt( recordSize ) != sizeof( recordSize ) ) {
+			common->Warning( "truncated server demo record in %s", serverDemoPlaybackPath.c_str() );
+			reachedEnd = true;
+			break;
+		}
+		if ( recordSize < 0 || recordSize > SERVER_DEMO_MAX_SNAPSHOT ) {
+			common->Warning( "invalid server demo record size %d in %s", recordSize, serverDemoPlaybackPath.c_str() );
+			reachedEnd = true;
+			break;
+		}
+		if ( recordTime > targetGameTime ) {
+			serverDemoPlaybackFile->Seek( recordOffset, FS_SEEK_SET );
+			break;
+		}
+		if ( recordType == SERVER_DEMO_RECORD_END ) {
+			reachedEnd = true;
+			break;
+		}
+
+		int snapshotSequence = 0;
+		int snapshotFrame = 0;
+		int snapshotTime = 0;
+		if ( recordType == SERVER_DEMO_RECORD_SNAPSHOT ) {
+			if ( serverDemoPlaybackFile->ReadInt( snapshotSequence ) != sizeof( snapshotSequence ) ||
+				serverDemoPlaybackFile->ReadInt( snapshotFrame ) != sizeof( snapshotFrame ) ||
+				serverDemoPlaybackFile->ReadInt( snapshotTime ) != sizeof( snapshotTime ) ) {
+				common->Warning( "truncated server demo snapshot header in %s", serverDemoPlaybackPath.c_str() );
+				reachedEnd = true;
+				break;
+			}
+		} else if ( recordType != SERVER_DEMO_RECORD_RELIABLE ) {
+			common->Warning( "unknown server demo record %d in %s", recordType, serverDemoPlaybackPath.c_str() );
+			reachedEnd = true;
+			break;
+		}
+
+		byte *payload = static_cast<byte *>( Mem_Alloc( Max( 1, recordSize ) ) );
+		if ( recordSize > 0 && serverDemoPlaybackFile->Read( payload, recordSize ) != recordSize ) {
+			Mem_Free( payload );
+			common->Warning( "truncated server demo payload in %s", serverDemoPlaybackPath.c_str() );
+			reachedEnd = true;
+			break;
+		}
+		idBitMsg record;
+		record.Init( payload, recordSize );
+		record.SetSize( recordSize );
+		record.BeginReading();
+		if ( recordType == SERVER_DEMO_RECORD_RELIABLE ) {
+			game->ClientProcessReliableMessage( MAX_ASYNC_CLIENTS, record );
+		} else {
+			game->ClientReadServerDemoSnapshot( snapshotSequence, snapshotFrame, snapshotTime, record );
+			gameFrame = snapshotFrame;
+			gameTime = snapshotTime;
+		}
+		Mem_Free( payload );
+	}
+
+	game->RepeaterFrame( serverDemoViewerOrigins, true, gameFrame );
+	if ( reachedEnd ) {
+		fileSystem->CloseFile( serverDemoPlaybackFile );
+		serverDemoPlaybackFile = NULL;
+		serverDemoPlaybackEnded = true;
+		common->Printf( "finished network server demo %s\n", serverDemoPlaybackPath.c_str() );
+	}
+}
+
+/*
+==================
+idAsyncServer::RecordServerDemoReliable
+==================
+*/
+void idAsyncServer::RecordServerDemoReliable( int recordType, int clientNum, const idBitMsg &msg ) {
+	if ( serverDemoFile == NULL ) {
+		return;
+	}
+	const int payloadSize = msg.GetSize() + 2;
+	serverDemoFile->WriteInt( SERVER_DEMO_RECORD_RELIABLE );
+	serverDemoFile->WriteInt( gameTime );
+	serverDemoFile->WriteInt( payloadSize );
+	serverDemoFile->WriteUnsignedChar( static_cast<unsigned char>( recordType ) );
+	serverDemoFile->WriteChar( static_cast<char>( clientNum ) );
+	serverDemoFile->Write( msg.GetData(), msg.GetSize() );
+	serverDemoReliableCount++;
+}
+
+/*
+==================
+idAsyncServer::WriteServerDemoSnapshot
+==================
+*/
+void idAsyncServer::WriteServerDemoSnapshot( void ) {
+	if ( serverDemoFile == NULL || serverTime - serverDemoLastSnapshotTime < idAsyncNetwork::serverSnapshotDelay.GetInteger() ) {
+		return;
+	}
+
+	byte *msgBuf = static_cast<byte *>( Mem_Alloc( SERVER_DEMO_MAX_SNAPSHOT ) );
+	idBitMsg msg;
+	msg.Init( msgBuf, SERVER_DEMO_MAX_SNAPSHOT );
+	msg.SetAllowOverflow( true );
+	game->ServerWriteServerDemoSnapshot( serverDemoSequence, msg, serverDemoLastSnapshotFrame );
+	if ( msg.IsOverflowed() ) {
+		Mem_Free( msgBuf );
+		common->Warning( "server demo snapshot exceeded %d bytes; stopping recording", SERVER_DEMO_MAX_SNAPSHOT );
+		StopServerDemo();
+		return;
+	}
+
+	serverDemoFile->WriteInt( SERVER_DEMO_RECORD_SNAPSHOT );
+	serverDemoFile->WriteInt( gameTime );
+	serverDemoFile->WriteInt( msg.GetSize() );
+	serverDemoFile->WriteInt( serverDemoSequence );
+	serverDemoFile->WriteInt( gameFrame );
+	serverDemoFile->WriteInt( gameTime );
+	serverDemoFile->Write( msg.GetData(), msg.GetSize() );
+	Mem_Free( msgBuf );
+
+	serverDemoLastSnapshotFrame = gameFrame;
+	serverDemoLastSnapshotTime = serverTime;
+	serverDemoSequence++;
+	if ( ( serverDemoSequence & 31 ) == 0 ) {
+		serverDemoFile->ForceFlush();
+	}
 }
 
 /*
@@ -678,7 +1178,11 @@ void idAsyncServer::InitClient( int clientNum, int clientId, int clientRate ) {
 	int i;
 
 	// clear the user info
-	sessLocal.mapSpawnData.userInfo[ clientNum ].Clear();	// always start with a clean base
+	if ( IsServerDemoPlaying() ) {
+		serverDemoViewerInfo[clientNum].Clear();
+	} else {
+		sessLocal.mapSpawnData.userInfo[ clientNum ].Clear();	// always start with a clean base
+	}
 
 	// clear the server client
 	serverClient_t &client = clients[clientNum];
@@ -708,7 +1212,11 @@ void idAsyncServer::InitClient( int clientNum, int clientId, int clientRate ) {
 	}
 
 	// let the game know a player connected
-	game->ServerClientConnect( clientNum, client.guid );
+	if ( IsServerDemoPlaying() ) {
+		game->RepeaterClientConnect( clientNum );
+	} else {
+		game->ServerClientConnect( clientNum, client.guid );
+	}
 }
 
 /*
@@ -793,20 +1301,29 @@ void idAsyncServer::DropClient( int clientNum, const char *reason ) {
 		msg.WriteByte( SERVER_RELIABLE_MESSAGE_DISCONNECT );
 		msg.WriteLong( clientNum );
 		msg.WriteString( reason );
-		for ( i = 0; i < MAX_ASYNC_CLIENTS; i++ ) {
-			// clientNum so SCS_PUREWAIT client gets it's own disconnect msg
-			if ( i == clientNum || clients[i].clientState >= SCS_CONNECTED ) {
-				SendReliableMessage( i, msg );
+		if ( IsServerDemoPlaying() ) {
+			SendReliableMessage( clientNum, msg );
+		} else {
+			for ( i = 0; i < MAX_ASYNC_CLIENTS; i++ ) {
+				// clientNum so SCS_PUREWAIT client gets it's own disconnect msg
+				if ( i == clientNum || clients[i].clientState >= SCS_CONNECTED ) {
+					SendReliableMessage( i, msg );
+				}
 			}
 		}
 	}
 
 	reason = common->GetLanguageDict()->GetString( reason );
 	common->Printf( "client %d %s\n", clientNum, reason );
-	cmdSystem->BufferCommandText( CMD_EXEC_NOW, va( "addChatLine \"%s^0 %s\"", sessLocal.mapSpawnData.userInfo[ clientNum ].GetString( "ui_name" ), reason ) );
+	cmdSystem->BufferCommandText( CMD_EXEC_NOW, va( "addChatLine \"%s^0 %s\"",
+		IsServerDemoPlaying() ? serverDemoViewerInfo[clientNum].GetString( "ui_name" ) : sessLocal.mapSpawnData.userInfo[ clientNum ].GetString( "ui_name" ), reason ) );
 
 	// remove the player from the game
-	game->ServerClientDisconnect( clientNum );
+	if ( IsServerDemoPlaying() ) {
+		game->RepeaterClientDisconnect( clientNum );
+	} else {
+		game->ServerClientDisconnect( clientNum );
+	}
 
 	client.clientState = SCS_ZOMBIE;
 }
@@ -1248,6 +1765,42 @@ bool idAsyncServer::SendSnapshotToClient( int clientNum ) {
 	return true;
 }
 
+/*
+==================
+idAsyncServer::SendRepeaterSnapshotToClient
+==================
+*/
+bool idAsyncServer::SendRepeaterSnapshotToClient( int clientNum ) {
+	idBitMsg msg;
+	byte msgBuf[MAX_MESSAGE_SIZE];
+	dword clientInPVS[MAX_ASYNC_CLIENTS >> 3];
+	serverClient_t &client = clients[clientNum];
+
+	if ( serverTime - client.lastSnapshotTime < idAsyncNetwork::serverSnapshotDelay.GetInteger() ) {
+		return false;
+	}
+	client.clientAheadTime = client.gameTime - gameTime;
+	memset( clientInPVS, 0, sizeof( clientInPVS ) );
+
+	msg.Init( msgBuf, sizeof( msgBuf ) );
+	msg.WriteLong( gameInitId );
+	msg.WriteByte( SERVER_UNRELIABLE_MESSAGE_SNAPSHOT );
+	msg.WriteLong( client.snapshotSequence );
+	msg.WriteLong( gameFrame );
+	msg.WriteLong( gameTime );
+	msg.WriteByte( 0 );
+	msg.WriteShort( idMath::ClampShort( client.clientAheadTime ) );
+	game->RepeaterWriteSnapshot( clientNum, client.snapshotSequence, msg, clientInPVS,
+		MAX_ASYNC_CLIENTS, serverDemoViewerOrigins[clientNum], 0 );
+	msg.WriteByte( MAX_ASYNC_CLIENTS );
+
+	client.channel.SendMessage( serverPort, serverTime, msg );
+	client.lastSnapshotTime = serverTime;
+	client.snapshotSequence++;
+	client.numDuplicatedUsercmds = 0;
+	return true;
+}
+
 /*
 ==================
 idAsyncServer::ProcessUnreliableClientMessage
@@ -1304,7 +1857,11 @@ void idAsyncServer::ProcessUnreliableClientMessage( int clientNum, const idBitMs
 
 		// send the user info of other clients
 		for ( i = 0; i < MAX_ASYNC_CLIENTS; i++ ) {
-			if ( clients[i].clientState >= SCS_CONNECTED && i != clientNum ) {
+			if ( IsServerDemoPlaying() ) {
+				if ( sessLocal.mapSpawnData.userInfo[i].GetNumKeyVals() > 0 ) {
+					SendUserInfoToClient( clientNum, i, sessLocal.mapSpawnData.userInfo[i] );
+				}
+			} else if ( clients[i].clientState >= SCS_CONNECTED && i != clientNum ) {
 				SendUserInfoToClient( clientNum, i, sessLocal.mapSpawnData.userInfo[i] );
 			}
 		}
@@ -1315,14 +1872,25 @@ void idAsyncServer::ProcessUnreliableClientMessage( int clientNum, const idBitMs
 		SendEnterGameToClient( clientNum );
 
 		// get the client running in the game
-		game->ServerClientBegin( clientNum, false, NULL );
+		if ( IsServerDemoPlaying() ) {
+			game->RepeaterClientBegin( clientNum );
+		} else {
+			game->ServerClientBegin( clientNum, false, NULL );
+		}
 
 		// write any reliable messages to initialize the client game state
-		game->ServerWriteInitialReliableMessages( clientNum );
+		if ( IsServerDemoPlaying() ) {
+			game->RepeaterWriteInitialReliableMessages( clientNum );
+		} else {
+			game->ServerWriteInitialReliableMessages( clientNum );
+		}
 	} else if ( client.clientState == SCS_INGAME ) {
 
 		// apply the last snapshot the client received
-		if ( game->ServerApplySnapshot( clientNum, client.acknowledgeSnapshotSequence ) ) {
+		const bool applied = IsServerDemoPlaying()
+			? game->RepeaterApplySnapshot( clientNum, client.acknowledgeSnapshotSequence )
+			: game->ServerApplySnapshot( clientNum, client.acknowledgeSnapshotSequence );
+		if ( applied ) {
 			SendApplySnapshotToClient( clientNum, client.acknowledgeSnapshotSequence );
 		}
 	}
@@ -1392,10 +1960,16 @@ void idAsyncServer::ProcessReliableClientMessages( int clientNum ) {
 	while ( client.channel.GetReliableMessage( msg ) ) {
 		id = msg.ReadByte();
 		switch( id ) {
-			case CLIENT_RELIABLE_MESSAGE_CLIENTINFO: {
+		case CLIENT_RELIABLE_MESSAGE_CLIENTINFO: {
 				idDict info;
-				msg.ReadDeltaDict( info, &sessLocal.mapSpawnData.userInfo[clientNum] );
-				SendUserInfoBroadcast( clientNum, info );
+				if ( IsServerDemoPlaying() ) {
+					msg.ReadDeltaDict( info, &serverDemoViewerInfo[clientNum] );
+					const idDict *modified = game->RepeaterSetUserInfo( clientNum, info );
+					serverDemoViewerInfo[clientNum] = modified != NULL ? *modified : info;
+				} else {
+					msg.ReadDeltaDict( info, &sessLocal.mapSpawnData.userInfo[clientNum] );
+					SendUserInfoBroadcast( clientNum, info );
+				}
 				break;
 			}
 			case CLIENT_RELIABLE_MESSAGE_PRINT: {
@@ -1415,7 +1989,11 @@ void idAsyncServer::ProcessReliableClientMessages( int clientNum ) {
 			}
 			default: {
 				// pass reliable message on to game code
-				game->ServerProcessReliableMessage( clientNum, msg );
+				if ( IsServerDemoPlaying() ) {
+					game->RepeaterProcessReliableMessage( clientNum, msg );
+				} else {
+					game->ServerProcessReliableMessage( clientNum, msg );
+				}
 				break;
 			}
 		}
@@ -1753,7 +2331,9 @@ void idAsyncServer::ProcessConnectMessage( const netadr_t from, const idBitMsg &
 	// but meanwhile, the max players may have been reached
 	msg.ReadString( password, sizeof( password ) );
 	char reason[MAX_STRING_CHARS];
-	allowReply_t reply = game->ServerAllowClient(clientId, numClients, Sys_NetAdrToString( from ), guid, password, password, reason );
+	allowReply_t reply = IsServerDemoPlaying()
+		? game->RepeaterAllowClient( clientId, numClients, Sys_NetAdrToString( from ), guid, true, password, password, reason )
+		: game->ServerAllowClient( clientId, numClients, Sys_NetAdrToString( from ), guid, password, password, reason );
 	if ( reply != ALLOW_YES ) {
 		common->DPrintf( "game denied connection for %s\n", Sys_NetAdrToString( from ) );
 
@@ -2244,6 +2824,8 @@ void idAsyncServer::SendReliableGameMessage( int clientNum, const idBitMsg &msg
 	outMsg.WriteByte( SERVER_RELIABLE_MESSAGE_GAME );
 	outMsg.WriteData( msg.GetData(), msg.GetSize() );
 
+	RecordServerDemoReliable( DEMO_RECORD_CLIENTNUM, clientNum, msg );
+
 	if ( clientNum >= 0 && clientNum < MAX_ASYNC_CLIENTS ) {
 		if ( clients[clientNum].clientState == SCS_INGAME ) {
 			SendReliableMessage( clientNum, outMsg );
@@ -2275,6 +2857,8 @@ void idAsyncServer::SendReliableGameMessageExcluding( int clientNum, const idBit
 	outMsg.WriteByte( SERVER_RELIABLE_MESSAGE_GAME );
 	outMsg.WriteData( msg.GetData(), msg.GetSize() );
 
+	RecordServerDemoReliable( DEMO_RECORD_EXCLUDE, clientNum, msg );
+
 	for ( i = 0; i < MAX_ASYNC_CLIENTS; i++ ) {
 		if ( i == clientNum ) {
 			continue;
@@ -2286,6 +2870,85 @@ void idAsyncServer::SendReliableGameMessageExcluding( int clientNum, const idBit
 	}	
 }
 
+/*
+==================
+idAsyncServer::SendRepeaterReliableGameMessage
+==================
+*/
+void idAsyncServer::SendRepeaterReliableGameMessage( int repeaterClient, const idBitMsg &msg, bool inhibitHeader, int clientNum ) {
+	idBitMsg outMsg;
+	byte msgBuf[MAX_MESSAGE_SIZE];
+	outMsg.Init( msgBuf, sizeof( msgBuf ) );
+	outMsg.WriteByte( SERVER_RELIABLE_MESSAGE_GAME );
+	if ( !inhibitHeader ) {
+		outMsg.WriteByte( DEMO_RECORD_CLIENTNUM );
+		outMsg.WriteChar( clientNum );
+	}
+	outMsg.WriteData( msg.GetData(), msg.GetSize() );
+
+	if ( repeaterClient >= 0 && repeaterClient < MAX_ASYNC_CLIENTS ) {
+		if ( clients[repeaterClient].clientState == SCS_INGAME ) {
+			SendReliableMessage( repeaterClient, outMsg );
+		}
+		return;
+	}
+	for ( int i = 0; i < MAX_ASYNC_CLIENTS; ++i ) {
+		if ( clients[i].clientState == SCS_INGAME ) {
+			SendReliableMessage( i, outMsg );
+		}
+	}
+}
+
+/*
+==================
+idAsyncServer::SendRepeaterReliableGameMessageExcluding
+==================
+*/
+void idAsyncServer::SendRepeaterReliableGameMessageExcluding( int clientNum, const idBitMsg &msg, bool inhibitHeader ) {
+	idBitMsg outMsg;
+	byte msgBuf[MAX_MESSAGE_SIZE];
+	outMsg.Init( msgBuf, sizeof( msgBuf ) );
+	outMsg.WriteByte( SERVER_RELIABLE_MESSAGE_GAME );
+	if ( !inhibitHeader ) {
+		outMsg.WriteByte( DEMO_RECORD_EXCLUDE );
+		outMsg.WriteChar( clientNum );
+	}
+	outMsg.WriteData( msg.GetData(), msg.GetSize() );
+	for ( int i = 0; i < MAX_ASYNC_CLIENTS; ++i ) {
+		if ( clients[i].clientState == SCS_INGAME ) {
+			SendReliableMessage( i, outMsg );
+		}
+	}
+}
+
+/*
+==================
+idAsyncServer::SetRepeaterInfo
+==================
+*/
+void idAsyncServer::SetRepeaterInfo( const idDict &info ) {
+	if ( !IsServerDemoPlaying() ) {
+		return;
+	}
+	sessLocal.mapSpawnData.serverInfo = info;
+	sessLocal.mapSpawnData.serverInfo.SetBool( "si_tv", true );
+	sessLocal.mapSpawnData.serverInfo.SetBool( "si_pure", false );
+}
+
+/*
+==================
+idAsyncServer::GetRepeaterClientNum
+==================
+*/
+int idAsyncServer::GetRepeaterClientNum( int clientId ) const {
+	for ( int i = 0; i < MAX_ASYNC_CLIENTS; ++i ) {
+		if ( clients[i].clientState >= SCS_PUREWAIT && clients[i].clientId == clientId ) {
+			return i;
+		}
+	}
+	return -1;
+}
+
 /*
 ==================
 idAsyncServer::LocalClientSendReliableMessage
@@ -2372,7 +3035,9 @@ void idAsyncServer::RunFrame( bool allowBlocking ) {
 
 		do {
 			const int nextGameFrameMsec = AsyncServer_NextGameFrameMsec( gameFrame );
-			const int packetTimeout = allowBlocking ? ( nextGameFrameMsec - gameTimeResidual - 1 ) : -1;
+			const int packetTimeout = allowBlocking
+				? ( IsServerDemoPlaying() ? 5 : ( nextGameFrameMsec - gameTimeResidual - 1 ) )
+				: -1;
 
 			// Foreground listen-server play polls instead of blocking so the render loop can
 			// present repeated-state frames between authoritative server game ticks.
@@ -2395,7 +3060,7 @@ void idAsyncServer::RunFrame( bool allowBlocking ) {
 			break;
 		}
 
-	} while( gameTimeResidual < AsyncServer_NextGameFrameMsec( gameFrame ) );
+	} while( !IsServerDemoPlaying() && gameTimeResidual < AsyncServer_NextGameFrameMsec( gameFrame ) );
 
 	// send heart beat to master servers
 	MasterHeartbeat();
@@ -2403,7 +3068,7 @@ void idAsyncServer::RunFrame( bool allowBlocking ) {
 	// check for clients that timed out
 	CheckClientTimeouts();
 
-	if ( idAsyncNetwork::idleServer.GetBool() == ( !GetNumClients() || GetNumIdleClients() != GetNumClients()  ) ) {
+	if ( !IsServerDemoPlaying() && idAsyncNetwork::idleServer.GetBool() == ( !GetNumClients() || GetNumIdleClients() != GetNumClients()  ) ) {
 		idAsyncNetwork::idleServer.SetBool( !idAsyncNetwork::idleServer.GetBool() );
 		// the need to propagate right away, only this
 		sessLocal.mapSpawnData.serverInfo.Set( "si_idleServer", idAsyncNetwork::idleServer.GetString() );
@@ -2417,7 +3082,7 @@ void idAsyncServer::RunFrame( bool allowBlocking ) {
 	}
 
 	// check for synchronized cvar changes
-	if ( cvarSystem->GetModifiedFlags() & CVAR_NETWORKSYNC ) {
+	if ( !IsServerDemoPlaying() && ( cvarSystem->GetModifiedFlags() & CVAR_NETWORKSYNC ) ) {
 		idDict newCvars;
 		newCvars = *cvarSystem->MoveCVarsToDict( CVAR_NETWORKSYNC );
 		SendSyncedCvarsBroadcast( newCvars );
@@ -2425,7 +3090,7 @@ void idAsyncServer::RunFrame( bool allowBlocking ) {
 	}
 
 	// check for user info changes of the local client
-	if ( cvarSystem->GetModifiedFlags() & CVAR_USERINFO ) {
+	if ( !IsServerDemoPlaying() && ( cvarSystem->GetModifiedFlags() & CVAR_USERINFO ) ) {
 		if ( localClientNum >= 0 ) {
 			idDict newInfo;
 			game->ThrottleUserInfo( );
@@ -2435,6 +3100,10 @@ void idAsyncServer::RunFrame( bool allowBlocking ) {
 		cvarSystem->ClearModifiedFlags( CVAR_USERINFO );
 	}
 
+	if ( IsServerDemoPlaying() ) {
+		RunServerDemoPlaybackFrame();
+		gameTimeResidual = 0;
+	} else {
 	// advance the server game
 	while( gameTimeResidual >= AsyncServer_NextGameFrameMsec( gameFrame ) ) {
 		const int nextGameFrameMsec = AsyncServer_NextGameFrameMsec( gameFrame );
@@ -2459,6 +3128,10 @@ void idAsyncServer::RunFrame( bool allowBlocking ) {
 	// duplicate usercmds so there is always at least one available to send with snapshots
 	DuplicateUsercmds( gameFrame, gameTime );
 
+	// The demo uses the game's complete, PVS-independent server-demo snapshot path.
+	WriteServerDemoSnapshot();
+	}
+
 	// send snapshots to connected clients
 	for ( i = 0; i < MAX_ASYNC_CLIENTS; i++ ) {
 		serverClient_t &client = clients[i];
@@ -2489,13 +3162,19 @@ void idAsyncServer::RunFrame( bool allowBlocking ) {
 		}
 
 		if ( client.clientState == SCS_INGAME ) {
-			if ( !SendSnapshotToClient( i ) ) {
+			const bool sentSnapshot = IsServerDemoPlaying()
+				? SendRepeaterSnapshotToClient( i )
+				: SendSnapshotToClient( i );
+			if ( !sentSnapshot ) {
 				SendPingToClient( i );
 			}
 		} else {
 			SendEmptyToClient( i );
 		}
 	}
+	if ( IsServerDemoPlaying() ) {
+		game->RepeaterEndSnapshots();
+	}
 
 	if ( com_showAsyncStats.GetBool() ) {
 
diff --git a/src/framework/async/AsyncServer.h b/src/framework/async/AsyncServer.h
index d209207..fff6fff 100644
--- a/src/framework/async/AsyncServer.h
+++ b/src/framework/async/AsyncServer.h
@@ -177,6 +177,18 @@ public:
 
 	void				PrintLocalServerInfo( void );
 
+	void				StartServerDemo( void );
+	void				StopServerDemo( void );
+	bool				IsServerDemoRecording( void ) const { return serverDemoFile != NULL; }
+	bool				StartServerDemoPlayback( const char *path );
+	void				StopServerDemoPlayback( void );
+	bool				IsServerDemoPlaying( void ) const { return serverDemoPlaybackActive; }
+	bool				IsServerDemoPlaybackEnded( void ) const { return serverDemoPlaybackEnded; }
+	void				SendRepeaterReliableGameMessage( int repeaterClient, const idBitMsg &msg, bool inhibitHeader, int clientNum );
+	void				SendRepeaterReliableGameMessageExcluding( int clientNum, const idBitMsg &msg, bool inhibitHeader );
+	void				SetRepeaterInfo( const idDict &info );
+	int				GetRepeaterClientNum( int clientId ) const;
+
 // jmarshall
 	int					AllocOpenClientSlotForAI(const char* botName, int maxPlayersOnServer);
 	int 				ServerSetBotUserCommand(int clientNum, int frameNum, const usercmd_t& cmd);
@@ -201,6 +213,29 @@ private:
 	int					gameTime;					// local game time
 	int					gameTimeResidual;			// left over time from previous frame
 
+	idFile *			serverDemoFile;
+	idStr				serverDemoPartPath;
+	idStr				serverDemoFinalPath;
+	idStr				serverDemoStartedAt;
+	idStr				serverDemoMap;
+	idStr				serverDemoGameType;
+	int					serverDemoSequence;
+	int					serverDemoLastSnapshotFrame;
+	int					serverDemoLastSnapshotTime;
+	int					serverDemoStartTime;
+	int					serverDemoReliableCount;
+
+	bool				serverDemoPlaybackActive;
+	bool				serverDemoPlaybackEnded;
+	bool				serverDemoPlaybackLoading;
+	bool				serverDemoPlaybackStarted;
+	idFile *			serverDemoPlaybackFile;
+	idStr				serverDemoPlaybackPath;
+	int					serverDemoPlaybackInitialGameTime;
+	int					serverDemoPlaybackStartTime;
+	userOrigin_t		serverDemoViewerOrigins[MAX_ASYNC_CLIENTS];
+	idDict				serverDemoViewerInfo[MAX_ASYNC_CLIENTS];
+
 	netadr_t			rconAddress;
 	
 	int					nextHeartbeatTime;
@@ -242,6 +277,10 @@ private:
 	bool				SendPingToClient( int clientNum );
 	void				SendGameInitToClient( int clientNum );
 	bool				SendSnapshotToClient( int clientNum );
+	bool				SendRepeaterSnapshotToClient( int clientNum );
+	void				WriteServerDemoSnapshot( void );
+	void				RunServerDemoPlaybackFrame( void );
+	void				RecordServerDemoReliable( int recordType, int clientNum, const idBitMsg &msg );
 	void				ProcessUnreliableClientMessage( int clientNum, const idBitMsg &msg );
 	void				ProcessReliableClientMessages( int clientNum );
 	void				ProcessChallengeMessage( const netadr_t from, const idBitMsg &msg );
diff --git a/src/framework/async/NetworkSystem.cpp b/src/framework/async/NetworkSystem.cpp
index 214851a..425d1fa 100644
--- a/src/framework/async/NetworkSystem.cpp
+++ b/src/framework/async/NetworkSystem.cpp
@@ -80,6 +80,28 @@ void idNetworkSystem::ServerSendReliableMessageExcluding( int clientNum, const i
 	}
 }
 
+/*
+==================
+idNetworkSystem::RepeaterSendReliableMessage
+==================
+*/
+void idNetworkSystem::RepeaterSendReliableMessage( int repeaterClient, const idBitMsg &msg, bool inhibitHeader, int clientNum ) {
+	if ( idAsyncNetwork::server.IsServerDemoPlaying() ) {
+		idAsyncNetwork::server.SendRepeaterReliableGameMessage( repeaterClient, msg, inhibitHeader, clientNum );
+	}
+}
+
+/*
+==================
+idNetworkSystem::RepeaterSendReliableMessageExcluding
+==================
+*/
+void idNetworkSystem::RepeaterSendReliableMessageExcluding( int clientNum, const idBitMsg &msg, bool inhibitHeader ) {
+	if ( idAsyncNetwork::server.IsServerDemoPlaying() ) {
+		idAsyncNetwork::server.SendRepeaterReliableGameMessageExcluding( clientNum, msg, inhibitHeader );
+	}
+}
+
 /*
 ==================
 idNetworkSystem::ServerGetClientPing
@@ -92,6 +114,26 @@ int idNetworkSystem::ServerGetClientPing( int clientNum ) {
 	return 0;
 }
 
+/*
+==================
+idNetworkSystem::RepeaterSetInfo
+==================
+*/
+void idNetworkSystem::RepeaterSetInfo( const idDict &info ) {
+	if ( idAsyncNetwork::server.IsServerDemoPlaying() ) {
+		idAsyncNetwork::server.SetRepeaterInfo( info );
+	}
+}
+
+/*
+==================
+idNetworkSystem::RepeaterGetClientNum
+==================
+*/
+int idNetworkSystem::RepeaterGetClientNum( int clientId ) {
+	return idAsyncNetwork::server.IsServerDemoPlaying() ? idAsyncNetwork::server.GetRepeaterClientNum( clientId ) : -1;
+}
+
 /*
 ==================
 idNetworkSystem::ServerGetClientPrediction
diff --git a/src/framework/async/NetworkSystem.h b/src/framework/async/NetworkSystem.h
index 3efd2ff..fc77af2 100644
--- a/src/framework/async/NetworkSystem.h
+++ b/src/framework/async/NetworkSystem.h
@@ -98,6 +98,8 @@ public:
 
 	virtual void			ServerSendReliableMessage( int clientNum, const idBitMsg &msg );
 	virtual void			ServerSendReliableMessageExcluding( int clientNum, const idBitMsg &msg );
+	virtual void			RepeaterSendReliableMessage( int repeaterClient, const idBitMsg &msg, bool inhibitHeader = false, int clientNum = -1 );
+	virtual void			RepeaterSendReliableMessageExcluding( int clientNum, const idBitMsg &msg, bool inhibitHeader = false );
 	virtual int				ServerGetClientPing( int clientNum );
 	virtual int				ServerGetClientPrediction( int clientNum );
 	virtual int				ServerGetClientTimeSinceLastPacket( int clientNum );
@@ -142,7 +144,7 @@ public:
 	virtual bool			HTTPEnable(bool enable) { return 0; }
 
 	virtual void			ClientSetServerInfo(const idDict& serverSI) { }
-	virtual void			RepeaterSetInfo(const idDict& info) { }
+	virtual void			RepeaterSetInfo(const idDict& info);
 
 	virtual const char* GetViewerGUID(int clientNum) { return 0; }
 
@@ -152,7 +154,7 @@ public:
 	// returns the new clientNum or -1 if there weren't any free.
 	virtual int				ServerConnectBot(void) { return 0; }
 
-	virtual int				RepeaterGetClientNum(int clientId) { return 0; }
+	virtual int				RepeaterGetClientNum(int clientId);
 };
 
 extern idNetworkSystem *	networkSystem;
