Skip to main content

Pajama App: Shared Experiences System Documentation

1. Overview

The Shared Experiences system in the Pajama app allows multiple users in a real-time communication (RTC) call to participate in a synchronized activity, such as co-reading a story. The system is designed to be extensible, allowing new types of shared experiences to be added in the future.

Core Goals:

  • Real-time Synchronization: Ensure all participants have a consistent view of the experience state.
  • Persistence: Allow experiences to be re-joined if a user disconnects and reconnects, or even across call sessions (though full cross-session persistence might be a future enhancement depending on ExperienceSession lifecycle).
  • Resilience: Handle network issues, participant joins/leaves, and owner changes gracefully.
  • Extensibility: Provide a clear framework for defining and integrating new shared experiences.

2. Core Concepts & Key Components

  • Experience: A shared activity (e.g., co-reading, co-drawing).
  • Experience Definition (ExperienceDefinition): A blueprint for a specific type of experience. It defines how to create the necessary components (Actor, ViewModel) and manage its state.
  • Experience Registry (ExperienceRegistry): A singleton that holds all registered ExperienceDefinitions, allowing the system to look them up by ID.
  • Experience Manager (ExperienceManager): The central orchestrator for shared experiences. It manages the lifecycle, activation/deactivation, and UI creation for the active experience.
  • Experience Initializer (ExperienceInitializer): Handles the complex, multi-step process of setting up a new or existing experience session.
  • Experience Session Actor (ExperienceSessionActor): An actor (in the Swift actor model sense) responsible for managing the specific state of an active experience instance (e.g., CoReadingSessionActor for co-reading).
  • Experience View Model (ExperienceViewModel): An ObservableObject that bridges the ExperienceSessionActor's state to the SwiftUI views.
  • Experience System Context (ExperienceSystemContext): An actor that manages shared system-level context for an experience, primarily participant state and owner ID.
  • State Sync Engine (StateSyncEngine): Handles the low-level transmission and reception of state change events between participants. Uses a dual PubSub (Stream Video) and Firestore approach.
  • State Sync Event (StateSyncEvent): A message representing a change in the experience's state.
  • State Snapshot (ExperienceStateSnapshot, StateSnapshotManager): A complete representation of an experience's state at a point in time, used for persistence and for new participants to catch up.
  • Experience Session (ExperienceSession): A Firestore document representing an active shared experience tied to a call.
  • Experience Session Discoverer (ExperienceSessionDiscoverer): Listens for active ExperienceSession documents on Firestore to allow users to join ongoing experiences.

3. Architecture Diagram

graph TD
subgraph AppUI
ExperienceContentView["Experience Content View (SwiftUI)"]
CallView["Call View (Hosts Experience)"]
end

subgraph ExperienceManagerScope["ExperienceManager (ObservableObject)"]
EM["ExperienceManager"]
EM_LS["lifecycleState: ExperienceLifecycleState"]
EM_Actor["activeSessionActor: ExperienceSessionActor?"]
EM_VM["activeViewModel: ExperienceViewModel?"]
EM_SE["activeSyncEngine: StateSyncEngine?"]
EM_SSM["stateSnapshotManager: StateSnapshotManager?"]
EM_SysCtx["systemContext: ExperienceSystemContext?"]
EM_ESD["sessionDiscoverer: ExperienceSessionDiscoverer"]
EM_EI["initializer: ExperienceInitializer"]

EM --- EM_LS
EM --> EM_Actor
EM --> EM_VM
EM --> EM_SE
EM --> EM_SSM
EM --> EM_SysCtx
EM --> EM_ESD
EM --> EM_EI
end

subgraph ExperienceDefinitionScope["Experience Definition"]
ED_Registry["ExperienceRegistry"]
ED_Def["AnyExperienceDefinition (Wrapper)"]
ED_CoReadingDef["CoReadingExperienceDefinition (Concrete)"]
ED_Registry -- "1..* has a" --> ED_Def
ED_Def -- "wraps" --> ED_CoReadingDef
end

subgraph ActorScope["ExperienceSessionActor (Actor)"]
ESA_Protocol["ExperienceSessionActor (Protocol)"]
ESA_CoReading["CoReadingSessionActor (Concrete)"]
ESA_State["actorSpecificState (e.g., CoReadingState)"]
ESA_SysCtxRef["systemContext: ExperienceSystemContext"]

ESA_CoReading -- "implements" --> ESA_Protocol
ESA_CoReading --> ESA_State
ESA_CoReading -- "uses" --> ESA_SysCtxRef
end

subgraph ViewModelScope["ExperienceViewModel (ObservableObject)"]
EVM_Protocol["ExperienceViewModel (Protocol)"]
EVM_CoReading["CoReadingViewModel (Concrete)"]
EVM_RTC["rtcViewModel: RTCViewModel"]

EVM_CoReading -- "implements" --> EVM_Protocol
EVM_CoReading -- "uses" --> EVM_RTC
end

subgraph StateSyncScope["State Synchronization"]
SSE["StateSyncEngine (Protocol)"]
DSSE["DefaultStateSyncEngine (Actor)"]
SSE_Events["events: Publisher<StateSyncEvent, Never>"]
SSE_SeqVal["sequenceValidator: StateSyncEventSequenceValidator"]
SSE_PubSub["pubSubService: PubSubService (Stream)"]
SSE_FirestoreDep["firestore: Firestore"]

SSM_Manager["StateSnapshotManager (Actor)"]
SSR["StateSnapshotReader (Protocol)"]
SSW["StateSnapshotWriter (Protocol)"]
FSSR["FirestoreStateSnapshotReader (Concrete)"]
FSSW["FirestoreStateSnapshotWriter (Concrete)"]
SSEP["StateSyncEvent (Protocol)"]
BaseSSEP["BaseStateSyncEvent (Struct)"]
FSER["FirestoreStateSyncEventReader (Concrete)"]

DSSE -- "implements" --> SSE
DSSE --> SSE_Events
DSSE -- "has a" --> SSE_SeqVal
DSSE -- "uses" --> SSE_PubSub
DSSE -- "uses" --> SSE_FirestoreDep

SSM_Manager -- "uses" --> SSR
SSM_Manager -- "uses" --> SSW
FSSR -- "implements" --> SSR
FSSW -- "implements" --> SSW
BaseSSEP -- "implements" --> SSEP
end

subgraph ContextScope["System Context"]
SysCtx["ExperienceSystemContext (Actor)"]
SysCtx_Participants["participants: Set<UserID>"]
SysCtx_Owner["ownerId: UserID"]
SysCtx --> SysCtx_Participants
SysCtx --> SysCtx_Owner
end

subgraph Services
UserSessionSvc["UserSessionService"]
ContactSvc["ContactService"]
ExperienceSessionSvc["ExperienceSessionService"]
RTCSvc["RTCService / RTCViewModel"]
StorySvc["StoryService (for CoReading)"]
end

FirestoreDb["Firestore Database"]

%% Connections
CallView --> EM
EM_VM --> ExperienceContentView
EM --> ED_Registry
EM_EI --> ED_Def

ED_Def -- "creates" --> ESA_Protocol
ED_Def -- "creates" --> EVM_Protocol

EM_Actor --> ESA_Protocol
EM_VM --> EVM_Protocol

ESA_Protocol -- "updates" --> ESA_State
EVM_Protocol -- "observes" --> ESA_State
EVM_Protocol -- "observes" --> SysCtx_Participants

ESA_Protocol -- "uses" --> SSE
ESA_Protocol -- "uses" --> UserSessionSvc
ESA_Protocol -- "uses" --> ContactSvc
ESA_Protocol -- "uses" --> SysCtx

EM_EI -- "uses" --> SSE
EM_EI -- "uses" --> SSR
EM_EI -- "uses" --> FSER
EM_EI -- "uses" --> UserSessionSvc
EM_EI -- "uses" --> ContactSvc
EM_EI -- "uses" --> ExperienceSessionSvc

EM_SE --> SSE
EM_SSM --> SSM_Manager
EM_SysCtx --> SysCtx

EM_ESD -- "uses" --> ExperienceSessionSvc
EM -- "uses" --> RTCSvc

SSM_Manager -- "uses" --> ESA_Protocol
DSSE -- "uses" --> UserSessionSvc
DSSE -- "uses" --> RTCSvc

FSSR -- "reads from" --> FirestoreDb
FSSW -- "writes to" --> FirestoreDb
SSE_FirestoreDep -- "accesses" --> FirestoreDb
FSER -- "reads from" --> FirestoreDb
ExperienceSessionSvc -- "accesses" --> FirestoreDb

4. Detailed Component Breakdown

4.1. ExperienceManager

  • Responsibilities:
    • Acts as the primary interface for the UI to interact with shared experiences.
    • Manages the overall lifecycle of a shared experience (ExperienceLifecycleState).
    • Initiates activation of new experiences or re-activation of existing ones (via ExperienceInitializer).
    • Coordinates the creation of ExperienceSessionActor and ExperienceViewModel using ExperienceRegistry and ExperienceDefinition.
    • Handles deactivation and cleanup of active experiences.
    • Listens to RTCViewModel for call state changes and participant updates, propagating relevant information to the active experience (primarily via ExperienceSystemContext).
    • Uses ExperienceSessionDiscoverer to find ongoing experiences in the current call.
    • Subscribes to the StateSyncEngine's event stream to process system-level events (participant join/left, experience ended).
    • Manages the StateSnapshotManager for the active experience.
    • Publishes UI-relevant state like isFullScreenMode (derived from actor state) and isOwner.
  • Key Properties/Methods:
    • lifecycleState: ExperienceLifecycleState: Published state of the experience.
    • isFullScreenMode: Bool: Published, derived from actor.
    • isOwner: Bool: Published, indicates if the current user is the owner.
    • activateExperience(withId: String): Initiates a new experience.
    • deactivateCurrentExperience(): Stops the current experience.
    • createContentView() -> AnyView: Returns the SwiftUI view for the current experience.
    • processLifecycleAction(_: ExperienceLifecycleAction): Core method to drive state changes.
    • handleSideEffects(...): Manages tasks that need to occur upon state transitions.
  • Interactions:
    • ExperienceRegistry: To get definitions.
    • ExperienceInitializer: To set up new/existing sessions.
    • ExperienceSessionActor: Holds the active actor.
    • ExperienceViewModel: Holds the active view model.
    • StateSyncEngine: Subscribes to its events.
    • StateSnapshotManager: Manages it.
    • ExperienceSystemContext: Manages it and passes it to actors.
    • RTCViewModel: Observes call state and participant list.
    • ExperienceSessionDiscoverer: Receives discovery notifications.

4.2. ExperienceRegistry & ExperienceDefinition

  • ExperienceRegistry:
    • Responsibilities: Singleton that stores and provides access to all available ExperienceDefinitions.
    • Key Methods: register(_: ExperienceDefinition), getDefinition(withId: String) -> AnyExperienceDefinition?.
  • ExperienceDefinition (Protocol):
    • Responsibilities: Defines the contract for a specific type of experience.
    • Key Properties/Methods:
      • static var id: String: Unique identifier (e.g., "co_reading").
      • createSessionActor(context: ExperienceInitializationContext) -> Result<Actor, ExperienceError>: Factory method for the experience's state actor.
      • createViewModel(sessionActor: Actor, rtcViewModel: RTCViewModel, manager: ExperienceManager) -> Result<ViewModel, ExperienceError>: Factory method for the experience's view model.
      • decodeSnapshotData(...) -> AnyExperienceStateSnapshot: Decodes raw snapshot data into the specific experience's typed snapshot.
  • AnyExperienceDefinition: Type-erased wrapper to store heterogeneous definitions in the registry.

4.3. ExperienceInitializer

  • Responsibilities: Orchestrates the complex setup process for an experience. This is crucial for both starting a new experience and joining an existing one.
  • Key Steps in initialize():
    1. (Conditional) Create Firestore Session Document: If it's a new experience, create an ExperienceSession document in Firestore via ExperienceSessionService.
    2. Create StateSyncEngine: Instantiates DefaultStateSyncEngine and connects it.
    3. Create ExperienceSystemContext: Initializes with session ID, experience ID, and owner ID.
    4. Fetch Snapshot & Deltas:
      • Uses StateSnapshotReader (e.g., FirestoreStateSnapshotReader) to get the latest ExperienceStateSnapshot.
      • Uses the ExperienceDefinition to decode this snapshot into a typed one.
      • Updates ExperienceSystemContext participants from the snapshot.
      • Uses StateSyncEventReader (e.g., FirestoreStateSyncEventReader) to fetch historical events (deltas) that occurred after the snapshot's sequence number.
      • Buffers these delta events into the StateSyncEngine.
    5. Perform Self-Join: Adds the current user to ExperienceSystemContext and sends a "participant_joined" StateSyncEvent via the engine.
    6. Create ExperienceSessionActor: Uses the ExperienceDefinition's createSessionActor method, passing the engine, context, and potentially the loaded snapshot.
    7. Wait for Actor Readiness: Subscribes to the actor's readinessStream to ensure the actor has completed its internal setup (e.g., applying snapshot, setting up its own event listeners).
    8. Engine Go Live: Calls syncEngine.goLive(snapshotSequenceNumber), which processes buffered historical events and starts emitting live events to the actor.
    9. Setup StateSnapshotManager: Creates the manager, which will handle periodic state saving if the current user is the owner.
  • Returns: InitializedExperienceComponents containing the ready-to-use components.

4.4. ExperienceLifecycleState & ExperienceLifecycleReducer

  • ExperienceLifecycleState (Enum):
    • Defines the various states an experience can be in: inactive, initializing, active, degraded, deactivating, error.
    • Provides computed properties like isActive, sessionId, experienceId, ownerId.
  • ExperienceLifecycleAction (Enum):
    • Defines all possible actions that can trigger a state transition (e.g., activate, deactivate, syncConnected, participantJoined, error).
  • experienceLifecycleReducer (Function):
    • A pure function: (state: ExperienceLifecycleState, action: ExperienceLifecycleAction) -> ExperienceLifecycleState.
    • Takes the current state and an action, and returns the new state.
    • This is the core logic dictating how the ExperienceManager transitions its lifecycleState.

4.5. ExperienceSessionActor (Protocol) & Concrete Implementations (e.g., CoReadingSessionActor)

  • Responsibilities:
    • Manages the specific state for an instance of an experience (e.g., CoReadingState for CoReadingSessionActor).
    • Processes incoming StateSyncEvents (filtered by the ExperienceManager and passed from the StateSyncEngine) relevant to its experience type, updating its internal state.
    • Handles local user actions (e.g., selectStory in CoReading), updates its state, and creates corresponding StateSyncEvents to be sent via the StateSyncEngine.
    • Exposes its state and any errors via AsyncStreams (stateStream, errorStream).
    • Signals its readiness via readinessStream after internal setup.
    • Provides its current state snapshot via getState() for persistence.
    • Performs cleanup of its resources.
  • Key Interactions:
    • StateSyncEngine: Receives events from and sends events to.
    • ExperienceSystemContext: Observes participant changes and owner ID.
    • ExperienceViewModel: The ViewModel subscribes to the actor's state streams.
  • Example (CoReadingSessionActor):
    • Manages CoReadingState (stories, selectedStory, currentPageIndex).
    • Uses CoReadingReducer to process CoReadingActions.
    • Handles CoReadingEffects (e.g., loading stories from StoryService).
    • Converts CoReadingAction to/from StateSyncEvent.

4.6. ExperienceSystemContext (Actor)

  • Responsibilities:
    • Provides a centralized, thread-safe store for system-level information shared across an experience instance.
    • Manages the participants: Set<UserID> and ownerId: UserID.
    • Streams changes to participants (participantsStream()).
    • Ensures data consistency for snapshots.
  • Interactions:
    • ExperienceInitializer: Creates and populates it.
    • ExperienceSessionActor: Observes it for participant changes and owner ID.
    • StateSnapshotManager: Uses it to get participant/owner info when creating a snapshot.

4.7. StateSyncEngine (Protocol) & DefaultStateSyncEngine (Actor)

  • StateSyncEngine (Protocol):
    • Defines the contract for sending and receiving StateSyncEvents.
    • Key methods: connect, disconnect, send(event:), events publisher, bufferHistoricalEvents, goLive.
  • DefaultStateSyncEngine (Actor):
    • Dual Transport: Uses PubSub (via Stream Video's custom event mechanism, StreamPubSubService) for low-latency event delivery and Firestore for persistent event storage and resilience.
    • Event Sequencing & Deduplication: Internally uses a StateSyncEventSequenceValidator to ensure events are processed in order and only once, even if received from both PubSub and Firestore.
    • Lifecycle:
      • connect(): Sets up listeners for PubSub and Firestore. Enters buffering state.
      • bufferHistoricalEvents(): Called by ExperienceInitializer to feed historical events fetched from Firestore (deltas after a snapshot).
      • goLive(snapshotSequenceNumber): Primes the sequenceValidator with the last sequence number from a snapshot, processes buffered events, and transitions to live state. In live state, incoming events are immediately processed by the sequenceValidator and emitted via the events publisher if valid.
      • send(event): Applies event locally (via sequenceValidator), then sends via PubSub and persists to Firestore.
      • disconnect(): Cleans up listeners and resets state.
  • StateSyncEventSequenceValidator (Actor):
    • Maintains lastAppliedSeq: Int64 and processedEventIds: SetQueue<String>.
    • shouldProcessEvent(_:source:): Checks if an event is a duplicate or out of order. Updates internal state if the event is valid and new.

4.8. State Snapshots

  • ExperienceStateSnapshotProtocol & ExperienceStateSnapshot<T: Codable>:
    • Represents a full state of an experience at a specific lastEventSequenceNumber. Includes experienceState: T, participants, and ownerId.
    • AnyExperienceStateSnapshot is a type-erased version.
  • StateSnapshotReader (Protocol) & FirestoreStateSnapshotReader:
    • fetchLatestSnapshotData(): Retrieves the raw Data and sequenceNumber of the latest snapshot from Firestore (experience_sessions/{sessionId}/state_snapshots/latest).
  • StateSnapshotWriter (Protocol) & FirestoreStateSnapshotWriter:
    • writeSnapshot(_: AnyExperienceStateSnapshot): Serializes the snapshot and writes it to Firestore. Only used by the experience owner.
  • StateSnapshotManager (Actor):
    • Responsibilities:
      • Facade for StateSnapshotReader and StateSnapshotWriter.
      • Instantiated by ExperienceInitializer.
      • If the current user is the owner, it creates a StateSnapshotWriter and starts a periodic task (startPeriodicSaving()) to save snapshots.
      • saveSnapshotIfNeeded(force:): Called periodically or forcefully (e.g., on deactivation) to save state. It checks if lastSavedSequenceNumber has changed since the last save.
  • StateSyncComponentFactory: Provides static factory methods to create snapshot and event reader/writer components.

4.9. State Sync Events

  • StateSyncEvent (Protocol) & BaseStateSyncEvent (Struct):
    • Defines the structure of an event: id, sequenceNumber, type, senderId, data: [String: String].
    • sequenceNumber is crucial for ordering and is typically a timestamp combined with a client-specific component to break ties.
  • StateSyncEventReader (Protocol) & FirestoreStateSyncEventReader:
    • fetchEvents(afterSequence: Int64): Fetches all events from Firestore (experience_sessions/{sessionId}/events) that have a sequence number greater than the one provided. Used to bridge the gap between a loaded snapshot and the current live state.

4.10. Session Discovery

  • ExperienceSession (Struct):
    • The Codable model for documents stored in the experience_sessions Firestore collection.
    • Fields: id (document ID), callId, experienceId, ownerId, status (active/inactive), lastEventId, lastEventSequenceNumber.
  • ExperienceSessionDiscoverer (Class):
    • Listens to the experience_sessions collection in Firestore, filtered by the current callId and status: .active.
    • When it finds an active session (that isn't the one the ExperienceManager might already be running), it notifies its delegate.
  • ExperienceSessionDiscovererDelegate (Protocol):
    • Implemented by ExperienceManager.
    • discovererDidFindSession(session: ExperienceSession): Called when a joinable session is found. The ExperienceManager then initiates the .reactivate lifecycle action.
  • ExperienceSessionService:
    • Handles CRUD operations for ExperienceSession documents in Firestore (e.g., creating a new session document, updating its status).

4.11. RTCViewModel Integration

  • The ExperienceManager observes RTCViewModel.$callingState.
    • When a call becomes .inCall, the ExperienceSessionDiscoverer starts listening for that callId.
    • If the call ends or changes state away from .inCall, the discoverer stops, and any active experience is deactivated.
  • The RTCViewModel is passed to ExperienceViewModel implementations (e.g., CoReadingViewModel) to allow experiences access to RTC features like participant video streams or participant information (though participant IDs are primarily managed by ExperienceSystemContext).
  • DefaultStateSyncEngine uses RTCViewModel.streamCallViewModel to get a Call object for the Stream PubSub service.

5. Data Flow & Key Scenarios

5.1. Activating a New Experience (User A starts CoReading)

  1. UI Interaction: User A taps "Start Co-Reading" button in the call UI.
  2. ExperienceManager.activateExperience(withId: "co_reading"):
    • lifecycleState -> initializing.
    • isOwner set to true.
    • Calls ExperienceInitializer.initialize(existingExperience: false).
  3. ExperienceInitializer.initialize():
    • ExperienceSessionService.create(): Creates experience_sessions/{new_session_id} document in Firestore with status: .active, ownerId: UserA_ID.
    • Creates DefaultStateSyncEngine, connects to PubSub (Stream) channel {new_session_id} and Firestore event subcollection.
    • Creates ExperienceSystemContext (ownerId: UserA_ID, empty participants initially).
    • Fetches snapshot: (Returns nil as it's a new session). snapshotSequenceNumber = 0.
    • Fetches deltas: (Returns empty array). syncEngine.bufferHistoricalEvents([]).
    • Performs Self-Join:
      • Adds User A to systemContext.participants.
      • Sends participant_joined event via syncEngine.
    • ExperienceRegistry.getDefinition(withId: "co_reading") -> CoReadingExperienceDefinition.
    • CoReadingExperienceDefinition.createSessionActor():
      • Creates CoReadingSessionActor (passes engine, systemContext, initial CoReadingState, seq 0).
      • Actor initializes, sets up its internal event/context listeners, loads initial library (based on User A), signals readiness via readinessStream.
    • syncEngine.goLive(snapshotSequenceNumber: 0): Engine processes empty buffer, transitions to live state.
    • Creates StateSnapshotManager (with writer, since User A is owner). Starts periodic saving.
    • CoReadingExperienceDefinition.createViewModel(): Creates CoReadingViewModel (passes actor, RTCViewModel, manager).
  4. ExperienceManager (continues from initialize completion):
    • Stores created components (actor, VM, engine, etc.).
    • Subscribes to actor's stateStream and errorStream.
    • Subscribes to syncEngine.events (for system events).
    • processLifecycleAction(.experienceReady) -> lifecycleState -> active.
  5. UI: ExperienceManager.createContentView() now returns CoReadingContainerView.

5.2. Joining an Existing Experience (User B joins User A's CoReading)

  1. Call Active: User B is in the same call as User A. RTCViewModel.callingState == .inCall.
  2. ExperienceManager (User B's instance):
    • ExperienceSessionDiscoverer is listening to Firestore for active sessions in this call.
  3. ExperienceSessionDiscoverer:
    • Detects the experience_sessions/{new_session_id} document created by User A.
    • Calls ExperienceManager.discovererDidFindSession(session: foundSession).
  4. ExperienceManager.discovererDidFindSession():
    • processLifecycleAction(.reactivate(experienceId: "co_reading", sessionId: foundSession.id, ownerId: foundSession.ownerId)).
    • lifecycleState -> initializing.
    • isOwner set to false (assuming foundSession.ownerId is User A's ID).
    • Calls ExperienceInitializer.initialize(existingExperience: true).
  5. ExperienceInitializer.initialize() (for User B):
    • Skips creating Firestore session document.
    • Creates DefaultStateSyncEngine, connects.
    • Creates ExperienceSystemContext (ownerId from foundSession).
    • Fetches snapshot:
      • FirestoreStateSnapshotReader.fetchLatestSnapshotData() gets latest snapshot for {new_session_id} (might be empty if User A hasn't done anything yet, or might have some state if A interacted).
      • CoReadingExperienceDefinition.decodeSnapshotData() decodes it.
      • systemContext.participants updated from snapshot.
    • Fetches deltas:
      • FirestoreStateSyncEventReader.fetchEvents(afterSequence: snapshot.lastEventSequenceNumber) gets events that occurred since snapshot.
      • syncEngine.bufferHistoricalEvents(deltaEvents).
    • Performs Self-Join:
      • Adds User B to systemContext.participants.
      • Sends participant_joined event for User B.
    • Creates CoReadingSessionActor (with snapshot state, systemContext). Actor loads snapshot, signals readiness.
    • syncEngine.goLive(snapshot.lastEventSequenceNumber): Processes buffered deltas, actor's state catches up.
    • Creates StateSnapshotManager (reader-only, User B is not owner).
    • Creates CoReadingViewModel.
  6. ExperienceManager (User B):
    • Stores components.
    • Subscribes to streams.
    • processLifecycleAction(.experienceReady) -> lifecycleState -> active.
  7. UI (User B): Shows CoReadingContainerView, reflecting the state synchronized from User A.

5.3. State Synchronization (e.g., Owner selects a story in CoReading)

  1. UI (Owner): Owner taps a story in CoReadingLibraryView.
  2. CoReadingViewModel.selectStory(selectedStory):
    • Calls CoReadingSessionActor.selectStory(selectedStory).
  3. CoReadingSessionActor.handleCoReadingAction(.selectStory(selectedStory)):
    • action.shouldSync is true.
    • Creates story_selected StateSyncEvent (senderId: Owner's ID).
    • Calls syncEngine.send(event) (async). The event hub tracks sequence numbers internally.
    • Applies coReadingReducer(state, .selectStory): newState.selectedStory = selectedStory, newState.isFullScreenMode = true.
    • Updates self.state = newState.
    • stateSubject.send(newState).
  4. CoReadingViewModel (Owner):
    • Receives new state from stateSubject subscription.
    • Updates its @Published var state.
    • UI re-renders to show CoReadingStoryView.
  5. DefaultStateSyncEngine.send(event) (Owner):
    • sequenceValidator.shouldProcessEvent(event, source: .local) (processes locally, updates its lastAppliedSeq).
    • Sends event via PubSub (Stream).
    • Persists event to Firestore.
  6. DefaultStateSyncEngine (Participant):
    • Receives event from PubSub (or Firestore listener if PubSub missed).
    • If engine.state == .live:
      • sequenceValidator.shouldProcessEvent(event, source: .pubsub/.firestore): Checks for duplicates/order. If valid, updates its lastAppliedSeq.
      • eventSubject.send(event).
  7. ExperienceManager (Participant):
    • Subscribed to engine.events.
    • Passes the event to activeSessionActor.processReceivedEvent(event).
  8. CoReadingSessionActor.processReceivedEvent(event) (Participant):
    • Ignores system events (sequence tracking happens in the event hub).
    • CoReadingAction.from(syncEvent: event) -> CoReadingAction.applyRemoteStorySelection(storyId).
    • Calls self.handleCoReadingAction(.applyRemoteStorySelection(storyId)).
  9. CoReadingSessionActor.handleCoReadingAction(.applyRemoteStorySelection(storyId)) (Participant):
    • action.shouldSync is false (it's a remote event).
    • Applies coReadingReducer(state, .applyRemoteStorySelection):
      • If story found locally: newState.selectedStory updated.
      • If story not found: effect = .loadAndSelectStory(storyId).
    • Updates self.state, sends to stateSubject.
    • If effect is .loadAndSelectStory, it triggers loadAndSelectStorySideEffect():
      • Loads story from StoryService.
      • Adds to state.stories.
      • Recursively calls handleCoReadingAction(.applyRemoteStorySelection(storyId)) to now select the loaded story.
  10. CoReadingViewModel (Participant): Receives new state, UI updates.

6. Lifecycle Management

  • States (ExperienceLifecycleState):
    • inactive: No experience active.
    • initializing: Setting up (fetching data, creating components). ExperienceManager shows a loading UI.
    • active: Fully operational.
    • degraded: Partially functional (e.g., sync lost, owner left). UI might show warnings.
    • deactivating: Shutting down.
    • error: A non-recoverable (or retryable) error occurred.
  • Actions (ExperienceLifecycleAction): Drive transitions. Examples:
    • .activate: User initiates a new experience.
    • .reactivate: User joins an existing experience discovered via ExperienceSessionDiscoverer.
    • .deactivate: User or system requests shutdown.
    • .experienceReady: Internal signal from ExperienceInitializer that all components are set up.
    • .syncConnected/.syncDisconnected: From StateSyncEngine.
    • .participantJoined/.participantLeft: From StateSyncEngine (system events).
    • .ownerLeft: Special system event.
    • .error: An error occurred.
    • .retry: Attempt to re-initialize after an error.
  • Reducer (experienceLifecycleReducer):
    • A pure function that determines the next ExperienceLifecycleState based on the current state and the dispatched ExperienceLifecycleAction.
    • The ExperienceManager calls this reducer and then handles side effects based on the transition.

7. Implementing a New Shared Experience

  1. Define State & Actions:
    • Create a struct YourExperienceState: Codable, Equatable for your experience's specific data.
    • Create an enum YourExperienceAction: Equatable for local and remote actions. Include toSyncEvent() and from(syncEvent:) methods.
  2. Create Reducer & Effects:
    • Implement yourExperienceReducer(_ state: YourExperienceState, _ action: YourExperienceAction) -> (YourExperienceState, YourExperienceEffect).
    • Define enum YourExperienceEffect if your actor needs to perform side effects (like network calls).
  3. Implement ExperienceSessionActor:
    • Create class YourSessionActor: ExperienceSessionActor.
    • Manage YourExperienceState.
    • Implement handleYourExperienceAction which uses your reducer and effect handler.
    • Process incoming StateSyncEvents (convert to YourExperienceAction).
    • Implement stateStream(), errorStream(), readinessStream(), getState(), cleanup().
  4. Implement ExperienceViewModel:
    • Create class YourViewModel: ObservableObject, ExperienceViewModel.
    • Observe YourSessionActor's state stream.
    • Provide methods for UI to dispatch actions to the actor.
    • Implement createContentView() -> AnyView to return your experience's main SwiftUI view.
  5. Implement ExperienceDefinition:
    • Create struct YourExperienceDefinition: ExperienceDefinition.
    • static let id = "your_experience_id".
    • Implement createSessionActor() to return an instance of YourSessionActor.
    • Implement createViewModel() to return an instance of YourViewModel.
    • Implement decodeSnapshotData() to decode ExperienceStateSnapshot<YourExperienceState>.
  6. Register Definition:
    • In AppServices.setupExperiences() (or similar app setup location): ExperienceRegistry.shared.register(YourExperienceDefinition(dependencies...))
  7. Create UI:
    • Develop the SwiftUI views for your experience, which will be hosted by your YourViewModel.

8. Error Handling & Edge Cases

  • Initialization Timeout: ExperienceInitializer uses withTimeout to prevent indefinite blocking. If timeout occurs, ExperienceManager transitions to an error state.
  • Sync Disconnection: StateSyncEngine (implicitly) or ExperienceManager can detect sync issues. ExperienceManager can transition to a degraded state.
  • Owner Leaves: If the owner of the experience leaves the call (detected via RTCViewModel participant updates or a specific "owner_left" event), ExperienceManager can transition to a degraded state or end the experience.
  • Actor Errors: ExperienceSessionActor implementations can publish errors via their errorStream. ExperienceManager subscribes and transitions to an error state.
  • Snapshot/Event Fetch Failures: ExperienceInitializer handles errors from StateSnapshotReader and StateSyncEventReader, leading to an error state in ExperienceManager.
  • Event Send Failures: StateSyncEngine.send() can return an error. Actors should handle this, potentially publishing to their errorStream.

9. Threading Model

  • ExperienceManager, ExperienceViewModels, UI: Primarily @MainActor as they interact with SwiftUI.
  • ExperienceInitializer: Uses @MainActor for its main initialize method but can dispatch work to background tasks.
  • ExperienceSessionActor, ExperienceSystemContext, DefaultStateSyncEngine, StateSnapshotManager, StateSyncEventSequenceValidator: These are Swift actors, ensuring thread-safe access to their mutable state. Operations on them are async and can be called from any context.
  • Service Calls (Firestore, etc.): Typically performed on background threads/tasks within actors or service methods. Callbacks or continuations then switch back to the appropriate actor or @MainActor context if needed.
  • Combine Publishers: receive(on: RunLoop.main) is used extensively to ensure that updates from Combine pipelines that will affect the UI are delivered on the main thread.

10. Configuration (DebugConfig)

The DebugConfig.swift file contains several flags relevant to shared experiences:

  • enablePubSub: Toggles the use of PubSub for real-time events. If false, relies solely on Firestore.
  • enableFirestorePersistence: Toggles saving experience state snapshots and events to Firestore.
  • pubSubLatency: Simulates latency for PubSub messages for testing.

These flags are crucial for debugging synchronization issues and testing resilience under different conditions.