> ## Documentation Index
> Fetch the complete documentation index at: https://docs.arcane-powered.com/sdk/llms.txt
> Use this file to discover all available pages before exploring further.

# Client

> One client built at launch, holding the state your game reads all session.

`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:

| What | Accessor | Source |
| - | - | - |
| Game id | `game_id()` | the id Arcane Powered launched you with (`ARCANE_GAME_ID`) |
| Signed-in account | `user_id()` | `ARCANE_USER_ID`, then the ticket file, `session.json`, or the desktop |
| Ownership | `ownership()` / `is_owned()` | the ownership check |
| Device fingerprint | `device_hash()` | this machine, for this account |
| Ticket expiry | `ticket_expires_at()` | the ticket's `exp` claim |
| Last check | `checked_at()` | when `init` or `refresh` last succeeded |
| Play session | `session()` | the [session](/sdk/sdk/concepts/session) opened by `init` |
| Achievements | `achievements()` | the [achievement](/sdk/sdk/concepts/achievements) routes, plus the cache `list()` fills |
| Friends | `friends()` | the [friends](/sdk/sdk/concepts/friends) route on the Arcane desktop app |
| P2P lobbies | `p2p()` | the [lobby](/sdk/sdk/concepts/lobbies) routes, plus the event queue the session thread fills |

More Arcane surfaces will hang off this client as they ship — that is the point of having it.

## Lifecycle

```rust theme={null}
use arcane_sdk::ArcaneClient;

// Once, at launch. The game id comes from ARCANE_GAME_ID.
let mut client = ArcaneClient::init()?;

// For the rest of the session — free, reads memory.
if client.is_owned() {
    println!("player {:?}", client.user_id());
}

// Only when you decide to re-confirm.
client.refresh()?;
# Ok::<(), arcane_sdk::SdkError>(())
```

<Warning>
  **Ownership** is never revalidated on its own: `ownership()` reflects the last `init` or `refresh`, and a session running for hours keeps whatever `init` decided until you call `refresh()`. The one background thread the client runs — `arcane-session` — reports playtime and FPS, and polls lobby events once the game has called `p2p()`. It never re-checks ownership and never opens the Arcane desktop app.
</Warning>

`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.

```rust theme={null}
client.frame();                              // once per rendered frame, for FPS
client.set_graphics("2560x1440", "high");    // when display settings change
client.session();                            // tracking, playtime, samples
client.shutdown();                           // end it now, 2-second timeout
```

Achievements, friends and lobbies hang off the same client, and cost nothing until you call them:

```rust theme={null}
client.achievements().unlock("first_blood")?;      // idempotent, one loopback call
client.achievements().is_unlocked("first_blood");  // from the cache list() filled
client.friends().list()?;                          // friends, online, in_game
client.p2p().create_lobby(4, Visibility::Code, my_endpoint)?;   // a lobby and a join code
client.p2p().poll_events();                        // what happened since last time
# Ok::<(), arcane_sdk::SdkError>(())
```

Calling `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](/sdk/sdk/concepts/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](/sdk/sdk/concepts/session). Friends have nothing to share: the SDK caches no list, so `friends().list()` always asks the Arcane desktop app. See [Friends](/sdk/sdk/concepts/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:

1. `ARCANE_USER_ID` names the account Arcane Powered launched the game for → read exactly that account's ticket, with no fallback.
2. `session.json` names a user → the same lookup for that account. If the ticket is not there, the result is `ticket_missing` — another account's ticket is never substituted.
3. `session.json` records a signed-out state → `not_authenticated`.
4. No `session.json` (an Arcane desktop build that predates it) → scan the ticket directories. Exactly one match is used; several matches give [`ambiguous_session`](/sdk/sdk/concepts/errors) rather than a guess.

The variable only picks the file. The ticket inside is verified in full — signature, title, device — so naming an account is a hint, never proof.

This is why `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:

```c theme={null}
char err[512];
if (arcane_sdk_init(err, sizeof(err)) != 0) { /* handle */ }

char user_id[64];
int written = arcane_sdk_user_id(user_id, sizeof(user_id));

arcane_sdk_frame();     /* once per rendered frame */
arcane_sdk_achievement_unlock("first_blood", err, sizeof(err));

char friends[8192];
arcane_sdk_friends_json(friends, sizeof(friends));

char lobby[8192];
arcane_sdk_lobby_create(4, ARCANE_LOBBY_FRIENDS_AND_CODE, "dWRwOi8v...", lobby, sizeof(lobby));

arcane_sdk_shutdown();  /* ends the play session, then drops the client */
```

Calling a getter before `arcane_sdk_init` returns `not_initialized` rather than a null or an empty string. Full signatures: [C ABI](/sdk/sdk/reference/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](/sdk/sdk/concepts/local-development), along with the workflow for starting your game outside the launcher.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.