Build a Game Streaming Platform with Next.js and Dolby.io

For this article idea, I plan on developing and styling the user interface with Next.js, then adding the stream functionality with Dolby.io. The idea is to show how easily integrable Dolby.io streaming services can be included in web applications. For this piece on "Building a game streaming platform with Next.js and Dolby.io", the article will feature the following sections: a chat interface, and a user and streamer sharing features

Introduction

Game streaming platforms have generated a lot of popularity in recent years, with millions of application users tuning in to various platforms to watch their favorite games. This stems from the fact that the gaming industry has created a lot of popular franchises, and many people love to stream gameplay in search of tips and tricks and hints and watch their favorite gamers play and compete online. Nowadays, a lot of popular platforms such as YouTube, Twitch, and TikTok allow users to stream live gameplay, and interact with streams and other viewers, from virtually anywhere in the world. As an added perk, there are also options for gamers to monetize their gaming skills and build up an online presence. In this article, we will present an idea of game streaming platforms and their benefits, and also create a web application for such a purpose, making use of Next.js and Dolby.io a powerful cloud-based audio and video real-time communication service.

Overview of Game Streaming Platforms

A game streaming platform, as its name implies, is an online platform that allows users to create live transmission of a screen (usually containing gameplay) to a live audience. This industry has become quite popular in recent years and can serve as a lucrative job for professional gamers. Below are the benefits game streaming platforms can provide to their users:

  • Entertainment: With a wide range of games streamed on a platform, from casual games to online multiplayer, role-playing, open-world games, etc. users can find game walkthroughs, watch their favorite gamers play games at challenging difficulties, or even sit back and enjoy watching highly-competitive multiplayer gameplays. This piques interest in games and provides entertainment for viewers.

  • Monetization: To encourage gamers to participate, game streaming platforms usually have a reward-based system where streamers earn a set amount of money on meeting certain criteria such as getting a particular number of streams of followers (this is dependent on the streaming platform). Popular streamers can further develop their skills and also gain advertisement and sponsorship deals. Some platforms also allow viewers to provide incentives to streamers in the form of donations.

  • Community development: Game streaming platforms help to develop communities with fans of the same game. It provides an environment where users can interact around shared interests. These platforms are also easily accessible to users around the globe as long as one has a connection to the internet.

What is Dolby.io

According to its Docs, Dolby.io is a Streaming is a cutting-edge real-time streaming solution that offers exceptional performance with ultra-low latency, professional-grade audio, and video quality, robust scalability for large audiences, and secure end-to-end encryption. Powered by WebRTC streaming technology, these APIs are specifically designed to prioritize latency and deliver superior quality for a wide range of applications including REMI production, sports broadcasting, music streaming, live events, remote post-production, gaming, and immersive virtual experiences.

Dolby.io Streaming provides comprehensive support for popular tools and platforms such as OBS (Open Broadcaster Software), Unreal Engine plugins, and Unity plugins, as well as software development kits (SDKs) for all major client environments. Dolby.io provides various tools and services for audio processing, noise reduction, voice enhancement, spatial audio, and more, allowing developers to create engaging and immersive experiences across a wide range of applications such as communication platforms, streaming services, gaming, and virtual reality.

Developing the interface with Next.js

In this section, we will build the general user interface for the game streaming platform. This web application will feature the following:

  • A login form for viewers and streamers

  • The video-sharing interface

  • A chat panel

To build this application, we will use Next.js and TailwindCSS for the user interface, and power the chat application with Pusher. The entirety of the streaming audio and video functions will be handled using Dolby.io. For the purpose of this tutorial, we will create a simple Next.js application with TypeScript and TailwindCSS enabled. To create a Next app you can check this guide.

