CEEJAYDocs

Unreal Engine

Link a player of your Unreal game to their Discord account, and show the community's quests, shop, locker, rank and daily reward in the game.

The Unreal plugin starts a connect session, returns the link, the code and the URL of the QR image to your game, polls for the result every 3 seconds while the game is in the foreground, waits for the player's confirmation in the game, keeps the player signed in, reads the member profile summary and disconnects. When the player is connected it also provides the game with the community's quests, shop, locker, rank and daily reward. It draws nothing: your game shows the button.

It does what the Connect client of the TypeScript SDK and the game kits do, request for request, with the same numbers and the same four states of the button. The flow, the proof levels and the player's confirmation are explained on Connect.

Preview: not published yet

The plugin is not published yet, and its name, CeejayConnect, may change before the first release. Its core logic is standard C++17 with no Unreal dependency, and its 205 test cases run without Unreal. The plugin builds with Unreal Engine 5.8 on macOS, for the editor and for a game (Development and Shipping, Apple silicon and Intel). It is not yet tested in a running game, on Windows, consoles or mobile, or with Unreal Engine 5.3 to 5.7. See What is being checked for what has been verified.

What you need

  • Unreal Engine 5.3 or later, and a C++ project. The plugin has code, so a project with no C++ needs one class added, or the plugin built for the engine first.
  • Connect enabled for the community, and the public key of your game: ceejay_pk_ followed by letters and digits. The key ships inside the game and is not a secret. A secret key is rejected, and the error message never names it. See Connect.

Install

The plugin is not on the Marketplace or Fab, so add it to your project from a checkout of the CEEJAY repository.

Copy the folder sdk/unreal/CeejayConnect into <YourProject>/Plugins/.

Right-click the .uproject, choose Generate Visual Studio project files (or the Xcode ones), and build the project.

Open Edit → Plugins, search for "CEEJAY Connect", tick it and restart the editor. Or enable it in the .uproject:

Enable the plugin in the .uproject
{
  "Plugins": [
    { "Name": "CeejayConnect", "Enabled": true }
  ]
}

If your own C++ module calls the plugin, add "CeejayConnect" to the dependencies in its Build.cs.

Add the plugin to your module
PublicDependencyModuleNames.AddRange(new string[] { "CeejayConnect" });

Project Settings

Open Project Settings → Plugins → CEEJAY Connect. The settings are saved in Config/DefaultGame.ini.

Prop

Type

You can also set the key and the dashboard URL from your own code, for example with a key read from a data asset: call Configure(PublicKey, DashboardAddress, OutError) on the subsystem. It returns true when the key was accepted, and fills OutError when it was not. Do not call it from inside one of the plugin's own delegates.

The client is UCeejayConnectSubsystem, a UGameInstanceSubsystem. It is not created on a dedicated server.

Connect a player

Every call returns its result through a delegate, shortly afterwards, on the game thread. A delegate is never called from inside the call that triggered it.

In Blueprint

  1. Get Game Instance Subsystem with the class CeejayConnectSubsystem.
  2. Bind the events you need: On State Changed, On Session Started, On Needs Confirmation, On Connected, On Ended, On Profile and On Error.
  3. Call Restore once when the game starts.
  4. When the player presses the button, build a Ceejay Start Params (the player's ID as text, also when your IDs are numbers, and optionally the player's name) and call Start Connect.
  5. In On Session Started, show the code and call Open Session Link.
  6. In On Needs Confirmation, show "Connect to name?" with Yes and No, and call Answer Confirmation with what the player chose.

The sample widget puts all of this together.

In C++

Connect a player in C++
#include "CeejayConnectSubsystem.h"

void UMyConnectScreen::NativeConstruct()
{
	Super::NativeConstruct();

	Ceejay = GetGameInstance()->GetSubsystem<UCeejayConnectSubsystem>();
	Ceejay->OnStateChanged.AddDynamic(this, &UMyConnectScreen::HandleStateChanged);
	Ceejay->OnSessionStarted.AddDynamic(this, &UMyConnectScreen::HandleSessionStarted);
	Ceejay->OnNeedsConfirmation.AddDynamic(this, &UMyConnectScreen::HandleNeedsConfirmation);
	Ceejay->OnConnected.AddDynamic(this, &UMyConnectScreen::HandleConnected);
	Ceejay->OnEnded.AddDynamic(this, &UMyConnectScreen::HandleEnded);
	Ceejay->OnError.AddDynamic(this, &UMyConnectScreen::HandleError);

	Ceejay->Restore();                         // when the game starts: finds an open session or a signed-in player
}

void UMyConnectScreen::OnConnectPressed()
{
	FCeejayStartParams Params;
	Params.PlayerId = MyPlayerId;              // always text, also when your IDs are numbers
	Params.PlayerName = MyPlayerName;
	Ceejay->StartConnect(Params);              // starts the session and starts asking for the result
}

void UMyConnectScreen::HandleSessionStarted(const FCeejayConnectSession& Session)
{
	ShowWaiting(Session.Code);                 // always show the code too
	Ceejay->OpenSessionLink();                 // the system browser; not on a console
}

void UMyConnectScreen::HandleNeedsConfirmation(const FCeejayMember& Member, bool bAlreadyConnected)
{
	// Show "Connect to <name>?" with Yes and No. THE PLUGIN WAITS HERE. When the player chooses:
	//     Ceejay->AnswerConfirmation(bPlayerSaidYes);
	AskThePlayer(Member.DisplayName);
}

void UMyConnectScreen::HandleConnected(const FCeejayConnectedResult& Result)
{
	ShowMember(Result.Member);                 // name, level, coins, roles
}

void UMyConnectScreen::HandleError(const FString& Operation, const FCeejayError& Error)
{
	if (Error.bIsNotAvailable) { HideTheButton(); return; }   // this instance does not offer Connect yet
	UE_LOG(LogTemp, Warning, TEXT("%s failed: %s"), *Operation, *Error.Code);   // test Code, never Message
}

ShowWaiting, ShowMember, AskThePlayer and HideTheButton are your game's own. StartConnect(Params, bBeginWaiting) begins waiting by default. Pass false to start the session and call BeginWaiting yourself.

Player confirmation

The connection is saved when both the Discord member and the game have confirmed. The game's confirmation is an event and a call: OnNeedsConfirmation passes the game the Discord member, and AnswerConfirmation(bAccept) sends what the player chose. A code shown on a stream can be approved by anyone who reads it, and this is where the player sees a name that is not theirs and declines.

There is no default and no timeout. The plugin sends nothing after the confirmation prompt until the game calls AnswerConfirmation.

The four states of the button

GetState() is one of four values. It is known once Restore has run. Redraw the button from OnStateChanged, which provides the state and the attention.

StateWhat it meansWhat the button shows
Not connectedThis device has no connection and no open sessionConnect
WaitingA session is open. It survives a restart and ends after 10 minutesThe code and a Cancel button
ConnectedThis device has the player's connectionThe member profile summary
Needs attentionConnected, and the player has something to doWhat GetAttention() says

GetAttention() is None in every other state:

AttentionWhere to send the player
Not a memberThe Discord account never joined the server: GetJoinUrl()
Left the serverThe Discord account left the server: GetJoinUrl()
Screening pendingNowhere. Discord's membership screening is still open
Sharing neededThe game requests data beyond what the player consented to: GetManageUrl()

GetOpenSession() returns the open session, with its link, code, QR URL and expiry, and bIsOpen is false when none is open.

OpenSessionLink() opens the link of the open session with FPlatformProcess::LaunchURL. That is the system browser, never a web view inside the game, because Discord blocks sign-in there. To use a browser tab of your own, such as a Custom Tab or an ASWebAuthenticationSession plugin, give the subsystem an opener from C++: SetLinkOpener([](const FString& Url){ ... return true; }).

On a console or a television, open nothing. Show Session.Code, the URL GetBaseUrl() + "/connect", and a QR image. Session.QrUrl is the URL of an SVG image of a QR code for the link, and Unreal does not draw SVG. Either make a QR code from Session.Url with a QR library of your own, or show the code alone. The player types the code on any phone or computer.

Where the plugin stores data

Two things must outlast the game being closed, and both are private to the device: the refresh token, and the open session with the session secret used to poll for the result. A phone often closes a game while its player is in the browser. Without the session the player would have to authorise with Discord again. The plugin writes both as one JSON string under the key ceejay.connect, the same string every other CEEJAY client writes.

The default store is a USaveGame slot, UCeejaySaveGameStorage, with the slot name from the settings. That is a plain file in the game's own save folder:

  • on a phone it is private to the app;
  • on a console it is the platform's save system;
  • on a computer it is readable by the person who owns the account.

For a game that ships on a phone or a computer, give the plugin a safer store. Implement ICeejaySecureStorage over the platform's secure store (the iOS Keychain, the Android Keystore or the Windows Credential Manager) and call SetStorage before Restore. You can implement it in C++, by overriding GetValue_Implementation, SetValue_Implementation and RemoveValue_Implementation, or in a Blueprint. Each function returns true when it succeeds. A missing key is true with an empty value, and an error message must never contain the value.

Use one subsystem per save slot. Two copies of the game that renew from the same slot at the same moment, such as two Play In Editor windows, can sign the device out.

While the game is in the background

On a phone the system suspends a game that is in the background. The plugin polls for the result every 3 seconds while the game is in the foreground, stops while it is in the background, and polls immediately when it returns. It is told by the engine's FCoreDelegates::ApplicationWillEnterBackgroundDelegate and ApplicationHasEnteredForegroundDelegate, which Unreal broadcasts on iOS and Android and not on a computer, where the game keeps running while the player is in the browser. On a platform with no such delegate, call SetInForeground(false) and SetInForeground(true) yourself.

After a restart, Restore finds the open session and BeginWaiting resumes. PollNow() polls immediately whenever you call it.

More that the plugin does

  • Renews the player session. A player session lasts 15 minutes. The plugin renews it a minute before it ends, one renewal at a time, and the next refresh token is in the storage before the new player session is used. A game never handles a token.
  • A code from Discord. A member who pressed the game's button in Discord has a code. StartConnectWithCode(Code, Params) attaches the player to that session. OnSessionStarted identifies who created the code in Session.Member: show the name, and OnNeedsConfirmation follows. Say beside the field: "Only type a code you made yourself in Discord."
  • A proven player. If the game signs players in with Firebase Authentication, Unity Player Authentication or Steam, give the plugin the player and a fresh token (on Steam, the ticket of GetAuthTicketForWebApi("ceejay"), with the SteamID64 as the player ID) with SetProof (Blueprint) or SetProofProvider (C++, called when a token is needed). A Steam game needs SetProofProvider: Restore asks for a new ticket before Start connect, and SetProof holds one. Restore then signs in a player who connected on another device, with no Discord step.
  • A session your server started. Pass it, with its SessionSecret, to BeginWaitingForServerSession.
  • Retries. After a 429, a 5xx or no response, the plugin waits and retries, up to Max Retries times, for every request except Start connect and Check in. It waits for Retry-After when the response includes it, and never for more than a minute. A second Start connect is a second session.
  • Cancel and Disconnect. CancelConnect() is the game's own Cancel. Disconnect() ends the connection on every device, works with no player session (a player who never joined the server can still leave), is safe to repeat, and retains the tokens when CEEJAY cannot be reached.

The game loop

The same subsystem provides what the community has enabled for the game. Each function returns a RequestId, and the result is returned on the matching delegate with the same RequestId, so two concurrent calls can be told apart. A failure of any of them is reported on OnHubError(RequestId, Operation, Error).

FunctionResult delegateWhat it does
HubListQuests(Limit, Cursor)OnHubQuestsLists the quests the player can see, with progress, a page at a time
HubStartQuest(QuestId)OnHubQuestStartedStarts a quest. bAlreadyStarted is also returned when a request is repeated after a lost response
HubVerifyQuestTask(QuestId, TaskId)OnHubQuestTaskChecks a state (a role, a badge, a balance, a follow) and completes the task when the condition is met
HubAnswerQuestTaskText and HubAnswerQuestTaskNumberOnHubQuestTaskSends the answer to a task that asks a question
HubListShop(Limit, Cursor)OnHubShopLists what the game may sell, with the player's own state (bCanBuy)
HubBuy(ItemId, ExpectedPrice, IdempotencyKey, Answer)OnHubPurchaseBuys an item with the player's coins
HubListLocker(bIncludeClosed, Limit, Cursor)OnHubLockerLists what the player owns through the game
HubClaim(PurchaseId, Input)OnHubClaimClaims an item the player bought
HubGetRanks(Top)OnHubRanksReads the player's rank and the top list of this game's players
HubGetBalance()OnHubBalanceReads coins, streak, perks, XP boost and what the player may spend (bMaySpend)
HubCheckIn()OnHubCheckInClaims the daily reward

These are the eleven requests of The game loop, inside a game, under Unreal's names. The API reference has each request: List the quests, Start a quest, Verify a quest task, Answer a quest task, List the shop, Buy a shop item, Read the locker, Claim an item, Get the ranks, Get the balance and Check in.

Buy an item
Ceejay->OnHubPurchase.AddDynamic(this, &UMyShop::HandlePurchase);
Ceejay->OnHubError.AddDynamic(this, &UMyShop::HandleHubError);

BuyRequestId = Ceejay->HubBuy(Item.Id, static_cast<int32>(Item.Price));   // the Price the player was shown

HandlePurchase(int32 RequestId, const FCeejayPurchase& Purchase) and HandleHubError(int32 RequestId, const FString& Operation, const FCeejayError& Error) compare the RequestId with BuyRequestId to know which call the result belongs to.

What has to be true first

The community has enabled the loop for your game (otherwise hub_not_enabled: hide the loop). The player has consented to share their quests, rewards, shop and rank with it (sharing_needed: send the player to GetManageUrl(), which is also in Error.ManageUrl). The Discord account is in the server (membership_required: Error.Membership says pending, left or not_member, and for the last two Error.JoinUrl is where to send the player). A function needs a connected player who is in the server. Otherwise it returns a usage error and sends nothing, so check the state and the attention first. The gates are explained on The game loop, inside a game.

Lists, buying and spending

  • Lists return a page and a cursor. Pass NextCursor of the page before as Cursor for the next, until it is empty. A Limit of 0 is 50, and the most is 100.
  • Buy. Keep the Price of the item the player was shown and pass it as ExpectedPrice. If the price has changed since, the error is price_changed: read the shop again. The plugin sends every attempt of a call with one idempotency key, so after a lost response, a 5xx or a 429 it retries automatically, and CEEJAY returns the first purchase with bDuplicate instead of buying twice. After a 504 the key stays reserved for up to 60 seconds, so a request with it returns idempotency_in_progress. The plugin waits as long as Retry-After says and retries, for up to 75 seconds in all.
  • If the call still fails, its key is discarded. To retry a purchase after an error, generate the key when the player presses Buy with MakeIdempotencyKey(), store it, and pass it again as IdempotencyKey. Requests with the same key count as the same purchase, so nothing is bought twice. Use a new key for every separate purchase.
  • A player who cannot spend yet. spend_on_hold (a new connection or a new device must wait before it can spend) and daily_spend_cap_reached are not failures: Error.bIsSpendBlocked is true, and Error.HoldUntil or Error.ResetsAt indicates when to try again. Use bMaySpend and CannotSpendReason from HubGetBalance to grey out a Buy button before it is pressed.
  • Check in. A claim made too soon is checkin_cooldown, and Error.NextCheckInAt says when the next one opens. Treat it as "already claimed today". The plugin never retries a check in automatically.

A game never receives a Discord ID, the avatar of another member, or a code that belongs to a purchase.

Errors

Every failure carries an FCeejayError. It has Kind (Api, Connection, Usage or Storage), Status, Code, Message, bHasRetryAfter and RetryAfterSeconds, RequestId, Reason, Field, Feature, Body and bTimedOut. For the game loop it also has PlayerMessage (the message to show for a quest or a purchase that was rejected), HoldUntil, ResetsAt, AvailableAt, NextCheckInAt, JoinUrl, Membership and ManageUrl.

Test the Code, never the Message. Errors says what each code means. bIsNotAvailable is true when Connect is not enabled for this community: hide the button. No error ever includes a session secret, a refresh token, a proof or a player session. The plugin logs the code and the message, and nothing else, under LogCeejayConnect.

The sample widget

The plugin has a sample, Samples/WBP_CeejayConnectButton.layout.txt, that describes a whole User Widget, widget by widget and node by node. It is a description and not a saved asset, because Unreal's Widget Blueprint editor makes .uasset files. Making it takes about ten minutes: create a Widget Blueprint with the parent class User Widget, called WBP_CeejayConnectButton, and follow the file.

The widget tree is a Widget Switcher with one slot for each state, and a panel for the player's confirmation:

The widget tree
[Root]                 Vertical Box
  [StateSwitcher]      Widget Switcher
    Slot 0  NOT CONNECTED     [ConnectButton] "Connect Discord"
    Slot 1  WAITING           [CodeText], [AddressText], [OpenLinkButton], [CancelButton]
    Slot 2  CONNECTED         [NameText], [DetailText], [DisconnectButton]
    Slot 3  NEEDS ATTENTION   [AttentionText], [AttentionButton], [DisconnectButton2]
  [ConfirmPanel]       Collapsed. [ConfirmQuestion], [YesButton], [NoButton]
  [ErrorText]          Collapsed

On construct, the graph gets the subsystem, binds the events, calls Restore and then a Refresh function. Refresh reads Get State, sets the switcher's active index (0 to 3), shows the code while waiting, and calls Get Profile when connected. The buttons call Start Connect, Open Session Link, Cancel Connect, Answer Confirmation with Accept true or false, and Disconnect. On Error hides the whole button when Is Not Available is true.

A console or a television hides the OpenLinkButton and shows the code and the URL in large text. The Discord avatar is a URL on Discord's CDN. Unreal loads images from assets and not from URLs, so showing the avatar needs an image downloader of your own. The sample works without it. The game loop has no widget in the sample: bind On Hub Quests, On Hub Shop, On Hub Purchase and the rest, and use the Request Id each call returns.

What the plugin sends

The plugin only contacts your community's CEEJAY, over HTTPS. It sends two headers, Authorization and Content-Type (the platform's HTTP stack adds its own User-Agent and the like), and these values:

  • the player ID your game gives it, and the player name, language and age answer if you pass them;
  • the device's sign-in: the refresh token and the player session that CEEJAY issued to this device, and, if you pass one, the Firebase or Unity token, the Steam ticket or the connect pass that proves the player;
  • for the game loop: the IDs of quests, tasks, items and purchases, the prices the player saw, and the answers the player types.

It sends nothing else: no advertising ID, no device ID, no location, no contacts, no analytics and no crash reports. Unreal's own analytics and crash reporting are separate from the plugin and are yours to declare. The Discord sign-in happens in the browser on CEEJAY's page, and the game never sees the player's Discord password or token.

For the store privacy forms, the declarations are the same as for the other packages: see Game kits. If you use the game loop, also declare the answers a player types, as user-generated content. The rules for consoles and other stores differ, and so do their rules about opening a web page from a game and about signing in to another service's account, so check the current rules of each platform before you ship.

A game made for children connects nobody: every request returns audience_not_allowed. A game for a mixed audience must send the age answer with every start, in AgeAnswer of FCeejayStartParams.

What is being checked

The logic is tested without Unreal, and the plugin builds in Unreal Engine 5.8. Before the first release, the engine layer is checked by connecting a test game to a test CEEJAY. The steps, and what each should do:

  1. Build it. Make a blank C++ project, copy the plugin into Plugins/, generate the project files and build the editor target. The first errors from Unreal's header tool in CeejayConnectTypes.h, CeejayConnectStorage.h and CeejayConnectSubsystem.h are the ones to read.
  2. Enable it and set the key. Use a test-mode public key and a test CEEJAY instance with Connect enabled. A session started with a test key accepts the member's confirmation only from someone who owns the server or has a role with Administrator in it.
  3. Run the sample. Play in the editor. Restore ends with Not connected. Start Connect opens the browser and shows a code, and the log shows a result request about every 3 seconds. After the member confirms in Discord, the widget asks "Connect to name?" and waits as long as you like without sending anything. Yes gives Connected with the member profile summary, and Disconnect gives Not connected.
  4. Close and open the game during a session. Start Connect, close the game before approving, open it and call Restore: the state is Waiting and the same code is shown.
  5. Try the failures. Switch the network off during Start Connect (one failure, no second session) and during Buy (the same key goes out again, and after the network is back there is one purchase). Use an old refresh token, and the state goes to Not connected.
  6. On a phone. Go to the home screen while waiting: the log stops. Come back: a result request is sent immediately.

On this page

Community StudiosPrivacyTerms