Real-time multiplayer experiences in Chapter’s game world using Supabase Realtime Presence for seamless social interactions
Chapter’s game world brings students together in real-time multiplayer zones where they can explore, chat, and connect with peers. The multiplayer system uses Supabase Realtime Presence for live position sync and proximity-based interactions.
When a new player joins the zone, MultiplayerService parses their presence and spawns an OtherPlayerNode:
private func syncPlayersToScene(_ players: [NearbyPlayer]) { guard let scene = GameManager.shared.currentScene as? BaseHubScene else { return } // Sort by distance, limit to 30 visible let playerPos = scene.playerNode?.position ?? .zero let sortedPlayers = players.sorted { a, b in let da = hypot(a.x - Double(playerPos.x), a.y - Double(playerPos.y)) let db = hypot(b.x - Double(playerPos.x), b.y - Double(playerPos.y)) return da < db } let visibleIds = Set(sortedPlayers.prefix(maxVisiblePlayers).map(\.id)) // Spawn or update visible players for player in sortedPlayers.prefix(maxVisiblePlayers) { let pos = CGPoint(x: player.x, y: player.y) if let existingNode = scene.otherPlayers[player.id] { // Update existing player position scene.updateOtherPlayer(userId: player.id, position: pos) existingNode.updatePet(config: player.petConfig) } else { // Spawn new player scene.addOtherPlayer( userId: player.id, avatarConfig: player.avatarConfig, name: player.name, position: pos, petConfig: player.petConfig ) } }}
ZoneChatService stores recent messages for the overlay panel:
class ZoneChatService: ObservableObject { @Published var messages: [ZoneChatMessage] = [] private let maxMessages = 100 struct ZoneChatMessage: Identifiable { let id = UUID() let userId: UUID let userName: String let message: String let timestamp: Date } func addIncomingMessage(_ msg: ZoneChatMessage) { messages.append(msg) if messages.count > maxMessages { messages.removeFirst(messages.count - maxMessages) } }}
Students can open the chat overlay from the HUD to view scrollable history.
Filters apply both locally (who you see) and remotely (who sees you):
private func parsePresence(_ presence: PresenceV2, myId: UUID) -> NearbyPlayer? { // If remote player is in solo mode, don't show them if let remotePrivacy = state["privacy"]?.stringValue, remotePrivacy == "solo" { return nil } // Apply local privacy filtering let privacy = LobbyPrivacyService.shared switch privacy.privacyMode { case .solo: return nil case .friendsOnly: guard privacy.shouldShowPlayer(userId: userId) else { return nil } case .schoolOnly: let myUniId = AuthManager.shared.currentUser?.uni_id let mySchoolId = AuthManager.shared.currentUser?.schoolId let sameUni = myUniId == remoteUniId let sameSchool = mySchoolId == remoteSchoolId guard sameUni || sameSchool else { return nil } case .public: break } // Parse and return player return NearbyPlayer(...)}
Changing privacy mode triggers a re-filter or channel rejoin:
private func handlePrivacyModeChange() async { if privacy.privacyMode == .solo { await leaveZone() // Go invisible } else if connectionState == .disconnected { await joinZone(...) // Rejoin from solo } else { refilterNearbyPlayers() // Re-apply filters }}
Players who haven’t updated their presence in 20+ seconds are marked as “idle ghosts”:
private func cleanupStalePlayers() { let now = Date() for (userId, lastSeen) in lastHeardFrom { let elapsed = now.timeIntervalSince(lastSeen) if elapsed > staleTimeout * 3 { // Very stale - remove completely nearbyPlayers.removeAll { $0.id == userId } scene.removeOtherPlayer(userId: userId) } else if elapsed > staleTimeout { // Moderately stale - show as ghost if let node = scene.otherPlayers[userId] { node.setIdleGhost() // Fade to 35% opacity, gentle hover } } }}
Ghosts restore to full opacity when they move again.
Offer holder and firm choice hubs scope channels by university:
if hub == "offerHolder" || hub == "firmChoice" { let uniId = AuthManager.shared.currentUser?.uni_id return "\(hub):\(zone):uni_\(uniId)"}
Why? Offer holders only care about students at their prospective universities. Mixing all offer holders in one channel would be overwhelming and irrelevant.