Creating the Login Form To better organize our files, we will create a components folder at the root of our project directory. In this folder, we will create and store components that make up the web application. In the components folder, create a new file login-form.tsx and enter the following code in it:

    import React, { useState } from "react";
    import Link from "next/link";
    const LoginForm = () => {
      const [username, setUsername] = useState("Anonymous user");
      const [role, setRole] = useState("viewer");
      return (
        <div className="login">
          {/* this will be the component for the login page */}
          <h1 className="login__header">
            Welcome to my game streaming application
          </h1>
          <form className="login__form">
            <label className="login__form__label">Username</label>
            <input
              className="login__form__input"
              type="text"
              placeholder="Username"
              value={username}
              onChange={(e) => setUsername(e.target.value)}
            />
            <label className="login__form__label">Role</label>
            <select
              className="login__form__select"
              value={role}
              onChange={(e) => setRole(e.target.value)}
            >
              <option value="viewer">Viewer</option>
              <option value="streamer">Streamer</option>
            </select>
            <Link
              className="login__form__button"
              href={{
                pathname: "/game-streaming",
                query: { name: username, role: role },
              }}
            >
              <button>Login</button>
            </Link>
          </form>
        </div>
      );
    };
    export default LoginForm;

In the code block above, we have created a login page with an input field for username and a select field for the roles of viewer/streamer. We also used useState to set the values for these fields and will route to the main application page using next-link when the login button is clicked.

To style this page, add the following to the global.css file:

    @tailwind base;
    @tailwind components;
    @tailwind utilities;

    *, body{
      @apply m-0 p-0;
    }
    /* Login Page components */
    .login{
      @apply flex flex-col items-center justify-center h-screen;
    }
    .login__header{
      @apply text-3xl font-bold text-center mb-12;
    }
    .login__form{
      @apply flex flex-col items-center justify-center w-1/3;
    }
    .login__form__label{
      @apply w-full text-left text-gray-700;
    }
    .login__form__input, .login__form__select{
      @apply w-full px-4 py-2 mt-2 mb-8 border border-gray-300 rounded-md;
    }
    .login__form__button{
      @apply w-full px-4 py-2 mt-14 text-white text-center bg-blue-500 rounded-md;
    }
    .login__form__button:hover{
      @apply bg-blue-600;
    }
    /* End of Login styles */

Finally, to display this component at the index of our application, replace the code block in index.tsx with the following:

    import LoginForm from '@/components/login-form'

    export default function Home() {
      return (
        <>
         <LoginForm/> 
        </>
      )
    }

To run the application, open up the CLI and enter npm run dev. The results in the browser will be similar to the image below:

Login Page screenshot

Creating the Video Sharing Interface For this interface, we will create a new page in the pages directory and name it game-streaming.tsx. On this page, we will have the streaming window and the chat panel. Back in the components directory, create two new components video-interface.tsx and chat-window.tsx.

In video-interface.tsx, we will have the following lines of code:

    import React from "react";
    const VideoInterface = () => {
      return (
        <div className="video-interface">
          {/* This componnent will house the stream sharing and also display information regarding it */}
          <div className="video-interface__view-area">
            {/* view area */}
            <div className="video-interface__view-area__viewers">
              {/* number of viewers */}
              <p>500 Watching</p>
            </div>
          </div>
          <div className="video-interface__stream-info">
            {/* stream info */}
            <div className="video-interface__stream-info__streamer">
              {/* streamer name */}
              <p className="video-interface__stream-info__streamer__name">
                Streamer Name
              </p>
              {/* status */}
              <p className="video-interface__stream-info__streamer__status">Live</p>
            </div>
            <div className="video-interface__stream-info__stream">
              {/* game name */}
              <p className="video-interface__stream-info__stream__game-name">
                The last of Us
              </p>
              {/* reactions */}
              <div className="video-interface__stream-info__stream__actions">
                <button className="video-interface__stream-info__stream__actions__exit">
                  Exit
                </button>
                <button className="video-interface__stream-info__stream__actions__share">
                  Share
                </button>
              </div>
            </div>
          </div>
        </div>
      );
    };
    export default VideoInterface;

in the code block above, we have a component for the video-sharing interface, which displays the stream, the total number of viewers, the title of the stream and the name of the streamer, and finally the reaction buttons.

