Discord lobbies, messaging, and voice
Connect members to Discord communication features and receive results.
A Discord lobby uses a game-owned secret shared through an activity invite or your backend. Get Discord Lobbies returns this user’s loaded memberships, not every lobby in the application.
With the crossplay kit, Discord also serves as a lobby mirror and a voice provider; the crossplay nodes then create, join and call Discord lobbies for you.
Create or join a lobby
Loading the interactive viewer.
Game Instance variables: JoinedLobbyId (Integer64, 0), JoiningDiscordLobby and LeavingDiscordLobby (Boolean, false), DiscordLobbyMembers (Discord Lobby Member Summary array).
Game module dependencies: DiscordGame, BetideCore, Engine.
// Copyright (c) 2026 Betide Studio. All Rights Reserved.
// DiscordLobbyGameInstance.h
#pragma once
#include "Engine/GameInstance.h"
#include "DiscordGameSubsystem.h"
#include "DiscordLobbyGameInstance.generated.h"
UCLASS()
class UDiscordLobbyGameInstance : public UGameInstance
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable)
void JoinDiscordParty(const FString &SharedSecret);
UFUNCTION(BlueprintCallable)
void LeaveDiscordParty();
UPROPERTY(BlueprintReadOnly)
int64 JoinedLobbyId = 0;
UPROPERTY(BlueprintReadOnly)
TArray<FDiscordLobbyMemberSummary> DiscordLobbyMembers;
private:
bool JoiningDiscordLobby = false;
bool LeavingDiscordLobby = false;
UFUNCTION()
void OnLobbyId(FName Operation, bool bSuccess, int64 ResultId, const FBetideError &Error);
UFUNCTION()
void OnLobbyCommand(FName Operation, bool bSuccess, const FBetideError &Error);
UFUNCTION()
void OnLobbyUpdate(const TArray<FDiscordLobbySummary> &Lobbies);
void RefreshDiscordMembers();
};
// DiscordLobbyGameInstance.cpp
#include "DiscordLobbyGameInstance.h"
DEFINE_LOG_CATEGORY_STATIC(LogGameDiscordLobby, Log, All);
void UDiscordLobbyGameInstance::JoinDiscordParty(const FString &SharedSecret)
{
if (JoiningDiscordLobby || LeavingDiscordLobby || JoinedLobbyId > 0)
{
UE_LOG(LogGameDiscordLobby, Warning, TEXT("Already joined or a lobby request is pending"));
return;
}
UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
if (!Discord || !Discord->IsDiscordRunning() || !Discord->IsAuthenticated())
{
UE_LOG(LogGameDiscordLobby, Warning, TEXT("Sign in with Discord first"));
return;
}
JoiningDiscordLobby = true;
Discord->OnIdOperationComplete.AddUniqueDynamic(this, &ThisClass::OnLobbyId);
Discord->OnLobbiesUpdated.AddUniqueDynamic(this, &ThisClass::OnLobbyUpdate);
TMap<FString, FString> Metadata;
Metadata.Add(TEXT("mode"), TEXT("coop"));
Discord->CreateOrJoinDiscordLobby(SharedSecret, Metadata, {});
}
void UDiscordLobbyGameInstance::OnLobbyId(FName Operation, bool bSuccess, int64 ResultId,
const FBetideError &Error)
{
if (Operation != TEXT("CreateOrJoinLobby") || !JoiningDiscordLobby)
return;
JoiningDiscordLobby = false;
UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
if (!Discord)
return;
Discord->OnIdOperationComplete.RemoveDynamic(this, &ThisClass::OnLobbyId);
if (!bSuccess)
{
Discord->OnLobbiesUpdated.RemoveDynamic(this, &ThisClass::OnLobbyUpdate);
UE_LOG(LogGameDiscordLobby, Warning, TEXT("%s"), *Error.Message);
return;
}
JoinedLobbyId = ResultId;
RefreshDiscordMembers();
}
void UDiscordLobbyGameInstance::OnLobbyUpdate(const TArray<FDiscordLobbySummary> &)
{
RefreshDiscordMembers();
}
void UDiscordLobbyGameInstance::RefreshDiscordMembers()
{
if (JoinedLobbyId <= 0)
return;
const UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
if (!Discord)
return;
DiscordLobbyMembers = Discord->GetDiscordLobbyMembers(JoinedLobbyId);
for (const FDiscordLobbyMemberSummary &Member : DiscordLobbyMembers)
{
UE_LOG(LogGameDiscordLobby, Log, TEXT("%s (%lld), connected: %s"), *Member.DisplayName,
Member.UserId, Member.bConnected ? TEXT("true") : TEXT("false"));
}
}
void UDiscordLobbyGameInstance::LeaveDiscordParty()
{
if (JoiningDiscordLobby || LeavingDiscordLobby)
{
UE_LOG(LogGameDiscordLobby, Warning, TEXT("A lobby request is pending"));
return;
}
if (JoinedLobbyId <= 0)
{
UE_LOG(LogGameDiscordLobby, Warning, TEXT("No joined Discord lobby"));
return;
}
UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
if (!Discord)
return;
LeavingDiscordLobby = true;
Discord->OnOperationComplete.AddUniqueDynamic(this, &ThisClass::OnLobbyCommand);
Discord->LeaveDiscordLobby(JoinedLobbyId);
}
void UDiscordLobbyGameInstance::OnLobbyCommand(FName Operation, bool bSuccess,
const FBetideError &Error)
{
if (Operation != TEXT("LeaveLobby") || !LeavingDiscordLobby)
return;
LeavingDiscordLobby = false;
UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
if (!Discord)
return;
Discord->OnOperationComplete.RemoveDynamic(this, &ThisClass::OnLobbyCommand);
if (!bSuccess)
{
UE_LOG(LogGameDiscordLobby, Warning, TEXT("%s"), *Error.Message);
return;
}
JoinedLobbyId = 0;
DiscordLobbyMembers.Reset();
Discord->OnLobbiesUpdated.RemoveDynamic(this, &ThisClass::OnLobbyUpdate);
UE_LOG(LogGameDiscordLobby, Log, TEXT("Left Discord lobby"));
}Sign in first. Share the same non-empty secret with the intended players; reusing it joins the same lobby. Keep secrets out of public logs.
Route this client’s lobby joins and leaves through one owner: completion events identify the operation, not a request or lobby. A failed leave keeps the ID for retry. Member lists contain loaded SDK data; an empty list alone does not prove that you left.
Start lobby voice
Loading the interactive viewer.
Game Instance variables: VoiceLobbyId (Integer64, 0), VoiceConnected and EndingVoiceCall (Boolean, false).
Game module dependencies: DiscordGame, BetideCore, Engine.
// Copyright (c) 2026 Betide Studio. All Rights Reserved.
// DiscordVoiceGameInstance.h
#pragma once
#include "Engine/GameInstance.h"
#include "DiscordGameSubsystem.h"
#include "DiscordVoiceGameInstance.generated.h"
UCLASS()
class UDiscordVoiceGameInstance : public UGameInstance
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable)
void StartLobbyVoice(int64 JoinedLobbyId);
UFUNCTION(BlueprintCallable)
void StopLobbyVoice();
UPROPERTY(BlueprintReadOnly)
int64 VoiceLobbyId = 0;
UPROPERTY(BlueprintReadOnly)
bool VoiceConnected = false;
private:
bool EndingVoiceCall = false;
UFUNCTION()
void OnVoiceStatus(int64 LobbyId, EDiscordCallStatus Status, const FBetideError &Error);
UFUNCTION()
void OnVoiceCommand(FName Operation, bool bSuccess, const FBetideError &Error);
void ReleaseVoiceState();
};
// DiscordVoiceGameInstance.cpp
#include "DiscordVoiceGameInstance.h"
DEFINE_LOG_CATEGORY_STATIC(LogGameDiscordVoice, Log, All);
void UDiscordVoiceGameInstance::StartLobbyVoice(int64 JoinedLobbyId)
{
if (VoiceLobbyId > 0)
{
UE_LOG(LogGameDiscordVoice, Warning, TEXT("End the current call before starting another"));
return;
}
#if !PLATFORM_WINDOWS
UE_LOG(LogGameDiscordVoice, Warning,
TEXT("This call example supports Win64; Android permission cleanup is blocked"));
return;
#endif
UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
if (!Discord || !Discord->IsDiscordRunning() || !Discord->IsAuthenticated() ||
JoinedLobbyId <= 0)
{
UE_LOG(LogGameDiscordVoice, Warning, TEXT("Sign in and pass a joined Discord lobby ID"));
return;
}
VoiceLobbyId = JoinedLobbyId;
Discord->OnDiscordCallStatusChanged.AddUniqueDynamic(this, &ThisClass::OnVoiceStatus);
Discord->OnOperationComplete.AddUniqueDynamic(this, &ThisClass::OnVoiceCommand);
if (!Discord->StartDiscordCall(VoiceLobbyId))
{
UE_LOG(LogGameDiscordVoice, Warning, TEXT("Discord could not start the call"));
ReleaseVoiceState();
return;
}
FDiscordCallState State;
if (Discord->GetDiscordCallState(VoiceLobbyId, State) &&
State.Status != EDiscordCallStatus::Disconnected)
OnVoiceStatus(VoiceLobbyId, State.Status, FBetideError::Success());
}
void UDiscordVoiceGameInstance::OnVoiceStatus(int64 LobbyId, EDiscordCallStatus Status,
const FBetideError &Error)
{
if (VoiceLobbyId <= 0 || LobbyId != VoiceLobbyId)
return;
VoiceConnected = Status == EDiscordCallStatus::Connected;
UE_LOG(LogGameDiscordVoice, Log, TEXT("Voice connected: %s. %s"),
VoiceConnected ? TEXT("true") : TEXT("false"), *Error.Message);
if (Status == EDiscordCallStatus::Disconnected && !EndingVoiceCall)
ReleaseVoiceState();
}
void UDiscordVoiceGameInstance::StopLobbyVoice()
{
if (VoiceLobbyId <= 0)
{
UE_LOG(LogGameDiscordVoice, Warning, TEXT("No active voice call"));
return;
}
if (EndingVoiceCall)
{
UE_LOG(LogGameDiscordVoice, Warning, TEXT("Waiting for the call to end"));
return;
}
UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
if (!Discord)
return;
EndingVoiceCall = true;
Discord->EndDiscordCall(VoiceLobbyId);
}
void UDiscordVoiceGameInstance::OnVoiceCommand(FName Operation, bool bSuccess,
const FBetideError &Error)
{
if (Operation != TEXT("EndCall") || !EndingVoiceCall)
return;
EndingVoiceCall = false;
if (!bSuccess)
{
UE_LOG(LogGameDiscordVoice, Warning, TEXT("%s"), *Error.Message);
return;
}
ReleaseVoiceState();
}
void UDiscordVoiceGameInstance::ReleaseVoiceState()
{
VoiceLobbyId = 0;
VoiceConnected = false;
EndingVoiceCall = false;
if (UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>())
{
Discord->OnDiscordCallStatusChanged.RemoveDynamic(this, &ThisClass::OnVoiceStatus);
Discord->OnOperationComplete.RemoveDynamic(this, &ThisClass::OnVoiceCommand);
}
UE_LOG(LogGameDiscordVoice, Log, TEXT("Voice call ended"));
}Win64 example. Pass a lobby ID returned after joining. Own this client’s call lifecycle in one place; the EndCall result does not include a lobby ID. VoiceConnected becomes true only at Connected and clears during reconnects. Call Stop Lobby Voice and wait for VoiceLobbyId to clear before leaving the lobby.
Android blocker: ending while the microphone permission prompt is open does not cancel the queued start. A start failure after permission is granted is also lost. Android needs a plugin fix before this lifecycle can be used safely there.
Blueprint example
Positional voice
Add Discord Positional Voice to the actor that represents a speaker and set Discord User Id. While that user is in a call you joined, their voice plays from the component through its attenuation instead of through Discord, at the local mute and volume you set for them. A user without a component keeps normal Discord playback. Discord’s echo cancellation does not hear speech played this way, so recommend headsets. With the crossplay kit, use Crossplay Positional Voice, which finds the Discord user from the pawn’s player itself.
Devices and controls
Loading the interactive viewer.
Game Instance arrays: AudioInputs, AudioOutputs (Discord Audio Device). Booleans, initially false: AudioSettingsOpen, AudioQueryBusy, AudioRefreshNeeded, SelectingAudioDevice. Strings, initially empty: PendingDeviceOperation, PendingDeviceId.
Game module dependencies: DiscordGame, BetideCore, Engine.
// Copyright (c) 2026 Betide Studio. All Rights Reserved.
// DiscordAudioGameInstance.h
#pragma once
#include "Engine/GameInstance.h"
#include "DiscordGameSubsystem.h"
#include "DiscordAudioGameInstance.generated.h"
UCLASS()
class UDiscordAudioGameInstance : public UGameInstance
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable)
void OpenDiscordAudioSettings();
UFUNCTION(BlueprintCallable)
void RefreshDiscordAudioDevices();
UFUNCTION(BlueprintCallable)
void SelectDiscordAudioDevice(bool InputDevice, const FDiscordAudioDevice &Device);
UFUNCTION(BlueprintCallable)
void CloseDiscordAudioSettings();
UPROPERTY(BlueprintReadOnly)
bool AudioSettingsOpen = false;
UPROPERTY(BlueprintReadOnly)
TArray<FDiscordAudioDevice> AudioInputs;
UPROPERTY(BlueprintReadOnly)
TArray<FDiscordAudioDevice> AudioOutputs;
private:
bool AudioQueryBusy = false;
bool AudioRefreshNeeded = false;
bool SelectingAudioDevice = false;
FString PendingDeviceOperation;
FString PendingDeviceId;
UFUNCTION()
void OnDevices(bool bInputDevices, const TArray<FDiscordAudioDevice> &Devices,
const FBetideError &Error);
UFUNCTION()
void OnDevicesChanged(const TArray<FDiscordAudioDevice> &InputDevices,
const TArray<FDiscordAudioDevice> &OutputDevices);
UFUNCTION()
void OnDeviceCommand(FName Operation, bool bSuccess, const FBetideError &Error);
void ReportDiscordAudioDevices() const;
};
// DiscordAudioGameInstance.cpp
#include "DiscordAudioGameInstance.h"
DEFINE_LOG_CATEGORY_STATIC(LogGameDiscordAudio, Log, All);
void UDiscordAudioGameInstance::OpenDiscordAudioSettings()
{
if (AudioSettingsOpen)
{
UE_LOG(LogGameDiscordAudio, Warning, TEXT("Audio settings are already open"));
return;
}
UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
if (!Discord || !Discord->IsDiscordRunning() || !Discord->IsAuthenticated())
{
UE_LOG(LogGameDiscordAudio, Warning, TEXT("Sign in with Discord first"));
return;
}
AudioSettingsOpen = true;
Discord->OnAudioDevicesReceived.AddUniqueDynamic(this, &ThisClass::OnDevices);
Discord->OnDiscordAudioDevicesChanged.AddUniqueDynamic(this, &ThisClass::OnDevicesChanged);
Discord->OnOperationComplete.AddUniqueDynamic(this, &ThisClass::OnDeviceCommand);
RefreshDiscordAudioDevices();
}
void UDiscordAudioGameInstance::RefreshDiscordAudioDevices()
{
if (!AudioSettingsOpen)
return;
if (AudioQueryBusy)
{
AudioRefreshNeeded = true;
return;
}
UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
if (!Discord)
return;
AudioQueryBusy = true;
AudioRefreshNeeded = false;
Discord->GetDiscordAudioDevices(true);
}
void UDiscordAudioGameInstance::OnDevices(bool bInputDevices,
const TArray<FDiscordAudioDevice> &Devices,
const FBetideError &Error)
{
if (!AudioSettingsOpen || !AudioQueryBusy)
return;
if (Error.IsError())
UE_LOG(LogGameDiscordAudio, Warning, TEXT("%s"), *Error.Message);
if (bInputDevices)
{
AudioInputs = Devices;
if (UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>())
Discord->GetDiscordAudioDevices(false);
else
AudioQueryBusy = false;
return;
}
AudioOutputs = Devices;
AudioQueryBusy = false;
if (AudioRefreshNeeded)
RefreshDiscordAudioDevices();
else
ReportDiscordAudioDevices();
}
void UDiscordAudioGameInstance::OnDevicesChanged(const TArray<FDiscordAudioDevice> &InputDevices,
const TArray<FDiscordAudioDevice> &OutputDevices)
{
if (!AudioSettingsOpen)
return;
if (AudioQueryBusy)
{
AudioRefreshNeeded = true;
return;
}
AudioInputs = InputDevices;
AudioOutputs = OutputDevices;
ReportDiscordAudioDevices();
}
void UDiscordAudioGameInstance::ReportDiscordAudioDevices() const
{
UE_LOG(LogGameDiscordAudio, Log, TEXT("Audio devices: %d microphones, %d speakers"),
AudioInputs.Num(), AudioOutputs.Num());
}
void UDiscordAudioGameInstance::SelectDiscordAudioDevice(bool InputDevice,
const FDiscordAudioDevice &Device)
{
if (!AudioSettingsOpen)
{
UE_LOG(LogGameDiscordAudio, Warning, TEXT("Open audio settings first"));
return;
}
if (AudioQueryBusy || SelectingAudioDevice)
{
UE_LOG(LogGameDiscordAudio, Warning, TEXT("Wait for the pending audio request"));
return;
}
UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
if (!Discord)
return;
PendingDeviceId = Device.Id;
PendingDeviceOperation = InputDevice ? TEXT("SetInputDevice") : TEXT("SetOutputDevice");
SelectingAudioDevice = true;
Discord->SetDiscordAudioDevice(InputDevice, Device.Id);
}
void UDiscordAudioGameInstance::OnDeviceCommand(FName Operation, bool bSuccess,
const FBetideError &Error)
{
if (!SelectingAudioDevice ||
(Operation.ToString() != PendingDeviceOperation && Operation != TEXT("SetAudioDevice")))
return;
SelectingAudioDevice = false;
if (bSuccess)
UE_LOG(LogGameDiscordAudio, Log, TEXT("%s succeeded: %s"), *PendingDeviceOperation,
*PendingDeviceId);
else
UE_LOG(LogGameDiscordAudio, Warning, TEXT("%s"), *Error.Message);
}
void UDiscordAudioGameInstance::CloseDiscordAudioSettings()
{
if (AudioQueryBusy || SelectingAudioDevice)
{
UE_LOG(LogGameDiscordAudio, Warning,
TEXT("Wait for the pending audio request before closing"));
return;
}
AudioSettingsOpen = false;
if (UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>())
{
Discord->OnAudioDevicesReceived.RemoveDynamic(this, &ThisClass::OnDevices);
Discord->OnDiscordAudioDevicesChanged.RemoveDynamic(this, &ThisClass::OnDevicesChanged);
Discord->OnOperationComplete.RemoveDynamic(this, &ThisClass::OnDeviceCommand);
}
AudioInputs.Reset();
AudioOutputs.Reset();
}Pass a device from AudioInputs for a microphone or AudioOutputs for a speaker. Route audio queries and selections through one owner; their callbacks have no request IDs. Retry Close Discord Audio Settings after pending requests finish.
Is Default means the system default. To read Discord’s current selection, bind On Current Discord Audio Devices Received, then call Get Current Discord Audio Devices. In C++, use OnCurrentAudioDevicesReceived and GetCurrentDiscordAudioDevices().
Set Discord Call Self Mute affects one lobby call; Set Discord Self Mute affects every call. Muting locally does not remove or ban a lobby member.
Link a Discord channel
Linking copies messages between the lobby and a Discord channel. The player needs Can Link Lobby on their lobby member, granted through Discord’s server API. Linking also needs Manage Channels, View Channel, and Send Messages in a non-NSFW guild text channel that is not already linked elsewhere.
Load Discord servers and channels
Loading the interactive viewer.
Link or unlink a Discord channel
Loading the interactive viewer.
Picker Game Instance variables: LinkGuilds (Discord Guild Summary array), LinkChannels (Discord Guild Channel Summary array), LinkQueryBusy (Boolean, false), PendingLinkGuildId (Integer64, 0).
Link Game Instance variables: LinkChangeBusy (Boolean, false), PendingLinkLobbyId (Integer64, 0), PendingLinkOperation (String, empty).
Game module dependencies: DiscordGame, BetideCore, Engine.
// Copyright (c) 2026 Betide Studio. All Rights Reserved.
// DiscordChannelGameInstance.h
#pragma once
#include "Engine/GameInstance.h"
#include "DiscordGameSubsystem.h"
#include "DiscordChannelGameInstance.generated.h"
UCLASS()
class UDiscordChannelGameInstance : public UGameInstance
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable)
void LoadDiscordLinkGuilds();
UFUNCTION(BlueprintCallable)
void LoadDiscordLinkChannels(int64 GuildId);
UFUNCTION(BlueprintCallable)
void ChangeDiscordLobbyChannel(int64 JoinedLobbyId, bool LinkChannel,
const FDiscordGuildChannelSummary &SelectedChannel,
bool AllowPrivateChannel);
UPROPERTY(BlueprintReadOnly)
TArray<FDiscordGuildSummary> LinkGuilds;
UPROPERTY(BlueprintReadOnly)
TArray<FDiscordGuildChannelSummary> LinkChannels;
UPROPERTY(BlueprintReadOnly)
bool LinkQueryBusy = false;
UPROPERTY(BlueprintReadOnly)
bool LinkChangeBusy = false;
private:
int64 PendingLinkGuildId = 0;
int64 PendingLinkLobbyId = 0;
FString PendingLinkOperation;
bool CanQueryChannels() const;
UFUNCTION()
void OnLinkGuilds(const TArray<FDiscordGuildSummary> &Guilds, const FBetideError &Error);
UFUNCTION()
void OnLinkChannels(int64 GuildId, const TArray<FDiscordGuildChannelSummary> &Channels,
const FBetideError &Error);
UFUNCTION()
void OnLinkCommand(FName Operation, bool bSuccess, const FBetideError &Error);
};
// DiscordChannelGameInstance.cpp
#include "DiscordChannelGameInstance.h"
DEFINE_LOG_CATEGORY_STATIC(LogGameDiscordChannel, Log, All);
bool UDiscordChannelGameInstance::CanQueryChannels() const
{
if (LinkQueryBusy)
{
UE_LOG(LogGameDiscordChannel, Warning, TEXT("Wait for the current channel picker request"));
return false;
}
const UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
if (!Discord || !Discord->IsDiscordRunning() || !Discord->IsAuthenticated())
{
UE_LOG(LogGameDiscordChannel, Warning, TEXT("Sign in with Discord first"));
return false;
}
return true;
}
void UDiscordChannelGameInstance::LoadDiscordLinkGuilds()
{
if (!CanQueryChannels())
return;
UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
LinkQueryBusy = true;
PendingLinkGuildId = 0;
Discord->OnDiscordGuildsReceived.AddUniqueDynamic(this, &ThisClass::OnLinkGuilds);
Discord->RequestDiscordUserGuilds();
}
void UDiscordChannelGameInstance::LoadDiscordLinkChannels(int64 GuildId)
{
if (!CanQueryChannels())
return;
UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
LinkQueryBusy = true;
PendingLinkGuildId = GuildId;
Discord->OnDiscordGuildChannelsReceived.AddUniqueDynamic(this, &ThisClass::OnLinkChannels);
Discord->RequestDiscordGuildChannels(GuildId);
}
void UDiscordChannelGameInstance::OnLinkGuilds(const TArray<FDiscordGuildSummary> &Guilds,
const FBetideError &Error)
{
if (!LinkQueryBusy || PendingLinkGuildId != 0)
return;
LinkQueryBusy = false;
if (UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>())
Discord->OnDiscordGuildsReceived.RemoveDynamic(this, &ThisClass::OnLinkGuilds);
LinkGuilds = Guilds;
if (Error.IsError())
UE_LOG(LogGameDiscordChannel, Warning, TEXT("%s"), *Error.Message);
else
UE_LOG(LogGameDiscordChannel, Log, TEXT("Loaded %d Guilds"), LinkGuilds.Num());
}
void UDiscordChannelGameInstance::OnLinkChannels(
int64 GuildId, const TArray<FDiscordGuildChannelSummary> &Channels, const FBetideError &Error)
{
if (!LinkQueryBusy || GuildId != PendingLinkGuildId)
return;
LinkQueryBusy = false;
if (UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>())
Discord->OnDiscordGuildChannelsReceived.RemoveDynamic(this, &ThisClass::OnLinkChannels);
LinkChannels = Channels;
if (Error.IsError())
UE_LOG(LogGameDiscordChannel, Warning, TEXT("%s"), *Error.Message);
else
UE_LOG(LogGameDiscordChannel, Log, TEXT("Loaded %d Channels"), LinkChannels.Num());
}
void UDiscordChannelGameInstance::ChangeDiscordLobbyChannel(
int64 JoinedLobbyId, bool LinkChannel, const FDiscordGuildChannelSummary &SelectedChannel,
bool AllowPrivateChannel)
{
if (LinkChangeBusy)
{
UE_LOG(LogGameDiscordChannel, Warning, TEXT("Wait for the pending channel link change"));
return;
}
UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
if (!Discord || !Discord->IsDiscordRunning() || !Discord->IsAuthenticated() ||
JoinedLobbyId <= 0)
{
UE_LOG(LogGameDiscordChannel, Warning, TEXT("Sign in and pass a joined Discord lobby ID"));
return;
}
if (LinkChannel && (SelectedChannel.ChannelId <= 0 || !SelectedChannel.bLinkable ||
(!SelectedChannel.bPublic && !AllowPrivateChannel)))
{
UE_LOG(
LogGameDiscordChannel, Warning,
TEXT("Choose a linkable channel; private channels need explicit player confirmation"));
return;
}
PendingLinkLobbyId = JoinedLobbyId;
PendingLinkOperation =
LinkChannel ? TEXT("LinkChannelToLobby") : TEXT("UnlinkChannelFromLobby");
LinkChangeBusy = true;
Discord->OnOperationComplete.AddUniqueDynamic(this, &ThisClass::OnLinkCommand);
if (LinkChannel)
Discord->LinkDiscordChannelToLobby(JoinedLobbyId, SelectedChannel.ChannelId);
else
Discord->UnlinkDiscordChannelFromLobby(JoinedLobbyId);
}
void UDiscordChannelGameInstance::OnLinkCommand(FName Operation, bool bSuccess,
const FBetideError &Error)
{
if (!LinkChangeBusy || Operation.ToString() != PendingLinkOperation)
return;
LinkChangeBusy = false;
if (UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>())
Discord->OnOperationComplete.RemoveDynamic(this, &ThisClass::OnLinkCommand);
if (bSuccess)
UE_LOG(LogGameDiscordChannel, Log, TEXT("%s completed for lobby %lld"),
*PendingLinkOperation, PendingLinkLobbyId);
else
UE_LOG(LogGameDiscordChannel, Warning, TEXT("%s"), *Error.Message);
}Select a server from LinkGuilds, load its channels, then pass a LinkChannels entry with Link Channel enabled. Disable Link Channel to unlink; the channel input is then unused. Keep one owner for these requests because the link result identifies only the operation.
Before enabling Allow Private Channel, have the player confirm that every lobby member will see and reply to the linked channel’s messages, even if they cannot access that channel in Discord. Unlinking requires Can Link Lobby, but does not require channel permissions. It does not leave the lobby.
Send a lobby message
Loading the interactive viewer.
Game Instance variables: SendingLobbyMessage (Boolean, false), LastSentMessageId (Integer64, 0).
Game module dependencies: DiscordGame, BetideCore, Engine.
// Copyright (c) 2026 Betide Studio. All Rights Reserved.
// DiscordMessageGameInstance.h
#pragma once
#include "Engine/GameInstance.h"
#include "DiscordGameSubsystem.h"
#include "DiscordMessageGameInstance.generated.h"
UCLASS()
class UDiscordMessageGameInstance : public UGameInstance
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable)
void SendLobbyChat(int64 JoinedLobbyId, const FString &Message);
UPROPERTY(BlueprintReadOnly)
bool SendingLobbyMessage = false;
UPROPERTY(BlueprintReadOnly)
int64 LastSentMessageId = 0;
private:
UFUNCTION()
void OnSendId(FName Operation, bool bSuccess, int64 ResultId, const FBetideError &Error);
UFUNCTION()
void OnSendCommand(FName Operation, bool bSuccess, const FBetideError &Error);
void FinishLobbySend(bool Success, int64 MessageId, const FBetideError &Error);
};
// DiscordMessageGameInstance.cpp
#include "DiscordMessageGameInstance.h"
DEFINE_LOG_CATEGORY_STATIC(LogGameDiscordMessage, Log, All);
void UDiscordMessageGameInstance::SendLobbyChat(int64 JoinedLobbyId, const FString &Message)
{
if (SendingLobbyMessage)
{
UE_LOG(LogGameDiscordMessage, Warning, TEXT("Wait for the pending lobby message"));
return;
}
UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
if (!Discord || !Discord->IsDiscordRunning() || !Discord->IsAuthenticated())
{
UE_LOG(LogGameDiscordMessage, Warning, TEXT("Sign in with Discord first"));
return;
}
SendingLobbyMessage = true;
LastSentMessageId = 0;
Discord->OnIdOperationComplete.AddUniqueDynamic(this, &ThisClass::OnSendId);
Discord->OnOperationComplete.AddUniqueDynamic(this, &ThisClass::OnSendCommand);
Discord->SendDiscordLobbyMessage(JoinedLobbyId, Message, {});
}
void UDiscordMessageGameInstance::OnSendId(FName Operation, bool bSuccess, int64 ResultId,
const FBetideError &Error)
{
if (Operation == TEXT("SendLobbyMessage") && SendingLobbyMessage)
FinishLobbySend(bSuccess, ResultId, Error);
}
void UDiscordMessageGameInstance::OnSendCommand(FName Operation, bool, const FBetideError &Error)
{
if (Operation == TEXT("SendLobbyMessage") && SendingLobbyMessage)
FinishLobbySend(false, 0, Error);
}
void UDiscordMessageGameInstance::FinishLobbySend(bool Success, int64 MessageId,
const FBetideError &Error)
{
if (!SendingLobbyMessage)
return;
SendingLobbyMessage = false;
if (UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>())
{
Discord->OnIdOperationComplete.RemoveDynamic(this, &ThisClass::OnSendId);
Discord->OnOperationComplete.RemoveDynamic(this, &ThisClass::OnSendCommand);
}
if (Success)
{
LastSentMessageId = MessageId;
UE_LOG(LogGameDiscordMessage, Log, TEXT("Sent lobby message %lld"), LastSentMessageId);
}
else
UE_LOG(LogGameDiscordMessage, Warning, TEXT("%s"), *Error.Message);
}Pass a joined lobby ID and non-empty text. Keep one owner for sends: results identify SendLobbyMessage, not the lobby or request. SDK results arrive through On Discord ID Result; immediate validation failures arrive through On Discord Command Completed. Both paths must release the pending send.
Read lobby messages
Loading the interactive viewer.
Game Instance variables: ChatLobbyId (Integer64, 0), ChatQueryBusy, ChatRefreshQueued, ChatCloseRequested (Boolean, false), ChatMessages (Discord Message Summary array).
Game module dependencies: DiscordGame, BetideCore, Engine.
// Copyright (c) 2026 Betide Studio. All Rights Reserved.
// DiscordChatGameInstance.h
#pragma once
#include "Engine/GameInstance.h"
#include "DiscordGameSubsystem.h"
#include "DiscordChatGameInstance.generated.h"
UCLASS()
class UDiscordChatGameInstance : public UGameInstance
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable)
void OpenLobbyChat(int64 JoinedLobbyId);
UFUNCTION(BlueprintCallable)
void RefreshLobbyChat();
UFUNCTION(BlueprintCallable)
void CloseLobbyChat();
UPROPERTY(BlueprintReadOnly)
int64 ChatLobbyId = 0;
UPROPERTY(BlueprintReadOnly)
bool ChatQueryBusy = false;
UPROPERTY(BlueprintReadOnly)
TArray<FDiscordMessageSummary> ChatMessages;
private:
bool ChatRefreshQueued = false;
bool ChatCloseRequested = false;
UFUNCTION()
void OnChatMessages(FName Source, const TArray<FDiscordMessageSummary> &Messages,
const FBetideError &Error);
UFUNCTION()
void OnChatId(FName Operation, bool bSuccess, int64 ResultId, const FBetideError &Error);
UFUNCTION()
void OnChatCommand(FName Operation, bool bSuccess, const FBetideError &Error);
void FinishLobbyChatRead(const TArray<FDiscordMessageSummary> &ReceivedMessages,
const FBetideError &Error);
};
// DiscordChatGameInstance.cpp
#include "DiscordChatGameInstance.h"
DEFINE_LOG_CATEGORY_STATIC(LogGameDiscordChat, Log, All);
void UDiscordChatGameInstance::OpenLobbyChat(int64 JoinedLobbyId)
{
if (ChatLobbyId > 0)
{
UE_LOG(LogGameDiscordChat, Warning,
TEXT("Close the current chat before opening another lobby"));
return;
}
UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
if (!Discord || !Discord->IsDiscordRunning() || !Discord->IsAuthenticated() ||
JoinedLobbyId <= 0)
{
UE_LOG(LogGameDiscordChat, Warning, TEXT("Sign in and pass a joined Discord lobby ID"));
return;
}
ChatLobbyId = JoinedLobbyId;
Discord->OnMessagesReceived.AddUniqueDynamic(this, &ThisClass::OnChatMessages);
Discord->OnIdOperationComplete.AddUniqueDynamic(this, &ThisClass::OnChatId);
Discord->OnOperationComplete.AddUniqueDynamic(this, &ThisClass::OnChatCommand);
RefreshLobbyChat();
}
void UDiscordChatGameInstance::RefreshLobbyChat()
{
if (ChatLobbyId <= 0 || ChatCloseRequested)
return;
if (ChatQueryBusy)
{
ChatRefreshQueued = true;
return;
}
UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>();
if (!Discord)
return;
ChatQueryBusy = true;
ChatRefreshQueued = false;
Discord->GetDiscordLobbyMessages(ChatLobbyId, 50);
}
void UDiscordChatGameInstance::OnChatMessages(FName Source,
const TArray<FDiscordMessageSummary> &Messages,
const FBetideError &Error)
{
if (ChatLobbyId <= 0)
return;
if (Source == TEXT("Lobby"))
{
if (ChatQueryBusy)
FinishLobbyChatRead(Messages, Error);
return;
}
if (Source != TEXT("Created"))
return;
if (Messages.IsEmpty())
{
RefreshLobbyChat();
return;
}
for (const FDiscordMessageSummary &Message : Messages)
if (Message.LobbyId == ChatLobbyId)
RefreshLobbyChat();
}
void UDiscordChatGameInstance::OnChatId(FName Operation, bool, int64 ResultId, const FBetideError &)
{
if (ChatLobbyId <= 0 ||
(Operation != TEXT("MessageUpdated") && Operation != TEXT("MessageDeleted")))
return;
if (ChatQueryBusy)
{
RefreshLobbyChat();
return;
}
for (const FDiscordMessageSummary &Message : ChatMessages)
if (Message.MessageId == ResultId)
RefreshLobbyChat();
}
void UDiscordChatGameInstance::OnChatCommand(FName Operation, bool, const FBetideError &Error)
{
if (Operation == TEXT("GetLobbyMessages") && ChatQueryBusy)
FinishLobbyChatRead({}, Error);
}
void UDiscordChatGameInstance::FinishLobbyChatRead(
const TArray<FDiscordMessageSummary> &ReceivedMessages, const FBetideError &Error)
{
if (!ChatQueryBusy)
return;
ChatQueryBusy = false;
if (Error.IsError())
UE_LOG(LogGameDiscordChat, Warning, TEXT("%s"), *Error.Message);
else
{
ChatMessages = ReceivedMessages;
UE_LOG(LogGameDiscordChat, Log, TEXT("Loaded %d lobby messages"), ChatMessages.Num());
}
if (ChatCloseRequested)
CloseLobbyChat();
else if (ChatRefreshQueued)
RefreshLobbyChat();
}
void UDiscordChatGameInstance::CloseLobbyChat()
{
if (ChatQueryBusy)
{
ChatCloseRequested = true;
ChatRefreshQueued = false;
return;
}
ChatLobbyId = 0;
ChatRefreshQueued = false;
ChatCloseRequested = false;
if (UDiscordGameSubsystem *Discord = GetSubsystem<UDiscordGameSubsystem>())
{
Discord->OnMessagesReceived.RemoveDynamic(this, &ThisClass::OnChatMessages);
Discord->OnIdOperationComplete.RemoveDynamic(this, &ThisClass::OnChatId);
Discord->OnOperationComplete.RemoveDynamic(this, &ThisClass::OnChatCommand);
}
ChatMessages.Reset();
}This view keeps the newest 50 messages, newest first. Discord returns at most 200 messages from the last 72 hours; this is recent history, not a full archive. ChatMessages keeps the last successful result when a read fails; call Refresh Lobby Chat to retry.
Keep one owner for lobby-history requests: the Lobby result has no lobby or request ID. Call Close Lobby Chat before switching rooms or leaving; it waits for any pending read, then clears ChatLobbyId and removes its listeners.
Display Content for resolved mentions and emoji names. Keep disclosure messages and indicate attachments your chat UI cannot render; the returned summaries include both.
These Discord rooms are separate from UCIK gameplay lobbies and Crossplay voice; a Discord lobby ID is not a Crossplay lobby handle.