Skip to main content

Shared Experiences Framework

Table of Contents


Overview

Pajama's Shared Experiences framework enables real-time collaborative activities during video calls. The framework supports synchronized state management, multi-channel event distribution, and extensible experience types. Currently implements co-reading, with future support planned for co-drawing, co-puzzles, and other collaborative activities.

The framework uses a Redux-like pattern with session actors for state management, multi-channel event synchronization, and a factory pattern for experience creation.


Core Assumptions

  1. Call-dependent: Shared experiences only occur during active video calls
  2. Multi-channel sync: Real-time events via PubSub, persistent state via Firestore, with local channels for testing
  3. Single active experience: One experience active per call at a time
  4. Sequential experiences: Multiple experiences can run in sequence during one call
  5. Multi-participant support: Supports 1:1 and group calls
  6. Owner/follower model: One participant owns the experience, others follow
  7. Resumable sessions: Experiences can be resumed across calls using snapshots
  8. Event ordering: Sequence numbers ensure consistent state across participants
  9. Pluggable architecture: Video system and event channels are abstracted

Architecture

Component Diagram

┌─────────────────────────┐
│ ExperienceManager │ ← Orchestrates lifecycle, creates components
│ (MainActor) │
└────────────┬────────────┘

┌────────────▼────────────┐
│ ExperienceInitializer │ ← Factory for creating experience components
└────────────┬────────────┘

┌────────▼────────┐ ┌─────────────────────┐
│ ExperienceEvent │ │ ExperienceSnapshot │
│ Hub │◄────────┤ Manager │
└────────┬────────┘ └─────────────────────┘

┌────────▼────────┐
│ Event Channels │
├─────────────────┤
│ • Firestore │ ← Persistent state & event log
│ • PubSub │ ← Real-time synchronization
│ • Local │ ← Testing & debugging
└─────────────────┘

┌─────────────────────────┐ ┌─────────────────────────┐
│ Experience Definition │ │ Session Actor │
│ (Factory Pattern) │────────▶│ (Redux Pattern) │
│ │ │ │
│ • CoReadingDefinition │ │ • CoReadingSessionActor │
│ • CoDrawingDefinition │ │ • CoDrawingSessionActor │
│ • CoPuzzlesDefinition │ │ • CoPuzzlesSessionActor │
└─────────────────────────┘ └────────────┬────────────┘

┌─────────────▼────────────┐
│ Experience ViewModel │
│ (UI Coordination) │
│ │
│ • CoReadingViewModel │
│ • CoDrawingViewModel │
│ • CoPuzzlesViewModel │
└──────────────────────────┘

Component Breakdown

1. ExperienceManager

Central orchestrator for experience lifecycle

  • Manages experience activation/deactivation across the call
  • Creates experience components via ExperienceInitializer
  • Coordinates between RTC system and active experiences
  • Handles participant join/leave events
  • Publishes experience state (isFullScreenMode, isOwner, lifecycleState)
  • Discovers and joins existing experiences when entering calls
// Activate an experience
experienceManager.activateExperience(withId: "co_reading")

// Get UI for active experience
let experienceView = experienceManager.createContentView()

// End current experience (owner only)
experienceManager.endCurrentExperience()

2. ExperienceDefinition

Factory protocol for creating experience components

Each experience type implements this protocol to define how to create its session actor and view model:

protocol ExperienceDefinition {
associatedtype Actor: ExperienceSessionActor
associatedtype ViewModel: ExperienceViewModel
static var id: String { get }

func createSessionActor(context: ExperienceInitializationContext) -> Result<Actor, ExperienceError>
func createViewModel(sessionActor: Actor, rtcViewModel: RTCViewModel, manager: ExperienceManager) -> Result<ViewModel, ExperienceError>
}

3. ExperienceSessionActor

Redux-like state management for each experience

Session actors manage experience-specific state using a reducer pattern:

// Example for co-reading
struct CoReadingState {
var selectedStoryId: String?
var currentLocation: StoryLocation?
var isFullScreenMode: Bool = false
}

enum CoReadingAction {
case storySelected(storyId: String)
case navigationRequested(location: StoryLocation)
case fullScreenToggled
}

4. ExperienceEventHub

Multi-channel event distribution system

Coordinates events across multiple channels with deduplication and ordering:

  • Firestore Channel: Persistent events and state snapshots
  • PubSub Channel: Real-time event distribution via HMS
  • Local Channel: Testing and development
// Send events through all channels
try await eventHub.send(event: storySelectedEvent).asyncSingle()

// Subscribe to validated, deduplicated events
eventHub.events.sink { event in
// Process event
}.store(in: &cancellables)

5. ExperienceViewModel

UI coordination layer

View models handle UI presentation and user interactions:

protocol ExperienceViewModel: ObservableObject, Cleanupable {
func createContentView() -> AnyView
}

6. ExperienceSnapshotManager

State persistence for session resumption

Periodically saves experience state to Firestore for recovery:

// Automatic periodic snapshots
snapshotManager.startPeriodicSaving(interval: 30) // seconds

// Manual snapshot on important state changes
await snapshotManager.saveSnapshotIfNeeded(force: true)

Experience Lifecycle

Inactive ──activate──▶ Initializing ──ready──▶ Active ──end/leave──▶ Deactivating ──cleanup──▶ Inactive
▲ │ │ │
│ ▼ ▼ │
└──── error/timeout ── Error Degraded ─────────────────┘

States

  • Inactive: No experience running
  • Initializing: Creating components, connecting channels, loading state
  • Active: Experience running normally, all channels operational
  • Degraded: Experience running but some channels disconnected
  • Deactivating: Cleanup in progress
  • Error: Initialization or operation failed