To style this component, add the following styles to global.css:

    /* Game Streaming window styles */
    .game-streaming {
      @apply flex p-6 gap-12 items-center w-full bg-gray-900 justify-center h-screen;
    }
    /* video interface style */
    .video-interface {
      @apply relative w-[65%] h-[90%] rounded-2xl shadow-2xl;
    }
    /* view area */
    .video-interface__view-area {
      @apply relative w-full h-[70%] rounded-t-2xl  shadow-md;
    }
    /* number of viewers */
    .video-interface__view-area__viewers {
      @apply absolute top-4 left-5 px-5 py-3 z-10 bg-slate-400/20 opacity-50 rounded-xl text-white font-medium text-xs;
    }
    /* stream info */
    .video-interface__stream-info {
      @apply relative w-full h-[30%] pt-3 pl-6 rounded-b-2xl bg-gray-700 text-white;
    }
    /* streamer details */
    .video-interface__stream-info__streamer {
      @apply flex gap-4 items-center;
    }

    /* streamer name */
    .video-interface__stream-info__streamer__name{
      @apply text-lg font-semibold;
    }
    /* streamer status */
    .video-interface__stream-info__streamer__status{
      @apply text-sm py-2 px-6 text-blue-400 font-bold bg-white rounded-xl;
    }
    /* stream description */
    .video-interface__stream-info__stream{
      @apply text-gray-200 mt-8 flex gap-6 flex-col;
    }
    .video-interface__stream-info__stream__game-name{
      @apply text-4xl font-bold;
    }
    /* stream actions */
    .video-interface__stream-info__stream__actions{
      @apply flex justify-end pr-24 gap-4 items-center;
    }
    /* stream actions button */
    .video-interface__stream-info__stream__actions__exit{
      @apply text-sm py-2 px-4 text-blue-400 font-bold bg-white rounded-xl;
    }
    .video-interface__stream-info__stream__actions__share{
      @apply font-semibold;
    }

Finally, we can add the video-interface and chat-window component in the game-streaming.tsx page:

    import React from "react";
    import ChatWindow from "@/components/chat-window";
    import VideoInterface from "@/components/video-interface";
    import { useRouter } from "next/router";
    const GameStream = () => {
      const router = useRouter();
      const { name, role } = router.query;
      return (
        <div className="game-streaming">
          {/* Game Streaming Platform */}
          {/* Video Interface */}
          <VideoInterface />
          {/* Chat Panel */}
          <ChatWindow username={name} role={role} />
        </div>
      );
    };
    export default GameStream;

Creating a Chat Panel

The chat panel is an essential part of a game streaming platform as it lets viewers ask questions, make requests to the streamer or interact with one another. The chat panel we will build for this tutorial will leverage Pusher and will feature chat names and their messages. Also to make communication easy, there will be style differences between sent and received messages.

To get started, first, create a new user account on Pusher or if you are an existing user, you can sign in to your account. Once logged in, create a new project and on the dashboard, click on api keys. Here, we will use the copy-to-clipboard feature to obtain the authorization credentials we will be using in our application. In the root directory of your project, create a new file .env.local to store the credentials as environmental variables. Paste the credentials in this file and save it.

    # Server creds
    app_id = "your app id"
    key = "your app key"
    secret = "your app secret"
    cluster = "your cluster"
    # public keys
    NEXT_PUBLIC_APP_ID="your app id"
    NEXT_PUBLIC_CLUSTER="your cluster"

To make use of Pusher in our application, we also need to install the necessary dependencies. This can be installed with the following command in the CLI:

    npm install axios pusher pusher-js

Once the installation is complete, we will create an instance of pusher to be used in our application. In the project root directory, create a new folder lib and in it, create a file pusher.tsx:

    import Pusher from "pusher";

    const pusher = new Pusher({
      appId: process.env.app_id as string,
      key: process.env.key as string,
      secret: process.env.secret as string,
      cluster: process.env.cluster as string,
      useTLS: true,
    });

    export { pusher };

Next, we will define API routes to communicate with Pusher from our application. We will do this in two forms:

  • First, a route to authenticate users. For this, in the api directory, create a folder auth, and within it pushapi.tsx. In this file enter the following:
    import { NextApiRequest, NextApiResponse } from "next";
    import { pusher } from "../../../lib/pusher";
    export default async function handler(
      req: NextApiRequest,
      res: NextApiResponse
    ) {
      const { socket_id, username, channel_name } = req.body;
      //    unique random user id
      const rand_string = Math.random().toString(36).slice(2);
      //   capture user data
      const presenceData = {
        user_id: rand_string,
        user_info: {
          username,
        },
      };

      // authenticate user
      try {
        const auth = pusher.authenticate(socket_id, channel_name, presenceData);
        res.send(auth);
      } catch (err) {
        console.log(err);
        res.status(403).send("Forbidden");
      }
    }

Note that the code block above takes in socket_id , username, and channelname from the body of requests, and assigns numbers along with the username to return unique values, and finally the response is returned.

  • Next, we will create a route to allow communications between authenticated users and Pusher. In the api directory, create a file index.tsx. Open this file in the code:
    import { pusher } from "@/lib/pusher";
    import { NextApiRequest, NextApiResponse } from "next";
    export default async function handler(
      req: NextApiRequest,
      res: NextApiResponse
    ) {
      const { message, username } = req.body;

      // here presence-chat is the chat room, and chat-message is the event called when the chat is to be updated
      await pusher.trigger("presence-chat", "chat-message", {
        message,
        username,
      });
      res.status(200).json({ message: "Message sent" });
    }

This API route is responsible for taking in messages sent via the chat interfaces, along with the user names, and returns data responses to update the chat.

With the API routes for our application set up, we can build the client-side interface for the chat window. To do this, add the following code to chat-window.tsx:

    import React, { useState, useEffect } from "react";
    import Pusher from "pusher-js";
    import axios from "axios";
    // define type of data passed to the component
    type ChatWindowProps = {
      username: string | string[] | undefined;
      role: string | string[] | undefined;
    };
    const ChatWindow = ({ username, role }: ChatWindowProps) => {
      //   pusher
      const pusher = new Pusher(process.env.NEXT_PUBLIC_APP_ID as string, {
        cluster: process.env.NEXT_PUBLIC_CLUSTER as string,
        // authentication API endpoint
        authEndpoint: "/api/auth/pushapi",
        auth: {
          params: {
            username,
          },
        },
      });
      // chat array to store messages
      const [chat, setChat] = useState<{ username: string; message: string }[]>([]);
      // message field input
      const [message, setMessage] = useState("");
      // online users  count
      const [onlineUsersCount, setOnlineUsersCount] = useState(0);
      // online users
      const [usersOnline, setUsersOnline] = useState<
        { username: string; message: string }[]
      >([]);
      // subscribe users to the channel
      useEffect(() => {
        let mounted = true;
        if (mounted) {
          // name of chat room defined in the API
          const channel = pusher.subscribe("presence-chat");
          // bind user to channel
          channel.bind("pusher:subscription_succeeded", (members: any) => {
            setOnlineUsersCount(members.count);
          });
          channel.bind("pusher:subscription_error", (err: any) => {
            console.log(err);
          });
          // when new users enter chat
          channel.bind("pusher:member_added", (members: any) => {
            setOnlineUsersCount(members.count);
            // notify when new users join chat
            setUsersOnline((prev: { username: any; message: any }[]) => [
              ...prev,
              { username: members.info.username, message: "joined the chat" },
            ]);
            // to add to previous state
            setOnlineUsersCount(onlineUsersCount + 1);
          });
          channel.bind("chat-message", (data: any) => {
            const { message, username } = data;
            // update chat array and add to previous state
            setChat((prev: { username: any; message: any }[]) => [
              ...prev,
              { username, message },
            ]);
          });
        }
        return () => {
          pusher.unsubscribe("presence-chat");
          mounted = false;
        };
      }, []);
      // send message
      const sendMessage = async (e: any) => {
        e.preventDefault();
        // message API route at /api/index.tsx
        await axios.post("/api", {
          message,
          username,
        });
        setMessage("");
      };
      return (
        <div className="chat-window">
          {/* welcome user */}
          <div className="chat-window__welcome">
            <h1 className="chat-window__welcome__count">
              There are {onlineUsersCount} online
            </h1>
            <div className="welcome-cont">
              {usersOnline.map((user, id) => {
                // return number of users online and notify when new members join
                return (
                  <div key={id} className="chat-window__welcome__welcome-users">
                    <div className="welcome-users">
                      {" "}
                      <span className="welcome-users__user">
                        {user.username}
                      </span>{" "}
                      just joined the chat
                    </div>
                  </div>
                );
              })}
            </div>
          </div>
          <div className="chat-window__chat-container">
            {/* display chat messages */}
            <div className="chat-window__chat-container__messages">
              {chat.map((chat: any, id: any) => {
                {
                  /* display chat messages */
                }
                if (chat.username === username) {
                  return (
                    <div
                      key={id}
                      className="chat-window__message chat-window__message--right"
                    >
                      <p>me: {chat.message}</p>
                    </div>
                  );
                } else {
                  return (
                    <div
                      key={id}
                      className="chat-window__message chat-window__message--left"
                    >
                      <p>
                        {chat.username}: {chat.message}
                      </p>
                    </div>
                  );
                }
              })}
            </div>
            {/* send messages */}
            <form className="chat-window__form" onSubmit={sendMessage}>
              <input
                className="chat-window__form__input"
                type="text"
                placeholder="Type your message here"
                value={message}
                onChange={(e) => setMessage(e.target.value)}
              />
              <button className="chat-window__form__button">Send</button>
            </form>
          </div>
        </div>
      );
    };
    export default ChatWindow;

