
requestHostConnection method.import AtomicXCoreimport Combine// Host A's view controllerclass AnchorAViewController {private let liveId = "Host A's Room ID"private var cancellables: Set<AnyCancellable> = []private lazy var coHostStore: CoHostStore = {return CoHostStore.create(liveID: self.liveId)}()// User clicks the "Co-host" button and selects Host Bfunc inviteHostB(targetHostLiveId: String) {let layout: CoHostLayoutTemplate = .hostDynamicGrid // Choose a layout templatelet timeout: TimeInterval = 30.0 // Invitation timeoutcoHostStore.requestHostConnection(targetHost: targetHostLiveId,layoutTemplate: layout,timeout: timeout) { result inswitch result {case .success():print("Co-hosting invitation sent, waiting for response...")case .failure(let error):print("Failed to send invitation: \\(error.message)")}}}}
coHostEventPublisher to receive Host B's response.// Set up listener during AnchorAViewController initializationfunc setupListeners() {coHostStore.coHostEventPublisher.sink { [weak self] event inswitch event {case .onCoHostRequestAccepted(let invitee):print("Host \\(invitee.userName) accepted your co-hosting invitation")case .onCoHostRequestRejected(let invitee):print("Host \\(invitee.userName) rejected your invitation")case .onCoHostRequestTimeout:print("Invitation timed out, no response from the other party")default:break}}.store(in: &cancellables)}
coHostEventPublisher.import AtomicXCoreimport Combine// Host B's view controllerclass AnchorBViewController {// ... coHostStore and cancellables initialization ...// Set up listener during initializationfunc setupListeners() {coHostStore.coHostEventPublisher.sink { [weak self] event inif case let .onCoHostRequestReceived(inviter, _) = event {print("Received co-hosting invitation from host \\(inviter.userName)")// self?.showInvitationDialog(from: inviter)}}.store(in: &cancellables)}}
// Part of AnchorBViewControllerfunc acceptInvitation(fromHostLiveId: String) {coHostStore.acceptHostConnection(fromHostLiveID: fromHostLiveId, completion: nil)}func rejectInvitation(fromHostLiveId: String) {coHostStore.rejectHostConnection(fromHostLiveID: fromHostLiveId, completion: nil)}
requestBattle method.// Part of AnchorAViewControllerprivate lazy var battleStore: BattleStore = BattleStore.create(liveID: self.liveId)func startPK(with opponentUserId: String) {var config = BattleConfig(duration: 300) // PK lasts 5 minutesbattleStore.requestBattle(config: config, userIDList: [opponentUserId], timeout: 30.0, completion: nil)}
battleEventPublisher to monitor key PK events such as start and end.// Add to AnchorAViewController's setupListeners methodbattleStore.battleEventPublisher.sink { [weak self] event inswitch event {case .onBattleStarted:print("PK started")case .onBattleEnded:print("PK ended")default:break}}.store(in: &cancellables)
battleEventPublisher.// Add to AnchorBViewController's setupListeners methodbattleStore.battleEventPublisher.sink { [weak self] event inif case let .onBattleRequestReceived(battleId, inviter, _) = event {print("Received PK challenge from host \\(inviter.userName)")// Show dialog for Host B to choose "Accept" or "Reject"// self?.showPKChallengeDialog(battleId: battleId)}}.store(in: &cancellables)
// Part of AnchorBViewController// User clicks "Accept Challenge"func acceptPK(battleId: String) {battleStore.acceptBattle(battleID: battleId) { result in// ...}}// User clicks "Reject Challenge"func rejectPK(battleId: String) {battleStore.rejectBattle(battleID: battleId) { result in// ...}}
exitBattle:func stopPK(battleId: String) {battleStore.exitBattle(battleID: battleId) { result inswitch result {case .success:print("PK ended")// UI receives onBattleEnded event; refresh UI in callbackcase .failure(let error):print("Failed to end PK: \\(error.message)")}}}
exitHostConnection:func stopConnection() {coHostStore.exitHostConnection { result inswitch result {case .success:print("Co-hosting disconnected, back to solo live")// UI receives onCoHostUserLeft eventcase .failure(let error):print("Failed to disconnect co-hosting: \\(error.message)")}}}
func setupAdditionalListeners() {// Listen for PK end eventbattleStore.battleEventPublisher.sink { [weak self] event inif case .onBattleEnded(let battleInfo, let reason) = event {print("Received PK end event, reason: \\(reason)")// self?.removeBattleUI()}}.store(in: &cancellables)// Listen for co-host disconnect eventcoHostStore.coHostEventPublisher.sink { [weak self] event inif case .onCoHostUserLeft(let userInfo) = event {print("Anchor \\(userInfo.userName) has left co-hosting")// self?.resetToSingleStreamLayout()}}.store(in: &cancellables)}

LiveCoreView.VideoViewDelegate interface to overlay custom views on video streams, such as nicknames, avatars, PK progress bars, or placeholder images when the host’s camera is off.
CustomSeatView to display user information above the video stream.import UIKitimport SnapKit// Custom floating user info view (foreground)class CustomSeatView: UIView {lazy var nameLabel: UILabel = {let label = UILabel()label.textColor = .whitelabel.font = .systemFont(ofSize: 14)return label}()override init(frame: CGRect) {super.init(frame: frame)backgroundColor = UIColor.black.withAlphaComponent(0.5)addSubview(nameLabel)nameLabel.snp.makeConstraints { make inmake.bottom.equalToSuperview().offset(-5)make.leading.equalToSuperview().offset(5)}}}
CustomAvatarView to use as a placeholder when the user has no video stream.import UIKitimport SnapKit// Custom avatar placeholder view (background)class CustomAvatarView: UIView {lazy var avatarImageView: UIImageView = {let imageView = UIImageView()imageView.tintColor = .grayreturn imageView}()override init(frame: CGRect) {super.init(frame: frame)backgroundColor = .clearlayer.cornerRadius = 30addSubview(avatarImageView)avatarImageView.snp.makeConstraints { make inmake.center.equalToSuperview()make.width.height.equalTo(60)}}}
VideoViewDelegate.createCoHostView protocol method and return the appropriate view based on viewLayer.import AtomicXCoreimport RTCRoomEngine// 1. In your view controller, conform to the VideoViewDelegate protocolclass YourViewController: UIViewController, VideoViewDelegate {// ... other code ...// 2. Fully implement the protocol method, handling both viewLayer typesfunc createCoHostView(seatInfo: TUISeatFullInfo, viewLayer: ViewLayer) -> UIView? {guard let userId = seatInfo.userId, !userId.isEmpty else {return nil}if viewLayer == .foreground {let seatView = CustomSeatView()seatView.nameLabel.text = seatInfo.userNamereturn seatView} else { // viewLayer == .backgroundlet avatarView = CustomAvatarView()// Optionally load the user's avatar using seatInfo.userAvatarreturn avatarView}}}
Parameter | Type | Description |
seatInfo | TUISeatFullInfo | Seat information object containing user details |
seatInfo.userId | String? | User ID on the seat |
seatInfo.userName | String? | User nickname on the seat |
seatInfo.userAvatar | String? | User avatar URL |
seatInfo.userMicrophoneStatus | TUIDeviceStatus | User microphone status |
seatInfo.userCameraStatus | TUIDeviceStatus | User camera status |
viewLayer | ViewLayer | View layer enum .foreground: Foreground widget view, always displayed on top of the video.background: Background widget view, under the foreground view, displayed only when the user has no video stream (e.g., camera off), typically used for the user's default avatar or a placeholder image |

import AtomicXCoreimport RTCRoomEngineimport SnapKit// Custom PK user viewclass CustomBattleUserView: UIView {private let scoreView: UIView = {let view = UIView()view.backgroundColor = .black.withAlphaComponent(0.4)view.layer.cornerRadius = 12return view}()private lazy var scoreLabel: UILabel = {let label = UILabel()label.textColor = .whitelabel.font = .systemFont(ofSize: 14, weight: .bold)return label}()private var userId: Stringprivate let battleStore: BattleStoreprivate var cancellableSet: Set<AnyCancellable> = []init(liveId: String, battleUser: TUIBattleUser) {self.userId = battleUser.userIdself.battleStore = BattleStore.create(liveID: liveId)super.init(frame: .zero)backgroundColor = .clearisUserInteractionEnabled = falsesetupUI()subscribeBattleState()}private func setupUI() {addSubview(scoreView)scoreView.addSubview(scoreLabel)scoreLabel.snp.makeConstraints { make inmake.leading.trailing.equalToSuperview().inset(5)}scoreView.snp.makeConstraints { make inmake.height.equalTo(24)make.bottom.equalToSuperview().offset(-5)make.trailing.equalToSuperview().offset(-5)}}// Subscribe to PK score changesprivate func subscribeBattleState() {battleStore.state.subscribe(StatePublisherSelector(keyPath: \\BattleState.battleScore)).removeDuplicates().receive(on: RunLoop.main).sink { battleScore inguard let score = battleScore[self.userId] else { return }self.scoreLabel.text = "\\(score)"}.store(in: &cancellableSet)}}
VideoViewDelegate.createBattleView protocol.// 1. Let your view controller conform to the VideoViewDelegate protocolextension YourViewController: VideoViewDelegate {public func createBattleView(battleUser: TUIBattleUser) -> UIView? {// CustomBattleUserView is your custom PK user info viewlet customView = CustomBattleUserView(liveId: liveId, battleUser: battleUser)return customView}}
Parameter | Type | Description |
battleUser | TUIBattleUser | PK user info object |
battleUser.roomId | String | PK room ID |
battleUser.userId | String | PK user ID |
battleUser.userName | String | PK user nickname |
battleUser.avatarUrl | String | PK user avatar URL |
battleUser.score | UInt | PK score |

VideoViewDelegate.createBattleContainerView protocol.// Let your view controller conform to the VideoViewDelegate protocol and set the delegateextension YourViewController: VideoViewDelegate {func createBattleContainerView() -> UIView? {return CustomBattleContainerView()}}
Gift Type | Score Calculation Rule | Example |
Basic Gift | Gift value × 5 | 10 RMB gift → 50 points |
Intermediate Gift | Gift value × 8 | 50 RMB gift → 400 points |
Advanced Gift | Gift value × 12 | 100 RMB gift → 1200 points |
Special Effect Gift | Fixed high score | 520 RMB gift → 1314 points |

LiveKit backend actively notify your system when PK starts or ends.API | Function Description | Request Example |
Active API - Query PK Status | Check whether the current room is in PK | |
Active API - Update PK Score | Update the calculated PK score | |
Callback Configuration - PK Start Callback | Receive real-time notification when PK starts | |
Callback Configuration - PK End Callback | Receive real-time notification when PK ends |
Store/Component | Description | API Reference |
LiveCoreView | Core view component for displaying and interacting with live video streams. Handles video rendering and view widgets, supports host streaming, audience co-hosting, host connections, and more. | |
DeviceStore | Controls audio/video devices: microphone (on/off, volume), camera (on/off, switch, quality), screen sharing, and real-time device status monitoring. | |
CoHostStore | Handles host cross-room connections: supports multiple layout templates (dynamic grid, etc.), initiates/accepts/rejects connections, and manages co-host interactions. | |
BattleStore | Manages host PK battles: initiate PK (set duration/opponent), manage PK status (start/end), synchronize scores, and listen for battle results. |
targetHostLiveId is correct and that the other host's live room is broadcasting normally.CoHostStore and BattleStore include built-in heartbeat and timeout detection. If one party exits abnormally, the other party is notified via events such as onCoHostUserLeft or onUserExitBattle. You can update the UI accordingly, for example, by displaying "The other party has disconnected" and ending the interaction.VideoViewDelegate?Feedback