ArcaneClient is the SDK’s entry point. You build one at launch — passing nothing — and read from it for the rest of the session.
Why a client
Before 0.4 every call was a free function taking the game id, and every call redid the whole job: resolve paths, read the ticket from disk, parse the JWKS, verify the JWT, sometimes talk to the desktop app over loopback. Nothing was kept. The client does that work once and holds the result:
More Arcane surfaces will hang off this client as they ship — that is the point of having it.
Lifecycle
refresh() contacts the Arcane desktop app, re-runs the ownership check, and updates the client in place. On failure the client keeps its previous state, so a failed refresh never silently downgrades a player mid-session.
Session
init also opens a play session. It costs one thread that is asleep about 59 seconds out of 60, and it never blocks or fails init — if the Arcane desktop app is not reachable, tracking stays Pending and retries quietly.
p2p() is the one accessor with a side effect: it arms lobby event polling on the session thread, which then asks the Arcane desktop app for events on every tick — every 5 seconds while you are in an open lobby. A game that never calls it never polls. See Lobbies.
Cloning the client shares that session — it ends when the last clone is dropped, or on shutdown() — plus the achievement cache and the lobby state: events polled on one clone are drained by poll_events() on any of them. Full behaviour — sampling windows, the player’s setting, what is lost offline — is in Session. Friends have nothing to share: the SDK caches no list, so friends().list() always asks the Arcane desktop app. See Friends.
Which account’s ticket
Several Arcane accounts can have tickets cached on one machine. The client resolves the signed-in one, in this order:ARCANE_USER_IDnames the account Arcane Powered launched the game for → read exactly that account’s ticket, with no fallback.session.jsonnames a user → the same lookup for that account. If the ticket is not there, the result isticket_missing— another account’s ticket is never substituted.session.jsonrecords a signed-out state →not_authenticated.- No
session.json(an Arcane desktop build that predates it) → scan the ticket directories. Exactly one match is used; several matches giveambiguous_sessionrather than a guess.
user_id matters beyond being nice to display. Picking the wrong account’s ticket is not a cosmetic bug: it lets one player run on another’s entitlement on a shared machine.
C ABI: a process-wide singleton
Native engines get the same client as a singleton, so nothing has to carry a handle through C# or Blueprint:arcane_sdk_init returns not_initialized rather than a null or an empty string. Full signatures: C ABI.
Game id validation
init reads ARCANE_GAME_ID and validates it before any filesystem or network work. An unset or empty variable gives missing_game_id — the game was not started by Arcane Powered. A value longer than 256 bytes, or containing anything outside ASCII letters, digits, _, - and ., gives invalid_game_id immediately, naming the offending character and its index.
That turns a whole class of confusing downstream failures — a stray quote, trailing whitespace from a shell profile, a path pasted instead of an id — into one obvious error at startup.
The game id is a public identifier, not a credential: it says which title you are, and nothing more. Ownership is proven by the signed ticket init verifies, so a game id in a log or a crash dump gives an attacker nothing.
Developer overrides
ARCANE_GAME_ID and ARCANE_USER_ID are set by the Arcane desktop app in production, and by you when you run your build by hand. Every other variable the SDK reads — ARCANE_DRM_ROOT, ARCANE_SDK_PORT, ARCANE_OFFLINE_ONLY, ARCANE_SESSION_TICK_MS — is local testing and QA tooling, and must never be set in a shipped game.
Both sets are documented in Local development, along with the workflow for starting your game outside the launcher.