In the code block above, we have a chat interface built with Pusher. We can style this interface by adding the following styles to the global.css file:

    .chat-window {
      @apply w-[30%] relative h-[90%] rounded-xl shadow-2xl;
    }
    .chat-window__welcome {
      @apply relative bg-slate-700 backdrop-blur-2xl px-3 py-2 rounded-md top-0 flex pl-4 items-end w-full flex-col;
    }
    .chat-window__welcome__count {
      @apply font-medium text-purple-600 absolute uppercase top-[-50px];
    }
    .chat-window__welcome__welcome-users {
      @apply text-white font-bold;
    }
    .welcome-cont {
      @apply max-h-32 min-h-[4rem] overflow-y-scroll;
    }
    .chat-window__chat-container {
      @apply mt-24 px-4 h-3/5;
    }
    .chat-window__message--right {
      @apply flex justify-end mb-3;
    }
    .chat-window__message--right p {
      @apply p-2 rounded-lg bg-blue-500 text-white;
    }
    .chat-window__message--left {
      @apply flex justify-start mb-3;
    }
    .chat-window__message--left p {
      @apply p-2 rounded-lg bg-gray-200 text-gray-700;
    }
    .chat-window__chat-container__messages {
      @apply w-full h-[300px] relative overflow-y-scroll;
    }
    .chat-window__form {
      @apply w-full flex mt-24 justify-center items-center gap-5;
    }
    .chat-window__form__input {
      @apply w-[800px] px-3 py-2 outline-none border-none;
    }
    .chat-window__form__button {
      @apply rounded-md px-5 py-3 bg-blue-500 text-white;
    }

At this moment, we can run the application, log in and make use of the chat interface:

"chat interface in action"

Integrating Dolby.io Streaming Services

In this section, we will add the video streaming functionality within the space provided on the user interface and share it via the Dolby.io cloud-streaming feature. To handle conference room creation and screen sharing, we will require the Voxeet SDK. This can be installed as follows:

    npm i @voxeet/voxeet-web-sdk

For the conference room, we will also require the role and username props from the Login component. We can pass these props to the VideoInterface component from the pages/game-streaming.tsx file as shown below:

    //...
    <VideoInterface role={role as string} username = {name as string} />

