How to Build a Twitch Clone Game Live Streaming App for iOS

Matheus C.
Matheus C.
Published May 26, 2020 Updated May 5, 2022

When adding live video to your applications on Stream, we recommend checking out our newly released Video API!

Using Stream Video, developers can build live video calling and conferencing, voice calling, audio rooms, and livestreaming from a single unified API, complete with our fully customizable UI Kits across all major frontend platforms.

To learn more, check out our Video homepage or dive directly into the code using one of our SDKs.

In this tutorial, we'll use Stream Chat and Dolby.io's Client SDK to build an app that lets you create a room that streams the contents of your screen and your voice for a large number of viewers and allows them to interact with each other via chat. It will be as simple as possible for the sake of keeping this tutorial as short as possible, but it will function enough to have fun with your friends.

Animation shows an app window with the chat screen on the bottom with a video feed of a Minecraft gameplay on the top

That animation shows an iPhone accessing a channel created by an iPad user who is playing Minecraft. The iPhone user can hear the streamer's voice, see the gameplay, and interact with other watchers via chat.

If you get lost during this tutorial, you can check the completed project in this GitHub repo.

What is Stream Chat?

Build real-time chat in less time. Rapidly ship in-app messaging with our highly reliable chat infrastructure. Drive in-app conversion, engagement, and retention with the Stream Chat messaging platform API & SDKs.

What is Dolby.io's Client SDK?

Dolby Interactivity APIs provide a platform for unified communications and collaboration. In-flow communications refers to the combination of voice, video, and messaging integrated into your application in a way that is cohesive for your end-users. This is in contrast to out-of-app communications where users must stop using your application and instead turn to third-party tools.

Requirements

Set up project

Create the Xcode project

First, we open Xcode and create a Single View App project.

Screenshot shows a single view app being created on Xcode 11

And make sure to select 'UIKit' for the User Interface.

Install dependencies

To install the Stream Chat and Dolby.io's Client SDK dependencies, we'll use CocoaPods. If you prefer Carthage, both frameworks support it as well.

In the folder where you saved the project, run pod init and add StreamChat, VoxeetSDK, and VoxeetScreenShareKit to the Podfile. It should look similar to this:

# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target 'Smitch' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  # Pods for Smitch
  pod 'StreamChat', '~> 2.1'
  pod 'VoxeetSDK', '~> 2.3'
  pod 'VoxeetScreenShareKit', '~> 1.0'
end

After you do that, run pod install, wait a bit for it to finish, and open the project via the .xcworkspace that was created.

Configure the Stream Chat dashboard

Sign up at GetStream.io, create the application, and make sure to select development instead of production.

Screenshot of a user creating a development application at GetStream.io

To make things simple for now, let's disable both auth checks and permission checks. Make sure to hit save. When your app is in production, you should keep these enabled.

Screenshot of skip auth checks and permission being enabled in a Stream App dashboard

You can see the documentation about authentication here and permissions here.

Now, save your Stream credentials, as we'll need them to power the chat in the app. Since we disabled auth and permissions, we'll only really need the key for now, but in production, you'll use the secret in your backend to create JWTs to allow users to interact with your app securely.

Screenshot of credentials on stream dashboard

As you can see, I've blacked out my keys. You should make sure to keep your credentials safe.

Configure the Dolby.io dashboard

Configuring the Dolby.io dashboard is simpler. Just create an account there, and it should already set up an initial application for you.

Screenshot of credentials on dolby.io dashboard

Now, save your credentials, as we'll need them to power the audio and video streaming in the app. As with the Stream credentials, you use these for development. In production, you'll need to set up proper authentication.

Configure Stream Chat and Dolby's SDKs

The first step with code is to configure the Stream and Dolby SDK with the credentials from the dashboards. Open the AppDelegate.swift file and modify it, so it looks similar to this:

import UIKit
import VoxeetSDK
import StreamChatClient

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        VoxeetSDK.shared.initialize(consumerKey: "ZTBib3I3NzkzMmt0aA==", consumerSecret: "NDUyM2kzMTc0ZHNvZWxjaHRucG41dmpidnE=")
        VoxeetSDK.shared.appGroup = "group.so.cardo.Smitch.Group"
        VoxeetSDK.shared.preferredExtension = "so.cardo.Smitch.Broadcast"

        Client.configureShared(.init(apiKey: "74e5enp33qj2", logOptions: .info))
        Client.shared.set(user: .init(id: generateUserId()), token: .development)
        
        return true
    }
}

That code initializes the Dolby.io and Stream Chat SDKs with credentials you got in the previous two steps.

Create the Join Screen

Let's start building the "Join" screen. This screen consists of two elements: a UITextField for determining the channel's name and a UIButton to join or create the channel depending on whether it was already created. If you're creating it, you're automatically its admin, and you may share the code with other users to join as watchers. It will look similar to the screenshot below.

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!
Screenshot shows an app with a text field and a button for joining a  channel

Go to the Storyboard, select the default view controller, and click Editor > Embed In > Navigation Controller. That will place it under a navigation controller, which we'll use to navigate to the channel screen.

Image shows storyboard with a JoinViewController embedded in a navigation controller

Make sure to rename ViewController to JoinViewController so you don't get confused later on. You can do this easily by right-clicking on ViewController in ViewController.swift and selecting refactor.

To make things simple, let's leave the storyboard like this and use only code from now on. To set up the UITextField and UIButton we need the following code in JoinViewController.swift:

import UIKit

class JoinViewController: UIViewController {
    let channelTextField = UITextField()
    let joinButton = UIButton()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        title = "Join Channel"
        
        setupViews()
        setupConstraints()
        setupHandlers()
    }
}

That code sets up the views, the constraints, and the handlers we need. Let's start by extending JoinViewController to define setupViews:

extension JoinViewController {
    func setupViews() {
        setupChannelTextField()
        setupJoinButton()
    }
    
    func setupChannelTextField() {
        channelTextField.translatesAutoresizingMaskIntoConstraints = false
        channelTextField.becomeFirstResponder()
        
        view.addSubview(channelTextField)
    }
    
    func setupJoinButton() {
        joinButton.translatesAutoresizingMaskIntoConstraints = false
        joinButton.setTitleColor(.systemBlue, for: .normal)
        joinButton.setTitle("Join/Create", for: .normal)
        
        view.addSubview(joinButton)
    }
}

That code will create the UITextField, UIButton, and add them to the controller's view. Next, we need to define constraints between those three. Let's do this by extending JoinViewController to define setupConstraints:

extension JoinViewController {
    func setupConstraints() {
        view.addConstraints([
            channelTextField.widthAnchor.constraint(equalToConstant: 100),
            channelTextField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            channelTextField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
            joinButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            joinButton.topAnchor.constraint(equalTo: channelTextField.bottomAnchor)
        ])
    }
}

That code will make sure the channelTextField stays on top and center of the screen and the joinButton right below it. Now we need to set up the handler for when the user presses the button. Let's do this again by extending the controller to define setupHandlers:

import Foundation

extension JoinViewController {
    func setupHandlers() {
        joinButton.addTarget(self, action: #selector(handleButtonPress), for: .touchUpInside)
    }
    
    @objc func handleButtonPress() {
        let watchVC = WatchViewController()
        watchVC.channelName = channelTextField.text!
        navigationController?.pushViewController(watchVC, animated: true)
    }
}

That code will make it so when the user presses the button a WatchViewController is created with its .channelName property set to the contents of the UITextField. We'll create WatchViewController in the next step.

Create the Watch Screen

Now, let's create the screen where we can view or transmit a stream and chat. We'll start by defining WatchViewController. It will look similar to the animation below.

Animation shows an app window with the chat screen on the bottom with a video feed of a Minecraft gameplay on the top

First step is to create a WatchViewController.swift file and paste the code below.

import UIKit
import VoxeetSDK
import StreamChat

class WatchViewController: UIViewController {
    var channelName: String = "default"
    
    let voxeet = VoxeetSDK.shared
    let userId = generateUserId()
    let videoView = VTVideoView()
    let chatViewController = ChatViewController()
    
    override var prefersStatusBarHidden: Bool { true }
    
    override func viewDidLoad() {
        super.viewDidLoad()

        setupChat()
        setupStream()
        setupViews()
        setupConstraints()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            self.navigationController?.navigationBar.isHidden = true
        }
    }
    
    deinit {
        voxeet.conference.leave()
    }
}

That code simply defines some variables that we'll need frequently and calls some functions to set everything up on viewDidLoad. We'll create those next. It also makes sure to leave the conference once the view is freed from memory in deinit and hides the status bar on viewDidAppear for presentation purposes.

Additionally, generateUserId() is a simple function that generates a random id for the user. You should define it in a generateUserId.swift file as below.

import Foundation

func generateUserId(length: Int = 10) -> String {
  let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
  return String((0..<length).map{ _ in letters.randomElement()! })
}

Next, let's define the functions to set up the views, chat, and stream. First, define setupViews as below.

