feat(live): Add live spectating of matches in progress via a relay server - #561
Open
doopey655 wants to merge 2 commits into
Open
Conversation
…rver Adds the client side of live spectating. A player's client streams its replay bytes to a relay server; a spectator's client receives that stream and plays it back locally as a live game. GO owns the orchestration (stream registration, watch tickets, broadcast delay); this is the transport and playback half on both ends. - LiveObserver: plays back the growing replay file over WebSocket - live-edge tracking, buffering pause behind the broadcast delay, fast-forward gate, spectator chat, rate-matched playback. - LiveStreamer: a sink on the recorder that forwards the recorded bytes plus a frame heartbeat and match telemetry. - Recorder: RECORDERMODETYPE_LIVE_OBSERVER, startLiveObserverPlayback/endLivePlayback, an IReplayStreamSink, EOF-waiting read for the growing file. - Pre-game observer lobby (read-only slots with roll prediction, chat both ways, host /observerchat kill switch), a live-games browser, watch tickets and stream registration (websocket opcodes 44-51), a host-controlled broadcast delay, and password-protected streams.
doopey655
force-pushed
the
feature/live-observer-upstream
branch
from
August 16, 2026 19:03
2327bab to
f5f1edb
Compare
An observer could not begin loading until the streamer's match had produced its first replay record, which only happens after the streamer finished its own map load - roughly four seconds that had to be made up by fast-forwarding. The stream header is queued one logic frame before the streamer's map load and already carries everything needed, so start there instead. - RecorderClass::playbackFile skips the cursor-seeding read for a live start. Its result was discarded by startLiveObserverPlayback anyway, and with no body on disk it failed and closed the file. - LiveObserver::isPlaybackReady drops the "at least one body record" condition, which existed only to satisfy that read. Delay coverage still gates and is trivially true at delay 0. - updatePlaybackGate holds whenever getLiveEdge() == 0, regardless of the pre-roll warmup exemption, so a frame is never simulated before its records have arrived when the observer wins the load race. - tryStartNewGame keeps the load screen up until the stream produces frame 1, on the same principle as the network game's isProgressComplete() wait. A replay has neither that wait nor the 2 s minimum display time, so the observer used to finish first and sit on a black screen for ~500 ms. - GAME_REPLAY uses MultiPlayerLoadScreen for a live observer - map preview, players, factions, colours, start positions - and keeps ShellGameLoadScreen for an ordinary replay. Not GameSpyLoadScreen: its update() drives TheNetwork, which is null during playback. Load screen and lobby correctness, found while testing the above: - isSlotLocalAlly now recognises a live observer. It resolved against TheGameInfo's local slot, which for a replayed stream is the streamer's, so everything outside the streamer's own team was masked to its pre-roll value and a random start position read back as -1. This also removes the per-call-site workaround in MultiPlayerLoadScreen::init. - updateMapStartSpots' load-screen branch applies the guards its sibling branch always had. Indexing the button array with -1 was not a harmless out-of-bounds read: m_buttonMapStartPosition is preceded by m_mapPreview, so [-1] yielded a live window and the player number was stamped onto the map preview. Observer slots are skipped too - populateRandomStartPosition parks them on an already-taken spot for their camera, which is not a claim on the map. - The observer lobby no longer predicts the random roll. It revealed factions and start positions before the players who were about to play them, and the pre-game lobby has no broadcast delay to soften that. GameLogic:: rollRandomSlots existed only for the prediction and is removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
doopey655
force-pushed
the
feature/live-observer-upstream
branch
from
August 17, 2026 20:31
973f0dd to
9a433f2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Adds the client side of live spectating. A player's client streams its replay bytes to a relay
server; a spectator's client receives that stream and plays it back locally as a live game. GO
owns the orchestration — it registers streams, mints watch tickets and enforces the broadcast
delay — while this PR is the transport and playback half on both ends. The spectator is not a
network peer: it plays back a continuously growing replay file, delivered over a WebSocket.
Nothing here changes existing behaviour. The feature ships inert: no relay URL is configured
anywhere — GO returns a fully-formed one on both the register and the watch-ticket path — and
without GO's livestream endpoints (the companion Services PR) the new screens have nothing to
show and stream registration never succeeds. A deployment without them behaves exactly as it
does today.
What it adds
LiveObserver— the watching half. Connects to the relay's WebSocket, appends thestreamed bytes to a local replay file, and plays it back through the recorder in a new mode:
live-edge tracking from the streamer's frame heartbeat, a buffering pause that holds only
until the broadcast delay is satisfied, fast-forward disabled within the delay, spectator
chat, and playback that follows the streamer's own logic rate instead of a fixed 60 fps.
LiveStreamer— the broadcasting half. A sink attached to the recorder forwards therecorded bytes to the relay: header, header patches, body and end-of-stream, plus a frame
heartbeat and match telemetry (logic fps, ping) so observers can track the live edge.
RECORDERMODETYPE_LIVE_OBSERVER,startLiveObserverPlayback()/endLivePlayback(), a read path that waits for records on the growing file instead oftreating a partial record as EOF, and the
IReplayStreamSinkinterface the streamer attachesto.
LobbyObserverMenu: a read-only view of a lobby while it isstill setting up — slot assignments with the random roll predicted, the map, chat in both
directions,
Observer <name> joined the lobbyannouncements — with a host/observerchat on|offkill switch.LiveGamesMenu+ Watch Live — browse running games instead of typing an id, watch bylobby id, priority rows sorted to the top. Entry is Online → Watch Live, behind sign-in like
the rest of Online.
/streamtoggle,stream password prompt, observer counts announced to lobby members; all over new websocket
opcodes 42–49.
Design notes for reviewers
The observer is not a network peer. It plays back a replay file: no slot, no mesh
connection, no CRC agreement with the players. Its own divergence is caught by pairing the
observer's CRCs against the stream's recorded CRCs through the existing
RecorderClass::handleCRCMessagemachinery, and the simulation is made deterministic byseeding the game-logic RNG from the game seed at
tryStartNewGame(), so the slot draw andevery later decision are a pure function of the seed.
The broadcast delay is the server's admission gate; the client only waits. The client still
refuses to spoil it: fast-forward is disabled within the delay, and again on the cinematic path
— the command translator is disabled during cinematics, which is why the gate has a second copy
in
MetaEvent.cpp. Consuming the streamer's rate is the client's job: the buffering pause ispolled outside the halted path in
GameEngine::update(), because the pause itself haltsGameLogic::UPDATE().Chat never enters the replay stream. In-game chat is a
NetChatCommandMsg, not aGameMessage, so it travels as a side channel on the relay:MSG_CHATfrom the streamer,frame-stamped;
MSG_SPECTATOR_CHATback to it. Only genuinely public chat is captured — achat is public iff it reaches someone who is neither the sender nor an ally of the sender. This
also fixes a leak where the previous recipient-count heuristic forwarded team chat in
1v1/2v1/3v1 games.
Streaming is a second consumer of the replay bytes, not a replacement for recording.
IReplayStreamSinksits on the recorder; replay files continue to be written exactly asbefore. A failed registration or relay connection degrades to a normal game — never an error
state.
Compatibility
Core/) files are#if defined(GENERALS_ONLINE)guarded; outsideGeneralsOnline the feature compiles to nothing.
them and the new screens simply have nothing to show.
new and only spoken between feature clients and the relay.
.csfchanges: new strings useFETCH_OR_SUBSTITUTEwith English defaults.Wire protocol
No database or config-file schema changes — stream and session state lives on the relay and in
the replay file. The relay message types are new and shared by both subsystems:
Testing
registration, the Watch Live browser, live playback through the broadcast delay, desync
detection, spectator chat, and the pre-game observer lobby.
Behaviour changes outside the feature
isPlaybackMode()now includesRECORDERMODETYPE_LIVE_OBSERVER— re-points everyexisting caller of the mode query.
updatePlayback()loop condition== curFrame→<= curFrame— changes ordinaryreplay playback if the cursor ever falls behind.
GetQueueSize() > 0— previously an empty queue read 0and reported a mismatch.
READWRITE|CREATEinstead ofWRITE— verified behaviour-identical(
USE_BUFFERED_IOmaps both to a truncating open), but it is a visible change.MSG_NEW_GAMEcheat guard now usesisInInteractiveGame()—isInGame()includes theshell, so the old guard rejected the legitimate
MSG_NEW_GAMEa live join sends from themain menu. A general fix that the feature happens to require.
appendNextCommand()leakedparserandmsgon an early return — memory-leak fix ina path the feature now exercises heavily.
m_frame > 0) — makes the MP and release branches consistentwith the existing
DEBUG_CRCbranch; its own comment ties it to the stock "mismatchvirtually every replay" warning.
GameWindow::winCopyVisualsFrom()— a new public method on a core GUI class (so it alsoenlarges the Generals-facing API), whose ten call sites are all in this feature. Happy to
narrow or relocate it.