PlanyDocs
Encore

Other engines

Encore from Source, a custom C++ engine, or any language that can POST JSON.

Two routes, depending on what your engine can link.

C++, for Source and custom engines

The core is C++17 and the standard library, compiled with -fno-exceptions -fno-rtti so a regression fails in the SDK's own build rather than in yours. It performs no I/O of its own: you hand it an HttpClient, a Scheduler and a SteamBridge, and every call completes through a callback on the thread that drives the client.

Adapters you can opt into:

HeaderWhat
adapters/steamworks_bridge.hppSteamBridge over the Steamworks SDK.
adapters/steam_http_client.hppHttpClient over ISteamHTTP, so Encore adds nothing to link.
adapters/curl_http_client.hppAn example HttpClient over libcurl.

Requirements

  • Steamworks SDK 1.57 or later, for ISteamUser::GetAuthTicketForWebApi. The Source SDK 2013 tree ships an older steam_api: update both the headers and the steam_api(64) library the game loads.
  • Construct SteamworksBridge after SteamAPI_Init(), and once — its callback listeners register in its constructor, and a bridge created at the moment of purchase can miss a player's answer that arrived before the order id did.
  • Something must call SteamAPI_RunCallbacks() every frame. Source already does. If your engine pumps it on another thread, pass SteamworksBridge a dispatcher that posts to the game thread.

Install

Add encore/src/*.cpp to your build — or add_subdirectory(encore) and link plany::encore — and put encore/include on the include path. The SDK is in plany-cloud/apis under sdk/cpp/encore.

Quick start

C++
#include "plany/encore/encore.hpp"
#include "plany/encore/adapters/steam_http_client.hpp"
#include "plany/encore/adapters/steamworks_bridge.hpp"
static std::unique_ptr<plany::encore::SteamHttpClient> g_encoreHttp;
static std::unique_ptr<plany::encore::SteamworksBridge> g_encoreSteam;
static std::unique_ptr<plany::encore::Client> g_encore;
// After Steam is up — not at static initialisation.
void Encore_Init() {
g_encoreHttp = std::make_unique<plany::encore::SteamHttpClient>();
g_encoreSteam = std::make_unique<plany::encore::SteamworksBridge>();
plany::encore::ClientOptions options;
options.scheduler = &g_frameScheduler; // enables retries — see below
options.newRequestId = [] { return MakeUuid(); }; // a real random UUID
g_encore = std::make_unique<plany::encore::Client>("ek-01j9...", options, *g_encoreHttp);
}
void Encore_Shutdown() { // the Client first
g_encore.reset();
g_encoreSteam.reset();
g_encoreHttp.reset();
}

A store:

C++
void Store_Open() {
g_encore->ListOffers([](plany::encore::Result<plany::encore::OfferList> offers) {
if (!offers) return; // offers.error().reason is typed
for (const auto& offer : offers.value().offers) {
if (!offer.price) continue;
Store_AddRow(offer.offerId, offer.displayName,
offer.price->ToDecimalString(), offer.price->currencyCode,
/*enabled=*/offers.value().purchasable);
}
});
}

A "keep the servers running" button:

C++
void Store_Buy(const std::string& offerId) {
std::string language = plany::encore::IsoLanguageFromSteam(
steamapicontext->SteamApps()->GetCurrentGameLanguage());
g_encore->Purchase(offerId, *g_encoreSteam, language,
[](plany::encore::Result<plany::encore::PurchaseResult> result) {
if (!result) {
// No order was opened. Nothing was charged; safe to let the player try again.
return;
}
switch (result.value().state) {
case plany::encore::PurchaseState::Credited: Store_ShowThanks(); break;
case plany::encore::PurchaseState::Authorized: Store_ShowProcessing(); break; // paid; still settling
case plany::encore::PurchaseState::Cancelled: break; // declined
default: Store_ShowNotCharged(); break;
}
if (result.value().funding) Store_ShowRunway(*result.value().funding);
});
}

Purchase gets the ticket, opens the order, waits for the overlay and settles it. done receives an Error only when no order was opened. Once an order exists the result is always a value — telling a player "purchase failed" about an order they may have approved is the worst thing a store can say — and a decline is Cancelled. When Encore could not be told the answer, confirmed is false and unconfirmedBecause says why; an approval still lands, because Encore settles approved orders on its own.

Showing the runway:

C++
void Store_ShowRunway(const plany::encore::PublicFunding& f) {
if (!f.funded) ShowText("The servers are asleep until somebody keeps them running.");
else if (!f.runwayDays) ShowText("The servers are funded."); // nothing to project from
else if (*f.runwayDays == 0) ShowText("Less than a day of servers left."); // zero is real
else ShowText(std::to_string(*f.runwayDays) + " days of servers left.");
}

runwayDays is a std::optional: absent is "no estimate", zero is "less than a day". See What players see.

Retries need a timer

Without a Scheduler the client never retries. With one, it retries reads, CompletePurchase, and BeginPurchase when it carries a request id, with jittered backoff, and asks CompletePurchase again up to settlementPolls times while an order settles.

C++
struct FrameScheduler : plany::encore::Scheduler {
void After(std::chrono::milliseconds delay, std::function<void()> task) override {
timers.push_back({Now() + delay, std::move(task)}); // run from your frame loop
}
// ...
};

Request ids come from ClientOptions::newRequestId, because the core has no random source it can trust on every platform. Use a real random UUID. An id that repeats across attempts is answered with somebody's earlier order — and with no generator at all, BeginPurchase is sent without an id and never retried.

Errors

A failed Result carries an Error with a gRPC status, a typed reason, the raw reasonName, the httpStatus (0 when no response arrived) and retryable.

reasonMeaning
EncoreNotVerifiedNot verified yet. Disable buying.
EncoreNotReadyVerified, servers not yet moved into their project.
OfferInactiveThe offer was withdrawn.
PublisherLimitReachedThe studio's credit line with Plany is used up.
PublisherUnavailableThe studio's Steam key is broken.
OrderRefusedSteam would not open the order. Nothing was charged.
PlayerNotAuthenticatedSteam did not accept the player's ticket. HTTP 401.
ProjectCreditExhaustedNot sent by these calls.
UnrecognizedA reason newer than this SDK.
Transport · MalformedResponse · SteamNo response; an unreadable one; the SteamBridge failed.

All named refusals but PlayerNotAuthenticated are FailedPrecondition (HTTP 400), none improves by retrying, and every refusal of a purchase happens before the player is charged. See Refusals.

Threading and lifetime

The Client is not thread-safe. The HttpClient, the Scheduler and any SteamBridge must outlive it; the Client itself may be destroyed with calls in flight, and their callbacks are then dropped rather than run — so a callback capturing an object that is already gone is never invoked.

Testing before launch

Point the client at a sandbox link's Encore key and buy from a signed-in Steam client: the same overlay and the same calls, with nothing charged and nothing credited.

Any other language

Encore's game-facing API is four POSTs with JSON bodies and no credential. A port to another language is a JSON client, a Steam ticket, and a handler for MicroTxnAuthorizationResponse_t. The contract is on HTTPS API.