extension WatchViewController {
    func setupViews() {
        setupVideoView()
        setupChatView()
    }
    
    func setupVideoView() {
        view.addSubview(videoView)
    }
    
    func setupChatView() {
        addChild(chatViewController)
        view.addSubview(chatViewController.view)
    }
}

That code just adds the video and chat views to the view hierarchy.

Next, let's define setupConstraints to specify the layout that those views should take.

extension WatchViewController {
    func setupConstraints() {
        setupVideoConstraints()
        setupChatConstraints()
    }
    
    func setupVideoConstraints() {
        videoView.translatesAutoresizingMaskIntoConstraints = false
        view.addConstraints([
            videoView.topAnchor.constraint(equalTo: view.topAnchor),
            videoView.rightAnchor.constraint(equalTo: view.rightAnchor),
            videoView.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 1/3),
            videoView.leftAnchor.constraint(equalTo: view.leftAnchor)
        ])
    }
    
    func setupChatConstraints() {
        view.addConstraints([
            chatViewController.view.topAnchor.constraint(equalTo: videoView.bottomAnchor),
            chatViewController.view.rightAnchor.constraint(equalTo: view.rightAnchor),
            chatViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            chatViewController.view.leftAnchor.constraint(equalTo: view.leftAnchor)
        ])
    }
}

That code will make sure the video view sits on top of the chat view on a 1 to 3 ratio for the height.

Next, let's define the setupChat function.
https://gist.github.com/cardoso/dd6d16e39eae24812799cfd34983f741

That code simply tells the chatViewController to present a channel with id equal to the channelName property and with type livestream. There are several channel types with different behaviors, and more can be created. You can read about them here. Livestream is the one that fits our use case the most.

Next, we need the functions to configure the video portion.

import VoxeetSDK
import ReplayKit

extension WatchViewController {
    func setupStream() {
        let info = VTParticipantInfo(externalID: userId, name: userId, avatarURL: nil)
        
        voxeet.conference.delegate = self
        voxeet.session.open(info: info) { error in
            self.joinOrCreateConference()
        }
    }
    
    func joinOrCreateConference() {
        let options = VTConferenceOptions()
        options.alias = channelName
        
        voxeet.conference.create(options: options, success: { conf in
            if conf.isNew {
                self.shareScreen(conf: conf)
            } else {
                self.watch(conf: conf)
            }
        }, fail: { error in
            // TODO: Handle errors
        })
    }
    
    func shareScreen(conf: VTConference) {
        self.voxeet.conference.join(conference: conf, success: { conf in
            DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
                self.voxeet.conference.startScreenShare(broadcast: true) { error in
                    // TODO: Handle errors
                }
            }
        }, fail: { error in
            // TODO: Handle errors
        })
    }
    
    func watch(conf: VTConference) {
        self.voxeet.conference.listen(conference: conf, success: { conf in 
            
        }, fail: { error in
            // TODO: Handle errors
        })
    }
}

Those functions will set up a conference session. If the conference is new, the user's screen will be presented. If not, the user will watch the presentation by the user who created the channel.

Now, we need to conform WatchViewController to the VTConferenceDelegate protocol.

import VoxeetSDK

extension WatchViewController: VTConferenceDelegate {
    func streamAdded(participant: VTParticipant, stream: MediaStream) {
        switch stream.type {
        case .ScreenShare:
            videoView.alpha = 1
            videoView.attach(participant: participant, stream: stream)
        default:
            break
        }
    }
    
    func streamUpdated(participant: VTParticipant, stream: MediaStream) {

    }
    
    func streamRemoved(participant: VTParticipant, stream: MediaStream) {
        switch stream.type {
        case .ScreenShare:
            videoView.alpha = 0
        default:
            break
        }
    }
}

That code displays the video feed when a screen share is detected and hides the video when the screen share finishes.

Enable System-wide Screen Sharing

If you run the app now, and create a channel, when you put it in the background, the video stream remains in the app. Of course, this is not good, because we want to stream games and other apps. To fix this, you need to set up App Groups and a Broadcast Upload extension. This process is simple and well described in Dolby.io's documentation.

At the time of writing this tutorial, ReplayKit doesn't work with the new UIScene lifecycle, so you need to rollback to the old AppDelegate approach.

Wrapping up

Congratulations! You've built the basis of a functioning game live streaming app with Stream Chat and Dolby.io. I encourage you to browse through Stream Chat's docs and gaming chat API as well as Dolby's docs, and experiment with the project you just built. For more, check out our UX best practices for livestream chat.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->