Lifecycle Events

  1. Activation: User initiates experience → creates session actor → connects event hub → transitions to active
  2. Discovery: Entering call with existing experience → joins session → syncs state
  3. Deactivation: Owner ends experience → sends end event → cleanup → transitions to inactive
  4. Error Recovery: Failed channels → degraded state → potential recovery

Event System

Event Structure

All events conform to ExperienceEvent protocol:

protocol ExperienceEvent: Codable {
var id: String { get } // Unique identifier
var sequence: Int64 { get } // Ordering timestamp
var type: String { get } // Event type
var senderId: String { get } // User who sent event
var data: [String: String] { get } // Event payload
}

Event Flow

  1. Send: User action triggers event creation
  2. Validate: Sequence number validation and deduplication
  3. Distribute: Event sent through all active channels
  4. Process: Session actor receives event and updates state
  5. UI Update: View model observes state changes and updates UI

Built-in Event Types

  • participant_joined: User joins experience
  • participant_left: User leaves experience
  • experience_ended: Owner terminates experience

Experience-specific events are defined by each implementation.


State Management

Redux Pattern

Each experience uses a Redux-like pattern with:

  • State: Immutable data structure representing current experience state
  • Actions: Events that describe state changes
  • Reducer: Pure function that applies actions to create new state
  • Actor: Manages state transitions and side effects
// Example reducer
func coReadingReducer(state: CoReadingState, action: CoReadingAction) -> CoReadingState {
var newState = state
switch action {
case let .storySelected(storyId):
newState.selectedStoryId = storyId
case let .navigationRequested(location):
newState.currentLocation = location
}
return newState
}

State Synchronization

  • Optimistic Updates: Local state updated immediately
  • Event Broadcasting: Changes sent to other participants
  • Conflict Resolution: Sequence numbers ensure consistent ordering
  • Snapshot Recovery: Periodic state snapshots for crash recovery

Threading Guidelines

MainActor Usage

  • ExperienceManager: All public methods on main thread
  • ExperienceViewModel: UI updates must be main thread
  • State Updates: View model state changes on main thread

Background Processing

  • Event Processing: Hub processes events on background threads
  • Network Operations: Firestore/PubSub operations offloaded
  • State Computation: Reducers can run on background threads
// Correct threading pattern
Task { @MainActor in
// UI updates on main thread
self.currentPage = newPage
}

Task.detached {
// Network operations on background
try await eventHub.send(event: pageEvent).asyncSingle()
}

Creating New Experiences

Step 1: Define State and Actions

struct CoDrawingState: Codable {
var canvasData: DrawingCanvas = DrawingCanvas()
var currentTool: DrawingTool = .pen
var isFullScreenMode: Bool = false
}

enum CoDrawingAction {
case strokeAdded(stroke: DrawingStroke)
case toolChanged(tool: DrawingTool)
case canvasCleared
}

Step 2: Create Session Actor

actor CoDrawingSessionActor: ExperienceSessionActor {
typealias State = CoDrawingState
typealias Action = CoDrawingAction

// Implement required methods
func reducer(state: State, action: Action) -> State {
// Apply action to state
}

func handleEvent(_ event: ExperienceEvent) async -> Action? {
// Convert events to actions
}
}

Step 3: Create View Model

@MainActor
class CoDrawingViewModel: ExperienceViewModel {
@Published var canvasData: DrawingCanvas = DrawingCanvas()
@Published var currentTool: DrawingTool = .pen

func createContentView() -> AnyView {
AnyView(CoDrawingView(viewModel: self))
}
}

Step 4: Create Experience Definition

struct CoDrawingExperienceDefinition: ExperienceDefinition {
typealias Actor = CoDrawingSessionActor
typealias ViewModel = CoDrawingViewModel
static let id = "co_drawing"

func createSessionActor(context: ExperienceInitializationContext) -> Result<Actor, ExperienceError> {
// Create and configure actor
}

func createViewModel(sessionActor: Actor, rtcViewModel: RTCViewModel, manager: ExperienceManager) -> Result<ViewModel, ExperienceError> {
// Create and configure view model
}
}

Step 5: Register Experience

// In app initialization
ExperienceRegistry.shared.register(CoDrawingExperienceDefinition())

Firestore Layout

Session Documents

/calls/{callId}/experience_sessions/{sessionId}
{
"experienceId": "co_reading",
"sessionId": "session_123",
"createdAt": "2025-01-01T10:00:00Z",
"createdBy": "user_abc",
"ownerId": "user_abc",
"participantIds": ["user_abc", "user_xyz"],
"status": "active",
"lastUpdated": "2025-01-01T10:15:00Z"
}

Event Log

/calls/{callId}/experience_sessions/{sessionId}/events/{eventId}
{
"id": "event_456",
"sequence": 1704110400000,
"type": "story_selected",
"senderId": "user_abc",
"data": {
"storyId": "story_789"
},
"timestamp": "2025-01-01T10:00:00Z"
}

State Snapshots

/calls/{callId}/experience_sessions/{sessionId}/snapshots/{snapshotId}
{
"snapshotId": "snapshot_101",
"sequence": 1704110400000,
"experienceState": {
"selectedStoryId": "story_789",
"currentLocation": {
"beatId": "beat_1",
"pageId": "page_1",
"lineIndex": 0
},
"isFullScreenMode": true
},
"createdAt": "2025-01-01T10:00:00Z"
}

Event Ordering Rules

  • Events include monotonic sequence field (Unix milliseconds)
  • Conflicts resolved by sender ID lexical comparison
  • Clients track lastAppliedSequence to prevent duplicates
  • Firestore timestamps used for persistence only, not ordering