PlanyDocs
Encore

Unreal Engine

A "keep the servers running" store in an Unreal game, from C++ or Blueprint, on the dependency-free C++ SDK.

Unreal uses Encore's C++ SDK: C++17 and the standard library, no exceptions, no RTTI, no threads — the way Unreal builds — plus a complete sample UGameInstanceSubsystem that wraps it for C++ and Blueprint.

The subsystem is a sample, written against UE 5.3–5.5, for you to copy into your game and own. The handful of lines most likely to differ between engine versions are marked VERIFY in the source.

Requirements

  • Steamworks SDK 1.57 or later, for ISteamUser::GetAuthTicketForWebApi.
  • The OnlineSubsystemSteam plugin enabled and configured as usual. It is what initialises Steam and pumps its callbacks.
  • A Steam client signed in, and the game launched through Steam or with a steam_appid.txt in development.

Install

  1. Copy the SDK and the sample

    From sdk/cpp/encore in plany-cloud/apis: copy include/ and src/ into your game module — say Source/YourGame/ThirdParty/PlanyEncore/ — and the two files in samples/unreal/ into Source/YourGame/. Replace YOURGAME_API with your module's API macro.

  2. Add the modules

    In YourGame.Build.cs:

    C#
    PublicDependencyModuleNames.AddRange(new[] { "Core", "CoreUObject", "Engine", "HTTP" });
    AddEngineThirdPartyPrivateStaticDependencies(Target, "Steamworks");
    PrivateIncludePaths.Add(Path.Combine(ModuleDirectory, "ThirdParty/PlanyEncore/include"));
    PrivateIncludePaths.Add(Path.Combine(ModuleDirectory, "ThirdParty/PlanyEncore/src"));
  3. Configure the Encore key

    In Config/DefaultGame.ini:

    INI
    [/Script/YourGame.PlanyEncoreSubsystem]
    EncoreKey=ek-01j9…

    The key is not a secret — it ships in every copy of the game — but it is the game's, so it belongs in config. See The Encore key.

How the subsystem is put together

UPlanyEncoreSubsystem is a game instance subsystem: one Encore client for the life of the running game, surviving level travel — which is exactly when a store widget is most likely to be torn down with a purchase still in flight. It is not created on a dedicated server.

When it initialises it builds:

  • an HttpClient over FHttpModule,
  • a Scheduler over FTSTicker, which is what enables retries,
  • a request-id generator over FGuid::NewGuid(), which is what makes opening a purchase safely retryable, and
  • a SteamworksBridge — once, at start-up, because Steam can deliver the player's answer before the HTTP response naming the order has arrived, and a bridge created at the moment of purchase would miss it. Steam's callbacks are posted to the game thread.

If Steam is not running, it logs a warning and purchases report that Steam is unavailable; funding and offers still load.

A "keep the servers running" button

C++
void UStoreWidget::NativeConstruct()
{
Super::NativeConstruct();
UPlanyEncoreSubsystem* Encore = GetGameInstance()->GetSubsystem<UPlanyEncoreSubsystem>();
Encore->OnOffersLoaded.AddDynamic(this, &UStoreWidget::HandleOffers);
Encore->OnFundingLoaded.AddDynamic(this, &UStoreWidget::HandleFunding);
Encore->OnPurchaseFinished.AddDynamic(this, &UStoreWidget::HandlePurchase);
Encore->RefreshOffers();
Encore->RefreshFunding();
}
void UStoreWidget::HandleOffers(bool bSuccess, const TArray<FPlanyEncoreOffer>& Offers, const FString& Error)
{
if (!bSuccess || Offers.Num() == 0)
{
StatusText->SetText(FText::FromString(TEXT("The store is unavailable right now.")));
return;
}
Offer = Offers[0];
BuyLabel->SetText(FText::FromString(FString::Printf(
TEXT("Keep the servers running — %s %s"), *Offer.Price, *Offer.CurrencyCode)));
// Offers are listed before the game is verified. When they are not purchasable
// yet, Error carries the reason — ENCORE_NOT_VERIFIED, ENCORE_NOT_READY — and
// the button stays disabled.
BuyButton->SetIsEnabled(Error.IsEmpty());
}
void UStoreWidget::OnBuyClicked()
{
GetGameInstance()->GetSubsystem<UPlanyEncoreSubsystem>()->Purchase(Offer.OfferId);
}
void UStoreWidget::HandlePurchase(bool bSuccess, EPlanyEncorePurchaseState State, bool bConfirmed, const FString& Error)
{
if (!bSuccess)
{
// No order was opened, so nothing was charged. Safe to try again.
StatusText->SetText(FText::FromString(TEXT("The store is unavailable right now. You have not been charged.")));
return;
}
switch (State)
{
case EPlanyEncorePurchaseState::Credited:
StatusText->SetText(FText::FromString(TEXT("Thank you — you are keeping the servers running.")));
break;
case EPlanyEncorePurchaseState::Authorized:
// The player has paid; Encore finishes crediting it on its own.
StatusText->SetText(FText::FromString(TEXT("Thank you! Your support will show up shortly.")));
break;
case EPlanyEncorePurchaseState::Cancelled:
break; // the player declined — not an error
default:
StatusText->SetText(FText::FromString(TEXT("Steam did not complete the purchase. You have not been charged.")));
break;
}
}

