ivãstival

Tormenta20 · Architecture

Architecture: Tormenta Campaign Real-Time Communication

Overview

Tormenta campaign sessions are shared, continuously changing spaces. Dice rolls, initiative, presence, party resources, chat, scene images, and status messages must reach every participant without waiting for the next polling interval.

The system uses WebSockets as the primary delivery mechanism and polling as a recovery path. Symfony remains the source of truth, RabbitMQ decouples request handling from live delivery, pusherService relays opaque events through Laravel Reverb, and the Next.js frontend renders them.

This separation fits the backend's classic, non-worker FrankenPHP model. A request can persist an action, publish an event, and finish without retaining a list of connected browsers. Reverb is the long-running process that owns those connections and fans messages out.


Why WebSockets

Polling asks the server whether anything changed on a fixed schedule. Its latency is bounded by that schedule, and its cost grows with the number of open tabs and polling frequency even when nothing happens.

A WebSocket keeps one connection open. The server sends an update as soon as an event occurs, so latency is primarily network latency and cost tracks actual activity.

Two columns. Polling is drawn as evenly spaced request ticks on a timer; WebSocket push is drawn as one continuous connection with irregular event dots.

Tormenta already uses this model for several features:

CommunicationCurrent behaviorRequired completion
Dice rollsLive on a public channelMove to the private campaign channel
Turn and initiative orderLive on a public channelMove to the private campaign channel
PresenceLive on a public channelMove to the private campaign channel
Party HP, PM, and conditionsLive signal with polling fallbackMove to the private campaign channel
Text chatNot implementedPersist messages and deliver them live
GM scene imageNot implementedAdd durable object storage and broadcast the resulting URL
Status or scene bannerNot implementedPersist current state and broadcast updates
Interactive shared mapNot implementedRemains out of scope for this design

The existing live rows prove the transport path. Chat, images, and banners are new domain features that reuse it rather than new transport systems.


Channels and Events

Each campaign has one named channel. Event names distinguish the kinds of message inside that room. The frontend subscribes once and binds one handler for each event it understands.

pusherService treats the event name and JSON payload as opaque. It does not interpret dice rolls, messages, images, or banners. As a result, adding a feature normally affects only the Tormenta backend and frontend; the relay does not acquire domain-specific code.

The existing public-campaign-{id} channel is the critical security gap. Anyone who can discover a campaign ID can subscribe. Before chat or uploaded images use the wire, campaign traffic must move to a private channel whose subscription is approved only after the backend confirms that the user is the GM or a member.

One channel named private-campaign-{id} joined by lines to seven event names: DiceRollPosted, InitiativeChanged, PresenceChanged and PartyStatusChanged marked live today, and ChatMessagePosted, SceneImageChanged and StatusBannerChanged marked new.

The relay in the middle needs no code change per feature, which is why three new kinds of communication are a backend and frontend concern only.

Sockets should carry compact state and references. Image bytes never travel in a WebSocket event; the backend stores the object and publishes its URL. This also respects Reverb's default message-size ceiling of approximately 10 KB.


Responsibilities by Application

Tormenta backend

The Symfony backend decides what happened, validates authorization, persists durable state, and publishes events.

Real-time publication is already isolated behind CampaignEventPublisherInterface in src/Service/Realtime/. CampaignEventPublisher forwards to RabbitMQ, while NullCampaignEventPublisher supports tests. Domain services follow the existing DiceLogService::post() pattern: persist first, then call the publisher.

The proposed additions are:

  • Add POST /api/v1/campaigns/{id}/broadcasting/auth. It resolves the campaign with CampaignRepository::findOneForGmOrMember, returns 404 to outsiders, and obtains a signed channel authorization from pusherService.
  • Extend the publisher contract for a posted chat message, changed scene image, and changed status banner.
  • Persist chat with a ChatMessage entity containing campaign, author, body, and timestamp.
  • Persist the current scene in a CampaignScene entity. This is latest-value state rather than a history because late joiners need the current image and banner.
  • Store uploaded images through a Flysystem-compatible client backed by MinIO, then publish only the stored object's URL.

Tormenta frontend

The Next.js frontend owns presentation and recovery. src/lib/realtime.ts owns the shared socket connection and exports subscribeToCampaign() for campaign features.

The private-channel transition adds an authorizer to this module. Immediately before subscribing, it calls the backend authorization endpoint with the existing cookie-based session and returns the signed approval to the Pusher client. Existing consumers keep the same subscription entry point.

Each new feature follows the frontend's established vertical slice:

  1. Define the payload with a Zod schema.
  2. Add the HTTP service operation used for initial state and mutations.
  3. Add a React Query hook that fetches or polls for recovery and listens for live events.
  4. Render the chat feed, scene-image panel, or status banner.

Image upload also requires the HTTP helper to support multipart file uploads in addition to JSON.

pusherService

The Laravel/Reverb application consumes RabbitMQ events and broadcasts them over the Pusher protocol. Its generic relay does not change for new campaign event types.

Its new responsibility is subscription authorization for campaign channels:

  • Accept a narrowly scoped internal request from the Tormenta backend.
  • Verify the inter-service credential.
  • Sign a standard private-channel authorization for the requested campaign channel.
  • Replace unconditional campaign-channel authorization with a rule that requires this trusted hand-off.
  • Leave channel behavior for other applications unchanged.

jungleforge

The shared infrastructure already hosts Postgres, Redis, and RabbitMQ. Chat and banner text need no additional service.

Scene images require MinIO, running as a shared service with a named persistent volume and an S3-compatible API. The backend connects over the existing shared network. Container-local storage is not durable because application containers are replaced during deployment.


Private-Channel Authorization

