Appearance
Real-time and Collaboration
Overview
The Real-time and Collaboration feature transforms the Progressive World-Building Platform from a static content management tool into a living, collaborative workspace. When a Storyteller publishes a new timeline entry, connected players see it appear immediately. When a Co-Creator adds a character, others working in the same project receive an activity notification without refreshing. When someone comments on a faction and @mentions a teammate, that teammate is notified in real time.
This feature encompasses four interconnected subsystems: SignalR-based real-time event delivery, an activity feed that records all meaningful actions within a project, a comments system that enables discussion on any content type, and a notifications system that alerts users to events that require their attention. These subsystems are tightly integrated -- a domain event (e.g., a character being created) triggers an activity feed entry, which triggers a SignalR broadcast to connected users, and if the event is relevant to a specific user (e.g., their character was mentioned), it also triggers a personal notification.
The entire system is permission-aware. A Player who cannot see a private character will never receive a SignalR event, activity feed entry, or notification about that character. The real-time layer respects the same visibility rules defined in the Permission and Visibility System.
Goals
- Deliver real-time updates to connected users via SignalR, starting with activity feed events in Phase 1
- Record a comprehensive activity feed for every meaningful action in a project
- Provide a threaded comment system that can target any content type (timelines, characters, factions)
- Support @mentions in comments that generate targeted notifications
- Build a cross-project notification system with read/unread tracking
- Ensure all real-time events, activity entries, and notifications respect the permission and visibility rules
- Design for horizontal scaling via Redis backplane from the start
- Handle disconnection and reconnection gracefully
User Stories
As a Game Master (Storyteller), I want to see a live activity feed showing what my co-creators have been working on, so I can stay informed about project changes without manually checking each section.
As a Writer (Co-Creator), I want to comment on a timeline entry and @mention another co-creator to ask for their input, so we can discuss story decisions in context without leaving the platform.
As a Player, I want to receive a notification when the Storyteller publishes new timeline content, so I know when there is new material to read for our next session.
As a Game Master (Owner), I want to resolve comment threads after a discussion is settled, so the comment section stays focused on open questions.
As a Co-Creator, I want to see a real-time indicator when someone else is currently editing the same timeline entry, so I can avoid conflicting changes.
As a Player, I want to see my notifications across all projects I belong to in a single feed, so I do not need to check each project individually.
Functional Description
SignalR Hub Architecture
The platform uses a single SignalR hub (ProjectHub) with project-scoped groups. When a user connects, they join a group for each project they are a member of. Events are broadcast to the relevant project group, and per-user filtering ensures that only authorized users receive each event.
A single hub (rather than per-feature hubs) is chosen because:
- Events from different features (timelines, characters, comments) all flow to the same project audience
- Connection management is simpler with one hub
- The hub itself is thin -- it manages connections and groups, while event delivery is handled by background services injecting
IHubContext
Connection Lifecycle
Event Types by Phase
| Phase | Event Category | Events | Description |
|---|---|---|---|
| Phase 1 | Activity | ContentCreated | A timeline, character, or faction was created |
| Phase 1 | Activity | ContentUpdated | Content was modified |
| Phase 1 | Activity | ContentDeleted | Content was removed |
| Phase 1 | Activity | MemberJoined | A new member accepted an invitation |
| Phase 2 | Graph | CharacterAdded | A new character appeared on the graph |
| Phase 2 | Graph | RelationshipAdded | A new relationship edge appeared |
| Phase 2 | Graph | FactionUpdated | A faction or faction relationship changed |
| Phase 3 | Comment | CommentAdded | A new comment was posted |
| Phase 3 | Comment | CommentResolved | A comment thread was resolved |
| Phase 3 | Mention | UserMentioned | A user was @mentioned |
| Phase 3 | Presence | UserEditingStarted | A user started editing content |
| Phase 3 | Presence | UserEditingStopped | A user stopped editing content |
| Phase 4 | Changelog | ChangelogEntryAdded | A new changelog entry was created |
Permission-Aware Broadcasting
Every SignalR event must be filtered so that users only receive events for content they are authorized to see. A Player must never receive a CharacterAdded event for a private character, and a Co-Creator must never receive a FactionUpdated event for a secret faction relationship.
The filtering logic reuses the same IPermissionService.FilterByVisibility used by query handlers. This ensures consistency: if a user cannot see a character in a GET response, they also will not see a real-time event about that character.
For events about public content (visible to all project members), the event is broadcast to the entire project group without per-user filtering. Per-user filtering is only applied when the content has restricted visibility (private characters, private relationships, secret faction relationships, non-published timelines).
Scaling Considerations
Redis backplane: When the application runs on multiple server instances (load-balanced), SignalR connections are distributed across instances. A Redis backplane ensures that events published on one instance reach clients connected to other instances. This is configured in the Infrastructure layer and is transparent to feature code.
Connection limits: Each connected user maintains a persistent WebSocket connection. For projects with many simultaneous users, the server must handle the connection overhead. SignalR's automatic fallback to Server-Sent Events or long polling provides degradation paths.
Event batching: In scenarios where many events occur in rapid succession (e.g., bulk operations), events can be batched to avoid flooding connected clients. A short debounce window (e.g., 500ms) can aggregate multiple changes into a single update event.
Reconnection and Missed Events
When a client disconnects (network interruption, browser tab backgrounded), they may miss events. The platform handles this through:
- Automatic reconnection: The SignalR client library attempts to reconnect with exponential backoff. On successful reconnection, the client rejoins its project groups.
- State refresh on reconnect: After reconnecting, the client requests a lightweight "what changed since timestamp" summary from the activity feed API. This is a standard GET request to the activity feed endpoint with a
sincequery parameter. - No server-side event buffering: The platform does not buffer missed events on the server. The activity feed serves as the durable record, and the client can catch up from it after reconnection.
This approach keeps the server stateless with respect to event delivery while ensuring clients can recover from disconnections.
Activity Feed
The activity feed records every meaningful action within a project. It serves as both a real-time collaboration tool (pushed via SignalR) and a historical log (queryable via API).
Domain Event to Activity Feed Pipeline
Domain events are dispatched after the entity changes are persisted (post-commit). This ensures that the activity entry references data that actually exists in the database. If the save fails, no activity entry is created and no event is broadcast.
Activity Entry Structure
Each activity entry captures:
| Field | Description |
|---|---|
| Id | UUIDv7 |
| ProjectId | Which project this activity belongs to |
| UserId | Who performed the action |
| Action | What happened (created, updated, deleted, published, joined, commented) |
| TargetType | Type of content affected (timeline, character, faction, relationship, comment) |
| TargetId | Internal ID of the affected entity |
| TargetDisplayName | Human-readable name at the time of the event (e.g., "Marcus Dubois") |
| TargetSequenceNumber | Public sequence number for linkable reference |
| Metadata | Optional JSON with additional context (e.g., which fields changed, old status, new status) |
| CreatedAt | Timestamp |
Activity Feed API Behavior
The activity feed endpoint (GET /api/projects/{key}/activity) supports:
- Pagination: Cursor-based pagination for efficient loading of large feeds
- Filtering by user:
?userId={id}shows only a specific user's actions - Filtering by content type:
?type=charactershows only character-related activity - Filtering by date range:
?since=2026-01-15T00:00:00Zfor catch-up after reconnection
Activity entries are subject to the same visibility filtering as the content they reference. If a Player queries the activity feed, entries about private characters or non-published timelines are omitted. The feed appears as though the hidden content never existed.
Comments System
The comments system enables discussion on any content type within a project. Comments are threaded (support replies), support @mentions, and can be resolved by moderators to indicate that a discussion is settled.
Comment Target Model
Comments use a polymorphic target pattern: each comment references a TargetType (timeline, character, faction) and a TargetId. This avoids creating separate comment tables for each content type.
Threaded Comments
Comments support one level of nesting: top-level comments and replies. A reply's ParentCommentId points to the top-level comment. Replies to replies are stored as replies to the same top-level parent (flat within the thread), keeping the data model simple while supporting focused conversations.
Comment Resolution
The Owner and Storyteller roles can resolve and unresolve comment threads. Resolution is a moderation tool: once a discussion point is addressed, the thread is marked as resolved. Resolved threads are still visible but can be collapsed or de-emphasized in the UI.
Resolution applies to top-level comments only. Resolving a top-level comment implicitly resolves its entire reply thread. The resolution tracks who resolved it and when.
Comment Permissions
| Action | Owner | Storyteller | Co-Creator | Player | Viewer |
|---|---|---|---|---|---|
| Post comment | Yes | Yes | Yes | Yes | - |
| Edit own comment | Yes | Yes | Yes | Yes | - |
| Delete own comment | Yes | Yes | Yes | Yes | - |
| Delete any comment | Yes | Yes | - | - | - |
| Resolve/unresolve thread | Yes | Yes | - | - | - |
Comments on content that a user cannot see are never returned. If a Player cannot see a private character, they also cannot see (or post) comments on that character. The comment visibility follows the visibility of the target content.
@Mention Processing
@mentions are parsed from the comment body text. The parser resolves display names to user IDs within the project context (only project members can be mentioned). Each mention generates a Mention record linking the comment to the mentioned user, and triggers a notification.
A mention notification is only created if the mentioned user can see the target content. If a Storyteller @mentions a Player in a comment on a private character, the Player does not receive the notification (because they cannot see the character or its comments).
Notifications
The notification system delivers targeted alerts to individual users across all their projects. Notifications are the personal counterpart to the project-wide activity feed.
Notification Types
| Type | Trigger | Recipients |
|---|---|---|
mention | @mentioned in a comment | The mentioned user |
comment_on_own_content | Someone commented on content the user created | The content creator |
content_published | A timeline entry status changed to Published | All Players and Viewers in the project |
invitation | User was invited to a project | The invited user |
role_changed | User's role in a project was changed | The affected user |
comment_reply | Someone replied to a comment the user posted | The original comment author |
Notification Delivery Pipeline
Notification Entity Structure
| Field | Description |
|---|---|
| Id | UUIDv7 |
| UserId | Recipient |
| Type | Notification type (mention, comment_on_own_content, etc.) |
| ProjectId | Which project this relates to (null for system notifications) |
| ActorUserId | Who triggered this notification |
| TargetType | Type of content referenced |
| TargetId | ID of the referenced content |
| TargetDisplayName | Human-readable name for display |
| Message | Pre-rendered notification text (e.g., "Elena commented on Marcus Dubois") |
| IsRead | Read status |
| ReadAt | When the user marked it as read |
| CreatedAt | When the notification was created |
Cross-Project Notification Feed
The GET /api/notifications endpoint returns notifications across all of a user's projects, ordered by creation date (newest first). This is the user's personal notification inbox.
- Pagination: Cursor-based, defaulting to 50 per page
- Unread count: The response includes a total unread count for badge display
- Mark as read:
PUT /api/notifications/{id}/readmarks a single notification;PUT /api/notifications/read-allmarks all unread notifications as read - Filtering: Optional
?projectId={id}filter to see notifications for a specific project
Notification Preferences (Future)
A future enhancement allows users to configure which notification types they want to receive. For Phase 3, all notification types are enabled by default. The notification handler checks preferences before creating a notification record. Preferences are per-user, not per-project.
Editing Indicators (Phase 3)
Editing indicators show when another user is currently editing the same content. This is a lightweight presence feature that helps avoid edit conflicts.
The flow:
- When a user begins editing content (opens the editor), the client sends a
StartEditingmessage to the SignalR hub with the content type and ID. - The hub broadcasts a
UserEditingStartedevent to other users in the project group who can see that content. - When the user stops editing (saves, navigates away, or a timeout expires), the client sends a
StopEditingmessage, and aUserEditingStoppedevent is broadcast. - If the client disconnects without sending
StopEditing, the server cleans up the editing state after a configurable timeout (e.g., 60 seconds).
Editing indicators are advisory only -- they do not lock content. Two users can edit the same content simultaneously, with the last save winning. The indicators simply help users coordinate informally.
Data Flow
End-to-End: Character Created with Real-Time Updates
End-to-End: Comment with @Mention
Key Components
ProjectHub (Infrastructure Layer)
The single SignalR hub that manages WebSocket connections for the platform. Handles connection lifecycle (connect, disconnect), group management (adding connections to project groups), and client-to-server messages (StartEditing, StopEditing). Feature code does not call the hub directly; instead, background event handlers inject IHubContext<ProjectHub> to send server-to-client messages.
Event Dispatcher
Coordinates the dispatch of domain events after successful persistence. Invokes registered event handlers (activity feed handler, notification handler, SignalR broadcast handler) for each domain event. This can be implemented via MediatR's notification pipeline or a custom dispatcher.
ActivityFeedHandler
Listens for domain events and creates ActivityEntry records. Maps each event type to the appropriate activity action (created, updated, deleted, published). Enriches entries with display names and sequence numbers for human-readable feed items.
NotificationHandler
Determines which users should be notified based on the event type and their relationship to the content. Creates Notification records and triggers real-time push via SignalR. Respects permission visibility: never creates a notification that references content the recipient cannot see.
MentionParser
Extracts @mention tokens from comment body text. Resolves display names to user IDs within the project context. Returns a list of mentioned user IDs for notification creation. Handles edge cases like mentioning a user who is not a project member (ignored) or mentioning oneself (no self-notification).
PermissionAwareBroadcaster
A service that wraps SignalR broadcasting with permission checks. When broadcasting an event about potentially restricted content, it iterates through connected users in the project group, checks each user's visibility for that content, and sends the event only to authorized users. For events about public content, it broadcasts to the entire group without per-user filtering.
Feature Interactions
- Permission and Visibility System (06): The real-time layer is fundamentally dependent on the permission system. Every broadcast, activity entry, and notification must be filtered through visibility rules. The
IPermissionServiceis used by event handlers to determine who should receive events and notifications. - Authentication (01): SignalR connections are authenticated via the same JWT mechanism as REST endpoints. The token is passed during the WebSocket handshake and validated by the hub's authentication middleware.
- Project Management (02): Project membership determines which SignalR groups a user joins. When a user accepts an invitation, their connection is added to the new project's group. When a user is removed from a project, their connection is removed from the group.
- Content Timeline System (03): Timeline changes generate activity feed events and may trigger
content_publishednotifications when status changes to Published. - Character System (04): Character creation and updates generate activity feed events and graph update events (Phase 2). Private character events are filtered from unauthorized users.
- Faction System (05): Faction changes generate activity feed events. Secret faction relationship events are filtered to only Owner and Storyteller connections.
- Version History and Changelog (08): Changelog entries generate activity feed events (Phase 4) and are broadcast via SignalR.
- Search and Navigation (09): Search results are not real-time, but the activity feed helps users discover recent content that they might want to search for or navigate to.
Edge Cases and Error Handling
User connected to multiple devices: A user may have multiple active SignalR connections (desktop and mobile). Events are sent to all connections for that user. The notification unread count is consistent across connections because it is stored in the database, not in connection state.
Comment on deleted content: If content is deleted while a user is composing a comment, the comment creation fails with a 404 for the target. If content is deleted after comments exist, the comments are soft-deleted or orphaned (design decision: cascade delete or preserve comment history).
@mention of a user who leaves the project: If a mentioned user is removed from the project after being mentioned, their notification for that mention remains (it was valid at the time). Future notifications about that comment thread are not sent to the removed user.
High-frequency events: Bulk operations (e.g., reordering 20 timeline items) could generate 20 individual events. The event handler should batch these into a single activity entry and a single SignalR event (e.g., "Co-Creator reordered timelines") rather than flooding the feed and connections.
SignalR connection failure: If the SignalR connection cannot be established (e.g., WebSocket blocked by proxy), the client falls back to Server-Sent Events, then long polling. The activity feed API serves as the fallback: if real-time delivery fails, the user can always refresh to see the latest activity.
Permission change while connected: If a user's role is downgraded (e.g., from Co-Creator to Player) while they are connected, subsequent events are filtered based on the new role. The client does not need to reconnect -- the permission check happens at broadcast time, not at connection time.
Notification volume: Active projects may generate many notifications. The API defaults to returning the 50 most recent notifications. Older notifications are available via pagination. No automatic deletion of old notifications in Phase 3; a future cleanup job can archive notifications older than a configurable threshold.
Race condition: activity entry before entity commit: Domain events are dispatched after successful persistence (post-commit). This ensures that if a client receives a SignalR event and immediately queries the API, the referenced entity exists in the database.
Resolved comment reopened: A Storyteller can unresolve a previously resolved thread if the discussion needs to continue. The resolution metadata (who resolved, when) is cleared when unresolved.
Phase and Priority
Phase 1: SignalR hub infrastructure, basic activity feed events (content created/updated/deleted, member joined). This establishes the real-time architecture that later phases build upon.
Phase 2: Graph update events (character, relationship, faction changes pushed to connected users viewing the relationship map).
Phase 3: Comments system, @mentions, notifications, and editing indicators. This is the core collaboration phase.
Phase 4: Changelog update events (live changelog pushed via SignalR).
The phased approach ensures that the real-time infrastructure is battle-tested with simple events before taking on the complexity of comments, mentions, and notifications.