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.
Source, id Tech, a custom engine. C++17, no dependencies, no exceptions, no threads.
Anything else — Godot's GDScript, a Java or Lua game, a browser game. Plain JSON.
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:
| Header | What |
|---|---|
adapters/steamworks_bridge.hpp | SteamBridge over the Steamworks SDK. |
adapters/steam_http_client.hpp | HttpClient over ISteamHTTP, so Encore adds nothing to link. |
adapters/curl_http_client.hpp | An example HttpClient over libcurl. |
Requirements
- Steamworks SDK 1.57 or later, for
ISteamUser::GetAuthTicketForWebApi. The Source SDK 2013 tree ships an oldersteam_api: update both the headers and thesteam_api(64)library the game loads. - Construct
SteamworksBridgeafterSteamAPI_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, passSteamworksBridgea 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
#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:
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:
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:
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.
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.
reason | Meaning |
|---|---|
EncoreNotVerified | Not verified yet. Disable buying. |
EncoreNotReady | Verified, servers not yet moved into their project. |
OfferInactive | The offer was withdrawn. |
PublisherLimitReached | The studio's credit line with Plany is used up. |
PublisherUnavailable | The studio's Steam key is broken. |
OrderRefused | Steam would not open the order. Nothing was charged. |
PlayerNotAuthenticated | Steam did not accept the player's ticket. HTTP 401. |
ProjectCreditExhausted | Not sent by these calls. |
Unrecognized | A reason newer than this SDK. |
Transport · MalformedResponse · Steam | No 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.