Then in `components/video-interface.tsx`, we can access and make use of these props:

    //...
    type UserProps = {
      username: string | undefined;
      role: string | undefined;
    };
    const VideoInterface = ({ username, role }: UserProps) => {
    //...

For the screen-sharing feature, we will have a Ref VideoRef to store this, we will also have userRole, name , and conferenceName variables.

    //...
    const videoRef = useRef<HTMLVideoElement>(null);
    // default role is streamer
    const userRole = role ? role : "Streamer";
    // Set the name of the current logged in user
    const [name, setName] = useState<string>("");
    // name of the conference
    const conferenceName = "gaming-room";

To make use of the Dolby.io service, we will require an access token from the “communications & media” section of the dashboard. Copy the access token and add it to your .env.local file:

    NEXT_PUBLIC_CLIENT_ACCESS_TOKEN= "your access token"

In components/video-interface.tsx we can access this key and create a conference as follows:

    useEffect(() => {
      const main = async () => {
        // Generate a client access token from the Dolby.io dashboard and insert into access_token variable
        let access_token = process.env.NEXT_PUBLIC_CLIENT_ACCESS_TOKEN as string;
        VoxeetSDK.initializeToken(access_token, (isExpired: any) => {
          return new Promise((resolve, reject) => {
            if (isExpired) {
              reject("The access token has expired.");
            } else {
              resolve(access_token);
            }
          });
        });
        try {
          // Open the session
          await VoxeetSDK.session.open({ name: username });
          setName(username as string);
          /*
           * 1. Create a conference room with an the defined conference name
           * 2. Join the conference with user defined name
           */
          VoxeetSDK.conference
            .create({ alias: conferenceName })
            .then((conference) => VoxeetSDK.conference.join(conference, {}))
            .catch((err) => console.error(err));
            // manage participants
        } catch (e) {
          alert("Something went wrong : " + e);
        }
      };
      main();
    }, []);

The code block above checks if the access token is valid and then creates a session using the username and conferenceName. To handle screen sharing and display the shared media, add the following lines of code:

    const sharingScreen = async () => {
      if (userRole.toLowerCase() === "streamer") {
        // if user is a streamer, they can start screen sharing. If a screen is already been shared it will alert the user
        VoxeetSDK.conference
          .startScreenShare()
          .then(() => {
            console.log("Screen sharing started");
            displayStream(videoRef.current as HTMLVideoElement);
          })
          .catch((err) => console.error(err));
      } else {
        // if user is a viewer, they will be alerted that they won't be able to share their screen
        alert("welcome viewer");
        displayStream(videoRef.current as HTMLVideoElement);
      }
    };
    const displayStream = (videoElement: HTMLVideoElement) => {
      // dis play the stream
      VoxeetSDK.conference.on("streamAdded", (participant, stream) => {
        if (stream.type === "ScreenShare") {
          videoElement.srcObject = stream;
          videoRef.current?.play();
        }
        if (stream.getVideoTracks().length > 0) {
          videoElement.srcObject = stream;
          videoRef.current?.play();
        }
      });
    };

To run the sharingScreen function, we will add an onClick event handler to the share button:

    <button
      className="video-interface__stream-info__stream__actions__share"
      onClick={() => sharingScreen()}
    >
      Share
    </button>

To exit the stream, we can add an onClick() event handler to the exit button and a function to perform this functionality:

    <button
      className="video-interface__stream-info__stream__actions__exit"
      onClick={() => EndSharing()}
    >
      Exit
    </button>

Then, create the EndSharing() function:

    const EndSharing = () => {
      VoxeetSDK.conference
        .stopScreenShare()
        .then(() => {
          console.log("Screen sharing ended");
        })
        .catch((err) => console.error(err));
    };

Testing the Application

To run the application, open up the CLI in the project directory, key in npm run dev in the terminal, and press the enter button to execute the command. If we join the stream from two alternate windows, we can make use of the chat window and also stream application windows as shown in the GIF below:

"Final results"

Note that the GIF seems choppy due to its length and low framerate. The original stream works perfectly. We can also use Voxeet to display the number of participants:

    // state to manage participants
    const [participants, setParticipants] = useState<any[]>([]);
    // ...
    // In the main() function, after a user joins the conference, we can add the user to the participants list
    // Listen to participant events
    VoxeetSDK.conference.on("participantAdded", (participant) => {
      setParticipants((prevParticipants) => [
        ...prevParticipants,
        participant,
      ]);
    });

And finally, we can display the total number of watching viewers:

    //...
    <div className="video-interface__view-area__viewers">
      {/* number of viewers */}
      <p>
        {participants.length} Watching, you are logged in as {name}
      </p>
    </div>

We can also set the streamer name from the name prop passed to the component:

    //...
    {/* streamer name */}
    <p className="video-interface__stream-info__streamer__name">
      {name}
    </p>

Conclusion

Congratulations on making it to the end of this article 🎉 . In this article, we built a streaming application with a chat interface to enhance participant communications. For this feature, we made use of Dolby.io; a real-time streaming provider with low latency and support for large audiences. To further improve this project, you can allow streamers to enter access tokens, room, and game names, and create a unique URL with this data to be shared with viewers to participate in the stream.