Unity
A "keep the servers running" store in a Unity game: install, connect Steam, sell an offer, show the runway.
Plany.Encore is the game's half of Encore for C#: the servers' funding, the offers,
and a player buying one. Unity 2021.3 or later, Godot 4 (.NET), and any
netstandard2.1 runtime.
It has no dependencies — not even System.Text.Json, which Unity does not ship —
and nothing in it is a credential.
Install
From the Package Manager's Add package from git URL…:
https://github.com/plany-cloud/apis.git?path=/sdk/csharpAppend # and a commit to pin a version. Samples → Unity store imports a
drop-in store screen that does everything on this page.
For Godot or any other .NET project, reference sdk/csharp/Plany.Encore.csproj from a
checkout of plany-cloud/apis.
Connect Steam
Encore needs two things from Steam — a Web API ticket for the player, and the player's answer in the overlay — and ships an adapter for each common wrapper. Turn one on under Project Settings → Player → Scripting Define Symbols:
| Wrapper | Symbol | Minimum |
|---|---|---|
| Steamworks.NET | PLANY_STEAMWORKS_NET | 20.2.0 |
| Facepunch.Steamworks | PLANY_FACEPUNCH_STEAMWORKS | a release with GetAuthTicketForWebApiAsync |
Any other wrapper implements ISteamBridge's two methods, and should build the
second on MicroTxnAuthorizations, which handles the part that is easy to get wrong.
Create the bridge once, when Steam initialises, and keep it. Steam's servers open the overlay when Plany opens the order, so a fast player can answer before the HTTP response telling your game which order to wait for has even arrived. A bridge listening since start-up keeps that answer; one created at the moment of purchase misses it and waits forever.
Something must also pump Steam's callbacks every frame — SteamAPI.RunCallbacks()
or SteamClient.RunCallbacks().
using Plany.Encore;using UnityEngine; public class EncoreBootstrap : MonoBehaviour{ public static EncoreClient Encore { get; private set; } public static ISteamBridge Steam { get; private set; } void Awake() { // After SteamAPI.Init() / SteamClient.Init(). Steam = new SteamworksNetBridge(); // or new FacepunchBridge() Encore = new EncoreClient("ek-01j9…"); // your Encore key DontDestroyOnLoad(gameObject); } void OnDestroy() { (Steam as System.IDisposable)?.Dispose(); Encore?.Dispose(); }}The Encore key comes from the console's Integration tab or
plany encore key, and is safe to ship: it names the game the
way a Steam app id does. See The Encore key.
A "keep the servers running" button
using Plany.Encore;using UnityEngine;using UnityEngine.UI; public class KeepTheServersRunning : MonoBehaviour{ [SerializeField] Button button; [SerializeField] Text label; PublicOffer offer; async void Start() { button.interactable = false; try { var offers = await EncoreBootstrap.Encore.ListOffersAsync(); offer = offers.Offers.Count > 0 ? offers.Offers[0] : null; if (offer == null) return; label.text = $"Keep the servers running — {offer.Price}"; // Offers are listed before the game is verified, with Purchasable false, // so the store can be built and shown with its button disabled. button.interactable = offers.Purchasable; button.onClick.AddListener(Buy); } catch (EncoreException e) { label.text = "The store is unavailable right now."; Debug.LogWarning(e); } } async void Buy() { button.interactable = false; try { var result = await EncoreBootstrap.Encore.PurchaseAsync( offer.OfferId, EncoreBootstrap.Steam, SteamApps.GetCurrentGameLanguage()); switch (result.State) { case PurchaseState.Credited: label.text = "Thank you — you are keeping the servers running."; break; case PurchaseState.Authorized: // The player has paid; Plany finishes crediting it on its own. label.text = "Thank you! Your support will show up shortly."; break; case PurchaseState.Cancelled: break; // they declined in the overlay — not an error default: label.text = "Steam did not complete the purchase. You have not been charged."; break; } if (result.Funding != null) ShowRunway(result.Funding); } catch (EncoreException e) { label.text = Describe(e); } finally { button.interactable = true; } }}PurchaseAsync does the whole purchase: gets the Steam ticket, opens the order with
a fresh request id, waits for the player's answer in the overlay, and settles it.
- A decline is
PurchaseState.Cancelled, never an exception. - An approval is never reported as a failure. Steam can take a few seconds to
settle, so
PurchaseAsyncasks again every two seconds for up toEncoreClientOptions.SettlementWait(ten seconds by default). Still unsettled, or Plany unreachable, it returnsPurchaseState.Authorized: the player has paid, and Plany settles approved orders itself within about two minutes. - Pass Steam's language name —
"german","schinese"— and the client converts it to the ISO 639-1 code the overlay wants.
Await from the main thread, and never block on these tasks with .Result: nothing
uses ConfigureAwait(false), so continuations come back to Unity's main thread, which
is where Steam has to be called — and blocking it deadlocks.
Showing the runway
async void RefreshFunding(){ PublicFunding f = await EncoreBootstrap.Encore.GetFundingAsync(); ShowRunway(f);} void ShowRunway(PublicFunding f){ if (!f.Funded) runwayLabel.text = "The servers are asleep until somebody keeps them running."; else if (f.RunwayDays == null) runwayLabel.text = "The servers are funded."; // nothing to project from — not a warning else if (f.RunwayDays == 0) runwayLabel.text = "Less than a day of servers left."; // zero is real, and urgent else runwayLabel.text = $"{f.RunwayDays} days of servers left.";}PublicFunding carries only what the studio chose to publish — see
What players see. Balance is set only under
EXACT_BALANCE, and RunwayDays is null under FUNDED_FLAG.
RunwayDays == null and RunwayDays == 0 are different answers. Null is
"nothing to project from"; zero is "less than a day". A game that shows "0 days
left" on a quiet, well-funded server is telling its players something false and
alarming.
Handling errors
Everything Encore refuses is an EncoreException with a StatusCode, a typed
Reason, the RawReason string, the HttpStatus, and IsRetryable. Cancelling
through your CancellationToken throws OperationCanceledException instead.
static string Describe(EncoreException e) => e.Reason switch{ EncoreErrorReason.EncoreNotVerified or EncoreErrorReason.EncoreNotReady => "The store opens soon.", EncoreErrorReason.OfferInactive => "That offer has ended.", EncoreErrorReason.PlayerNotAuthenticated => "Sign in to Steam to support the servers.", EncoreErrorReason.OrderRefused => "Steam could not offer this purchase. You have not been charged.", EncoreErrorReason.PublisherLimitReached => "The store is paused for now. Try again later.", EncoreErrorReason.PublisherUnavailable => "The store is unavailable right now.", _ => e.IsRetryable ? "Could not reach the store. Try again in a moment." : "The store is unavailable right now.",};Reason | What to do |
|---|---|
EncoreNotVerified | The game is not verified yet. Hide the buy buttons. |
EncoreNotReady | Verified; its servers have not moved into their funded project yet. Same, and it clears on its own. |
OfferInactive | The studio withdrew the offer. Refresh ListOffersAsync. |
PublisherLimitReached | The studio's line of credit with Plany is used up. Try again later. |
PublisherUnavailable | The studio's Steam key is not working. Only the studio can fix it. |
OrderRefused | Steam refused to open the order. Nothing was charged. |
PlayerNotAuthenticated | Steam did not vouch for the player — offline, not signed in, or the ticket expired. Also raised when the bridge could not get a ticket. |
ProjectCreditExhausted | Not returned by these calls; it refuses a server start. A store sees Funded == false instead. |
Unknown | Anything else; StatusCode and RawReason say what. |
All but PlayerNotAuthenticated (401) arrive as FailedPrecondition (400), none is
fixed by retrying in the next few seconds, and every refusal of a purchase happens
before the player is charged. The full table, with the unnamed errors, is in
Refusals.
What the client retries for you
Every call is retried on a network failure, a timeout, and HTTP 429, 502, 503 and
504, with capped, jittered exponential backoff (EncoreClientOptions.MaxRetries,
default 3). A named refusal is never retried.
Opening a purchase is retried only under a request id. PurchaseAsync generates
a fresh one per call. BeginPurchaseAsync without a requestId is attempted exactly
once, because a blind retry could open a second Steam order. A game driving the
overlay itself should check BeginPurchaseResult.Retried: a retried open can hand
back a purchase that already ended, and waiting for its overlay would wait forever.
Testing before launch
Test the whole store against a sandbox link: the
same overlay and the same calls, with nothing charged and nothing credited. Point a
test build at the sandbox Encore's key, sign in to a 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.
Godot: the same API, from C#. Define the adapter's symbol in your .csproj
(<DefineConstants>$(DefineConstants);PLANY_FACEPUNCH_STEAMWORKS</DefineConstants>)
and copy the adapter's .cs file from Runtime/Adapters/ in the package's
repository — the NuGet package does not contain the adapters, because it cannot
depend on either Steam library.