The same three events and three functions are Blueprint-assignable and Blueprint-callable, under the Encore category.

OnPurchaseFinished is the one to read carefully:

ParameterMeaning
bSuccessfalse only when no order was opened — the ticket failed, or opening the purchase was refused. Nothing was charged.
StateOnce an order exists, always a value. A decline is Cancelled, not a failure.
bConfirmedfalse when Encore could not be told the player's answer. An approval still lands: Encore settles approved orders itself within about two minutes.
ErrorWhy, when there is a reason to give.

Purchase is one at a time: a second call while one is in flight is ignored, because two overlays for two orders is how a player is charged twice. IsPurchaseInFlight() tells a widget to disable its button.

The player's Steam language is passed to the overlay automatically.

Showing the runway

C++
void UStoreWidget::HandleFunding(bool bSuccess, const FPlanyEncoreFunding& Funding, const FString& Error)
{
FString Text;
if (!bSuccess)
Text = TEXT(""); // unknown is not "unfunded"
else if (!Funding.bFunded)
Text = TEXT("The servers are asleep until somebody keeps them running.");
else if (Funding.RunwayDays < 0)
Text = TEXT("The servers are funded."); // nothing to project from
else if (Funding.RunwayDays == 0)
Text = TEXT("Less than a day of servers left."); // zero is real, and urgent
else
Text = FString::Printf(TEXT("%lld days of servers left."), Funding.RunwayDays);
RunwayText->SetText(FText::FromString(Text));
}

In the sample, RunwayDays is -1 when there is no runway to show, and 0 means less than a day. Blueprint has no optional integer, so absent is -1; never render it as "0 days left". A successful purchase also broadcasts OnFundingLoaded with the new figures, so the runway updates the moment a player's purchase lands.

What a funding response contains depends on what the studio chose to publish — see What players see.

Handling errors

The C++ Error carries a gRPC status, a typed reason, the raw reasonName, the httpStatus (0 when no response arrived), and retryable. The sample turns it into the Error string on each event; a game that needs to branch on the reason should read reason in the sample's callbacks.

ErrorReasonWhat to do
EncoreNotVerifiedNot verified yet. Keep the buy button disabled.
EncoreNotReadyVerified; servers not yet moved into their project. Same; it clears on its own.
OfferInactiveThe studio withdrew the offer. RefreshOffers().
PublisherLimitReachedThe studio's line of credit with Plany is used up. Try later.
PublisherUnavailableThe studio's Steam key is broken. Only the studio can fix it.
OrderRefusedSteam would not open the order. Nothing was charged.
PlayerNotAuthenticatedSteam did not vouch for the player. HTTP 401.
ProjectCreditExhaustedNot sent by these calls; a store sees bFunded == false.
UnrecognizedA reason newer than this SDK; reasonName has it.

All named refusals but PlayerNotAuthenticated are HTTP 400, none improves by retrying, and none is retried. Every refusal of a purchase happens before the player is charged. The full list is in Refusals.

Retries. With the sample's scheduler, reads and CompletePurchase are retried on transient failures with jittered backoff, and opening a purchase is retried only under its request id — without one, a retry could open a second Steam order. While an order settles, CompletePurchase is asked again up to settlementPolls times (four by default).

Testing before launch

Test the whole store against a sandbox link: the same overlay and the same calls, with nothing charged and nothing credited. Put the sandbox Encore's key in a test build's DefaultGame.ini, launch it through a signed-in Steam client, and buy — the purchase walks through every state a real one does and ends in Credited, with nothing added to the project's balance.