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.SignalRPC. - 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
Signalis 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
IceServerproto message carries this list, handed to the client atJoinSessiontime. - DataChannels. WebRTC also carries arbitrary binary/text data over a
DataChannelthat 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
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.statusmoves"waiting"โ"active"โ"ended".game_uuid/game_typesoftly 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 aParticipant(the first joiner becomes host) and returns theIceServerlist needed before opening anRTCPeerConnection.SessionService/ParticipantServicealso expose standard CRUD for tooling and to stay compatible with the platform’s sharedarchaea/basegenerics.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
Participantrow or create a new one? Affects whetherParticipant.uuidneeds to be client-persisted. - Host migration โ if the host disconnects, does the session end or does host status
transfer?
Session.host_participant_uuidexists 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
JoinSessionfor a givengame_uuid.