The browser has two separate conversations. The long-lived WebSocket goes directly to pusherService; the Symfony backend is not in the delivery path. A short authorization conversation occurs once for each private campaign channel the browser wants to join.

Two stacked diagrams. Above, the browser and pusherService hold a direct persistent WebSocket carrying every event. Below, the browser asks the Tormenta backend to unlock a private channel, the backend verifies membership and obtains a signed approval from pusherService, and that approval travels back the way it came.

It is easy to read the sequence below and conclude the browser only ever talks to the backend. It does not — the socket that carries every event is a direct line to pusherService, and the backend is never in that path.

The backend is the guest-list authority; pusherService is the channel gate. Campaign membership must not be duplicated in the relay.

Option A: direct shared-secret hand-off

After validating membership, the backend calls an internal pusherService endpoint over the private Docker network and authenticates with a secret known only to the two services. pusherService signs and returns the standard channel approval.

This is the recommended direction in the source design because subscription is user-facing and latency-sensitive. It adds one direct local call and isolates risk to a new campaign-specific endpoint and rule.

Option B: extend the JWT request/reply flow

After validating membership, the backend publishes a token request to RabbitMQ. The existing HandleWebSocketTokenRequest job and WebSocketTokenService create a JWT for the real campaign channel and publish it to token.responses. The backend must correlate and wait for that response before it can answer the browser.

This reuses the existing ws_tokens machinery and JWT_SECRET, but requires new request/reply coordination and changes behavior that other pusherService consumers may depend on.

AspectOption A: direct hand-offOption B: queued JWT flow
Subscription latencyOne local HTTP round tripRabbitMQ request, worker execution, and response wait
New codeInternal endpoint, middleware, client, and backend endpointSchema/service/job changes plus backend correlation and waiting
Reuse of token flowNoYes
Risk to sibling applicationsLow; campaign-specific surfaceHigher; modifies shared token behavior
CredentialNew rotatable inter-service secretExisting JWT secret with a new claim
Temporary relay outageFails immediately and can be retried visiblyCan remain queued but is harder to surface quickly

Option B remains valid if pusherService adopts a deliberate platform-wide policy that all private channels use its JWT system. That is a broader service decision, not a requirement of Tormenta campaign communication.


Data Flows

Durable live event

Persistence occurs before publication. Polling can reconstruct state after a disconnect because the database, not the socket, is authoritative.

GM image update


Delivery Order

Two groundwork tracks can proceed independently:

  1. Secure the channel across the backend, pusherService, and frontend.
  2. Add MinIO and the backend storage client.

After channel security is complete, chat and status banners can ship independently. The scene-image feature waits for both the private channel and durable storage.

No chat, image, or banner payload may be released over the old public channel, even temporarily.


Operational Characteristics

Strengths

  • The stack is self-hosted and avoids per-message or per-connection vendor billing.
  • Symfony remains stateless with respect to socket connections.
  • New event types are inexpensive because the relay remains generic.
  • Polling provides graceful recovery when live delivery disconnects.
  • Campaign-level channels are already the correct sharding boundary.
  • The Pusher protocol supplies mature channel, event, reconnection, and framing behavior.

Trade-offs

  • A message crosses several services, increasing operational surface and adding small latency.
  • Current deployment has a single consumer and Reverb instance.
  • Contract changes require coordination across repositories.
  • Payload size must remain disciplined.
  • Every inter-service trust relationship introduces credential rotation work.

Scaling path

The current design does not require speculative scaling work. If traffic grows:

  • Enable Reverb's Redis scaling mode so multiple socket nodes share a backplane.
  • Run competing RabbitMQ consumers for throughput and redundancy.
  • Replace custom presence signals with a native Pusher presence channel.
  • Add per-user posting limits only when misuse becomes observable.
  • Monitor RabbitMQ queue depth first, then add Reverb connection metrics.

These are configuration and deployment extensions of the existing boundaries, not a redesign.


How to Add a Real-Time Campaign Feature

  1. Decide which state must be persisted and how a late-joining browser reconstructs it.
  2. Add the backend mutation and authorization checks.
  3. Persist before publishing.
  4. Add a typed method to CampaignEventPublisherInterface and its implementations.
  5. Publish a compact JSON payload or object reference, never large binary data.
  6. Add the frontend schema, HTTP operation, React Query hook, and private-channel event binding.
  7. Retain a fetch or polling recovery path for durable state.
  8. Test that outsiders cannot subscribe or retrieve the same state over HTTP.

Glossary

TermMeaning in this architecture
WebSocketPersistent connection used by the server to push updates without client polling
Pusher protocolChannel-and-event protocol implemented by Reverb and consumed through pusher-js
ChannelNamed room for one campaign
Private channelChannel that requires a server-signed subscription authorization
EventNamed JSON message delivered within a channel
MinIOSelf-hosted S3-compatible object storage used for durable scene images

Referenced Files and Interfaces

The paths and symbols below are reported by the source dossier. Their repositories are not part of this documentation workspace.

Path or symbolResponsibility
tormenta_app/backend/src/Service/Realtime/Publisher interface and RabbitMQ/null implementations
CampaignEventPublisherInterfaceDomain-facing real-time publication contract
DiceLogService::post()Existing persist-then-publish example
CampaignRepository::findOneForGmOrMemberCampaign membership authorization
tormenta_app/frontend/src/lib/realtime.tsShared frontend socket connection and subscription API
subscribeToCampaign()Frontend campaign subscription entry point
HandleWebSocketTokenRequestExisting queued token request handler in pusherService
WebSocketTokenServiceExisting JWT-generation service in pusherService
token.requests / token.responsesExisting RabbitMQ token request and response queues