ODIN in Unreal C++
Although the Unreal Engine plugin comes with full Blueprint support to make it as easy as possible to use ODIN in your game, it is also easily possible to implement the plugin in your C++ Unreal Project. Please make sure to have a basic understanding of how ODIN works as this helps a lot understanding the next steps. Additionally this guide assumes that you have basic knowledge of the Unreal Engine, its Editor and the C++ API.
This manual highlights the key steps to take to get started with ODIN. For a more detailed implementation please refer to our Unreal Sample project. Copy the C++ classes in the Unreal Sample Project and paste them in your own project to get started quickly!
Basic Process
If you have read the introduction you have learned that every user connected to the same ODIN room (given by a string of your choice) will be able to exchange data and voice. An ODIN room is automatically created by the ODIN server when the first user joins and is automatically removed once the last user leaves.
To join a room a room token needs to be created. A room token gives access to an ODIN room and can be created within the client. For testing and development purposes that’s fine. In production you should create a room token on server side. We’ll provide various packages for JavaScript (npm) or PHP (Composer) to create room tokens and we also have a complete server ready to go that you can deploy as a cloud function to AWS or Google Cloud.
After the room has been joined, data can be exchanged, i.e. text chat messages or other real-time data. If your user should be able to talk to others, a microphone stream (a so called media) has to be added to the room. Now, every user can talk to every other user in that room. More advanced techniques include 3D audio that allows you to update your position every couple of seconds to the server which then makes sure, that only users nearby hear your voice to reduce traffic bandwidth and CPU usage. But more on that later.
So, to summarize, the basic process is:
- Get an access key
- Create a room token with the access key for a specific room id (just a string identifying the room)
- Join the room with the room token
- Add a media stream to connect the microphone to the room
Implementing with C++
Let’s get started adding ODIN to an existing (or new) Unreal Engine Project. The needed C++ classes are found in the Odin
and OdinLibrary
modules of the Odin-Plugin. So after installation you need to add these modules to your project’s build file, found in your project in Source/<YourProject>/<YourProject>.build.cs
. Additionally you should add dependencies to the Unreal Engine’s AudioCapture
and AudioCaptureCore
modules, since we will use functionality of these for the capturing of the user’s microphone input:
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "Odin", "OdinLibrary", "AudioCapture", "AudioCaptureCore" });
Overview
This is the full class that we are about to create. In the sample we derive from UActorComponent
, but you can put the code anywhere you like. An Actor Component can make sense, since you can add it easily on an Actor to add user’s to your Voice Chat functionality.
Header File:
#pragma once
#include "OdinTokenGenerator.h"
#include "OdinRoom.h"
#include "CoreMinimal.h"
#include "AudioCapture.h"
#include "Components/ActorComponent.h"
#include "OdinClientComponent.generated.h"
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class ODINUNREALCPPSAMPLE_API UOdinClientComponent : public UActorComponent
{
GENERATED_BODY()
public:
// Sets default values for this component's properties
UOdinClientComponent();
protected:
// Called when the game starts
virtual void BeginPlay() override;
UPROPERTY()
UOdinTokenGenerator* tokenGenerator;
UPROPERTY()
FString roomToken;
UPROPERTY()
UOdinRoom* room;
UPROPERTY()
FOdinApmSettings apmSettings;
UFUNCTION()
void OnPeerJoinedHandler(int64 peerId, FString userId, const TArray<uint8>& userData, UOdinRoom* odinRoom);
UFUNCTION()
void OnMediaAddedHandler(int64 peerId, UOdinPlaybackMedia* media, UOdinJsonObject* properties, UOdinRoom* odinRoom);
UFUNCTION()
void OnRoomJoinedHandler(int64 peerId, const TArray<uint8>& roomUserData, UOdinRoom* odinRoom);
UFUNCTION()
void OnOdinErrorHandler(int64 errorCode);
FOdinRoomJoinError OnRoomJoinError;
FOdinRoomJoinSuccess OnRoomJoinSuccess;
FOdinRoomAddMediaError OnAddMediaError;
FOdinRoomAddMediaSuccess OnAddMediaSuccess;
UPROPERTY()
UAudioCapture* capture;
public:
// Called every frame
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
};
Source File:
#include "OdinClientComponent.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/Character.h"
#include "OdinSynthComponent.h"
#include "AudioCaptureBlueprintLibrary.h"
#include "OdinFunctionLibrary.h"
// Sets default values for this component's properties
UOdinClientComponent::UOdinClientComponent()
{
// Set this component to be initialized when the game starts, and to be ticked every frame. You can turn these features
// off to improve performance if you don't need them.
PrimaryComponentTick.bCanEverTick = true;
}
// Called when the game starts
void UOdinClientComponent::BeginPlay()
{
Super::BeginPlay();
tokenGenerator = UOdinTokenGenerator::ConstructTokenGenerator(this, "AQGEYTtGuFdlq6Msk+bO9ki6dDJ+fG8UmjfZD+VZOuUt");
roomToken = tokenGenerator->GenerateRoomToken("Test", "Player", EOdinTokenAudience::Default);
UE_LOG(LogTemp, Warning, TEXT("%s"), *roomToken);
apmSettings = FOdinApmSettings();
apmSettings.bVoiceActivityDetection = true;
apmSettings.fVadAttackProbability = 0.9;
apmSettings.fVadReleaseProbability = 0.8;
apmSettings.bEnableVolumeGate = false;
apmSettings.fVolumeGateAttackLoudness = -90.0;
apmSettings.fVolumeGateReleaseLoudness = -90.0;
apmSettings.bHighPassFilter = false;
apmSettings.bPreAmplifier = false;
apmSettings.noise_suppression_level = EOdinNoiseSuppressionLevel::OdinNS_Moderate;
apmSettings.bTransientSuppresor = false;
apmSettings.bEchoCanceller = true;
room = UOdinRoom::ConstructRoom(this, apmSettings);
room->onPeerJoined.AddUniqueDynamic(this, &UOdinClientComponent::OnPeerJoinedHandler);
room->onMediaAdded.AddUniqueDynamic(this, &UOdinClientComponent::OnMediaAddedHandler);
OnRoomJoinSuccess.BindUFunction(this, TEXT("OnRoomJoinedHandler"));
OnRoomJoinError.BindUFunction(this, TEXT("OnOdinErrorHandler"));
TArray<uint8> userData = { 0 };
UOdinRoomJoin* Action = UOdinRoomJoin::JoinRoom(this, room, TEXT("https://gateway.odin.4players.io"), roomToken, userData, FVector(0, 0, 0), OnRoomJoinError, OnRoomJoinSuccess);
Action->Activate();
}
void UOdinClientComponent::OnPeerJoinedHandler(int64 peerId, FString userId, const TArray<uint8>& userData, UOdinRoom* joinedRoom)
{
UE_LOG(LogTemp, Warning, TEXT("Peer joined"));
}
void UOdinClientComponent::OnMediaAddedHandler(int64 peerId, UOdinPlaybackMedia* media, UOdinJsonObject* properties, UOdinRoom* addedInRoom)
{
ACharacter* player = UGameplayStatics::GetPlayerCharacter(this, 0);
UActorComponent* comp = player->AddComponentByClass(UOdinSynthComponent::StaticClass(), false, FTransform::Identity, false);
UOdinSynthComponent* synth = StaticCast<UOdinSynthComponent*>(comp);
synth->Odin_AssignSynthToMedia(media);
synth->Activate();
UE_LOG(LogTemp, Warning, TEXT("Odin Synth Added"));
}
void UOdinClientComponent::OnRoomJoinedHandler(int64 peerId, const TArray<uint8>& roomUserData, UOdinRoom* joinedRoom)
{
UE_LOG(LogTemp, Warning, TEXT("Joined Room"));
capture = (UAudioCapture*)UOdinFunctionLibrary::CreateOdinAudioCapture(this);
UAudioGenerator* captureAsGenerator = (UAudioGenerator*)capture;
auto media = UOdinFunctionLibrary::Odin_CreateMedia(captureAsGenerator);
OnAddMediaError.BindUFunction(this, TEXT("OnOdinErrorHandler"));
UOdinRoomAddMedia* Action = UOdinRoomAddMedia::AddMedia(this, room, media, OnAddMediaError, OnAddMediaSuccess);
Action->Activate();
capture->StartCapturingAudio();
}
void UOdinClientComponent::OnOdinErrorHandler(int64 errorCode)
{
FString errorString = UOdinFunctionLibrary::FormatError(errorCode, true);
UE_LOG(LogTemp, Error, TEXT("%s"), *errorString);
}
// Called every frame
void UOdinClientComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
}
You can add that component to any Actor in your project, but it makes most sense on your Default Player Controller - since it lives exactly once on each client and is owned only by the client. Just make sure that you have it on an Actor that exists on each client (for example the Game Mode only exists on the server and therefore is not eligible for this logic).
In the following paragraphs we want to build this component from scratch and explain on the way.
Creating the Component
First, we will need to create the component class in C++. The easiest way is to open the project in the Unreal Editor. In the menu bar, go to Tools->New C++ Class...
and select the desired parent class. We will use ActorComponent
for this guide, but you can use anything that fits your project better.
Give the new class a name, like OdinClientComponent
, make it public and then hit the “Create” button. This will bring up Visual Studio again, the Visual Studio Project File should update on the way, if not, Generate Visual Studio Project Files...
of the Unreal Project again.
Once the class is open in Visual Studio we can begin coding.
Creating an Access Key
First, you need to create an access key. As the access key is used to authenticate your requests to the server and also includes information about your tier, e.g. how many users are able to join the same room and a few other settings. You can create a free access key for up to 25 users right here. If you need more or want to go into production, you need to sign up with one of our tiers. See pricing for more information.
More information about access keys can be found in our Understanding Access Keys guide.
For now, you can use this widget to create a demo access key suitable for up to 25 concurrent users:
Press the Create Access Key
button and write the access key down somewhere. We’ll need this access key a bit later down the road.
Creating a Room Token
For now, we want to create the room token on the client. In the BeginPlay()
function of the component. In most use cases you might not want players to be able to talk to everyone else right from the start. Choose another event
in this case. But for testing purposes this should be fine.
First, we will create Token Generator and a String, place them as instance variables in the Header File and call the GenerateRoomToken()
function. Additionally we will need to include all relevant header file:
#include "OdinTokenGenerator.h"
// ...
UOdinTokenGenerator* tokenGenerator;
FString roomToken;
And initialize it in the BeginPlay()
function:
tokenGenerator = UOdinTokenGenerator::ConstructTokenGenerator(this, "<YOUR_ACCESS_KEY>");
roomToken = tokenGenerator->GenerateRoomToken("RoomName", "UserName", EOdinTokenAudience::Default);
Please note: In production you would load the token from your cloud function (see above) but for now we just generate a random room token directly in the game. Also while trying this out, make sure that you do not commit your (paid) acces key to an online repository.
RoomName
and UserName
are placeholders for your project’s logic to distribute rooms and user names. For the purpose of testing it is only necessary for the clients to be in the same Odin Room, identified by the room name.
The same is true for <YOUR_ACCESS_KEY>
- this should be replaced either by your free access key or by logic reading the access key from a local file.
Configure the Room Access
ODIN supports various settings when joining a room (APM settings). Here you can set features like “Voice Activity Detection” and many other features.
Create a new FOdinApmSettings
object as an instance variable and put the desired initial settings in the BeginPlay()
function. Then you can construct a UOdinRoom
from it and assign it to an instance variable as well.
You can play around with APM settings to work out good values for your type of game.
It should look like this.
Header:
#include "OdinRoom.h"
// ...
UPROPERTY()
UOdinRoom* room;
UPROPERTY()
FOdinApmSettings apmSettings;
Source:
apmSettings = FOdinApmSettings();
apmSettings.bVoiceActivityDetection = true;
apmSettings.fVadAttackProbability = 0.9;
apmSettings.fVadReleaseProbability = 0.8;
apmSettings.bEnableVolumeGate = false;
apmSettings.fVolumeGateAttackLoudness = -90.0;
apmSettings.fVolumeGateReleaseLoudness = -90.0;
apmSettings.bHighPassFilter = false;
apmSettings.bPreAmplifier = false;
apmSettings.noise_suppression_level = EOdinNoiseSuppressionLevel::OdinNS_Moderate;
apmSettings.bTransientSuppresor = false;
apmSettings.bEchoCanceller = true;
room = UOdinRoom::ConstructRoom(this, apmSettings);
Event Flow
Once you are connected to the ODIN server, a couple of events will be fired that allow you to setup your scene and connecting audio output to your player objects in the scene.
Have a look at this application flow of a very basic lobby application. Events that you need to implement are highlighted in red.
Adding a Peer Joined Event
To create event handling, create a new function that we will bind to the corresponding event delegates. In this guide we will handle the three crucial events for setup: onPeerJoined
, onMediaAdded
and onRoomJoined
.
Since they are dynamic delegates (which means that they can be used in Blueprints), you need to define them with the UFUNCTION()
decorator. The onRoomJoined
events are handled by the classes’ own delegates, that need an additional instance variable of that delegates’ types:
Header:
UFUNCTION()
void OnPeerJoinedHandler(int64 peerId, FString userId, const TArray<uint8>& userData, UOdinRoom* odinRoom);
UFUNCTION()
void OnMediaAddedHandler(int64 peerId, UOdinPlaybackMedia* media, UOdinJsonObject* properties, UOdinRoom* odinRoom);
UFUNCTION()
void OnRoomJoinedHandler(int64 peerId, const TArray<uint8>& roomUserData, UOdinRoom* odinRoom);
UFUNCTION()
void OnOdinErrorHandler(int64 errorCode);
FOdinRoomJoinError OnRoomJoinError;
FOdinRoomJoinSuccess OnRoomJoinSuccess;
Now we can bind these functions in the BeginPlay()
function.
Source:
room->onPeerJoined.AddUniqueDynamic(this, &UOdinClientComponent::OnPeerJoinedHandler);
room->onMediaAdded.AddUniqueDynamic(this, &UOdinClientComponent::OnMediaAddedHandler);
OnRoomJoinSuccess.BindUFunction(this, TEXT("OnRoomJoinedHandler"));
OnRoomJoinError.BindUFunction(this, TEXT("OnOdinErrorHandler"));
Continue to implement these functions. In this paragraph we will start with the OnPeerJoinedHandler
. Normally, you can use this function to read the peer’s UserData
and propagate it as needed inside your application. For this guide we will not provide any user data, so we can simply print to the log, that a peer has joined:
void UOdinClientComponent::OnPeerJoinedHandler(int64 peerId, FString userId, const TArray<uint8>& userData, UOdinRoom* joinedRoom)
{
UE_LOG(LogTemp, Warning, TEXT("Peer %s joined"), *userId);
}
You should always setup event handling before joining a room.
The On Media Added Event
The onMediaAdded
event is triggered whenever a user connected to the room activates their microphone, or directly after joining a room you’ll get these events for all active users in that room.
You’ll get a Media
object that represents the microphone input for the peer (i.e. connected user) that this media belongs to. The Media
object is a real-time representation which is basically just a number of floats that represent the users voice. A component needs to be created that translates that into audio output. This is the Odin Synth Component
. You can use UOdinSynthComponent::Odin_AssignSynthToMedia()
that will connect both and actually activates the audio. After assigning the synth to the media, we will need to activate the component.
The easiest way to support 3D voice chat is to add the Odin Synth Component
to your player character asset and place it somewhere near the head. Then, in your code you can use the GetComponentByClass()
function to get the Odin Synth Component
from the corresponding player character - based on which peer the media belongs to. This provides the Odin Synth Component
with the correct position for 3D audio attenuation.
But in this guide we will ignore 3D audio output and only provide 2D output to simplify the setup. So we can simply use the AActor::AddComponentByClass()
function to create the component and attach it to the locally controlled player character at runtime - this way we do not need to find out which character belongs to the Odin peer in question.
#include "Kismet/GameplayStatics.h"
#include "GameFramework/Character.h"
#include "OdinSynthComponent.h"
// ...
void UOdinClientComponent::OnMediaAddedHandler(int64 peerId, UOdinPlaybackMedia* media, UOdinJsonObject* properties, UOdinRoom* odinRoom)
{
ACharacter* player = UGameplayStatics::GetPlayerCharacter(this, 0);
UActorComponent* comp = player->AddComponentByClass(UOdinSynthComponent::StaticClass(), false, FTransform::Identity, false);
UOdinSynthComponent* synth = StaticCast<UOdinSynthComponent*>(comp);
synth->Odin_AssignSynthToMedia(media);
synth->Activate();
UE_LOG(LogTemp, Warning, TEXT("Odin Synth Added"));
}
That’s it. Now, every user connected to the same room will be heard with full volume where the local player is located.
In a real 3D shooter with 3D audio you would not choose the local player, but map the Odin Peer Id with your Unreal Player Id and then assign the Media to the corresponding player character. This way, Unreal will automatically process the audio in its audio engine and apply attenuation, e.g. dampen the volume the farther away the player is from the listener.
Joining a Room
Now, we have everything in place to join a room: We have created a room token with the room id “RoomName” and have configured room settings for our client. Now let’s connect them to join a room.
Since the joining process will take some time just waiting for the Odin server to respond, it is implemented with an asynchronous task - with blueprint usability in mind. We can still call this task easily from C++ by first constructing it and then calling its Activate()
function. Again, we will simplify the setup by not providing any relevant userData:
TArray<uint8> userData = { 0 };
UOdinRoomJoin* Action = UOdinRoomJoin::JoinRoom(this, room, TEXT("https://gateway.odin.4players.io"), roomToken, userData, FVector(0, 0, 0), OnRoomJoinError, OnRoomJoinSuccess);
Action->Activate();
In case anything goes wrong, we implement the OnOdinErrorHandler()
. It passes an error code that we can format using UOdinFunctionLibrary::FormatError()
and then print out in the log:
void UOdinClientComponent::OnOdinErrorHandler(int64 errorCode)
{
FString errorString = UOdinFunctionLibrary::FormatError(errorCode, true);
UE_LOG(LogTemp, Error, TEXT("%S"), *errorString);
}
We have passed the Odin Room
together with its APM Settings
, the default Odin gateway URL, the local Room Token
, empty User Data
, an identity vector for the Initial Position
and our two delegates for the onRoomJoinError
and onRoomJoinSuccess
events.
Adding a Media Stream
Now that we have joined a room, we need to add our microphone (at least if users should be able to talk) to the room so everyone else in the room hears what we are saying. To do that, we need to create an Odin Audio Capture
using UOdinFunctionLibrary::CreateOdinAudioCapture()
and assign it to a new media constructed with UOdinFunctionLibrary::Odin_CreateMedia()
. Then we call the asynchronous task UOdinRoomAddMedia::AddMedia()
with it. Again, we need to declare according delegates and pass them to the AddMedia()
call. Since you need to start capturing from the audio device, we keep the Audio Capture
object in an instance variable and use that later to activate the microphone. By stopping the audio capture you can implement mute very easily later or something like push to talk.
Header:
#include "OdinAudioCapture.h"
// ...
FOdinRoomAddMediaError OnAddMediaError;
FOdinRoomAddMediaSuccess OnAddMediaSuccess;
UOdinAudioCapture* capture;
void UOdinClientComponent::OnRoomJoinedHandler(int64 peerId, const TArray<uint8>& roomUserData, UOdinRoom* odinRoom)
{
UE_LOG(LogTemp, Warning, TEXT("Joined Room"));
capture = UOdinFunctionLibrary::CreateOdinAudioCapture(this);
// cast pointer to capture to UAudioGenerator for Odin_CreateMedia
UAudioGenerator* captureAsGenerator = (UAudioGenerator*)capture;
auto media = UOdinFunctionLibrary::Odin_CreateMedia(captureAsGenerator);
OnAddMediaError.BindUFunction(this, TEXT("OnOdinErrorHandler"));
UOdinRoomAddMedia* Action = UOdinRoomAddMedia::AddMedia(this, room, media, OnAddMediaError, OnAddMediaSuccess);
Action->Activate();
capture->StartCapturingAudio();
}
We can assign the same function to the OnAddMediaError
delegate, that we have bound to OnRoomJoinError
. It passes an error code again, that we can simply format and print out to the log.
Make sure to only execute the StartCapturingAudio()
after successfully constructing audio capture with CreateOdinAudioCapture()
, constructing the media with Odin_CreateMedia()
, and finally adding it to the room with AddMedia()
.
Testing with ODIN client
As ODIN is working cross platform, you can use our ODIN client app to connect users to your Unreal based game. There are numerous use cases where this is a great option (see Use Cases Guide) but its also great for development.
Fire up your browser and load our ODIN client: https://4p.chat/. We need to configure that client to use the same access key that we use within Unreal. Click on the Gear
icon next to the Connect
button. You should see something like this:
If you don’t see the Your Access Key
option at the end of the dialog, you need to scroll down a bit.
Enter your access key that you have created earlier and that you have set in the Access Key
variable exposed in
the Blueprint and click on Save
. Now, the ODIN client will use the same access key as your Unreal based game, connecting both platforms together.
In the connection dialog, set the same room name as you did in Unreal (i.e. RoomName
), make sure the same gateway is
set as in Unreal (i.e. https://gateway.odin.4players.io
) and enter a name. Then click on Connect
.
You should see something like this:
Now, get back to Visual Studio and press F5, let the code compile and once the editor opens, hit the Play In Editor button. Unreal will fire up your game and will join the same room as you have in your browser. You should here a nice sound indicating that another user has joined the room. Navigate to your browse, and now you should see another entry in the users list: “Unknown”. If you talk, you should here yourself.
Ask a colleague or fried to setup the ODIN client with your access key and the same room and you’ll be able to chat together, one inside your game and the other one in their browser. This is a great way of quickly testing if everything works fine and we do that internally at 4Players very often.
Enabling 3D Audio
So far we have enabled the voice chat in your application, but most likely you want to use the 3D Audio Engine of Unreal. This for itself might be a trivial step since you can simply assign proper Attenuation Settings to your Odin Synth Component. But you also need to consider another problem: the positioning of the Odin Synth Components in your scene.
The simplest solution is to attach the Odin Synth Components to the Pawns representing the respective players, but you somehow need to keep track which Odin Peer is associated with which player - so let’s have a look at how to do that.
The implementation in detail depends on your networking and replication system of course, but most of the time you will want to use the native Unreal Engine Networking, so we will assume that you use that. If you use another system you will need to adjust the steps accordingly.
Creating the Necessary Classes
The goal of the next steps is to create a mapping of each Odin Peer Id to the respective Character in the scene so that we can assign each Odin Media Stream to the correct Actor. In order to achieve this we will need to create two more classes in addition to our UOdinClientComponent
.
To create and propagate a unique identifier for each Unreal Client, we will derive a class from the Engine’s ACharacter
class. An ACharacter
exists on all clients and there is one for each connected client (including a possible ListenServer), and so it is perfect to replicate a player’s ID from the server to every client.
In this guide the new class is called AOdinCharacter
. Make sure to reference the new class in your Default Game Mode again to actually use the new functionality. Instead you can also consider deriving your old Default Player Character class from the new class to keep your old functionality. In the sample project we have reparented the Blueprint BP_ThirdPersonCharacter
to the AOdinCharacter
to achieve this.
In addition we create the needed custom Map on a class derived from UGameInstance
. A Game Instance object is created and maintained on each client and it is not replicated at any time, so we can use it to create and maintain data for each client. In this guide the derived class is called UOdinGameInstance
.
After creating these two classes and reloading the Visual Studio project, we can start implementing the logic.
Creating the Player Character Maps
In order to assign the correct Odin Synth Component to the according Player Controlled Character we will need to keep track of a unique identifier for each player, their actors and their Odin Peer Ids.
In the UOdinGameInstance
class you will need to create two maps for this: one of them maps the unique player id to a player character - using an FGuid
. And the other one maps the Odin Peer ID to a player character - using an int64
. The former is needed to keep notice of which player character belongs to which player. Once another Odin Peer joins the voice chat room, we can use that map to keep track of which peer id corresponds to which player character.
Your header file should look similar to this:
#pragma once
#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "OdinGameInstance.generated.h"
UCLASS()
class ODINUNREALCPPSAMPLE_API UOdinGameInstance : public UGameInstance
{
GENERATED_BODY()
public:
TMap<FGuid, ACharacter*> PlayerCharacters;
TMap<int64, ACharacter*> OdinPlayerCharacters;
};
Adjusting the Join Room Routine
Next we want to move the routine to join an odin room from the BeginPlay()
function of the UOdinClientComponent
to an own function - this is because the client needs to wait until it knows its own PlayerId
, since it needs to pass it to the Join Room
call as User Data
.
Simply remove the code from the BeginPlay()
function to an own function and declare the function in the header file as well. Additionally we take a FGuid
that we will pass as User Data
.
Header:
void ConnectToOdin(FGuid playerId);
To pass the PlayerId
as User Data
we can use the UOdinJsonObject
helper class from the Odin SDK. Create a new UOdinJsonObject
and add a String Field
to it, passing PlayerId
as a key and the Guid - cast to a FString
- as the value. Using the EncodeJsonBytes()
function we can convert the json to a TArray<uint8>
that is excpected by the JoinRoom
Action.
Source:
#include "OdinJsonObject.h"
void UOdinClientComponent::ConnectToOdin(FGuid playerId)
{
// Old Join Room Routine
auto json = UOdinJsonObject::ConstructJsonObject(this);
json->SetStringField(TEXT("PlayerId"), *playerId.ToString());
TArray<uint8> userData = json->EncodeJsonBytes();
UOdinRoomJoin* Action = UOdinRoomJoin::JoinRoom(this, room, TEXT("https://gateway.odin.4players.io"), roomToken, userData, FVector(0, 0, 0), OnRoomJoinError, OnRoomJoinSuccess);
Action->Activate();
}
Propagating an Identifier
If you have not done that earlier, now is the time to move the component to your Player Controller. For this sample we will assume you have assigned the UOdinClientComponent
to the default PlayerController
of your game. You can do that either via C++ code in your PlayerController
class or you can add the Component via the Blueprint Editor if your default player controller is a Blueprint.
With this out of the way we can start. First we need to propagate an identifier of the player for your game session. You can use GUIDs, or your Unique Player Identifiers that you already have due to a login process or something similar. First create the according replicated variable in C++ and adjust the class to properly replicate. We want to react on each client once the PlayerId
is replicated to add it to the local map of player characters and player IDs. So the header will look something like this in the end:
Header:
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "OdinCharacter.generated.h"
UCLASS()
class ODINUNREALCPPSAMPLE_API AOdinCharacter : public ACharacter
{
GENERATED_BODY()
public:
// Sets default values for this character's properties
AOdinCharacter();
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
UPROPERTY(ReplicatedUsing = OnRep_PlayerId)
FGuid PlayerId;
UFUNCTION()
void OnRep_PlayerId();
void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
Next implement the GetLifetimeReplicatedProps()
needed to start replication of the PlayerId
.
Source:
void AOdinCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AOdinCharacter, PlayerId);
}
You can set the ID at any point in time that is called during startup, a good entry point is the Begin Play
function of the Character Class - here you can simply check if you are running on the Server and then you set a variable that is replicated across all Clients. Also make sure to put the newly created PlayerId
in the Game Instance’s PlayerCharacters
map.
Then if this character is controlled locally we additionally want to start the odin room join routine on the UOdinClientComponent
.
#include "OdinGameInstance.h"
#include "Net/UnrealNetwork.h"
#include "Kismet/GameplayStatics.h"
// ...
void AOdinCharacter::BeginPlay()
{
Super::BeginPlay();
// check if running on any kind of server
if (GetNetMode() == NM_DedicatedServer || GetNetMode() == NM_ListenServer)
{
// create new guid
PlayerId = FGuid::NewGuid();
// add guid and reference to this character to the game instance's player character map
UOdinGameInstance* gameInstance = StaticCast<UOdinGameInstance*>(UGameplayStatics::GetGameInstance(this));
gameInstance->PlayerCharacters.Add(PlayerId, this);
UE_LOG(LogTemp, Warning, TEXT("Created PlayerId: %s"), *PlayerId.ToString());
// if this character is also controlled locally we want to start the routine to join the Odin Room
if (IsLocallyControlled())
{
UGameplayStatics::GetPlayerControllerFromID(this, 0)->GetComponentByClass<UOdinClientComponent>()->ConnectToOdin(PlayerId);
}
}
}
Next we need to implement the OnRep_PlayerId()
function which is called on each client once the PlayerId
is replicated there. In here we essentially do the same as before without creating a new Guid. Since the client now knows its PlayerId
it can start the ConnectToOdin()
routine.
void AOdinCharacter::OnRep_PlayerId()
{
UOdinGameInstance* gameInstance = StaticCast<UOdinGameInstance*>(UGameplayStatics::GetGameInstance(this));
gameInstance->PlayerCharacters.Add(PlayerId, this);
UE_LOG(LogTemp, Warning, TEXT("Replicated PlayerId: %s"), *PlayerId.ToString());
if (IsLocallyControlled())
{
UGameplayStatics::GetPlayerControllerFromID(this, 0)->GetComponentByClass<UOdinClientComponent>()->ConnectToOdin(PlayerId);
}
}
Handling the Peer Joined Event with an Identifier
Now each other client will receive an event once a player has joined the Odin Room. Here we can extract the given player identifier from the user data. The next problem we will handle here is that once we get the Odin Media
object we do not get the user data in the same event, so we need to also map the Odin Peer Id
to our Player Characters. Now you can find the correct player character and add it to the new map using the passed Peer Id
. This is what the finished OnPeerJoinedHandler()
function will look like:
void UOdinClientComponent::OnPeerJoinedHandler(int64 peerId, FString userId, const TArray<uint8>& userData, UOdinRoom* odinRoom)
{
// create Json Object from User Data Byte Array
auto json = UOdinJsonObject::ConstructJsonObjectFromBytes(this, userData);
// Get Guid String from Json
FString guidString = json->GetStringField(TEXT("PlayerId"));
UE_LOG(LogTemp, Warning, TEXT("Peer with PlayerId %s joined. Trying to map to player character ..."), *guidString);
// Parse String into Guid
FGuid guid = FGuid();
if (FGuid::Parse(guidString, guid))
{
// if successful, get the UOdinGameInstance and use the PlayerCharacters map to obtain the correct character object
UOdinGameInstance* gameInstance = StaticCast<UOdinGameInstance*>(UGameplayStatics::GetGameInstance(this));
ACharacter* character = gameInstance->PlayerCharacters[guid];
// Finally add that character together with the Odin Peer Id to the OdinPlayerCharacters map of the Game Instance - for later use in the OnMediaAdded Event
gameInstance->OdinPlayerCharacters.Add(peerId, character);
UE_LOG(LogTemp, Warning, TEXT("Peer %s joined"), *userId);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("Peer %s joined, but could not be mapped to a player."), *userId);
}
}
Handling the Media Added Event with an Identifier
Now we can finally add the Odin Synth Component to the correct player character. Find your event or function that handles the Media Added
event and now instead of simply adding an Odin Synth Component
to the local player character we can now actually find the correct character and attach the synth component there.
Use the passed Peer Id
to get the correct player character from the Game Instance
’s Odin Player Character
map and assign the Odin Synth Component there now:
void UOdinClientComponent::OnMediaAddedHandler(int64 peerId, UOdinPlaybackMedia* media, UOdinJsonObject* properties, UOdinRoom* odinRoom)
{
// get corresponding player character
ACharacter* player = StaticCast<UOdinGameInstance*>(UGameplayStatics::GetGameInstance(this))->OdinPlayerCharacters[peerId];
// create, attach and cast a new UOdinSynthComponent to the correct player character
UActorComponent* comp = player->AddComponentByClass(UOdinSynthComponent::StaticClass(), false, FTransform::Identity, false);
UOdinSynthComponent* synth = StaticCast<UOdinSynthComponent*>(comp);
// assign Odin media as usual
synth->Odin_AssignSynthToMedia(media);
// Here we need to set any wanted attenuation settings
synth->bOverrideAttenuation = true;
synth->AttenuationOverrides.bAttenuate = true;
// more attenuation settings as desired
// Lastly activate the Synth Component an we are good to go
synth->Activate();
UE_LOG(LogTemp, Warning, TEXT("Odin Synth Added"));
}
Conclusion
This is all you need to do for now to add the Odin Synth Components to the correct player characters. Now you can change the attenuation settings to whatever you need for your voice chat to work in your 3D world - from what we have now it is the most straight forward to simply add any attenuation settings or load any attenuation settings file created in the Unreal Editor.
Odin is agnostic of the audio engine, so you can really just use any engine you like, be it the native Unreal Audio Engine, or a 3rd Party Engine, like Steam Audio, Microsoft Audio, FMOD, Wwise, and so on. It is all possible simply by changing the project settings and the attenuation settings of the Odin Synth Component accordingly.