Recreating Valorant’s in-game voice chat system using Huddle01 SDK ⚔️ 🎙
Last week was a good week at Huddle01; we rolled out our much-awaited Audio Spaces feature and had a super successful launch party with some of the biggest names in web3 as guests 🚀
So this weekend I wanted to take some time off and build something fun. I was on my usual Valorant ranked grind when a thought occurred to me -
”How difficult would it be to build this communication system?”
So I gave it a shot.
Recreating Valorant’s complete in-game voice chat system from scratch
With around 20M active players every month, Valorant is one of the hottest FPS games in the market right now. It also has one of the most active esports presences with multiple tournaments at the highest tier running all around the year. Some of the professional Valorant players rake in close to 200K USD in prize pool earnings.
In-game communication is vital in a game like Valorant since it is in a 5v5 format and teamwork is essential for victory in every match.
The voice chat system is as follows:
Every time you get into a match, you have the team voice chat option through which you can communicate with all 4 of your teammates.
Additionally, if you team up with a friend or two, you have a party voice chat to communicate only with them, so your party is a subset of your entire team.
Both these voice channels are activated through a push-to-talk mechanism using two keys on your keyboard. I’m going to be creating a web app for this, using Next.js, TailwindCSS and NextUI.
I’m going to be creating a web app for this, using Next.js, TailwindCSS and NextUI.
npx create-next-app@latest
After setting up the Next.js app, you need to install the following dependencies
npm i @huddle01/react axios firebase uuid @nextui-org/react
The first step is to have some kind of real-time database to store information about the players in different teams and parties. For this project, I’m gonna go with Firebase. Let’s go to the console and quickly set up our firebase project.
Click on the add web app button and follow the instructions.
Create a .env
file at the root of your project and the following variables.
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=
NEXT_PUBLIC_DATABASE_URL=
NEXT_PUBLIC_HUDDLE01_PROJECT_ID=
HUDDLE01_API_KEY=
Copy over the values from your Firebase console and paste them into your .env
file. For Huddle01 ProjectID and API key, go to https://docs.huddle01.com/docs/api-keys and connect your wallet to generate them.
Next, create a db.ts
file and paste the following code in there. This connects our app to the Firebase DB and exports the db object that we will use throughout the app, to read/write data.
import { getApps, initializeApp } from "firebase/app";
import { getDatabase } from "firebase/database";
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
databaseURL: process.env.NEXT_PUBLIC_DATABASE_URL,
};
const app =
getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
const db = getDatabase(app);
export default db;
This is how I structured my data.
So each team would be represented by a 5-character unique alphanumeric code at the top level and would consist of an id, a roomID
which would represent the Huddle01 room for that team, and an array of the players in the team. Each player object would have an id
, a peerID
that identifies them inside of the Huddle01 room, and a nullable partyID
which would be set upon joining a party with other players on the team. I’m gonna be using uuid
for the team, player, and party IDs.
This is what the types would look like for the same.
export interface ITeam {
roomID: string;
id: string;
players: IPlayer[];
}
export interface IPlayer {
id: string;
peerID?: string;
partyID?: string | null;
}
We are going to keep the landing page simple - a button to create a new team and a text input field to join an existing team using its team code.
Clicking any of these buttons would take you to a new page for that team.
I created a random code generator function for new teams.
const createTeam = async () => {
const randomCode = getRandomCode(5);
router.push(`/team/${randomCode}`);
};
const joinTeam = async () => {
if (teamCode === "") return;
router.push(`/team/${teamCode}`);
};
const getRandomCode = (length: number) => {
var result = "";
var characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
var charactersLength = characters.length;
for (var i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
Now on the team page, the first thing we wanna do is see if a team with this code exists. If it does, we will join that team by adding a player object to it. Otherwise, we will create a new team object with that team code on Firebase.
Once that is done, we will use Firebase’s onValue
method, to subscribe to real-time updates to the DB, and update our React component state accordingly.
const [myID, setMyID] = useState("");
const [team, setTeam] = useState<ITeam | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const { teamCode } = router.query;
if (!teamCode) return;
findOrCreateTeam(teamCode as string);
}, [router.query]);
const findOrCreateTeam = async (teamCode: string) => {
const myNewID = uuidv4();
const snapshot = await get(child(ref(db), `teams/${teamCode}`));
if (snapshot.exists()) {
const foundTeam: ITeam = snapshot.val();
console.log("Team found: ", team);
const myPlayerObj = {
id: myNewID,
};
foundTeam.players.push(myPlayerObj);
const newObj: any = {};
newObj[`teams/${teamCode}`] = foundTeam;
update(ref(db), newObj);
setIsLoading(false);
setTeam(foundTeam);
setMyID(myNewID);
joinTeamVC(foundTeam.roomID);
} else {
console.log("No team found. Creating...");
const teamID = uuidv4();
let roomID: string;
//create a Huddle01 room
const resp = await fetch("/api/create-team-room");
const { data } = await resp.json();
roomID = data.roomId;
const myPlayerObj = {
id: myNewID,
};
await set(ref(db, "teams/" + teamCode), {
id: teamID,
players: [myPlayerObj],
roomID,
});
setIsLoading(false);
setMyID(myNewID);
joinTeamVC(roomID);
}
//subscribe to realtime changes in Firebase DB
const teamRef = ref(db, "teams/" + teamCode);
onValue(teamRef, (snapshot) => {
const data = snapshot.val();
console.log("DB Update!", data);
//update state
setTeam(data);
});
};
When creating a new team, we are also calling an API to create a Huddle01 room and save its ID on our team object.
import axios from "axios";
import type { NextApiRequest, NextApiResponse } from "next";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
const { data } = await axios.post(
"https://api.huddle01.com/api/v1/create-room",
{
title: "Huddle01-Test",
roomLock: false,
},
{
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.HUDDLE01_API_KEY,
},
}
);
res.status(200).json(data);
} catch (error) {
res.status(500).json(error);
}
};
export default handler;
Also, when we close the tab, we will auto-leave the team by deleting our player object from it. I am gonna be using the beforeunload
event for this. It will fire when the window is about to be closed, and it will run our quitTeam
function before that.
useEffect(() => {
window.addEventListener("beforeunload", quitTeam);
return () => {
window.removeEventListener("beforeunload", quitTeam);
};
}, [myID, team]);
const quitTeam = async (e: any) => {
const { teamCode } = router.query;
const currentID = myID;
if (!team) return;
const newTeam = { ...team };
if (newTeam.players.length > 1) {
const removeIndex = newTeam.players.findIndex(
(player) => player.id === currentID
);
if (removeIndex > -1) {
const updatedPlayers = [
...newTeam.players.slice(0, removeIndex),
...newTeam.players.slice(removeIndex + 1),
];
newTeam.players = [...updatedPlayers];
}
const newObj: any = {};
newObj[`teams/${teamCode}`] = newTeam;
await update(ref(db), newObj);
} else {
await remove(child(ref(db), `teams/${teamCode}`));
}
e.returnValue = "Quit team!";
};
After the team has been joined, you now have a Huddle01 roomID using which you can initiate the voice chat mechanism. It’s now time to integrate the Huddle01 SDK! For that, let’s create a hook called useVoiceChatRoom()
import { useHuddle01 } from "@huddle01/react";
import db from "@/db";
import {
useAudio,
useEventListener,
useLobby,
usePeers,
useRoom,
} from "@huddle01/react/hooks";
import { useEffect, useMemo } from "react";
import { ITeam } from "@/types";
import { child, get, ref, update } from "firebase/database";
import { useRouter } from "next/router";
export default function useVoiceChatRoom(myID: string, team: ITeam | null) {
const router = useRouter();
const partyPeers = useMemo(() => {
const partyID = team?.players.find((p) => p.id === myID)?.partyID;
const partyPeers: string[] = [];
team?.players.map((player) => {
if (player.partyID === partyID) partyPeers.push(player.id);
});
return partyPeers;
}, [team]);
const { initialize, me } = useHuddle01();
const { joinLobby } = useLobby();
const {
fetchAudioStream,
stopAudioStream,
produceAudio,
stopProducingAudio,
createMicConsumer,
closeMicConsumer,
} = useAudio();
const { joinRoom } = useRoom();
const { peers } = usePeers();
//auto-join room as soon as user enters Huddle01 lobby
useEventListener("lobby:joined", async () => {
joinRoom();
});
//save peerID in Firebase when Huddle01 Client is initialized
useEffect(() => {
const { teamCode } = router.query;
const currentID = myID;
if (currentID === "" || teamCode === "" || me.meId === "") return;
get(child(ref(db), `teams/${teamCode}`)).then((snapshot) => {
if (snapshot.exists()) {
const foundTeam: ITeam = snapshot.val();
const myPlayerObj = foundTeam.players.find(
(player) => player.id === currentID
);
if (myPlayerObj) myPlayerObj.peerID = me.meId;
const newObj: any = {};
newObj[`teams/${teamCode}`] = foundTeam;
update(ref(db), newObj);
}
});
}, [me.meId, router.query, myID]);
//add event listeners for push to talk mechanism
useEffect(() => {
window.addEventListener("keyup", onKeyUpHandler);
window.addEventListener("keydown", onKeyDownHandler);
return () => {
window.removeEventListener("keyup", onKeyUpHandler);
window.removeEventListener("keydown", onKeyDownHandler);
};
}, []);
//join Huddle01 room
const joinTeamVC = async (roomID: string) => {
console.log("Joining VC room for team roomID: ", roomID);
initialize(process.env.NEXT_PUBLIC_HUDDLE01_PROJECT_ID as string);
await joinLobby(roomID);
};
//push to talk key released - turn mic off
const onKeyUpHandler = (event: KeyboardEvent) => {
if (event.key === "Shift" || event.key === "Control") {
stopProducingAudio();
stopAudioStream();
}
};
//push to talk key pressed - turn mic on
const onKeyDownHandler = async (event: KeyboardEvent) => {
//shift key pressed for team voice chat
if (event.key === "Shift") {
console.log("Producing audio to team peers...");
const micStream = await fetchAudioStream();
//send audio stream to all peers in room
if (micStream) await produceAudio(micStream);
}
//control key pressed for party voice chat
else if (event.key === "Control") {
console.log("Producing audio to party peers: ", partyPeers);
const micStream = await fetchAudioStream();
//send audio stream to only peers in current party
// @ts-ignore
if (micStream) await produceAudio(micStream, [partyPeers]);
}
};
const mutePeer = async (peerID: string) => {
await closeMicConsumer(peerID);
};
const unmutePeer = async (peerID: string) => {
await createMicConsumer(peerID);
};
return {
joinTeamVC,
teamPeers: peers,
mutePeer,
unmutePeer,
};
}
There are a couple of things happening here, so let’s break it down.
Inside this hook, we have a useEffect
that saves the peerID
returned from the Huddle01 SDK to our Firebase database. This allows us to associate each player object in our DB with its entity inside a Huddle01 room.
We have one more useEffect
which adds and removes the keyup
and keydown
event listeners to allow us to listen to keyboard events for push-to-talk functionality.
We have also added a useEventListener
hook which is returned from the Huddle01 SDK. This event listener listens for the “lobby:joined”
event which we will get back to in a minute.
After that, we have a few functions:
The joinTeamVC
function initializes the Huddle01 client and moves the user to the lobby stage. At this point, the “lobby:joined”
event is fired, and the useEventListener
mentioned above is run, instantly moving the user into the room.
We are bypassing the lobby logic since it is not relevant to our project. We can instantly move the user into the Huddle01 room where the audio streams can be shared and received.
The next two functions are onKeyUpHandler
and onKeyDownHandler
.
The onKeyDownHandler
starts sharing the user’s audio stream with other teammates. I am binding the Shift key for team VC and the Control key for party VC.
If the Shift key is pressed, we produce our audio stream to all peers in the room. This is done by calling produceAudio()
returned from Huddle01’s useAudio()
hook.
If the Control key is pressed, we selectively produce our audio stream to only those peers who are present at our party. This is done by passing a second argument to the same produceAudio()
function, the array of peerIDs you want to share your audio stream with. You can see how sharing your audio stream with only particular peers is a breeze using the concept of selective producing offered by the Huddle01 SDK.
The last two functions are mutePeer
and unmutePeer
which enables the user to mute/unmute any other teammate. This is an integral part of Valorant’s comms system since the players have the option to mute other players who could be causing trouble through their mic. We achieve this using the concept of selective consuming, another feature offered by the Huddle01 SDK. This allows the user to block incoming audio streams from the other peers, essentially muting them in the game.
And that’s it! This gives us a fully working audio room with selective production and consumption.
The next thing we need to add is the mechanism to join and leave parties. Let’s create two functions joinPartyHandler
and leavePartyHandler
which will save/delete the partyID
associated with our player object on Firebase.
const joinPartyHandler = async (playerID: string) => {
const { teamCode } = router.query;
if (!team || !teamCode) return;
const newTeam = { ...team };
const myPlayerObj = newTeam.players.find((player) => player.id === myID);
const otherPlayerObj = newTeam.players.find(
(player) => player.id === playerID
);
if (otherPlayerObj && myPlayerObj) {
if (otherPlayerObj.partyID) {
myPlayerObj.partyID = otherPlayerObj.partyID;
} else {
const newPartyID = uuidv4();
myPlayerObj.partyID = newPartyID;
otherPlayerObj.partyID = newPartyID;
}
}
const newObj: any = {};
newObj[`teams/${teamCode}`] = newTeam;
await update(ref(db), newObj);
};
const leavePartyHandler = async () => {
const { teamCode } = router.query;
if (!team || !teamCode) return;
const newTeam = { ...team };
const myPlayerObj = newTeam.players.find((player) => player.id === myID);
if (myPlayerObj) myPlayerObj.partyID = null;
const newObj: any = {};
newObj[`teams/${teamCode}`] = newTeam;
await update(ref(db), newObj);
};
A bit of UI is all that’s remaining now. I used NextUI to quickly build out a minimalistic UI. I created cards with the image of a random Valorant character to represent each player. I created buttons to join and leave a teammate’s party and added a speaker icon to the cards to show active speakers.
Here’s what the final result looked like!
import { Badge, Button, Card, Image, Row, Text } from "@nextui-org/react";
interface IPlayerCardProps {
index: number;
isMe: boolean;
playerID: string;
isPlayerInMyParty: boolean;
isPlayerSpeaking: boolean;
joinPartyHandler: (playerID: string) => void;
leavePartyHandler: () => void;
}
const playerImgs = [
"https://images.contentstack.io/v3/assets/bltb6530b271fddd0b1/bltf0200e1821b5b39f/5eb7cdc144bf8261a04d87f9/V_AGENTS_587x900_Phx.png",
"https://images.contentstack.io/v3/assets/bltb6530b271fddd0b1/bltf11234f4775729b7/5ebf2c275e73766852c8d5d4/V_AGENTS_587x900_ALL_Sova_2.png",
"https://images.contentstack.io/v3/assets/bltb6530b271fddd0b1/bltc825c6589eda7717/5eb7cdc6ee88132a6f6cfc25/V_AGENTS_587x900_Viper.png",
"https://images.contentstack.io/v3/assets/bltb6530b271fddd0b1/bltceaa6cf20d328bd5/5eb7cdc1b1f2e27c950d2aaa/V_AGENTS_587x900_Jett.png",
"https://images.contentstack.io/v3/assets/bltb6530b271fddd0b1/blt53405c26141beff8/5f21fda671ec397ef9bf0894/V_AGENTS_587x900_KillJoy_.png",
];
export default function PlayerCard({
isMe,
isPlayerInMyParty,
isPlayerSpeaking,
playerID,
index,
joinPartyHandler,
leavePartyHandler,
}: IPlayerCardProps) {
return (
<Card css={{ w: "100%", h: "400px", maxW: "300px" }}>
<Card.Header css={{ position: "absolute", zIndex: 1, top: 5 }}>
<Row justify="space-between">
{isPlayerSpeaking && (
<div>
<svg
width="28"
height="25"
viewBox="0 0 28 25"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.4984 0.803431C18.0344 0.611431 17.46 0.675423 17.04 1.09542C16.1072 2.02742 14.9282 2.80077 13.5792 3.37944C12.2089 3.96611 9.39571 4.72476 7.34144 4.71143C3.34531 4.68476 0.664906 6.95009 0.665039 11.3368C0.665039 15.1808 2.84717 17.4168 5.98811 17.9194L5.99837 20.7008C5.99837 22.9074 7.78917 24.6968 9.99837 24.6968C12.0284 24.6968 13.7005 23.1261 13.9586 21.1621C13.971 21.0674 13.9984 20.7008 13.9984 19.3674C15.1961 19.9261 16.1885 20.8101 17.04 21.6608C17.88 22.4994 19.3317 21.8901 19.3317 20.7035C19.3317 19.4981 19.3253 17.3154 19.3253 15.1381C20.897 14.5568 21.9984 13.0674 21.9984 11.3781C21.9984 9.68876 20.9582 8.16079 19.3389 7.59412C19.3389 5.41679 19.3317 3.25811 19.3317 2.05278C19.3317 1.45945 18.9625 0.996764 18.4984 0.803431ZM21.7066 3.17676C21.3764 3.2621 21.1016 3.48479 20.915 3.80079C20.5421 4.43545 20.7384 5.26078 21.3733 5.63278C23.3996 6.82211 24.665 8.98743 24.665 11.3781C24.665 13.7701 23.401 15.9341 21.3733 17.1234C20.7382 17.4954 20.5008 18.3194 20.8733 18.9541C21.2461 19.5888 22.0714 19.7848 22.7066 19.4128C25.5422 17.7501 27.3316 14.7248 27.3317 11.3781C27.3317 8.03276 25.5401 5.00609 22.7066 3.34343C22.3892 3.15676 22.0369 3.09143 21.7066 3.17676ZM16.6712 4.74744C16.6712 5.98077 16.665 7.26611 16.665 8.71411C16.665 11.3781 16.665 11.3781 16.665 14.0421C16.665 15.4901 16.6693 16.7234 16.6693 17.9567C13.9689 16.3634 11.1848 15.6568 8.66784 15.4301C8.66784 13.3968 8.66104 9.37543 8.66104 7.28076C8.92237 7.26876 9.22637 7.23545 9.66504 7.17412C11.3676 6.93012 13.0592 6.48744 14.665 5.79944C15.4296 5.47278 15.9978 5.17677 16.6712 4.74744ZM5.99637 7.50476C5.99637 9.55276 5.99437 13.1714 5.99437 15.2194C4.25931 14.7394 3.33171 13.5141 3.33171 11.3368C3.33171 9.18475 4.16771 7.90876 5.99637 7.50476ZM8.66504 18.1221C9.08717 18.1368 10.6165 18.4088 11.3182 18.5861L11.3317 20.7008C11.3317 21.4368 10.7348 22.0328 9.99837 22.0328C9.26197 22.0328 8.66504 21.4368 8.66504 20.7008V18.1221Z"
fill="#ffffff"
/>
</svg>
</div>
)}
{isPlayerInMyParty && <Badge color="primary">In Party</Badge>}
</Row>
</Card.Header>
<Card.Body css={{ p: 0, bgColor: "#16181A", border: "none" }}>
<Card.Image
src={playerImgs[index % 5]}
width="100%"
height="100%"
objectFit="contain"
alt="Card background"
/>
</Card.Body>
<Card.Footer
isBlurred
css={{
position: "absolute",
bgBlur: "#ffffff66",
borderTop: "$borderWeights$light solid rgba(255, 255, 255, 0.2)",
bottom: 0,
zIndex: 1,
}}
>
<Row align="center" justify="space-between">
<div>
<Text h1 weight="bold" size={"$xl"}>
Player {index + 1} {isMe && "(You)"}
</Text>
</div>
{!isMe &&
(isPlayerInMyParty ? (
<Button size="xs" color="error" onClick={leavePartyHandler}>
Leave Party
</Button>
) : (
<Button
size="xs"
color="primary"
onClick={() => joinPartyHandler(playerID)}
>
Join Party
</Button>
))}
</Row>
</Card.Footer>
</Card>
);
}
You can find all of the code inside this Github Repo.
This quick project aimed to think of an architecture that would mimic the way Valorant handles its communication system and to see how quickly and easily it could be replicated using Huddle01’s infra. This is of course not how the game’s communication system actually works, but this small web app does a good job of recreating it.
Huddle01 is building the 1st decentralized real-time communication network. Our current suite of developer-friendly SDKs enable powerful live audio and video experiences for web and mobile apps with just a quick plug-in. The Huddle01 app which is built on Huddle01's own robust media infrastructure, enables wallet-to-wallet video meetings and audio spaces with additional features like token-gating, multi-streaming, recording and much more coming soon.
Subscribe to my newsletter
Read articles from Huddle01 directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Huddle01
Huddle01
Building the 1st decentralized real-time communication network. Leverage our current suite of developer-friendly SDKs to enable live audio/video experiences on your web or mobile app with just a quick plug in.