---
title: "Discord lobbies, messaging, and voice"
description: "Connect members to Discord communication features and receive results."
seo:
  noindex: true
---

{/* Copyright (c) 2026 Betide Studio. All Rights Reserved. */}

A Discord lobby uses a game-owned secret shared through an [activity invite](/integrations/discord/social-invites) 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](/crossplay/lobbies) and a [voice provider](/crossplay/voice); the crossplay nodes then create, join and call Discord lobbies for you.

## Create or join a lobby

**Blueprint**

<BlueprintViewer title="Create or join a lobby" src="/blueprints/crossplay-discord-lobby.txt" />

Game Instance variables: `JoinedLobbyId` (**Integer64**, 0), `JoiningDiscordLobby` and `LeavingDiscordLobby` (**Boolean**, false), `DiscordLobbyMembers` (**Discord Lobby Member Summary** array).

**C++**

Game module dependencies: `DiscordGame`, `BetideCore`, `Engine`.

```cpp
// 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

**Blueprint**

<BlueprintViewer title="Start lobby voice on Win64" src="/blueprints/crossplay-discord-voice.txt" />

Game Instance variables: `VoiceLobbyId` (**Integer64**, 0), `VoiceConnected` and `EndingVoiceCall` (**Boolean**, false).

**C++**

Game module dependencies: `DiscordGame`, `BetideCore`, `Engine`.

```cpp
// 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.

<BlueprintViewer title="Android microphone permission and call cancellation" nodeNames={["Start Discord Call", "End Discord Call"]} />

## 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

**Blueprint**

<BlueprintViewer title="Choose audio devices" src="/blueprints/crossplay-discord-audio-devices.txt" />

Game Instance arrays: `AudioInputs`, `AudioOutputs` (**Discord Audio Device**). Booleans, initially false: `AudioSettingsOpen`, `AudioQueryBusy`, `AudioRefreshNeeded`, `SelectingAudioDevice`. Strings, initially empty: `PendingDeviceOperation`, `PendingDeviceId`.

**C++**

Game module dependencies: `DiscordGame`, `BetideCore`, `Engine`.

```cpp
// 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.

**Blueprint**

<Accordion>
  <AccordionItem title="Load Discord servers and channels">
<BlueprintViewer title="Load Discord servers and channels" src="/blueprints/crossplay-discord-channel-picker.txt" />
  </AccordionItem>
  <AccordionItem title="Link or unlink a Discord channel">
<BlueprintViewer title="Link or unlink a Discord channel" src="/blueprints/crossplay-discord-channel-link.txt" />
  </AccordionItem>
</Accordion>

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).

**C++**

Game module dependencies: `DiscordGame`, `BetideCore`, `Engine`.

```cpp
// 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

**Blueprint**

<BlueprintViewer title="Send a lobby message" src="/blueprints/crossplay-discord-send-message.txt" />

Game Instance variables: `SendingLobbyMessage` (**Boolean**, false), `LastSentMessageId` (**Integer64**, 0).

**C++**

Game module dependencies: `DiscordGame`, `BetideCore`, `Engine`.

```cpp
// 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

**Blueprint**

<BlueprintViewer title="Read and watch lobby messages" src="/blueprints/crossplay-discord-read-messages.txt" />

Game Instance variables: `ChatLobbyId` (**Integer64**, 0), `ChatQueryBusy`, `ChatRefreshQueued`, `ChatCloseRequested` (**Boolean**, false), `ChatMessages` (**Discord Message Summary** array).

**C++**

Game module dependencies: `DiscordGame`, `BetideCore`, `Engine`.

```cpp
// 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](/crossplay/voice); a Discord lobby ID is not a Crossplay lobby handle.
