Architecture

Firefly is a signaling relay and session store. It never negotiates or terminates a WebRTC connection itself โ€” it only forwards opaque SDP and ICE payloads between clients until their browsers connect directly.

WebRTC in one page

WebRTC lets two browsers open a direct connection for real-time data with no app server in the middle of the actual traffic. Getting there takes a few pieces:

  • Signaling (offer/answer). Two peers first describe what they can do (codecs, data channels, network info) using SDP. One side creates an offer, the other an answer. WebRTC has no built-in way to deliver these between peers โ€” you build that yourself. That is Firefly’s SignalService.Signal RPC.
  • ICE and trickle ICE. Most devices sit behind NAT and don’t know their own reachable address. ICE discovers every possible path to a peer โ€” local, STUN-learned, TURN-relayed โ€” and tests pairs until one works. Candidates are trickled to the other side as they’re found, which is why Signal is a stream rather than a single request/response.
  • STUN vs. TURN. STUN just tells a peer what its address looks like from outside, which is often enough for a direct “hole punch”. TURN is a relay used when a direct path really isn’t possible; it costs real bandwidth. Production WebRTC configures both. Firefly’s IceServer proto message carries this list, handed to the client at JoinSession time.
  • DataChannels. WebRTC also carries arbitrary binary/text data over a DataChannel that feels like a WebSocket but travels peer-to-peer. That’s what Firefly’s games use โ€” turn moves, positions, chat โ€” not audio/video tracks.

The handshake, end to end

sequenceDiagram participant A as Player A (browser) participant F as firefly (Signal stream) participant B as Player B (browser) A->>F: JoinSession F-->>A: Participant + ICE servers B->>F: JoinSession F-->>B: Participant + ICE servers F--)A: presence: B joined F--)B: presence: A joined A->>A: createOffer() A->>F: SignalMessage{sdp: offer, to: B} F->>B: relay B->>B: setRemoteDescription(offer)
createAnswer() B->>F: SignalMessage{sdp: answer, to: A} F->>A: relay A->>A: setRemoteDescription(answer) loop trickle ICE, both directions A->>F: SignalMessage{ice_candidate, to: B} F->>B: relay B->>F: SignalMessage{ice_candidate, to: A} F->>A: relay end Note over A,B: RTCPeerConnection connects A-)B: DataChannel (direct P2P, or via TURN)
โ€” firefly is out of the data path from here on

How this maps to Firefly’s protos

  • Session (schemas/firefly/v1/session.proto) โ€” one multiplayer game instance. status moves "waiting" โ†’ "active" โ†’ "ended". game_uuid / game_type softly reference whatever compiled game elsewhere on the platform this session is playing.
  • Participant (schemas/firefly/v1/participant.proto) โ€” one connected client within a session. connection_state ("pending" โ†’ "connected" โ†’ "disconnected") is driven by the signaling layer, not direct client writes.
  • SessionService.JoinSession โ€” the real entry point a client calls to enter a game: creates a Participant (the first joiner becomes host) and returns the IceServer list needed before opening an RTCPeerConnection. SessionService / ParticipantService also expose standard CRUD for tooling and to stay compatible with the platform’s shared archaea/base generics.
  • SignalService.Signal โ€” the bidirectional stream carrying the offer/answer/ICE exchange above, plus presence events (joined / left / ready). It is hand-written: no other service on the platform uses streaming RPCs, and the relay itself (lib/firefly/signal/hub.go) is a purely in-memory per-session registry with no database and no Kafka.

Browser clients

Browsers can’t use the Signal RPC directly โ€” a Connect bidi stream needs a streaming request body that no browser’s fetch() implementation supports. So lib/firefly/signal/ws bridges a plain WebSocket (/ws/signal?session_uuid=...&participant_uuid=...) into the same Hub, and a native Go client and a browser client in the same session relay through each other transparently. Messages are protojson-encoded to match the connect-web generated TypeScript message shapes.

Open questions

Still to decide, before or during implementation:

  • Reconnection / resume โ€” does a dropped stream rejoin the same Participant row or create a new one? Affects whether Participant.uuid needs to be client-persisted.
  • Host migration โ€” if the host disconnects, does the session end or does host status transfer? Session.host_participant_uuid exists but nothing defines the transition.
  • Topology at higher player counts โ€” full-mesh P2P is fine for 2โ€“4 players but grows O(nยฒ); larger sessions likely need a Pion-based SFU, not designed here.
  • TURN credential provisioning โ€” short-lived per-user credentials (HMAC-based) rather than one shared secret; self-hosted coturn vs. a managed provider is an infra decision.
  • Auth โ€” nothing currently restricts who can call JoinSession for a given game_uuid.