Skip to content

feat(live): Add live spectating of matches in progress via a relay server - #561

Open
doopey655 wants to merge 2 commits into
GeneralsOnlineDevelopmentTeam:mainfrom
nathan-soul:feature/live-observer-upstream
Open

feat(live): Add live spectating of matches in progress via a relay server#561
doopey655 wants to merge 2 commits into
GeneralsOnlineDevelopmentTeam:mainfrom
nathan-soul:feature/live-observer-upstream

Conversation

@doopey655

@doopey655 doopey655 commented Aug 16, 2026

Copy link
Copy Markdown

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 the
    streamed 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 the
    recorded 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.
  • Recorder changesRECORDERMODETYPE_LIVE_OBSERVER, startLiveObserverPlayback() /
    endLivePlayback(), a read path that waits for records on the growing file instead of
    treating a partial record as EOF, and the IReplayStreamSink interface the streamer attaches
    to.
  • Pre-game observer lobbyLobbyObserverMenu: a read-only view of a lobby while it is
    still setting up — slot assignments with the random roll predicted, the map, chat in both
    directions, Observer <name> joined the lobby announcements — with a host /observerchat on|off kill switch.
  • LiveGamesMenu + Watch Live — browse running games instead of typing an id, watch by
    lobby id, priority rows sorted to the top. Entry is Online → Watch Live, behind sign-in like
    the rest of Online.
  • Lobby integration — host-controlled broadcast delay, a per-player /stream toggle,
    stream password prompt, observer counts announced to lobby members; all over new websocket
    opcodes 42–49.
  • In-game UI — a live status bar (F6), spectator chat mode (F7), pace matching (F8).

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::handleCRCMessage machinery, and the simulation is made deterministic by
seeding the game-logic RNG from the game seed at tryStartNewGame(), so the slot draw and
every 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 is
polled outside the halted path in GameEngine::update(), because the pause itself halts
GameLogic::UPDATE().

Chat never enters the replay stream. In-game chat is a NetChatCommandMsg, not a
GameMessage, so it travels as a side channel on the relay: MSG_CHAT from the streamer,
frame-stamped; MSG_SPECTATOR_CHAT back to it. Only genuinely public chat is captured — a
chat 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.
IReplayStreamSink sits on the recorder; replay files continue to be written exactly as
before. A failed registration or relay connection degrades to a normal game — never an error
state.

Compatibility

  • All hunks in shared (Core/) files are #if defined(GENERALS_ONLINE) guarded; outside
    GeneralsOnline the feature compiles to nothing.
  • New websocket opcodes 42–49, additive. A server without the companion endpoints never answers
    them and the new screens simply have nothing to show.
  • Replay format and the existing wire protocol are untouched; the live relay protocol below is
    new and only spoken between feature clients and the relay.
  • No .csf changes: new strings use FETCH_OR_SUBSTITUTE with 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:

// client <-> relay, defined in LiveStreamer.h
LIVE_MSG_REGISTER       0  session handshake
LIVE_MSG_HEADER         1  replay header bytes
LIVE_MSG_PATCH          2  header patch
LIVE_MSG_BODY           3  frame records
LIVE_MSG_END            4  stream over
LIVE_MSG_ROLE           5  observer role / gate handshake
LIVE_MSG_ERROR          6
LIVE_MSG_CHAT           7  player chat, frame-stamped
LIVE_MSG_SPECTATOR_CHAT 8  spectator chat
LIVE_MSG_TICK           9  frame heartbeat
LIVE_MSG_STATS         10  logic fps + ping telemetry

Testing

  • Full Zero Hour build green; the only warnings are pre-existing ones from upstream files.
  • Exercised end to end with real game clients against a local GO + relay stack: stream
    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

  1. isPlaybackMode() now includes RECORDERMODETYPE_LIVE_OBSERVER — re-points every
    existing caller of the mode query.
  2. updatePlayback() loop condition == curFrame<= curFrame — changes ordinary
    replay playback if the cursor ever falls behind.
  3. CRC comparison now guarded by GetQueueSize() > 0 — previously an empty queue read 0
    and reported a mismatch.
  4. Replay file opened READWRITE|CREATE instead of WRITE — verified behaviour-identical
    (USE_BUFFERED_IO maps both to a truncating open), but it is a visible change.
  5. MSG_NEW_GAME cheat guard now uses isInInteractiveGame()isInGame() includes the
    shell, so the old guard rejected the legitimate MSG_NEW_GAME a live join sends from the
    main menu. A general fix that the feature happens to require.
  6. appendNextCommand() leaked parser and msg on an early return — memory-leak fix in
    a path the feature now exercises heavily.
  7. Frame-0 CRC exclusion (m_frame > 0) — makes the MP and release branches consistent
    with the existing DEBUG_CRC branch; its own comment ties it to the stock "mismatch
    virtually every replay" warning.
  8. GameWindow::winCopyVisualsFrom() — a new public method on a core GUI class (so it also
    enlarges the Generals-facing API), whose ten call sites are all in this feature. Happy to
    narrow or relocate it.

…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
doopey655 force-pushed the feature/live-observer-upstream branch from 2327bab to f5f1edb Compare August 16, 2026 19:03
@doopey655 doopey655 changed the title feat(live): spectate matches in progress via a relay server feat(live): Add live spectating of matches in progress via a relay server Aug 16, 2026
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
doopey655 force-pushed the feature/live-observer-upstream branch from 973f0dd to 9a433f2 Compare August 17, 2026 20:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant