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.
Tormenta already uses this model for several features:
| Communication | Current behavior | Required completion |
|---|---|---|
| Dice rolls | Live on a public channel | Move to the private campaign channel |
| Turn and initiative order | Live on a public channel | Move to the private campaign channel |
| Presence | Live on a public channel | Move to the private campaign channel |
| Party HP, PM, and conditions | Live signal with polling fallback | Move to the private campaign channel |
| Text chat | Not implemented | Persist messages and deliver them live |
| GM scene image | Not implemented | Add durable object storage and broadcast the resulting URL |
| Status or scene banner | Not implemented | Persist current state and broadcast updates |
| Interactive shared map | Not implemented | Remains 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.
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 withCampaignRepository::findOneForGmOrMember, returns404to outsiders, and obtains a signed channel authorization frompusherService. - Extend the publisher contract for a posted chat message, changed scene image, and changed status banner.
- Persist chat with a
ChatMessageentity containing campaign, author, body, and timestamp. - Persist the current scene in a
CampaignSceneentity. 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:
- Define the payload with a Zod schema.
- Add the HTTP service operation used for initial state and mutations.
- Add a React Query hook that fetches or polls for recovery and listens for live events.
- 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.
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.
| Aspect | Option A: direct hand-off | Option B: queued JWT flow |
|---|---|---|
| Subscription latency | One local HTTP round trip | RabbitMQ request, worker execution, and response wait |
| New code | Internal endpoint, middleware, client, and backend endpoint | Schema/service/job changes plus backend correlation and waiting |
| Reuse of token flow | No | Yes |
| Risk to sibling applications | Low; campaign-specific surface | Higher; modifies shared token behavior |
| Credential | New rotatable inter-service secret | Existing JWT secret with a new claim |
| Temporary relay outage | Fails immediately and can be retried visibly | Can 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:
- Secure the channel across the backend,
pusherService, and frontend. - 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
- Decide which state must be persisted and how a late-joining browser reconstructs it.
- Add the backend mutation and authorization checks.
- Persist before publishing.
- Add a typed method to
CampaignEventPublisherInterfaceand its implementations. - Publish a compact JSON payload or object reference, never large binary data.
- Add the frontend schema, HTTP operation, React Query hook, and private-channel event binding.
- Retain a fetch or polling recovery path for durable state.
- Test that outsiders cannot subscribe or retrieve the same state over HTTP.
Glossary
| Term | Meaning in this architecture |
|---|---|
| WebSocket | Persistent connection used by the server to push updates without client polling |
| Pusher protocol | Channel-and-event protocol implemented by Reverb and consumed through pusher-js |
| Channel | Named room for one campaign |
| Private channel | Channel that requires a server-signed subscription authorization |
| Event | Named JSON message delivered within a channel |
| MinIO | Self-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 symbol | Responsibility |
|---|---|
tormenta_app/backend/src/Service/Realtime/ | Publisher interface and RabbitMQ/null implementations |
CampaignEventPublisherInterface | Domain-facing real-time publication contract |
DiceLogService::post() | Existing persist-then-publish example |
CampaignRepository::findOneForGmOrMember | Campaign membership authorization |
tormenta_app/frontend/src/lib/realtime.ts | Shared frontend socket connection and subscription API |
subscribeToCampaign() | Frontend campaign subscription entry point |
HandleWebSocketTokenRequest | Existing queued token request handler in pusherService |
WebSocketTokenService | Existing JWT-generation service in pusherService |
token.requests / token.responses | Existing RabbitMQ token request and response queues |