L Boxity SDK docs
โšก AI Prompt bloxity.io โ†—

Boxity SDK

One drop-in SDK for cross-game identity. Add Boxity login, avatars, friends & presence, synced settings and Gems in-game currency to any HTML5 or Unity WebGL game โ€” embedded on bloxity.io or standalone on your own domain.

๐Ÿ” Auth  cross-game login + JWT ๐Ÿ‘• Avatars  equipped cosmetics & body ๐Ÿ‘ฅ Social  friends, presence, invites โš™๏ธ Settings  portal-synced ๐Ÿ’ฐ Gems  server-authoritative IAP

The SDK is published globally as window.Legion.SDK (the platform was formerly called Legion โ€” the package and namespace keep that name; the product is Boxity). It works two ways automatically:

  • Embedded โ€” your game runs in an iframe at bloxity.io/g/<your-slug>. Auth, friends and purchases flow through the portal over postMessage (no extra HTTP).
  • Standalone โ€” your game runs on your own domain. The same calls fall back to the Boxity REST API (apiUrl of the told environment) and a login popup window.

You write the same code for both โ€” the SDK detects its environment and routes accordingly.

โญ Start here: copy the test game. The single fastest way to integrate is to steal from the working reference. bloxity.io/test-game.html is one self-contained HTML file that implements every SDK feature end-to-end โ€” auth, avatar, friends, settings, the loading screen, Gems, and events โ€” so you (or your AI agent) can open it, View Source, and copy the exact pattern you need. Every function in these docs links back to it under a green Live line.

โ–ธ Standalone (own domain): bloxity.io/test-game.html
โ–ธ Embedded (in the portal): bloxity.io/g/test-game

Open both side by side to see how the same code behaves embedded vs standalone.

How it works #

CapabilityEmbedded (iframe on bloxity.io)Standalone (your domain)
LoginIn-game modal overlayPopup window โ†’ bloxity.io/auth
Friends / invitespostMessage to portalGET /v1/social/connections
SettingsPushed live from portal menuPushed from portal when present; defaults otherwise
Gems purchasePortal renders the confirm modalSDK renders the modal โ†’ POST /v1/bux/purchase
AvatarFrom the portal handshakeGET /v1/avatar/equipped
Hosts (prod): Portal https://bloxity.io ยท API https://api.bloxity.io ยท SDK https://sdk.bloxity.io ยท CDN https://static.bloxity.io. Stage and local use other hosts, and the platform tells your game which ones apply. See Environments.

What happens when your game boots (the handshake)

Almost every "why doesn't the SDK work?" question comes down to timing โ€” calling something before the data it needs has arrived. Here is the exact boot sequence when your game is embedded on bloxity.io, so you know precisely when each piece of data is ready:

  1. The portal opens your game in an iframe t = 0

    A loading overlay is already covering your game. The portal is waiting to hear from your code.

  2. The SDK script loads <script> onload

    window.Legion.SDK now exists. Nothing before this point can touch the SDK, so put your integration code after the script tag (or in its onload).

  3. You call Legion.SDK.init() you, once

    The SDK immediately posts a handshake request and an auth request to the portal, then returns. init() is synchronous and returns before the portal answers โ€” at this instant getUser() is still null.

  4. Your onUserChanged / settings.listen callbacks fire once immediately microtask

    They fire right away with the current (empty) state: onUserChanged with null, each settings.listen with its default value. This is why you subscribe first and let the callback drive everything โ€” it will fire again with real data.

  5. The portal answers with the handshake ~1 round-trip

    One reply carries everything at once: the logged-in user (or null for a guest), the access token, the equipped avatar, the resolved gameSlug, the debug flag, and the current settings. The SDK stores it all and fires your callbacks again โ€” onUserChanged (now with the real user), avatar.onAvatarChanged, and every settings.listen โ€” with the real values.

  6. Your game finishes its own loading, you call loadingEnd() you, when ready

    The overlay lifts and the player is in. (See Game lifecycle for the loading-screen rules.)

The three timing rules that prevent 90% of bugs. (1) Never read getUser() right after init() and assume it's populated โ€” it isn't yet. Drive your UI from onUserChanged, which fires again when the user arrives. (2) There is no timeout on the handshake; if the portal is slow, your callbacks simply fire later. Subscribe before you need the data, not after. (3) Register your settings.listen() keys and your onUserChanged subscription immediately after init(), before loadingEnd(), so the first push from the portal isn't missed.

Standalone (your own domain) is simpler: init() reads a saved token from localStorage, drops it if expired, and calls GET /v1/auth/me to refresh the user. No iframe, no postMessage, no portal. onUserChanged fires with null, then again with the restored user once that request returns (or stays null if there's no valid token).

Install & URLs #

The SDK is a single script โ€” no build step, no npm install required for the browser global.

html
<!-- Production (minified) -->
<script src="https://sdk.bloxity.io/legion-sdk.min.js"></script>

<!-- Pinned v1 (recommended for production stability) -->
<script src="https://sdk.bloxity.io/v1/legion-sdk.min.js"></script>

<!-- Development (unminified + source map, verbose logging) -->
<script src="https://sdk.bloxity.io/legion-sdk.js"></script>
CORS & caching: the SDK is served with Access-Control-Allow-Origin: * and Cache-Control: no-cache (ETag revalidated) so games never run a stale build after a deploy.

Quick start #

The smallest working integration โ€” log a user in and react to login state:

html
<script src="https://sdk.bloxity.io/legion-sdk.min.js"></script>
<script>
  // 1. Initialize once. gameSlug is the slug your game is registered under
  //    on bloxity.io (required for Gems purchases; auto-filled when embedded).
  Legion.SDK.init({ gameSlug: 'my-game' });

  // 2. React to login state. Fires immediately with the current user
  //    (or null), then again on every login / logout.
  Legion.SDK.auth.onUserChanged((user) => {
    if (user) {
      console.log('Hello', user.displayName || user.username);
    } else {
      console.log('Not logged in');
    }
  });

  // 3. Open the login UI (auto-picks modal vs popup) from a button.
  document.getElementById('login').onclick = () => Legion.SDK.auth.showAuthPopup();
</script>
Try it live: a full reference game exercising every API is at bloxity.io/test-game.html (standalone) and bloxity.io/g/test-game (embedded).

Environments: prod, stage, local #

Boxity runs as three environments. The same game build runs in all of them, and the platform tells the game which one it is in. Your game never works it out for itself.

prodstagelocal
Portalhttps://bloxity.iohttps://stage.bloxity.iohttp://localhost:3000
APIhttps://api.bloxity.iohttps://api.stage.bloxity.iohttp://localhost:4000
Matchmakerhttps://play.bloxity.iohttps://play.stage.bloxity.ioyour local server
Asset CDNhttps://static.bloxity.iohttps://static.stage.bloxity.iohttps://static.bloxity.io
Game pagehttps://<id>.play.bloxity.iohttps://<id>.play.stage.bloxity.iohttp://localhost:<port>
Game serverhttps://<id>.host.bloxity.iohttps://<id>.host.stage.bloxity.iohttp://localhost:2567
The one rule: never guess. Do not detect the environment from location.hostname, the referrer, a build flag or any URL, and never write api.bloxity.io, play.bloxity.io or static.bloxity.io into code, build config or .env files. Read the told environment, and fall back to prod only when nothing was told. That fallback is what keeps existing games working unchanged.

In the browser

Boxity tells a page its environment in two ways. Pages it serves get a window.BLOXITY_ENV object injected before your code runs. Games embedded in a Boxity page also receive it in the portal handshake. The SDK reads both, so after the script loads, ask the SDK:

javascript
const { env, apiUrl, playUrl, portalUrl, staticUrl } = Legion.SDK.environment();
// env: 'prod' | 'stage' | 'local'. Production when nothing was told.

// Embedded games learn it from the handshake, which can land after init:
Legion.SDK.onEnvironmentChanged((e) => reconnectTo(e.playUrl));

The SDK uses the same values for its own calls: login, avatar, Gems, Legion.SDK.api, Legion.SDK.net and party tickets. Code that needs a URL before the SDK has loaded reads window.BLOXITY_ENV through one helper with the prod fallback:

javascript
// boxityEnv.js - the only place Boxity URLs come from
const PROD = {
  env: 'prod',
  apiUrl: 'https://api.bloxity.io',
  playUrl: 'https://play.bloxity.io',
  portalUrl: 'https://bloxity.io',
  staticUrl: 'https://static.bloxity.io',
};

export function boxityEnv() {
  if (window.Legion?.SDK?.environment) return window.Legion.SDK.environment();
  const told = window.BLOXITY_ENV;
  return told && told.apiUrl ? told : PROD;
}

// e.g. fetch(`${boxityEnv().apiUrl}/v1/avatar/emotes`)

On your game server

Boxity hosting injects these variables into every deploy of your server:

javascript
export const boxity = {
  env: process.env.BLOXITY_ENV || 'prod',
  apiUrl: process.env.BLOXITY_API_URL || 'https://api.bloxity.io',
  matchmakerUrl: process.env.BLOXITY_MATCHMAKER_URL || 'https://play.bloxity.io',
  portalUrl: process.env.BLOXITY_PORTAL_URL || 'https://bloxity.io',
  staticUrl: process.env.BLOXITY_STATIC_URL || 'https://static.bloxity.io',
};
// e.g. POST `${boxity.apiUrl}/v1/auth/game-token/verify`

Local development

  • Inside the local portal (http://localhost:3000/g/<slug>): nothing to do. The portal sends env: 'local' in the handshake.
  • Standalone (your dev server on its own): tell the page itself, before the game's code runs. One line in index.html:
html
<script>window.BLOXITY_ENV = { env: 'local', apiUrl: 'http://localhost:4000', playUrl: 'http://localhost:2567', portalUrl: 'http://localhost:3000', staticUrl: 'https://static.bloxity.io' };</script>

With Vite, keep that tag out of production builds by injecting it only from the dev server:

javascript
// vite.config.js
const LOCAL_ENV = { env: 'local', apiUrl: 'http://localhost:4000', playUrl: 'http://localhost:2567', portalUrl: 'http://localhost:3000', staticUrl: 'https://static.bloxity.io' };

export default {
  plugins: [{
    name: 'boxity-local-env',
    apply: 'serve',
    transformIndexHtml: () => [{ tag: 'script', children: `window.BLOXITY_ENV = ${JSON.stringify(LOCAL_ENV)}`, injectTo: 'head-prepend' }],
  }],
};

Your local server reads the same BLOXITY_* variables from its own .env, e.g. BLOXITY_ENV=local, BLOXITY_API_URL=http://localhost:4000.

Release flow: one build, both platforms

No extra branch. Your branches mean what they always did, and every push goes to both platforms:

  • main → deploys the same build to prod and, when your repo has a stage token, to stage as well. The two deploys are independent: a staging failure never blocks or undoes prod.
  • dev → your playtest channel on prod (<game>.dev.play.bloxity.io) and, with a stage token, on stage (<game>.dev.play.stage.bloxity.io).
  • The Run workflow button deploys any branch to the platform you pick, e.g. to try a change on stage before it reaches players.

Staging is opt-in. Each environment has its own hosting API and its own deploy token: create the prod token on hosting.bloxity.io (secret LEGION_DEPLOY_TOKEN) and, if you want your game on stage, a stage token on hosting.stage.bloxity.io (secret LEGION_DEPLOY_TOKEN_STAGE). A token from one environment never works on the other. Each environment lists only the games deployed to it.

yaml
# .github/workflows/deploy.yml
on:
  push:
    branches: [main, dev]
  workflow_dispatch:
    inputs:
      target: { type: choice, options: [staging, prod], default: staging }

jobs:
  targets:
    runs-on: ubuntu-latest
    outputs: { list: ${{ steps.t.outputs.list }} }
    steps:
      - id: t
        env: { HAS_STAGE_TOKEN: ${{ secrets.LEGION_DEPLOY_TOKEN_STAGE != '' }} }
        run: |
          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo 'list=["${{ inputs.target }}"]' >> "$GITHUB_OUTPUT"
          elif [ "$HAS_STAGE_TOKEN" != "true" ]; then echo 'list=["prod"]' >> "$GITHUB_OUTPUT"
          else echo 'list=["prod","staging"]' >> "$GITHUB_OUTPUT"; fi

  deploy:
    needs: targets
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false   # staging can never take prod down with it
      matrix: { target: ${{ fromJSON(needs.targets.outputs.list) }} }
    env:
      BLOXITY_API_URL: ${{ matrix.target == 'staging' && 'https://api.stage.bloxity.io' || 'https://api.bloxity.io' }}
      BLOXITY_DEPLOY_TOKEN: ${{ secrets[matrix.target == 'staging' && 'LEGION_DEPLOY_TOKEN_STAGE' || 'LEGION_DEPLOY_TOKEN'] }}
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build   # one build: no environment baked in
      - run: npm run deploy             # your deploy script reads BLOXITY_API_URL + BLOXITY_DEPLOY_TOKEN

Getting your game onto stage

Stage is a separate platform with its own accounts, hosting whitelist and catalog, so a game joins it once:

  1. Create an account on https://stage.bloxity.io (your prod account does not exist there) and ask the Boxity team to enable hosting for it on stage.
  2. On https://hosting.stage.bloxity.io, create your game with the same game id it has on prod. Copy the deploy token it shows.
  3. Add it to your repo as the secret LEGION_DEPLOY_TOKEN_STAGE. Keep LEGION_DEPLOY_TOKEN for prod; never swap them.
  4. Push. With the workflow above, the same build now ships to both platforms. Your game is playable at https://<game>.play.stage.bloxity.io right away.
  5. Ask the Boxity team to list it on stage. Each environment lists only the games deployed to it, and a game appears on stage.bloxity.io once its card is added there.
CrazyGames and other builds Boxity does not serve: a page Boxity doesn't serve or embed is told nothing, so it runs against prod. That is the right behaviour for a release build.

Build with the Boxity MCP #

Connect the Boxity MCP server to your coding agent (Claude Code, Codex, Cursor, Gemini CLI) and build Boxity games from it. Nothing to install: the server is hosted at https://mcp.bloxity.io/mcp. The live preview runs on Boxity โ€” you never run the game locally.

Connect

bash
# Claude Code โ€” then run /mcp, pick boxity, choose Authenticate, sign in and click Allow
claude mcp add --transport http --scope user boxity https://mcp.bloxity.io/mcp

Other agents: add the same URL as a remote (HTTP) MCP server and sign in when asked. Agents without OAuth can use a personal token from bloxity.io/create as Authorization: Bearer <token>. Connected agents are listed (and can be disconnected) in Settings โ†’ Connected agents.

The loop

  1. Start: ask your agent to create_project (a new game from the SDK starter, with every SDK feature already wired) or clone_game (one of your games, or one marked remixable).
  2. Code: the agent git clones the returned remote and edits locally.
  3. Preview: git push origin HEAD:dev rebuilds the live preview in seconds at bloxity.io/create/<slug> โ€” inside the real Boxity player, with your avatar. Only the dev branch can be pushed; game-server changes take a few minutes.
  4. Share: get_share_link gives a link anyone can play before publishing; rotate it any time.
  5. Check: audit_sdk checks every SDK feature that fits the game (required to publish); integrate_sdk returns exact, project-specific fixes for anything missing.
  6. Publish: publish_game. Accounts with creator access publish at once; everyone else is reviewed by the Boxity team first.
Not applicable? If a feature truly does not fit your game (for example rewarded ads in a game with no rewards), declare it in BLOXITY_CODE_METADATA.md under notApplicable: with a reason. Reviewers see the reason.
Can the player die? That is your decision, so your agent asks you rather than guessing. Answer in BLOXITY_CODE_METADATA.md with canDie: true (the game then needs the death screen) or canDie: false. A fall that just resets the player counts only if you want it to.

โšก Integrate in one AI prompt

Copy a prompt below and paste it into your AI coding agent (Claude Code, Cursor, etc.) inside your game's repo. Each prompt is fully self-contained โ€” it tells the agent the entire SDK surface needed to wire up Boxity end-to-end.

๐ŸŒ
HTML5 / JavaScript game
Drop-in browser SDK ยท auth, avatar, friends, settings, Gems
📊
Report your game's stats
Show Speed, Cash or anything else on the player's Boxity profile
💃
Add emote support
Let players emote in your game — Boxity renders the picker, you play the animation
🤝
Add party support
Boxity parties of up to 6 friends land in one room — or get a clear error
🎮
Add CrazyGames & Play with friends support
Private rooms friends can join at once, invites, sign-in mid-game and guest progress
💬
Add overhead chat bubbles
Show each player's latest Boxity chat message above their character for 4 seconds
🌐
Show your live rooms
An Open Rooms list with a working Join button on your game page
💀
Add the death screen
Required for every game where the player can die: Boxity's ads + countdown, the game's own Respawn button
๐ŸŽฎ
Unity (WebGL) game
Host-page bridge + jslib externs + C# receivers

HTML5 / JavaScript browser global

Everything below is available on the global Legion.SDK after the script loads. Call Legion.SDK.init() once before using any namespace.

Legion.SDK.init(config?: { gameSlug?, apiUrl?, portalUrl? })both
What
Boots the SDK: wires up the message listener, starts the portal handshake (when embedded), and initializes every sub-module (auth, settings, social, avatar, purchases, join toasts). Nothing else works until this runs.
Call
Legion.SDK.init({ gameSlug: 'my-game' }). Call it exactly once, as early as possible after the script loads. Calling it again is a harmless no-op.
Returns
void โ€” it does not wait for the portal. See the boot sequence above for what's ready when.
Why
gameSlug is the slug your game is registered under on bloxity.io. It tells the server which IAP catalog to price Gems purchases from. When embedded, the portal sends the correct slug in the handshake and the SDK adopts it automatically โ€” but always pass it so standalone hosting and Gems both work out of the box. apiUrl/portalUrl are only for pointing at a non-production backend (they default to api.bloxity.io / bloxity.io, or your own origin when running on localhost).
When
First line of your integration, before any other Legion.SDK.* call and before loadingEnd(). Immediately after it, subscribe onUserChanged and register your settings.listen() keys.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.

Top-level namespaces

NamespaceWhat it does
Legion.SDK.authLogin, current user, JWT token, game-server auth
Legion.SDK.avatarEquipped cosmetics, body parts, proportions, in-game customizer
Legion.SDK.socialFriends, presence, invites, friend requests
Legion.SDK.settingsSettings synced from the portal menu
Legion.SDK.gameLoading/gameplay lifecycle, rooms, join toasts
Legion.SDK.playerEvents from the portal (respawn, chat, pointer lock)
Legion.SDK.gemsServer-authoritative in-game currency: purchases, balance, top-ups, gifting
Legion.SDK.adsRewarded ads and pausing for ad breaks (Boxity runs every ad)
Legion.SDK.deathScreenDeath screen: Boxity's ads and countdown while the player is dead; the game's own Respawn button waits for it
Legion.SDK.netMultiplayer matchmaker โ€” resolve a Colyseus server endpoint for "fill-server" games
Legion.SDK.portalMenu, fullscreen, embed detection
Legion.SDK.apiAuthenticated raw REST helpers (get/post/patch/delete)

Authentication #

The SDK manages an access token (JWT) and the current user. User objects are never cached โ€” only the token is persisted (localStorage["legion_token"]) and fresh user data is re-fetched on load. The golden rule of auth here: you never poll for the user โ€” you subscribe with onUserChanged and let it drive your UI. It fires once immediately with the current state and again on every login/logout, so a single subscription is your complete auth state machine.

Add login in five steps

  1. Initialize once, on load

    Legion.SDK.init({ gameSlug: 'my-game' }).

  2. Subscribe to onUserChanged โ€” your single source of truth right after init
    javascript
    Legion.SDK.auth.onUserChanged((user) => {
      if (user) showLoggedIn(user);   // render name/pfp, load friends + balance
      else     showLoginButton();     // show a "Log in" button
    });
  3. Wire your "Log in" button to showAuthPopup() on click

    loginBtn.onclick = () => Legion.SDK.auth.showAuthPopup();. You don't need its return value โ€” the login flow ends by firing onUserChanged, which your step-2 handler already catches.

  4. Wire "Log out" to logout() on click

    Legion.SDK.auth.logout() clears the token and fires onUserChanged(null) โ€” again, your handler does the rest.

  5. (If you have your own backend) verify the identity server-side when user arrives

    Inside the user branch of step 2, call authenticateWithServer(yourUrl) (see below) and use the session it returns.

Every function in this namespace

Legion.SDK.auth.onUserChanged(cb: (user | null) => void) => unsubscribeboth
What
Subscribes to auth state. Calls your callback once immediately with the current user (or null), then again on every login and logout.
Call
const off = Legion.SDK.auth.onUserChanged(user => {โ€ฆ}). The return value is an unsubscribe function โ€” call off() to stop listening.
Returns
() => void (unsubscribe).
Why
The only correct way to track login. Because it fires immediately and on change, one subscription handles first paint, login, and logout with the same code โ€” no polling, no race with the handshake.
When
Register it once, right after init(). The first (immediate) call is a microtask, so it runs just after your current function returns.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.auth.getUser() => LegionUser | nullboth
What
Returns the current user object synchronously, or null if signed out. Shape: { _id, username, displayName?, email?, pfp?, avatar? }.
Call
const user = Legion.SDK.auth.getUser().
Returns
LegionUser | null.
Why
A convenient point-in-time read for code that runs in response to a user action (a button handler, a purchase). For rendering login UI, prefer onUserChanged.
When
Any time after the handshake has populated the user. Not right after init() โ€” it returns null there because the portal hasn't answered yet.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.auth.getToken() => string | nullboth
What
Returns the raw Boxity JWT for the signed-in user, or null.
Call
const token = Legion.SDK.auth.getToken().
Returns
string | null.
Why
Use it to authenticate the player against your own backend (send it as a Bearer token; verify it there). The SDK's own Legion.SDK.api.* calls already attach it for you, so you only need this for custom server calls.
When
When you're about to call your backend. Note it is not re-checked for expiry at read time โ€” if you hand it to a long-lived process, verify exp server-side.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.auth.isLoggedIn() => booleanboth
What
true only when there is both a current user and a valid token.
Call
if (Legion.SDK.auth.isLoggedIn()) {โ€ฆ}.
Returns
boolean.
Why
A quick guard before an action that requires an account (e.g. a purchase). For persistent UI, use onUserChanged instead.
When
Inline, at the moment of a gated action.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.auth.showAuthPopup() ยท showAuthModal() ยท showAuthWindow() => Promise<user | null>both
What
Open the login UI. showAuthPopup() auto-picks the right one: an in-game modal overlay when embedded, a 500ร—600 popup window when standalone. showAuthModal() and showAuthWindow() force one specifically.
Call
await Legion.SDK.auth.showAuthPopup() from a click handler. Use the explicit variants only if you know your environment and want to skip auto-detection.
Returns
Promise<LegionUser | null> โ€” resolves with the user on success, or null if the user closed the login without signing in (the modal also gives up after 5 minutes).
Why
You usually don't even need the returned promise โ€” login completion fires onUserChanged. Await it only if you want to do something in that exact click's flow (e.g. immediately retry a purchase the login was blocking).
When
In direct response to a user click. Browsers block popups that aren't tied to a user gesture, so showAuthWindow() must run inside the click handler, not after an await.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.auth.logout()both
What
Signs the user out: clears the stored token and current user, then fires onUserChanged(null).
Call
Legion.SDK.auth.logout().
Returns
void
Why
Give players a way out. After it, the SDK falls back to the guest identity (see Guests), so there's still someone to display.
When
From your "Log out" control.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.auth.getGuest() => GuestIdentity | nullsigned-out
What
Returns the signed-out player's stable identity โ€” a Boxity-generated name like "Comet42" plus a derived profile picture โ€” so guests aren't anonymous. Returns null when a real user is logged in.
Call
const player = Legion.SDK.auth.getUser() || Legion.SDK.auth.getGuest(); โ€” this one-liner always gives you someone to show.
Returns
GuestIdentity | null: { username, displayName, pfp, isGuest: true, avatar }.
Why
Lets you label the player and show a PFP before (or without) login, and the name is stable across sessions (persisted in localStorage), so a guest keeps their identity.
When
Whenever getUser() is null and you still need a name/avatar. Full details under User & avatar data โ†’ Guests.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.

Quick reference โ€” the whole namespace in one block:

javascript
// Current user object, or null
const user = Legion.SDK.auth.getUser();

// Access token (JWT) for your own backend, or null. Auto-checked for expiry.
const token = Legion.SDK.auth.getToken();

const loggedIn = Legion.SDK.auth.isLoggedIn();

// Show login UI โ€” auto-detects the best method:
//   embedded  -> in-game modal overlay   (showAuthModal)
//   standalone-> 500x600 popup window    (showAuthWindow)
const u = await Legion.SDK.auth.showAuthPopup();

// Force a specific method if you need to:
await Legion.SDK.auth.showAuthModal();   // only works embedded in bloxity.io/g/[slug]
await Legion.SDK.auth.showAuthWindow();  // popup, for standalone domains

Legion.SDK.auth.logout();

// Subscribe to login/logout. Fires immediately with current state. Returns unsubscribe.
const off = Legion.SDK.auth.onUserChanged((user) => { /* ... */ });

Authenticate with your own game server

If your game has its own backend, validate the Boxity identity server-side. The SDK POSTs { token, user } to your endpoint; you verify the JWT and return your own session.

Legion.SDK.auth.authenticateWithServer<T>(serverUrl: string) => Promise<T | null>both
What
Hands the Boxity identity to your backend so it can mint its own session. The SDK sends POST serverUrl with Content-Type: application/json and body { token: <bloxity JWT>, user: <full user object> }. (No Authorization header โ€” the token is in the body.)
Call
const session = await Legion.SDK.auth.authenticateWithServer('https://yourgame.com/api/legion-auth').
Returns
Promise<T | null> โ€” your server's parsed JSON response on 2xx, or null if there's no logged-in user, the request fails, or the response isn't OK. Repeated calls to the same URL are de-duplicated (one in-flight request is shared).
Why
Only needed if your game keeps its own accounts/inventory. Your endpoint verifies the Boxity JWT, upserts a local user keyed by the Boxity id, and returns your own tokens โ€” so the rest of your game talks to your backend, not Boxity's.
When
Inside your onUserChanged handler, in the branch where user is non-null (and again is a no-op-safe retry if you call it more than once quickly). Skip it entirely if you have no backend โ€” just read getUser().
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
javascript
// SDK sends:  POST https://yourgame.com/api/legion-auth
//   Content-Type: application/json
//   { "token": "<bloxity JWT>", "user": { _id, username, ... } }
const session = await Legion.SDK.auth.authenticateWithServer(
  'https://yourgame.com/api/legion-auth'
);
// returns your server's JSON response, or null on failure

Debugging

javascript
Legion.SDK.auth.enableDebug();        // verbose console logging (standalone games)
const d = Legion.SDK.auth.diagnose();    // { isLoggedIn, hasToken, tokenLength, tokenPreview,
                                          //   isEmbedded, isStandalone, storedAuth }
Legion.SDK.auth.enableDebug() ยท diagnose()both
What
enableDebug() turns on verbose [Boxity SDK Debug] console logging. diagnose() returns a snapshot of the auth state for troubleshooting.
Call
Legion.SDK.auth.enableDebug() (before login, for standalone games) ยท console.log(Legion.SDK.auth.diagnose()).
Returns
diagnose() โ†’ { isLoggedIn, hasToken, tokenLength, tokenPreview, isEmbedded, isStandalone, storedAuth }. When embedded, the portal controls debug mode, so enableDebug() is mainly for standalone.
Why
Your fastest triage for "API calls return 401" or "login isn't sticking" โ€” diagnose() tells you at a glance whether a token exists, whether it's stored, and which environment the SDK thinks it's in.
When
While integrating, or when a player reports an auth problem. Safe to leave diagnose() calls behind a debug flag in production.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
401s? Run diagnose(). In standalone mode the SDK calls the told apiUrl directly with Authorization: Bearer <token>; an expired token returns 401 and clears auth. Embedded mode proxies through the portal, so no token is exposed to the page.

User & avatar data #

The LegionUser returned by getUser():

FieldTypeNotes
_idstringPermanent unique user id
usernamestringPermanent, unique handle
displayNamestring?Shown name; fall back to username
emailstring?Only present if the user shared it
pfpstring?Profile picture URL
avatarobject?Equipped cosmetics + body โ€” see below

The avatar object (all fields optional). '-1' / null mean nothing equipped / default mesh:

jsonc
{
  "hatId":   "6602f1...",  // ObjectId hex, or null = no hat
  "backId":  "6612a0...",  // or null = no back item
  "skinId":  "3",         // skin texture id; defaults to "-1"
  "headId":  "...",       // body-part mesh ids; undefined = default mesh
  "armLId":  "...", "armRId": "...",
  "legLId":  "...", "legRId": "...",
  "torsoId": "...",
  "hairId":  "...",       // hair, worn together with a hat; "-1" = none
  "maskId":  "...",       // mask / glasses / face props; "-1" = none
  "neckId":  "...", "chestId": "...",   // accessories; "-1" = none
  "waistId": "...", "handId":  "...",
  "shoesId": "...",                          // worn as a pair on both feet
  "faceId":  "...", "pantsId": "...",   // drawn on the skin; use getSkinTextureUrl()
  "shirtId": "...",                          // drawn on the skin; use getSkinTextureUrl()
  "proportions": {        // body sliders; every value defaults to 1.0
    "height": 1, "shoulderWidth": 1, "armLength": 1, "legOffsetX": 1,
    "torsoScaleX": 1, "neckHeight": 1, "headScale": 1
  }
}

Guests (signed-out players)

Not everyone logs in โ€” but you should never show an anonymous "Player 1". When getUser() is null, Legion.SDK.auth.getGuest() gives the signed-out player a stable, friendly identity: a Boxity-generated name (e.g. "Comet42") and a derived profile picture. The name is minted once and saved to localStorage, so the same browser keeps the same guest name across sessions.

javascript
// One line that ALWAYS gives you someone to display:
const player = Legion.SDK.auth.getUser() || Legion.SDK.auth.getGuest();
nameLabel.textContent = player.username;    // "coolplayer42"  or  "Comet42"
avatarImg.src      = player.pfp;         // real PFP or the guest's derived PFP

// Distinguish the two if you need to (e.g. gate a feature behind login):
if (player.isGuest) promptLoginForRankedMode();
FieldTypeNotes
usernamestringGenerated handle, e.g. "Comet42". Stable per browser.
displayNamestringSame as username (guests have no separate display name).
pfpstringDerived CDN URL for the guest's saved avatar.
isGuesttrueLiteral true โ€” the reliable way to tell a guest from a LegionUser.
avatarobjectThe guest's equipped items + proportions (they can customize before logging in; it's stored locally).
Returns null when logged in. getGuest() is only for the signed-out state โ€” it returns null if a real user is present, which is exactly why the getUser() || getGuest() pattern works. In a normal browser it never returns null for a guest; it mints and persists an identity on first call.

Avatar / cosmetics #

A player's Boxity avatar is a set of equipped item IDs โ€” a hat, a back item, a skin texture, and per-body-part meshes โ€” plus body proportions (height, arm length, etc.). Your job as a game is to read those IDs, load the matching meshes/textures from the CDN, and re-load them when the player changes something so their character looks the same in your game as it does on their profile. Everything here is read-mostly; the portal owns buying and (in standalone mode) the SDK can show a customizer.

Show the player's avatar โ€” the recipe

  1. Read the equipped IDs after login/handshake

    const eq = Legion.SDK.avatar.getEquipped(); โ€” gives you every slot at once.

  2. Turn each ID into a CDN URL, skipping empty slots per slot

    An ID of '-1', '', 'undefined' or null means "nothing equipped โ€” use the default mesh baked into player.glb". Only build a URL for real IDs. Base is {staticUrl}/avatars from Legion.SDK.environment(); the exact patterns are in CDN asset URLs.

    javascript
    const CDN = Legion.SDK.environment().staticUrl + '/avatars';
    const real = (id) => id && !['-1', 'undefined'].includes(id);
    
    if (real(eq.hatId)) loadMesh(`${CDN}/items/hats/${eq.hatId}.obj`,
                                 `${CDN}/textures/hats/${eq.hatId}.png`);
    if (real(eq.hairId)) loadMesh(`${CDN}/items/hats/${eq.hairId}.obj`,   // hair shares the hat folders and anchor
                                  `${CDN}/textures/hats/${eq.hairId}.png`);
    // Skin with any worn face / pants already drawn on it
    loadSkin(Legion.SDK.avatar.getSkinTextureUrl());

    Neck, chest, waist and hand accessories load the same way from /items/{neck|chest|waist|hand}/{id}.obj; where to attach each one is in CDN asset URLs.

  3. Subscribe so the character updates live once, at startup

    onAvatarChanged fires whenever the player equips/unequips (in the portal or the in-game customizer); onProportionsChanged fires on body-slider changes. Re-run your load/rescale logic inside them so the change shows up without a reload.

Every function in this namespace

Legion.SDK.avatar.getEquipped() ยท getHatId ยท getBackId ยท getSkinId ยท โ€ฆboth
What
Read the currently-equipped item IDs. getEquipped() returns them all as one object { hatId, backId, skinId, headId, armLId, armRId, legLId, legRId, torsoId, hairId, maskId, neckId, chestId, waistId, handId, shoesId, faceId, pantsId, shirtId }; the individual getters return one slot each. getSkinTextureUrl() returns the skin texture with any worn face, shirt and pants already drawn on.
Call
Legion.SDK.avatar.getEquipped(), or e.g. Legion.SDK.avatar.getHatId().
Returns
Strings (or undefined for an empty slot). getSkinId() defaults to '-1' rather than undefined. '-1'/''/'undefined'/null all mean "not equipped".
Why
These IDs are the keys to the CDN assets. Without loading them, every player looks like the default character in your game โ€” the cosmetics they bought (and may have paid Gems for) are invisible, which kills the reason to buy them.
When
Once the user/handshake is available (inside onUserChanged, or after the avatar arrives), and again inside onAvatarChanged.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.avatar.onAvatarChanged(cb) => unsubscribeboth
What
Fires whenever the equipped set changes โ€” from the portal's avatar shop, from the in-game customizer, or from a handshake update. The callback receives the same shape as getEquipped().
Call
const off = Legion.SDK.avatar.onAvatarChanged(eq => reskinCharacter(eq));
Returns
() => void (unsubscribe).
Why
Lets a player change a hat mid-game and see it instantly, instead of only on next launch. Essential if you show the customizer in-game.
When
Subscribe once at startup. It can fire both immediately after a change (optimistically) and again once the server confirms, so make your reskin idempotent.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.avatar.getProportions() ยท setProportions(partial) ยท resetProportions() ยท onProportionsChanged(cb)both
What
Read, set, reset, and subscribe to body proportions: height, shoulderWidth, armLength, legOffsetX, torsoScaleX, neckHeight, headScale (all numbers, default 1.0).
Call
await Legion.SDK.avatar.setProportions({ height: 1.3, headScale: 1.6 }) โ€” a partial merge, so you only pass the keys you're changing. await Legion.SDK.avatar.resetProportions() sets every key back to 1.
Returns
setProportions/resetProportions return a Promise<void> (they persist). getProportions() returns the object; onProportionsChanged returns an unsubscribe.
Why
Values are clamped to safe ranges so you never get a broken rig: height 0.5โ€“1.6, shoulderWidth 0.5โ€“1.5, armLength 0.05โ€“3, legOffsetX -0.7โ€“5, torsoScaleX 0.3โ€“2, neckHeight 0.94โ€“1.2, headScale 0.3โ€“2.6. Persistence is automatic โ€” guests to localStorage, logged-in players to the API (and, when embedded, also mirrored to the portal so their profile PFP re-renders).
When
onProportionsChanged fires before persistence completes, so your rig rescales instantly and the network write happens in the background. Subscribe at startup; call setProportions from your own body-editor UI if you have one.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.avatar.showCustomizer() ยท hideCustomizer ยท toggleCustomizer ยท isCustomizerOpen ยท setCharacterCustomizationWindow(open)both
What
Opens a ready-made avatar customization window so players can change their look without leaving your game.
Call
Legion.SDK.avatar.showCustomizer() (e.g. from a wardrobe button) ยท hideCustomizer() ยท toggleCustomizer() ยท setCharacterCustomizationWindow(true|false) ยท isCustomizerOpen() โ†’ boolean.
Returns
void (except isCustomizerOpen() โ†’ boolean).
Why
A drop-in dressing room โ€” you don't build any cosmetics UI yourself. Embedded on bloxity.io, this hands off to the portal's customizer. Standalone, the SDK renders its own window with a live 3D preview that shows the full catalog: owned items sort first, unowned show a price and can be bought with Gems right there (with a "+" top-up button for real-money Gems). Changes flow back through onAvatarChanged.
When
On demand from a player action. Whatever the player equips fires onAvatarChanged, so you don't need to poll the window โ€” just react to that event.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.

Quick reference โ€” every call in one place:

javascript
// All slots at once
const eq = Legion.SDK.avatar.getEquipped();
// { hatId, backId, skinId, headId, armLId, armRId, legLId, legRId, torsoId,
//   hairId, maskId, neckId, chestId, waistId, handId, shoesId, faceId, pantsId, shirtId }

// Individual getters
Legion.SDK.avatar.getHatId();   // string | undefined
Legion.SDK.avatar.getBackId();
Legion.SDK.avatar.getSkinId();  // string (default "-1")
Legion.SDK.avatar.getHeadId();  // + getArmLId/getArmRId/getLegLId/getLegRId/getTorsoId
Legion.SDK.avatar.getHairId();  // string | undefined
Legion.SDK.avatar.getSkinTextureUrl();  // skin + face + pants, ready to load

// Body proportions (all default to 1.0)
const p = Legion.SDK.avatar.getProportions();

// Set body proportions (partial merge, clamped, persists to the avatar). Ranges:
//   height 0.5โ€“1.6 ยท shoulderWidth 0.5โ€“1.5 ยท armLength 0.05โ€“3 ยท legOffsetX -0.7โ€“5
//   torsoScaleX 0.3โ€“2 ยท neckHeight 0.94โ€“1.2 ยท headScale 0.3โ€“2.6
await Legion.SDK.avatar.setProportions({ height: 1.3, headScale: 1.6, armLength: 0.8 });
await Legion.SDK.avatar.resetProportions();  // back to defaults

// React to changes made in the portal customizer (or your own setProportions)
Legion.SDK.avatar.onAvatarChanged((eq) => updateCharacter(eq));
Legion.SDK.avatar.onProportionsChanged((p) => rescaleRig(p));

// In-game customizer window. Embedded โ†’ portal customizer. Standalone โ†’ full
// catalog with in-window Gems buying + top-up. Equips flow back via onAvatarChanged.
Legion.SDK.avatar.showCustomizer();
Legion.SDK.avatar.hideCustomizer();
Legion.SDK.avatar.toggleCustomizer();
Legion.SDK.avatar.isCustomizerOpen();   // boolean
Loading the meshes: ids map to files on the CDN โ€” see CDN asset URLs. A value of '-1', '', 'undefined' or null means "not equipped โ€” use the default baked into player.glb".

Friends & social #

The social namespace lets your game show the player's Boxity friends (with live presence), let them invite friends into the exact room they're in, and send friend requests. When embedded, all of this routes through the portal over postMessage (no extra HTTP and no token exposed to the page); standalone, it falls back to the REST API.

The one rule for invites: both inviteFriend() and getInviteFriendsLink() send whatever room you last reported with Legion.SDK.game.updateRoom(roomId). Call updateRoom() before you invite, or the friend lands on your game's front page instead of in your match.
Legion.SDK.social.getFriends() => Promise<LegionFriend[]>both
What
Returns the player's friends, each with a presence object (are they online, in a game, which game/room). Empty array if not logged in.
Call
const friends = await Legion.SDK.social.getFriends();
Returns
Promise<LegionFriend[]> โ€” { _id, username, displayName?, pfp?, avatar?, presence }. presence.status is 'online' | 'in-game' | 'away' | 'offline'; when in-game it also carries currentGame, gameName, currentRoom, currentParty.
Why
Powers a friends list / "who's online" panel and gives you the _id values you need to invite or friend someone. Results are cached for ~5 seconds and concurrent calls are shared, so calling it often is cheap.
When
After login (inside onUserChanged's user branch), and refresh when the player opens a friends panel.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.social.inviteFriend(userId: string, gameId?: string) => Promise<boolean>both
What
Sends a specific friend a direct invite to join your current room. They get a clickable "join" message on Boxity.
Call
Legion.SDK.game.updateRoom('lobby-123'); await Legion.SDK.social.inviteFriend(friend._id); โ€” gameId is only needed in standalone mode; embedded, the portal knows the game.
Returns
Promise<boolean> โ€” true if the invite was sent.
Why
The targeted, "invite Alex specifically" path. For a broadcast link anyone can use, use getInviteFriendsLink() instead.
When
From an "Invite" button next to a friend โ€” after updateRoom().
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.social.getInviteFriendsLink(options?) => stringboth
What
Builds a plain shareable URL that drops whoever opens it into your current room/party. Copy it, put it behind an "Invite Friends" button, share it anywhere.
Call
Legion.SDK.game.updateRoom('match-456'); const link = Legion.SDK.social.getInviteFriendsLink();
Returns
string. Embedded โ†’ a portal URL like https://bloxity.io/g/my-game?roomId=match-456. Standalone โ†’ a link back to your current host (auto-detected). Defaults come from init()'s gameSlug and your last updateRoom(); override via options: { gameSlug, roomId, partyId, baseUrl, ref }.
Why
Unlike inviteFriend() (which DMs one Boxity user), this is a URL you can drop into Discord, a tweet, or a "copy link" button โ€” great for growth.
When
When the player hits "Invite Friends" or "Copy link" โ€” after updateRoom().
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.social.sendFriendRequest(userId: string) => Promise<{ success, status?, error? }>both
What
Sends a friend request to another user.
Call
const r = await Legion.SDK.social.sendFriendRequest(userId);
Returns
{ success, status?: 'accepted' | 'pending', error? }. 'accepted' means they'd already requested you (mutual), so it became a friendship immediately; 'pending' means they still need to accept.
Why
Lets players build their friends graph from inside your game (e.g. an "add friend" button on the post-match scoreboard), which feeds back into presence and invites.
When
From an explicit "add friend" action.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.

Quick reference โ€” every call in one place:

javascript
// Friends with live presence
const friends = await Legion.SDK.social.getFriends();
friends.forEach(f => {
  console.log(f.username, f.presence.status);  // 'online' | 'in-game' | 'away' | 'offline'
  if (f.presence.currentGame) console.log('Playing', f.presence.gameName);
});

// Invite a friend into YOUR current room. Set the room first!
Legion.SDK.game.updateRoom('lobby-123');
const ok = await Legion.SDK.social.inviteFriend(friends[0]._id);

// A shareable link that drops a friend straight into your room/party.
// Behaviour depends on where the game is running - no config needed either way:
//  - Embedded in the Boxity portal: links INTO the portal, which loads your
//    game and joins the room -> https://bloxity.io/g/my-game?roomId=lobby-123
//  - Standalone (game opened directly on its own host): links back to THIS
//    host's current URL (auto-detected) -> https://my-game.play.bloxity.io?roomId=lobby-123
// Pass { baseUrl } to force the portal-style link even when standalone.
const link = Legion.SDK.social.getInviteFriendsLink();

// Send a friend request
const r = await Legion.SDK.social.sendFriendRequest(userId);
// { success: true, status: 'accepted' | 'pending' }  โ€” or { success:false, error }

A LegionFriend is { _id, username, displayName?, pfp?, avatar?, presence }, where presence is { status, lastSeen?, currentGame?, currentRoom?, currentParty?, gameSlug?, gameName? }.

Synced settings #

Players change settings in the Boxity portal's in-game pause menu (volume, graphics quality, sensitivityโ€ฆ), and the SDK pushes those values into your game. Every value is a string โ€” "80", "High", "true" โ€” so parse as needed. You don't build any settings UI; you just listen for the keys you support and apply them.

What "registering" actually does. The portal menu always shows the standard controls, but the ones your game hasn't claimed appear greyed out with a "this game doesn't support X" note. Calling Legion.SDK.settings.listen(key, โ€ฆ) for the first time sends the portal a legion_settings_register message that enables that control. So: listening for a key is how you turn its slider/toggle on in the menu. Five keys are gated this way โ€” master_volume, music_volume, graphics_quality, camera_sensitivity, show_fps. Register late and the control enables live; the menu doesn't have to be closed.

Wire up settings in three steps

  1. Listen for each key you support right after init

    One listen() per setting. The callback fires immediately with the current value (a default until the portal's real value lands), then again on every change. Registering also un-greys the control in the menu.

  2. Apply the value โ€” remember it's a string in the callback

    parseInt(v,10)/100 for volumes, string-compare === 'true' for toggles, a switch for graphics_quality.

  3. Call triggerAll() once your systems exist after load

    This re-fires every listener with the current value, guaranteeing your audio, graphics, etc. all start in sync with the player's saved settings even if they were created after the first push arrived.

Legion.SDK.settings.listen(key: string, cb: (value: string) => void) => unsubscribeboth
What
Subscribes to one setting and, on first use of that key, tells the portal to enable its control. Fires immediately with the current value, then on every change.
Call
Legion.SDK.settings.listen('master_volume', v => { audio.volume = parseInt(v,10)/100; });
Returns
() => void (unsubscribe).
Why
The primary settings API. It does double duty: it applies values and it's how a control shows up as supported in the menu. If you don't listen for a key, its control stays greyed and you never receive its value.
When
Right after init(), for every key you support, before loadingEnd().
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.settings.get(key) ยท getAll() ยท onChanged(cb)both
What
get(key) reads one value now; getAll() returns the whole settings object; onChanged(cb) subscribes to any settings change (fires immediately with all values, then on change).
Call
Legion.SDK.settings.get('show_fps') ยท Legion.SDK.settings.getAll() ยท Legion.SDK.settings.onChanged(all => {โ€ฆ})
Returns
get() โ†’ string ('' for an unknown key). getAll() โ†’ GameSettings. onChanged() โ†’ unsubscribe.
Why
get() is a convenient point-in-time read; onChanged() is handy if you'd rather react to everything in one place than register per-key. Note: get() does not register/enable a control โ€” only listen() does that.
When
get() after values exist; onChanged() at startup.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.settings.triggerAll() ยท refresh()both
What
triggerAll() re-invokes every one of your listeners with the current values (no network). refresh() asks the portal to re-send its current settings.
Call
Legion.SDK.settings.triggerAll() ยท Legion.SDK.settings.refresh()
Returns
void
Why
triggerAll() fixes the classic ordering bug: your audio system is created after the portal's first settings push, so it missed the volume. Calling it once everything exists re-applies all settings. refresh() is for recovering after a reconnect.
When
triggerAll() once, right after your game's systems are constructed (typically just before or after loadingEnd()). refresh() only if you suspect you missed an update.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Two settings behave differently โ€” don't rely on them as normal keys. enable_chat is handled by the portal and is not currently delivered to games โ€” a game reading it always sees the default "true", so drive your own chat visibility from Legion.SDK.player.onEvent('chat_message_sent') instead of this key. fullscreen is applied by the portal directly to the browser; to go fullscreen from your game, call Legion.SDK.portal.requestFullscreen(), not this setting. Also parse music_volume defensively (default to 80 if you get a surprisingly low value on a fresh client).

Quick reference โ€” every call in one place:

javascript
// Listen for a key. Fires immediately with the current value, then on change.
// Registering a listener also tells the portal to SHOW that control.
Legion.SDK.settings.listen('master_volume', (v) => {
  audio.volume = parseInt(v, 10) / 100;     // "0".."100"
});
Legion.SDK.settings.listen('graphics_quality', applyQuality);  // "Low"|"Medium"|"High"|"Ultra"

Legion.SDK.settings.get('show_fps');     // "true" | "false"  ('' if unknown)
Legion.SDK.settings.getAll();          // { master_volume: "80", ... }
Legion.SDK.settings.onChanged((all) => {});  // any-change subscription
Legion.SDK.settings.triggerAll();      // re-fire every listener with current values (call on load)
Legion.SDK.settings.refresh();          // ask the portal to re-send (after reconnect)

Standard keys (full table in Reference โ†’ Settings keys): master_volume, music_volume, graphics_quality, show_fps, camera_sensitivity, enable_chat, fullscreen, background_transparency.

Game lifecycle & rooms #

The Legion.SDK.game namespace is how your game talks to the portal about what it's doing: when it has finished loading, when a match starts, and which room the player is in. The portal uses these signals to dismiss the loading screen, record analytics, and let friends join the player. Every call here is a one-way postMessage to the portal โ€” they only do something when your game is embedded on bloxity.io (inside the iframe). On your own domain they are harmless no-ops, so it is always safe to call them.

The loading screen & loadingEnd() โ€” read this first

This is the single most-misunderstood part of the SDK, so here is exactly how it works. The moment a player opens your game at bloxity.io/g/<slug>, the portal draws a full-screen loading overlay on top of your game's iframe (spinner + your game art). Your game is already running underneath โ€” the player just can't see it yet. That overlay stays up until it is dismissed. There are two ways it can be dismissed, and which one applies depends on a single checkbox on your game's developer page: "LoadingEnd Support".

  1. Portal opens your game and shows the loading overlay automatic

    Nothing for you to do โ€” the overlay is up from the first millisecond, covering the iframe while your assets download and your first frame renders.

  2. You report progress with loadingStep() optional, repeat

    Each call updates the small line of text at the bottom of the overlay ("Loading worldโ€ฆ", "Connectingโ€ฆ"). Purely cosmetic, but it tells the player something is happening. Call it as many times as you like while loading.

  3. You call loadingEnd() exactly once when the game is ready after first frame

    This tells the portal "I'm done โ€” take the overlay down." The overlay disappears and the player sees your game. This is also the moment the portal records that the game successfully loaded (a one-time POST /v1/games/<slug>/loaded analytics ping).

โš  The trap that gets everyone. When "LoadingEnd Support" is ON for your game, the portal waits forever for your loadingEnd() call โ€” there is no fallback timeout. If your game never calls it (or calls it before the SDK script has loaded, so the message is lost), the player is stuck staring at the loading spinner indefinitely. When the box is OFF (the default), the portal instead auto-dismisses the overlay 1.5 seconds after your frame first renders, whether or not you ever signal. So the box and the function call must agree. Turn the box on only once your game reliably calls Legion.SDK.game.loadingEnd().

The full truth table โ€” find your row:

"LoadingEnd Support" boxDoes your game call loadingEnd()?What the player experiences
OFF (default)Noโœ… Overlay auto-hides 1.5 s after the frame renders. Fine for quick games, but if you're still loading at 1.5 s the player briefly sees an unfinished game.
OFFYesโœ… Overlay hides the instant you call it, or at 1.5 s โ€” whichever comes first. Recommended if you can't guarantee the call every time.
ONYesโœ… Best UX. Overlay hides at exactly the right moment โ€” never early, never late. Use this once your loading is deterministic.
ONNoโŒ Stuck on the loading screen forever. This is the bug. Either add the call, or turn the box off.
One more condition: the portal only waits for loadingEnd() when your game also has SDK support enabled (the SDK Support toggle, on by default). A game with "LoadingEnd Support" on but SDK support off still takes the 1.5 s auto-dismiss path. In practice: both toggles on + a real loadingEnd() call = a clean, correctly-timed loading screen.

Every function in this namespace

Legion.SDK.game.loadingStep(step: string)embedded
What
Sets the small status line shown at the bottom of the portal's loading overlay.
Call
Legion.SDK.game.loadingStep('Loading worldโ€ฆ') โ€” pass any short human-readable string.
Returns
void
Why
Optional, but it turns a blank spinner into visible progress, which reduces the number of players who close a game they think has frozen.
When
Repeatedly, throughout your load sequence โ€” once per meaningful phase (assets, level, netcode). Skip it entirely if you don't have distinct phases.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.game.loadingEnd()embedded
What
Tells the portal your game has finished loading, so it takes the loading overlay down. Also fires the one-time "game loaded" analytics ping.
Call
Legion.SDK.game.loadingEnd() โ€” no arguments. Call it exactly once per session.
Returns
void
Why
Required if "LoadingEnd Support" is on โ€” without it the overlay never lifts and the game is unplayable (see the truth table above). Even with the box off, calling it hides the overlay sooner than the 1.5 s fallback, so the player isn't shown a half-loaded game.
When
The moment your game is genuinely playable: assets loaded, the first frame drawn, input ready. Not at the top of your script, and not before Legion.SDK exists โ€” if the SDK script hasn't loaded yet, the call is lost and the box-on path hangs. A safe pattern is to call it from your first requestAnimationFrame after setup completes.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.game.gameplayStart() / gameplayEnd()embedded
What
Marks the boundaries of an actual play session (a match, a round, a level) as opposed to sitting in a menu.
Call
Legion.SDK.game.gameplayStart() when a match begins; Legion.SDK.game.gameplayEnd() when it ends or the player returns to the menu.
Returns
void
Why
Feeds the platform's engagement analytics (session length, how many players who load actually play). It does not change anything the player sees.
When
At real gameplay transitions. If your game is "always playing" (no menu), call gameplayStart() once right after loadingEnd().
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.game.updateRoom(roomId?: string, partyId?: string)embedded
What
Tells the portal which room (and optionally which of your game's own squads) the player is currently in, so friends can join that specific room. The partyId here is your game's squad/team id, not a Boxity party (see Party play).
Call
Legion.SDK.game.updateRoom('match-456') in a room ยท updateRoom('match-456', 'party-789') with a squad ยท updateRoom('') when back in a menu / not joinable.
Returns
void
Why
This is the backbone of "join a friend". social.inviteFriend() and social.getInviteFriendsLink() both send your last reported room. If you never call updateRoom(), invites drop the friend into your game's front door instead of your match. An empty string correctly says "I'm not in a joinable room right now."
When
Every time the player's room or party changes โ€” joining a match, leaving to the lobby, switching servers. Cheap to over-call: identical consecutive calls are automatically de-duplicated, so calling it on every state change is fine.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.game.playerJoined(username: string) / playerInRoom(username: string)embedded
What
Reports a player's presence in your room so the portal can show a friendly "joined" toast โ€” but only when that player is actually one of the local user's Boxity friends.
Call
Legion.SDK.game.playerJoined(name) for someone who joins after you; Legion.SDK.game.playerInRoom(name) for people who were already there when you joined.
Returns
void
Why
Turns "some stranger appeared" into "๐ŸŽ‰ Alex joined you", which makes the platform feel social. Non-friends produce no toast, so you can safely report every player without spamming.
When
From your netcode's player-join handlers. On joining a room, loop your existing players through playerInRoom(); from then on, call playerJoined() as each new player arrives.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.

Quick reference โ€” every call in one place:

javascript
// Loading screen (portal shows your step text)
Legion.SDK.game.loadingStep('Loading worldโ€ฆ');
Legion.SDK.game.loadingEnd();          // dismiss the loading screen

// Analytics / state
Legion.SDK.game.gameplayStart();
Legion.SDK.game.gameplayEnd();

// Joinable presence โ€” friends can join this room/party
Legion.SDK.game.updateRoom('match-456');              // in a room
Legion.SDK.game.updateRoom('match-456', 'party-789'); // with a party (squad games)
Legion.SDK.game.updateRoom('');                       // in menu / not joinable

// Friend-join toasts โ€” call from your netcode's player-join handlers
Legion.SDK.game.playerJoined(p.username);  // "X joined you" if they're a friend
Legion.SDK.game.playerInRoom(p.username);  // "you joined X's lobby" for players already there

Open Rooms on your game page

Every Boxity game page can list your game's live rooms under Open Rooms, with player counts and a Join button. It appears on every page layout, and it stays hidden while your game has no rooms. Join opens your game with ?roomId=<id>, the same parameter friend invites use, so read roomId at launch and join that room, falling back to normal matchmaking when it is gone or full.

Where your game runsWhere the list comes fromWhat you do
Hosted on Boxity (Colyseus on Legion)The matchmaker's room directory, automaticallyKeep the room reporter installed. If one backend serves several catalogue rows by ?mode=, put { mode, modeName } in each room's metadata.
Your own serversA public URL you give Boxity (allRoomsUrl)Return { rooms: [{ id, region, type, players, maxPlayers, locked }], onlineServers } across all servers (a raw Colyseus room array also works), and filter by ?mode= if rows share a backend.
Prompt: the Show your live rooms card walks an AI assistant through both cases.

Player events #

The portal's menu and chat can trigger actions inside your game โ€” resetting the character, relaying a chat message, releasing the mouse. Your game receives these through one subscription, Legion.SDK.player.onEvent, and switches on the event name.

Legion.SDK.player.onEvent((event: string, data?) => void) => unsubscribeboth
What
The single handler for all portal-to-game events. Your callback gets an event name and optional data.
Call
Legion.SDK.player.onEvent((event, data) => { switch (event) {โ€ฆ} })
Returns
() => void (unsubscribe).
Why
Without it, the portal's "Reset Character" button, the chat box, and pointer-lock hand-offs do nothing in your game. It's the inbound half of the SDK โ€” game.* talks to the portal, player.onEvent listens back.
When
Subscribe once at startup. Handle the events you care about; ignore the rest.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Event namedataWhat it means & how to handle it
'respawn_request'โ€” (none)Player clicked Reset Character in the portal menu. Respawn / reset the local player to spawn.
'chat_message_sent'string (the message)Player sent a message in the portal chat box. Show it in your in-game chat / feed.
'pointer_lock_changed'boolean (locked?)The portal locked/unlocked the mouse (e.g. its menu opened over your game). Sync your camera-control lock state to match.
'play_emote'string (emote id)Player picked an emote in the portal's wheel. Play that animation on their character. Only sent to games that called game.registerFeature('emotes').
Legion.SDK.player.respawn()both
What
Signals a respawn/reset request, which surfaces to your game as a 'respawn_request' event.
Call
Legion.SDK.player.respawn()
Returns
void
Why
Lets you trigger the same "reset character" flow from your own UI, so both the portal's button and your in-game button route through the one onEvent handler.
When
From your own respawn control, if you have one. Otherwise you don't need to call it โ€” just handle 'respawn_request'.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
javascript
const off = Legion.SDK.player.onEvent((event, data) => {
  switch (event) {
    case 'respawn_request':      respawnPlayer(); break;        // "Reset Character" in menu โ€” no data
    case 'chat_message_sent':    showChat(data); break;       // data = message string
    case 'pointer_lock_changed': setLocked(data); break;      // data = boolean
  }
});

You can also open the portal's pause menu yourself (e.g. from your ESC handler):

javascript
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') Legion.SDK.portal.showMenu(true);  // true = re-lock pointer on Resume
});
Legion.SDK.portal.showMenu(lockCursorOnResume?: boolean)embedded
What
Opens the portal's pause menu (Resume, settings, reset character, leave) over your game.
Call
Legion.SDK.portal.showMenu(true) from your own ESC/pause handler.
Returns
void
Why
Gives players the standard Boxity menu instead of you rebuilding settings/leave UI. Pass true for lockCursorOnResume so pointer lock re-engages when they hit Resume โ€” important for FPS/3D games that use the mouse for the camera (you'll also get a 'pointer_lock_changed' event to sync to).
When
On your pause key (typically Escape). Only meaningful when embedded.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.

Emotes #

Boxity owns the emote picker. The button in the in-game overlay, the wheel, ownership, prices, purchases and the rendered thumbnails are all rendered by the portal — your game never builds an emote menu. It registers once, then receives a single event: play this emote.

The button only appears over games that have registered, so nothing changes for your players until you opt in, and a player can never be shown a control your game would ignore.

Legion.SDK.game.registerFeature(feature: string, version?: number)both
What
Tells the portal your game can service a platform-owned feature. Pass 'emotes' to have the emote button appear over your game.
Call
Legion.SDK.game.registerFeature('emotes')
Returns
void
Why
Without it the emote button never appears. It is the handshake that keeps a control your game cannot honour off the player's screen.
When
As soon as Legion.SDK.init() resolves — and not later, e.g. after your assets load. The portal decides what to show as soon as your game is up, and a button that arrives a second late is worse than one that very occasionally falls back. Do not call it before init resolves: the SDK global does not exist until its script has loaded over the network, so an early call silently does nothing and the button never appears.

Receiving the emote

The pick arrives through the same player.onEvent subscription every other portal-to-game event uses. data is the emote's catalogue id.

// 1. Opt in, once the SDK is ready. Calling before init() resolves is a
//    silent no-op - Legion.SDK does not exist yet.
await Legion.SDK.init({ gameSlug: 'your-slug' });
Legion.SDK.game.registerFeature('emotes');

// 2. Listen.
Legion.SDK.player.onEvent((event, data) => {
  if (event === 'play_emote') playEmote(data); // data = emote id, a string
});

Playing it

How you implement playEmote(id) depends on your character rig.

If your game has its own emote animations, fetch the catalogue once and map by name. Simple, but an emote added to Boxity later will not appear in your game until you add a mapping.

const { emotes } = await (await fetch(`${Legion.SDK.environment().apiUrl}/v1/avatar/emotes`)).json();
const byId = new Map(emotes.map(e => [e.id, e]));

function playEmote(id) {
  const emote = byId.get(id);
  if (emote) myAnimator.play(myClipFor(emote.name));
}

If your game uses the standard Boxity avatar (player.glb), the clips apply directly and every emote works automatically, including ones added after your game ships. This is the option that scales.

Each emote carries a clip:

{
  "len": 1.4,           // seconds
  "loop": true,
  "tracks": {
    // bone: [[timeSec, xDeg, yDeg, zDeg], ...]
    "ArmR_Offset": [[0, -60, 0, -10], [0.25, -144, 0, -16], [1.4, -60, 0, -10]]
  }
}
  • values are degrees, and are deltas over the bind pose, not absolute
  • euler order is XYZ, right-multiplied onto the bone's bind quaternion
  • ease between keys with smoothstep: s = s * s * (3 - 2 * s)
  • clamp outside the key range; loop wraps on time % len
  • an unknown bone name is ignored, never an error
function sampleTrack(keys, t) {
  if (t <= keys[0][0]) return keys[0].slice(1);
  const last = keys[keys.length - 1];
  if (t >= last[0]) return last.slice(1);
  for (let i = 0; i < keys.length - 1; i++) {
    const a = keys[i], b = keys[i + 1];
    if (t >= a[0] && t <= b[0]) {
      let s = (t - a[0]) / (b[0] - a[0]);
      s = s * s * (3 - 2 * s);                       // smoothstep
      return [1, 2, 3].map(j => a[j] + (b[j] - a[j]) * s);
    }
  }
  return null;
}

// per frame, per bone
const v = sampleTrack(clip.tracks[bone.name], clipTime);
if (v) {
  bone.quaternion.copy(bindQuaternion[bone.name]);
  bone.quaternion.multiply(new THREE.Quaternion().setFromEuler(
    new THREE.Euler(v[0] * DEG, v[1] * DEG, v[2] * DEG, 'XYZ')));
}

The standard rig

22 bones. These are the only names that do anything:

Rig1
  Spine1 -> Spine2 -> ArmR_Offset -> ArmR1 -> ArmR2
                      ArmL_Offset -> ArmL1 -> ArmL2
                      Neck_Offset -> Neck1
  LegR_Offset -> LegR1 -> LegR2
  LegL_Offset -> LegL1 -> LegL2

Raising an arm is negative X on ArmL_Offset / ArmR_Offset — the shoulder bones. Z there is sideways abduction, not lift, and ArmR1 is upper-arm twist. Getting this wrong swings the arm into the torso instead of up; it is the single most common mistake with this rig. Note also that the legs hang off Rig1, not Spine1, so rotating Spine1 bends the torso over stationary legs.

Joint limits

The meshes are rigid boxes with no deformation. Past these angles they self-intersect or visibly pull apart. You only need this table if you author your own clips — the ones Boxity serves are already inside it.

BoneAxisSafe rangeWhat breaks past it
Arm*2X−38 … 0forearm folds through the bicep
Arm*2Z±26—
Arm*1Z±14shoulder creases
Arm*_OffsetX−178 … 0lift — safe to the limit
Arm*_OffsetZ±88abduction — safe, swings away from the body
Spine1X±3torso floats off the legs
Spine1Z±9—
Neck1X±8head detaches from the torso
Neck1Y / Z±18 / ±8—
Leg*1Z±20leg separates at the hip
Leg*2X / Z0…14 / ±14—

Multiplayer

Boxity sends play_emote only to the player who picked it. If your game has other players, replicate the emote id and its start time through your own networking and run the same clip on every client. Keep the id an opaque string — they are 24-character catalogue ids and new ones appear without a game release.

Testing

  1. Open your game inside Boxity at bloxity.io/g/<your-slug>.
  2. The emote button appears in the overlay dock. If it does not, your registerFeature('emotes') is not firing — check it runs on boot.
  3. Pick an emote; your character should animate.

To test playback without the UI, run this in the browser console on the Boxity page (not inside the game frame). …e01 is Right Hand Wave, which is free for everyone:

document.querySelector('iframe').contentWindow.postMessage(
  { type: 'legion_play_emote', emoteId: '000000000000000000000e01' }, '*');

Gotchas

  • Register as soon as init() resolves. Earlier is a silent no-op (no SDK global yet); later means a late button. If your platform layer wraps the SDK, make sure the wrapper awaits its own init before forwarding the call — optional chaining through a not-yet-loaded SDK fails without a single error.
  • Do not check ownership yourself. Boxity only ever sends an id the player is entitled to. Duplicating the check is how the two drift apart.
  • Unknown id? Ignore it quietly. Never throw — an emote is cosmetic and must never be able to break a frame.
  • The catalogue grows. Do not hardcode ids or assume a count.
  • Stop on movement. An emote that keeps playing while the player walks looks broken.

Overhead chat bubbles # embed

Boxity owns in-game chat: the chat button, the message bar, the Enter shortcut and the floating feed are all drawn by the portal over your game. Only your game knows where each character's head is, though, so to show a player's message above their head your game subscribes to chat and draws the bubble itself. Boxity does the rest.

  • Every message in the room reaches every player in it, including the sender's own.
  • The sender is verified by Boxity, never taken from a browser, so a bubble can safely go over that player's character.
  • The text is already filtered. A message with a blocked word is never sent.
  • Nothing arrives while the player has chat turned off, or when the game's chat is off in its settings.

Subscribing is the opt-in: the portal only forwards chat to games that called chat.onMessage, so nothing changes until you do.

Legion.SDK.chat.onMessage(callback: (message: ChatMessage) => void)embed
What
Calls callback for every chat message in the player's current room from now on. Old messages are not replayed, so joining never floods heads with stale bubbles.
Returns
An unsubscribe function.
Message
{ userId, username, text, isGif, at, isLocalPlayer }. userId is the sender's Boxity account id: the same id your server sees when it verifies the player's token (sub). isLocalPlayer is true for messages this player sent.
When
Once Legion.SDK.init() resolves. Keep the subscription for the whole session.
Legion.SDK.chat.bubbleText(message)both
What
What the bubble should say: the message trimmed to two short lines, or "sent a GIF" for a GIF. Don't draw GIFs over heads.
Legion.SDK.chat.bubbleSecondsboth
What
4. How long a bubble stays up. Use it if you draw your own bubbles, so every game times them alike. A newer message from the same player replaces their bubble.
Legion.SDK.chat.attachBubble(THREE, target, text, options?)three.js
What
three.js only. Puts a camera-facing bubble above target (the player's root object or head bone), replaces any bubble already on it, and removes itself after bubbleSeconds. Pass your own THREE; the SDK does not bundle three.js.
Options
{ seconds?: 4, height?: 2.6, scale?: 0.55 }. height is world units above the target's origin; 2.6 clears a standard Boxity avatar's head.
Returns
{ remove() }, to take it down early (e.g. when the player leaves).

Matching a message to a character

Key your players by the Boxity id your server verified from their token. If your server sends that id to clients as, say, legion_<id>, strip the prefix. Guests have a Boxity id too when they play through Boxity.

three.js

await Legion.SDK.init({ gameSlug: 'your-slug' });

Legion.SDK.chat.onMessage((message) => {
  const player = message.isLocalPlayer ? localPlayer : playersByBoxityId.get(message.userId);
  if (!player) return; // not on screen in this room
  Legion.SDK.chat.attachBubble(THREE, player.root, Legion.SDK.chat.bubbleText(message));
});

Your own bubble (HTML overlay, canvas, any engine)

If you already draw name tags, put the bubble right above the tag and reuse its screen position.

Legion.SDK.chat.onMessage((message) => {
  const player = findPlayer(message);
  if (!player) return;
  player.showBubble(Legion.SDK.chat.bubbleText(message));        // your UI
  clearTimeout(player.bubbleTimer);
  player.bubbleTimer = setTimeout(() => player.hideBubble(), Legion.SDK.chat.bubbleSeconds * 1000);
});

Unity WebGL

Subscribe in the host page and hand each message to a C# receiver, as the other SendMessage receivers do.

// Host page, after Legion.SDK.init() resolves
Legion.SDK.chat.onMessage((message) => {
  unityInstance.SendMessage('BoxityChat', 'OnChatMessage', JSON.stringify({
    userId: message.userId,
    text: Legion.SDK.chat.bubbleText(message),
    isLocalPlayer: message.isLocalPlayer,
    seconds: Legion.SDK.chat.bubbleSeconds,
  }));
});
// BoxityChat.cs, on a GameObject named "BoxityChat"
[System.Serializable] class ChatLine { public string userId; public string text; public bool isLocalPlayer; public float seconds; }

public class BoxityChat : MonoBehaviour {
  public void OnChatMessage(string json) {
    var line = JsonUtility.FromJson<ChatLine>(json);
    var player = line.isLocalPlayer ? Players.Local : Players.FindByBoxityId(line.userId);
    if (player != null) player.ShowChatBubble(line.text, line.seconds); // a world-space Canvas above the head
  }
}

Testing

Open your game at bloxity.io/g/<slug> in two browsers signed in as different players, join the same room, press Enter and send a message. It should appear over the sender in both windows for 4 seconds, and a second message should replace the first.

Gems purchases (IAP) #

Gems are Boxity's in-game currency (called Bux before the rename). The model is deliberately server-authoritative so games can't be cheated and players can't be charged for nothing: your game only ever passes a sku (a product id) โ€” never a price โ€” and the server looks up the price from your game's IAP catalog, deducts the Gems, and confirms the sale to your backend via a webhook. If anything in that chain fails, the Gems are refunded automatically.

How a purchase actually flows

  1. Player clicks buy โ†’ you call requestPurchase(sku) client

    The SDK shows a confirm modal populated with the server's product name and price (you don't render it). Nothing is charged yet.

  2. Player confirms โ†’ Boxity prices & deducts Gems server

    The server re-reads the price from your catalog and atomically deducts it. If the player is short, the flow opens a real-money top-up for the smallest covering package, then continues automatically.

  3. Boxity calls your game server's webhook server โ†’ your server

    A signed POST with the transaction details (sku, user, amount, your metadata) lands on your fulfillment endpoint. This is the moment you actually grant the item.

  4. Your server grants the item and returns 2xx your server, โ‰ค10s

    Add the coins / unlock the skin in your database, then respond 2xx. The grant is only considered final once you do.

  5. requestPurchase resolves { success:true, transactionId } client

    Reflect the grant in the game UI. Record the transactionId if you want an audit trail.

The safety net: if your server doesn't return 2xx within 10 seconds (after a couple of quick retries), Boxity auto-refunds the Gems and requestPurchase resolves { success:false }. A missing, slow, or broken webhook can never leave a player out of pocket โ€” but it also means no webhook = no grant, so wiring up your endpoint is not optional if you sell anything.
Renamed from Bux: the namespace is Legion.SDK.gems. Legion.SDK.bux still works as a deprecated alias with the same methods, so games built before the rename keep working unchanged. The wire contract did not change: balances arrive in the API field bux (e.g. GET /v1/social/me returns { bux }), routes stay under /v1/bux/*, and an insufficient-balance failure still carries error: 'Insufficient Bux', now alongside code: 'INSUFFICIENT_FUNDS'. Match on code in new code.
Legion.SDK.gems.requestPurchase(sku | options, metadata?) => Promise<{ success, transactionId?, error? }>both
What
Runs the whole purchase flow above: shows the confirm modal, deducts Gems, waits for your webhook, and resolves the result.
Call
const r = await Legion.SDK.gems.requestPurchase('coins_500'); โ€” just the sku. Or pass options for metadata / a per-purchase webhook override: requestPurchase('coins_500', { playerId: 'p_123' }). The metadata object is passed straight through to your webhook.
Returns
{ success, transactionId?, error? }. On success, grant the item and store transactionId. On failure, error explains why ('User cancelled', 'Not authenticated', an auto-refund message, etc.). Only one purchase runs at a time โ€” a second concurrent call returns 'Another purchase is already in progress'.
Why
It's the entire client side of monetization in one call. gameSlug (from init() or the handshake) selects which catalog prices the sku, which is why passing it to init() matters. Signed-out players get a local simulated purchase so your UI still works while testing.
When
On a buy button. If the player isn't logged in, the SDK will prompt login first (embedded), so you don't have to gate the button yourself.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.gems.getBalance() => Promise<number>both
What
Returns the logged-in player's current Gems balance.
Call
const gems = await Legion.SDK.gems.getBalance();
Returns
Promise<number>. Signed-out players return 0.
Why
To show a "you have N Gems" figure, or to decide whether to nudge a top-up before a purchase. Don't use it to gate the purchase itself โ€” the server does the authoritative balance check.
When
When you render a shop/wallet, and after a successful purchase to refresh the displayed number.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.

Quick reference:

javascript
// gameSlug must be set (init or portal handshake) so the API knows which catalog to price from.
Legion.SDK.init({ gameSlug: 'my-game' });

// Show the confirm modal & run the transaction. Just pass the sku.
const res = await Legion.SDK.gems.requestPurchase('coins_500');
if (res.success) grantCoins(500);   // res.transactionId for your records

// Optional metadata (passed through to your webhook) + override webhook URL:
await Legion.SDK.gems.requestPurchase('coins_500', { playerId: 'p_123' });

// Current balance
const gems = await Legion.SDK.gems.getBalance();
No webhook? No grant. The purchase only succeeds if your game server returns 2xx to the webhook. A non-2xx or timeout (10 s) auto-refunds the Gems. See the exact webhook payload.

Buying Gems & gifting

Legion.SDK.gems.requestTopUp(neededGems?: number) => Promise<{ success, error?, package? }>both
What
Opens the real-money checkout for the smallest Gems package that covers neededGems, and resolves once the purchased Gems land on the player's balance.
Call
const r = await Legion.SDK.gems.requestTopUp(required - current);
Returns
{ success, error?, package? }. Resolves success:true once the credit is confirmed (the SDK polls the balance for a few seconds after payment).
Why
Most games never need this โ€” requestPurchase() already handles "not enough Gems" for you. Use requestTopUp() only for purchase flows that don't go through requestPurchase (e.g. the avatar catalog's own buy endpoint): when that returns { error: 'Insufficient Bux', required, current }, call requestTopUp(required - current) and retry your original action on success.
When
Right after a non-requestPurchase buy fails with insufficient balance.
Live
Not needed by most games โ€” the test game uses the simpler requestPurchase() flow (which tops up for you) at bloxity.io/test-game.html.
Legion.SDK.gems.getTopUpPackages() => Promise<Package[]>both
What
Returns the live catalog of real-money Gems packages.
Call
const packages = await Legion.SDK.gems.getTopUpPackages();
Returns
Array<{ id, amount, baseAmount, bonus, price, popular?, bestValue? }> โ€” amount is the Gems credited (base + bonus), price is USD.
Why
Only if you want to render your own "get Gems" storefront instead of the built-in checkout. If you just need the player to have enough Gems, use requestTopUp().
When
When building a custom Gems store screen.
Live
Not shown in the test game (it uses the built-in checkout). For the core Gems flow, see requestPurchase/getBalance in bloxity.io/test-game.html.
Legion.SDK.gems.transfer(toUserId: string, amount: number) => Promise<{ success, balance?, to?, transactionId?, error? }>logged-in
What
Sends Gems from the current player to another user (gifting/tipping).
Call
const r = await Legion.SDK.gems.transfer(recipientUserId, 50);
Returns
{ success, balance?, to?, transactionId?, error? } โ€” to is the recipient's username, balance is the sender's new balance.
Why
Everything is enforced server-side: real login required, the amount is floored, capped at 10,000 per transfer, and the deduct-then-credit is atomic (insufficient balance changes nothing). Show your own confirmation UI before calling โ€” this moves real currency and does not prompt on its own.
When
From an explicit "send Gems" / "tip" action, after the player confirms the amount and recipient.
Live
Gifting isn't in the test game. For the core Gems flow to copy from, see requestPurchase/getBalance in bloxity.io/test-game.html.

Implement your game server's webhook

If you sell anything, your backend needs one endpoint. Boxity calls it after deducting Gems; you grant the item and answer 2xx.

  1. Expose a POST endpoint once

    e.g. POST https://your-server.com/api/legion-webhook. Register its base URL as your game's default webhook (or pass a per-purchase webhookUrl in requestPurchase's options).

  2. Verify the secret header every request

    When LEGION_WEBHOOK_SECRET is configured, reject any request whose x-legion-webhook-secret header doesn't match. Never grant on an unverified call.

  3. Deduplicate by transactionId before granting

    Boxity retries on network errors and 502/503/504, so the same transactionId can arrive twice. Grant each one only once.

  4. Grant the item, then return 2xx within 10 seconds fast

    Use sku, userId, and your metadata to apply the grant, then respond 2xx. A 4xx fails fast (no retry) and refunds; a slow or 5xx response refunds after retries. See the exact payload.

Portal & raw API #

Two utility namespaces: Legion.SDK.portal for detecting the environment and driving fullscreen, and Legion.SDK.api for making authenticated calls to the told API (environment().apiUrl) when a wrapper doesn't already exist.

Legion.SDK.portal.isInIframe() ยท isEmbeddedInLegion() => booleanboth
What
isInIframe() โ€” is the game inside any iframe. isEmbeddedInLegion() โ€” is it specifically embedded in the Boxity portal.
Call
if (Legion.SDK.portal.isEmbeddedInLegion()) {โ€ฆ}
Returns
boolean
Why
Usually you don't need these โ€” the SDK already routes embedded vs standalone for you. Reach for them only to tweak UI (e.g. hide your own "login" button when the portal will handle it, or skip fullscreen controls when embedded).
When
At UI-setup time, if you have environment-specific chrome.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.portal.requestFullscreen() ยท exitFullscreen()embedded
What
Ask the portal to take the game iframe fullscreen (or exit).
Call
Legion.SDK.portal.requestFullscreen()
Returns
void
Why
When embedded, your game can't call the browser's Fullscreen API on the top document itself โ€” the portal owns it. This routes the request correctly. (This is also why the fullscreen setting key isn't something your game applies directly.)
When
From a fullscreen button / hotkey.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.
Legion.SDK.api.get ยท post ยท patch ยท delete(path, body?) => Promise<T>both
What
Authenticated JSON requests to the Boxity API. The current user's Bearer token is attached automatically.
Call
const me = await Legion.SDK.api.get('/v1/social/me'); ยท await Legion.SDK.api.post('/v1/some/endpoint', { a: 1 });
Returns
Promise<T> (parsed JSON). Throws on non-OK responses with the server's error message.
Why
An escape hatch for endpoints the typed namespaces don't wrap. Prefer the dedicated namespaces (auth, social, gemsโ€ฆ) when they exist; use this for anything else.
When
Only when you genuinely need a raw endpoint. See REST endpoints for what's available.
Live
Working example in the test game: bloxity.io/test-game.html (standalone) ยท bloxity.io/g/test-game (embedded). Open it, View Source, copy the pattern.

Quick reference:

javascript
// Environment
Legion.SDK.portal.isInIframe();          // boolean
Legion.SDK.portal.isEmbeddedInLegion();  // embedded on bloxity.io?

// Portal menu & fullscreen (when embedded)
Legion.SDK.portal.showMenu(/* lockCursorOnResume */ false);
Legion.SDK.portal.requestFullscreen();
Legion.SDK.portal.exitFullscreen();

// Authenticated raw requests to the told apiUrl (Bearer token added automatically)
const me  = await Legion.SDK.api.get('/v1/social/me');
const out = await Legion.SDK.api.post('/v1/some/endpoint', { a: 1 });
await Legion.SDK.api.patch('/v1/x', {});
await Legion.SDK.api.delete('/v1/x');

Ads #

Boxity runs every ad itself. When your game is embedded on Boxity, the portal picks the ad network, applies frequency rules and draws the ad over your game - you never load an ad script. Your game does two things: pause during ad breaks, and optionally ask for rewarded ads. Midroll (interstitial) ads are started by the platform, never by the game.

Required if you use the SDK: subscribe to onBreakStart / onBreakEnd and pause gameplay + mute audio in between. The platform may start a break at any time (for example between sessions), whether or not your game asked.
Player can die? The banners shown while a player is dead come from the death screen, which every game where the player can die must call.
Legion.SDK.ads.onBreakStart(callback) => unsubscribeembed
What
Called when an ad starts playing over your game. callback({ kind }), where kind is 'midroll' or 'rewarded'.
Call
Legion.SDK.ads.onBreakStart(() => { game.pause(); audio.mute(); });
Why
The ad covers the screen; a game that keeps running loses the round or plays sound over the ad.
When
Once, right after init().
Legion.SDK.ads.onBreakEnd(callback) => unsubscribeembed
What
Called when the ad break is over, however it ended. callback({ kind, result }).
Call
Legion.SDK.ads.onBreakEnd(() => { game.resume(); audio.unmute(); });
When
Once, next to onBreakStart.
Legion.SDK.ads.showRewarded(placement?) => Promise<{ status, reason? }>embed
What
Asks the portal for a rewarded video and resolves when it is over. Never rejects.
Call
const r = await Legion.SDK.ads.showRewarded('revive'); - placement is an optional lowercase name for the button (letters, digits, hyphens) used in Boxity's reporting.
Returns
status is one of 'completed' (grant the reward), 'skipped', 'no-fill' (no ad available), 'blocked' (ad blocker), 'error', 'denied' (too soon after the last ad - reason says which rule) or 'disabled' (not embedded on Boxity, or ads are off).
Why
Reward only on 'completed'. Anything else means the player did not watch an ad, so keep the button usable and tell them to try again later.
When
From a button the player pressed ("Watch an ad to revive"). Hide the button when isAvailable() is false.
Legion.SDK.ads.isAvailable() => booleanembed
What
True when the game is embedded in a Boxity portal that serves ads. Standalone or outside Boxity it is false and showRewarded resolves 'disabled'.

Quick reference:

javascript
// Pause for every ad break - platform midrolls included.
Legion.SDK.ads.onBreakStart(() => { game.pause(); audio.mute(); });
Legion.SDK.ads.onBreakEnd(() => { game.resume(); audio.unmute(); });

// Rewarded ad from a button.
reviveButton.hidden = !Legion.SDK.ads.isAvailable();
const r = await Legion.SDK.ads.showRewarded('revive');
if (r.status === 'completed') revivePlayer();

Death screen #

When the player dies, Boxity shows ads along the edges of the game and runs a short countdown. Your game keeps its own death UI and its own Respawn button, in its own style. The button shows the countdown and only works once it reaches 0, so the ads are seen. Boxity decides which ads show, where, and how long the countdown is (at least 4 seconds); your game never picks or places an ad.

Required in every game where the player can die. Call deathScreen.show() on death and respawn only when respawn() returns true. Never respawn on a timer of your own while a death screen is open.
Legion.SDK.deathScreen.show() => DeathScreenembed
What
Starts the death screen for the local player's death and returns its handle. Calling it again before it closes returns the same one.
Call
const death = Legion.SDK.deathScreen.show();
When
The moment the local player dies.
death.onCountdown(callback) => unsubscribe
What
callback(secondsLeft) right away with the current value (once Boxity has sent it) and on every tick: 4, 3, 2, 1, then 0 = the player may respawn. death.ready is a Promise that resolves at 0, and death.isReady() reads it.
Call
death.onCountdown(n => button.setLabel(n > 0 ? String(n) : 'RESPAWN'));
Why
The countdown runs on Boxity's clock, so it is the same for every game and cannot be shortened.
death.respawn() => boolean
What
Closes the death screen and returns true when the player may respawn now. Returns false and does nothing while the countdown is running, or once this death screen is closed.
Call
button.onClick(() => { if (death.respawn()) respawnPlayer(); });
Why
Respawning on true only is what keeps the countdown honest.
Legion.SDK.deathScreen.hide() => voidembed
What
Closes the open death screen without a respawn. Legion.SDK.deathScreen.isActive() tells whether one is open.
When
The round ends or the player goes back to a menu while dead.
Layout: keep the death UI (killer card, Respawn and any other buttons) in the centre of the screen. Boxity's banners sit along the top, the sides and the corners and may cover anything there. Every click outside a banner reaches your game.
Outside Boxity (standalone, local testing, or when Boxity runs no death screen ads) the countdown is 0 at once and respawn() returns true, so one code path works everywhere. Boxity's menu Reset Character (respawn_request) is separate and stays instant.

Quick reference:

javascript
function onLocalPlayerDied() {
  const death = Legion.SDK.deathScreen.show();
  deathUi.open();
  death.onCountdown(n => {
    respawnButton.label = n > 0 ? String(n) : 'RESPAWN';
    respawnButton.enabled = n === 0;
  });
  respawnButton.onClick = () => {
    if (death.respawn()) { deathUi.close(); respawnPlayer(); }
  };
}

// Round over while dead: close it without a respawn.
Legion.SDK.deathScreen.hide();

Multiplayer matchmaker # required for Boxity-hosted multiplayer

Every multiplayer game whose server is hosted on Boxity (Legion) must connect this way. Boxity's matchmaker fills each Colyseus server pod to your room cap, starts another pod when they're full, and shuts empty ones down. Legion.SDK.net.resolveEndpoint() asks the matchmaker which pod the player should join and hands you back a WebSocket endpoint to open. Single-player games, and games whose servers run somewhere other than Boxity, can skip this section.

Never connect the game socket to <id>.host.bloxity.io. That address is one fixed route to one pod: the matchmaker never sees your players and never starts a second pod. It works while the game is small. Once one pod holds more players than it can simulate, joins get slow, then fail, and the pod is restarted, which disconnects everyone in it. Keep <id>.host.bloxity.io for HTTP only (auth, API calls, Boxity's webhooks). Check your client build: no new Client('wss://<id>.host.bloxity.io'), no VITE_SERVER_URL (or similar) pointing there.
Legion.SDK.net.resolveEndpoint(gameSlug: string, opts?) => Promise<{ endpoint, roomId, cold }>both
What
Asks the matchmaker for a live server for your game and returns the WebSocket endpoint to connect to. Pass your hosting id: the <id> in <id>.host.bloxity.io.
Call
const { endpoint } = await Legion.SDK.net.resolveEndpoint('my-game'); then const room = await new Client(endpoint).joinOrCreate('arena', opts);
Returns
{ endpoint, roomId, cold } โ€” endpoint is a wss:// URL for new Client(); roomId is the room the player is pinned to; cold is true when the matchmaker had to wake a scaled-to-zero server for this request (so the first connection may take a moment longer).
Options
{ userId?, version?, matchmakerUrl? } โ€” userId for matchmaking tickets; version: 'preview' targets the playtest server pool instead of production; matchmakerUrl overrides the matchmaker host (testing only).
Why
You must resolve the endpoint through the matchmaker rather than hardcoding a server URL โ€” the matchmaker is what balances players across pods and wakes idle servers. Connecting to a fixed host would hit a scaled-to-zero (dead) instance.
When
Right before you open your multiplayer connection (on "Play"/"Find match"). Resolve once per connection and build a new Client from each endpoint (each one is pinned to one pod). If the resolve or the join fails, resolve again with a backoff (a cold start can take up to ~45 s; give up after ~60 s). If the room closes unexpectedly, resolve again and rejoin: that is how players move off a pod that is being replaced.
Rules
Load the SDK from https://sdk.bloxity.io/v1/legion-sdk.min.js, not a bundled copy, and don't hand-roll your own POST /v1/play: the SDK reuses the player's seat on retries and carries the party ticket that keeps parties together. A page on <id>.dev.play.bloxity.io reaches your dev backend automatically; a frontend on your own domain reaches prod unless you pass { version: 'preview' }. With more than one pod, Gems webhooks can land on any pod: record each grant in your database keyed by transactionId instead of looking the buyer up in one pod's memory.
Live
The test game is single-player, so matchmaker isn't shown there โ€” the copy-paste example is the Colyseus snippet just below.
javascript
import { Client } from 'colyseus.js';

// 1. Ask the matchmaker where to connect.
const { endpoint, cold } = await Legion.SDK.net.resolveEndpoint('my-game', {
  userId: Legion.SDK.auth.getUser()?._id,
});
if (cold) showToast('Waking up a serverโ€ฆ');   // optional UX for cold starts

// 2. Open the Colyseus connection to that endpoint.
const room = await new Client(endpoint).joinOrCreate('arena', { /* your join opts */ });
Tie it into presence: once you're in a room, call Legion.SDK.game.updateRoom(room.roomId) so friends can join you, and report players via game.playerJoined() / game.playerInRoom() for the join toasts.

Party play # embed

Players on Boxity can form a party of up to 6 friends and pick a game together. Boxity promises them one thing: everyone lands in the same room. If a game cannot keep that promise, Boxity shows the party "This game doesn't support party play yet" instead of launching it, so parties are never split without warning. Support is detected automatically - there is nothing to switch on in a dashboard.

Hosted on Boxity (Legion) and using the matchmaker? You get party play for free. Boxity reserves the party's seats in one room and steers your /v1/play and Colyseus joinOrCreate calls there. Your only requirements: load the SDK from https://sdk.bloxity.io/v1/legion-sdk.min.js (not a bundled copy) before your first matchmaker request, connect through the Boxity matchmaker (see Multiplayer), and give your game room a maxClients of 2-100 (not unlimited). Boxity learns your room size from the joins it relays, so a game that connects anywhere else is treated as not supporting party play. A game whose requests never carry the party ticket is detected after two launches and parties get the error instead.

Running your own servers? Use the four calls below. You seat the party yourself using the shared roomKey; Boxity checks every launch, and a game that fails to keep two parties in a row together is switched off for party play for 7 days.

Legion.SDK.party.enable({ maxPartySize }) => voidembed
What
Declares that this game keeps parties together. maxPartySize is the most players one of your rooms can hold (2-100); a bigger party gets "Your party is too big for this game's rooms" before anyone is invited.
Call
Legion.SDK.party.enable({ maxPartySize: 8 });
When
On boot, right after init() - not after assets load. Boxity learns support from real players loading your game, and it lapses 30 days after the last load.
Legion.SDK.party.getLaunch() => Promise<LegionPartyLaunch | null>embed
What
The party this player arrived with, or null when they are playing alone. Resolves within a few seconds.
Returns
{ launchId, roomKey, size, members: [{ userId, username, displayName? }], starterId, isStarter, managed }. roomKey is identical for every member of this launch. size counts this player too. When managed is true, Boxity is already seating the party (hosted games) - join normally.
Why
Members load the game at the same moment but arrive in any order, over up to a minute. The shared key is how your server knows who belongs together.
When
Right before you matchmake or join a room.
Legion.SDK.party.joined(roomId) => voidembed
What
Reports the room this player ended up in. Every member of a party must report the same id.
Why
This is how Boxity verifies your game kept the party together. Members in different rooms, or nobody reporting, counts as a failed launch.
When
Once the player is in the party's room, whenever getLaunch() returned a party.
Legion.SDK.party.failed(reason?) => voidembed
What
Tells Boxity (and every party member) that you could not keep the party together, e.g. 'room-full'. Counts as a failed launch.
When
Only when you genuinely cannot seat the party together. Never fall back to public matchmaking silently.
javascript
// On boot.
Legion.SDK.party.enable({ maxPartySize: 8 });

// When the player presses Play / the game connects.
const party = await Legion.SDK.party.getLaunch();
const room = party && !party.managed
  ? await joinOrCreatePartyRoom(party.roomKey, party.size)   // your server: one room per roomKey, strangers kept out
  : await joinPublicRoom();
if (party) Legion.SDK.party.joined(room.id);
Colyseus tip: define the room with .filterBy(['partyKey']), join with client.joinOrCreate('match', { partyKey: party.roomKey }), and in onAuth reject any client whose options.partyKey differs from the room's, so a stranger's plain joinOrCreate can never take a party seat. The whole party (party.size) must fit under maxClients.
Testing: party play only works inside your game's Boxity page (https://bloxity.io/g/<slug>), because getLaunch() gets its answer from that page. Opened on its own (a dev URL, your own domain, localhost), the game always gets null after ~5 s. On your own servers, open the deployed game on its Boxity page once while signed in before testing: Boxity records support the first time it sees enable(), and until then parties still get "doesn't support party play yet". Then make a party of two friend accounts (Messages → Start party) and pick your game.
Not the same "party": the optional partyId in Legion.SDK.game.updateRoom(roomId, partyId) is your game's own squad or team id. It has nothing to do with Boxity parties; never pass a launchId or roomKey there.

CrazyGames & Play with friends # embed

Boxity also runs on CrazyGames: the whole site at bloxity.io/?cg=true, and the /mini site. Your game runs there unchanged, but a few CrazyGames features need it to behave in a particular way. Build for them by default and the game is ready on both.

Play with friends (instant join)

A CrazyGames player can press Play with friends. They expect to land in a new private room that friends they invite can join straight away. Boxity shows them a short picker of the games that support it. A game is offered in one of two ways.

Hosted on Boxity and using the matchmaker? You get it for free. Boxity's matchmaker opens a fresh private room for the first player and seats each invited friend in it, using the same ticket as party play; strangers are kept out. Follow Multiplayer exactly and keep it working:
  • Load the SDK with a script tag before your game's code, never from code later and never a bundled copy.
  • Connect through Legion.SDK.net.resolveEndpoint and join with joinOrCreate. Never connect to <id>.host.bloxity.io.
  • Never pick a room by id yourself, except the ?roomId= the page was opened with.
  • Never move players to another room on your own. Start the next round in the same room.
  • Give the room a maxClients of 2-100.
Every friends room is checked: a game whose first player is never placed in a room of their own twice in a row stops being offered for 7 days.

Making your own private rooms instead (your own servers, or you want your own behaviour)? Handle ?instantMultiplayer=true and announce it:

Legion.SDK.game.registerFeature('play_with_friends') => voidembed
What
Announces that this game creates its own private room for CrazyGames' Play with friends. Boxity then offers it in the picker and opens it with ?instantMultiplayer=true.
Requires
When the page URL has ?instantMultiplayer=true: create a new private room at once (strangers' joinOrCreate must never be seated in it), keep an empty one alive ~30 s so a reconnect can return to it, and report it with Legion.SDK.game.updateRoom(relayRoomId + '~' + colyseusRoomId) as soon as the player is in it.
When
On boot, right after init(), and only once the above works. Detection lapses 30 days after the last load that announced it. Boxity admins can also force a game on or off.
javascript
// On boot, once private rooms work.
Legion.SDK.game.registerFeature('play_with_friends');

// When connecting.
const instant = new URLSearchParams(location.search).get('instantMultiplayer') === 'true';
const room = instant
  ? await client.create(ROOM_NAME, { ...options, private: true })   // server: setPrivate(true)
  : await client.joinOrCreate(ROOM_NAME, options);
Legion.SDK.game.updateRoom(relayRoomId + '~' + room.roomId);

Invites

A friend who accepts a CrazyGames invite opens the game with ?roomId=, exactly like Boxity's Join button (see rooms). If your game already joins ?roomId= at launch, there is nothing extra to build.

Signing in mid-game

CrazyGames players often sign in after they start. If your game reconnects when onUserChanged reports the new user, rejoin the same room by id (new Client(Legion.SDK.environment().playUrl.replace(/^http/, 'ws') + '/v1/ws/' + relayRoomId).joinById(colyseusRoomId, options)) and fall back to matchmaking only if it is gone. Re-matchmaking would pull the player out of their friends' room.

Guest progress

CrazyGames players start as Boxity guests. When that browser signs in to an account that already existed, the guest's progress should follow. If your game saves progress on its server, verify each player with:

POST {BLOXITY_API_URL}/v1/auth/game-token/verifyserver
Call
Header Authorization: Bearer <Legion.SDK.auth.getToken()>, JSON body { gameSlug } (this game's own slug).
Returns
{ valid, user: { _id, username, displayName, pfp }, carriedFrom? }. carriedFrom lists up to 5 Boxity guest user ids this browser played as before signing in (last 30 days).
Rule
If the account has no progress in this game yet, copy the first guest's progress into it, then mark that guest save as used so it can never be copied into a second account. If the account already has progress, leave it untouched.

Controls & the CrazyGames build

  • On CrazyGames, ESC exits fullscreen and P opens Boxity's pause menu. Never bind P, and show no fullscreen button of your own.
  • Optional: set a separate CrazyGames URL for the game in its dashboard entry (for example your URL + ?cg=true). Boxity loads it on CrazyGames. Read ?cg=true from the game's own location to leave out anything you do not want there, and keep one bundle for both.
  • Ads, gameplay start/stop and the CrazyGames login are handled by Boxity. Your game does nothing for them.
Testing: open https://www.crazygames.com/game/bloxity-io?instantJoin=true (or the /mini site in CrazyGames' QA tool) and pick your game. You should land in a room with no other real players. Invite a second account from CrazyGames and accept it: both of you should be in the same room. Then sign in mid-game: you should stay in that room.

Unity (WebGL) host bridge

A Unity WebGL game runs inside a host HTML page. That page loads the Legion SDK and bridges it to Unity in both directions. The two reference implementations are Gunblox (full: auth + avatars + Gems) and 2v2 / fps (auth + social).

The two directions of communication

DirectionMechanism
C# โ†’ JS[DllImport("__Internal")] externs implemented in a .jslib under Assets/Plugins/WebGL/, plus Application.ExternalEval("window.fn('x')") for arbitrary window.* calls.
JS โ†’ C#unityInstance.SendMessage(GameObject, Method, stringValue) โ€” always a single string argument.
Key invariant: SendMessage targets a GameObject by its exact scene name. Your scene must contain GameObjects named exactly AuthManager, LegionListener, FriendsManager (and IAPManager, CursorController, SDKManager for those features), each with a MonoBehaviour implementing the matching public methods. A missing object = a silent no-op.

Project setup #

  1. Create a WebGL template at Assets/WebGLTemplates/default/ with an index.html (the Unity loader) and a bridge file legion_integration.js.
  2. In index.html, load your bridge after the Unity loader and any ad SDK so it can wrap (not overwrite) lifecycle hooks:
html
// index.html โ€” load the Legion bridge last
const legionScript = document.createElement("script");
legionScript.src = "legion_integration.js?v=2";
document.head.appendChild(legionScript);
  1. The bridge injects the SDK and initializes it with your gameSlug:
javascript
// legion_integration.js (top)
const s = document.createElement('script');
s.src = 'https://sdk.bloxity.io/v1/legion-sdk.min.js';
s.onload = () => {
  window.Legion.SDK.init({ gameSlug: 'my-game' });   // required for Gems pricing
  /* wire onUserChanged, settings.listen, player.onEvent โ†’ SendMessage */
};
document.head.appendChild(s);
Add the externs: drop a .jslib in Assets/Plugins/WebGL/ (see jslib externs) and declare the matching [DllImport] methods in C#. The full copy-paste starter is in Minimal starter.

Host bridge โ€” window.* functions #

These are the functions Unity (C#) calls via Application.ExternalEval. The bridge awaits SDK init before each one.

window functionCalls / does
onUnityReady()Unity signals it can receive auth events; flushes any pending auth result. Replaces the old loginLegion().
showLegionAuthModal()Embedded login modal (game on bloxity.io/g/[slug]); retries game-server auth if already logged in.
showLegionAuthWindow()Standalone login popup (your own domain).
logoutLegion()Legion.SDK.auth.logout().
updateLegionRoom(roomId, partyId)game.updateRoom() โ€” joinable presence.
legionPlayerJoined(name) / legionPlayerInRoom(name)Friend-join toasts.
getLegionSetting(key)Synchronous string read (safe for DllImport return).
fetchLegionConnectionsForUnity()Gets friends โ†’ SendMessage('FriendsManager','OnConnectionsReceived', json).
sendLegionConnectionRequestForUnity(userId)Friend request โ†’ OnConnectionRequestSent.
inviteLegionFriendForUnity(friendId)Invite โ†’ OnInviteSent('true'|'false').
legionPurchase(sku, serverBaseUrl)Gems purchase โ†’ SendMessage('IAPManager','OnLegionPurchaseResult', json).
legionLoadingStep(msg) / legionLoadingEnd()Loading overlay text / dismiss (called via jslib externs).
legionPartyEnable(maxPartySize)party.enable({ maxPartySize: Number(maxPartySize) }) โ€” own servers only (see Party play).
legionPartyGetLaunch()party.getLaunch() โ†’ SendMessage('LegionListener','OnLegionPartyLaunch', json or '').
legionPartyJoined(roomId) / legionPartyFailed(reason)party.joined() / party.failed().
legionDeathShow()window.legionDeath = deathScreen.show(); its onCountdown(n) โ†’ SendMessage('LegionListener','OnLegionDeathCountdown', String(n)). See Death screen.
legionDeathRespawn()Synchronous: window.legionDeath ? window.legionDeath.respawn() : true; clears window.legionDeath on true. Respawn only on true.
legionDeathHide()deathScreen.hide() and clears window.legionDeath (round over while dead).

jslib externs (C# โ†’ JS) #

The Legion-essential externs bridge C# to the loading overlay. Put this in Assets/Plugins/WebGL/legion.jslib:

javascript
mergeInto(LibraryManager.library, {
  SDK_LegionLoadingStep: function (messagePtr) {
    var message = UTF8ToString(messagePtr);   // marshal C# string โ†’ JS
    if (typeof window.legionLoadingStep === "function") window.legionLoadingStep(message);
  },
  SDK_LegionLoadingEnd: function () {
    if (typeof window.legionLoadingEnd === "function") window.legionLoadingEnd();
  }
});

Declare them in C# (guarded so the Editor still compiles):

csharp
using System.Runtime.InteropServices;

#if UNITY_WEBGL && !UNITY_EDITOR
[DllImport("__Internal")] static extern void SDK_LegionLoadingStep(string message);
[DllImport("__Internal")] static extern void SDK_LegionLoadingEnd();
#endif
Same loading-screen rule as HTML5 โ€” this is the #1 Unity gotcha. SDK_LegionLoadingEnd() (โ†’ window.legionLoadingEnd()) is what lifts the portal's loading overlay. Call it once, when Unity has actually finished loading โ€” from your first-scene Start() after the game is interactive, or your Unity loader's completion callback. If your game has "LoadingEnd Support" enabled on its developer page but never fires this, the player is stuck on the loading screen forever; if the box is off, the overlay auto-clears 1.5 s after the frame renders. Use SDK_LegionLoadingStep("โ€ฆ") during your load to show progress text. See Game lifecycle for the full truth table.

Death screen externs

Required when the player can die (see Death screen). Add to the same legion.jslib; the respawn check returns an int because a DllImport cannot return a JS boolean:

javascript
  SDK_LegionDeathShow: function () {
    if (typeof window.legionDeathShow === "function") window.legionDeathShow();
  },
  SDK_LegionDeathRespawn: function () {
    if (typeof window.legionDeathRespawn !== "function") return 1;
    return window.legionDeathRespawn() ? 1 : 0;
  },
  SDK_LegionDeathHide: function () {
    if (typeof window.legionDeathHide === "function") window.legionDeathHide();
  }
csharp
#if UNITY_WEBGL && !UNITY_EDITOR
[DllImport("__Internal")] static extern void SDK_LegionDeathShow();
[DllImport("__Internal")] static extern int SDK_LegionDeathRespawn();
[DllImport("__Internal")] static extern void SDK_LegionDeathHide();
public static void DeathShow() => SDK_LegionDeathShow();
public static bool DeathRespawn() => SDK_LegionDeathRespawn() == 1;
public static void DeathHide() => SDK_LegionDeathHide();
#else
public static void DeathShow() { }
public static bool DeathRespawn() => true;   // Editor: no countdown
public static void DeathHide() { }
#endif

// On death:   LegionBridge.DeathShow();
// LegionListener.OnLegionDeathCountdown("4".."0"): show it on your Respawn button, active only at "0".
// Respawn button: if (LegionBridge.DeathRespawn()) RespawnPlayer();

SendMessage receivers (JS โ†’ C#) #

These are the public C# methods the bridge calls. Implement the ones for features you use, on GameObjects named exactly as shown. Every payload is a single string.

GameObjectMethodPayload
AuthManagerProcessJsonAfterLoginGame-server JSON { accessToken, refreshToken, user{โ€ฆ} } (success)
AuthManagerLogout2โ€” (logged out)
AuthManagerSetLoginPendingFromJS"true"/"false" (Gunblox; fps uses SetAutoLoginPendingFromJS)
LegionListenerOnLegionVolumeChanged"0".."100"
LegionListenerOnLegionMusicVolumeChanged"0".."100"
LegionListenerOnLegionGraphicsQualityChanged"Low"|"Medium"|"High"|"Ultra"
LegionListenerOnLegionShowFpsChanged"true"|"false"
LegionListenerOnLegionCameraSensitivityChanged"0.1".."5.0"
LegionListenerOnLegionChatMessageSentchat message string
LegionListenerOnLegionRespawnRequestedโ€”
LegionListenerOnLegionDeathCountdown"4".."1", then "0" = the player may respawn (Death screen)
LegionListenerOnLegionPartyLaunchParty launch JSON { launchId, roomKey, size, members, starterId, isStarter, managed }, or "" when playing alone
LegionListenerOnLegionAvatarChangedJSON of equipped items Gunblox
LegionListenerOnLegionProportionsChangedJSON of proportions Gunblox
FriendsManagerOnConnectionsReceivedJSON array of friends (or [])
FriendsManagerOnConnectionRequestSentJSON {success,status?,error?}
FriendsManagerOnInviteSent"true"|"false"
IAPManagerOnLegionPurchaseResultJSON {success,sku,transactionId,error} Gunblox
CursorControllerOnLegionExternalLockโ€” (Gunblox: pointer locked externally)
CursorControllerOnLockChange"true"|"false"
SDKManagerOnVideoAdEnded"true"|"false" (ad fallback)

Auth handshake #

The bridge listens to onUserChanged, then authenticates the Boxity identity against your own game server and forwards the resulting session to Unity.

text
Legion.SDK.auth.onUserChanged(user)
  โ”œโ”€ user != null
  โ”‚    โ”œโ”€ result = await Legion.SDK.auth.authenticateWithServer(GAME_AUTH_URL)
  โ”‚    โ”‚     (SDK POSTs { token: <bloxity JWT>, user } to YOUR server)
  โ”‚    โ”œโ”€ valid game-server response?  (must contain user.legionId / selected_*)
  โ”‚    โ”‚     โ”œโ”€ yes โ†’ SendMessage('AuthManager','ProcessJsonAfterLogin', JSON.stringify(result))
  โ”‚    โ”‚     โ””โ”€ no  โ†’ reject (retryable)
  โ””โ”€ user == null
       โ””โ”€ SendMessage('AuthManager','Logout2')

Example endpoints (your server, not Boxity): https://usa-1.gunblox.io/api/accounts/login/legion, https://usa-1.2v2.io/api/accounts/login/legion.

What your /login/legion endpoint must do

  • Accept the POST body { token, user } and verify the Boxity JWT (confirm the user _id).
  • Upsert a local game user keyed by legionId (sync pfp/skin/hat/back from Boxity).
  • Return your own session JSON โ€” the presence of user.legionId is how the bridge knows it's a real game-server response (raw Boxity avatar data is rejected):
jsonc
{
  "accessToken":  "<your game JWT>",
  "refreshToken": "<your refresh token>",
  "user": {
    "legionId": "...", "username": "...", "displayName": "...",
    "pfp": "/pfps/...", "selected_skin": "...", "selected_hat": "..."
    /* + coins, premium, whatever your game needs */
  }
}
No game server? Skip authenticateWithServer and read the Boxity user directly from Legion.SDK.auth.getUser() / forward it to Unity yourself. The game-server handshake is only needed if your game keeps its own accounts.

Rooms, party & social #

csharp
// When the local player joins/leaves a room or your game's own squad (not a Boxity party):
LegionBridge.UpdateRoom(currentRoomId, currentSquadId);   // "" when not joinable

// Boxity parties, own servers only (hosted on Boxity = automatic, see Party play):
LegionBridge.PartyEnable(maxPlayersPerRoom);   // in Start()
LegionBridge.PartyGetLaunch();                 // before joining โ†’ LegionListener.OnLegionPartyLaunch(json | "")
// json != "" and !managed: seat everyone sharing roomKey in ONE room, then:
LegionBridge.PartyJoined(room.Id);             // same id for every member
LegionBridge.PartyFailed("room-full");       // instead, when they cannot be seated together

// As remote players appear (escape nicknames before passing to JS!):
LegionBridge.PlayerInRoom(nickname);   // already in the room when you joined
LegionBridge.PlayerJoined(nickname);   // joined after you

// Friends list (async โ†’ SendMessage callback):
LegionBridge.FetchConnections();      // โ†’ FriendsManager.OnConnectionsReceived(json)
LegionBridge.InviteFriend(friendId);   // โ†’ FriendsManager.OnInviteSent("true"/"false")
Escape nicknames: values interpolated into Application.ExternalEval("window.fn('โ€ฆ')") must have \, ' and newlines stripped/escaped (the starter's Esc() helper does this).

Settings โ†’ Unity #

The bridge registers settings.listen(...) for each key and forwards changes to LegionListener:

Setting keySendMessage methodValue
master_volumeOnLegionVolumeChanged"0".."100"
music_volumeOnLegionMusicVolumeChanged"0".."100"
graphics_qualityOnLegionGraphicsQualityChanged"Low".."Ultra"
show_fpsOnLegionShowFpsChanged"true"|"false"
camera_sensitivityOnLegionCameraSensitivityChanged"0.1".."5.0"

At startup, call Legion.SDK.settings.triggerAll() (the bridge exposes it) to push every current value into Unity immediately.

Gems / IAP #

csharp
// C#: kick off a purchase. serverBaseUrl is your live game server.
LegionBridge.Purchase(sku, "https://fra-2.gunblox.io");

// JS bridge builds metadata.webhookUrl = serverBaseUrl + "/api/legion-webhook",
// calls Legion.SDK.bux.requestPurchase(sku, metadata), then:
//   SendMessage('IAPManager','OnLegionPurchaseResult',
//     '{"success":true,"sku":"coins_500","transactionId":"txn_โ€ฆ","error":""}')

Boxity prices the sku from your game's IAP catalog (selected by gameSlug), deducts Gems, and POSTs the fulfillment webhook to serverBaseUrl + "/api/legion-webhook". Your server grants the item and returns 2xx.

Minimal starter #

Distilled from the Gunblox/fps reference code. Drop these in, name the GameObjects to match, and fill in the bodies.

Assets/Scripts/LegionBridge.cs โ€” C# โ†’ JS helper

csharp
using System.Runtime.InteropServices;
using UnityEngine;

/// Declares the C#โ†’JS externs and wraps the window.* bridge functions.
public class LegionBridge : MonoBehaviour {
    public static LegionBridge Instance { get; private set; }

#if UNITY_WEBGL && !UNITY_EDITOR
    [DllImport("__Internal")] static extern void SDK_LegionLoadingStep(string m);
    [DllImport("__Internal")] static extern void SDK_LegionLoadingEnd();
#endif

    void Awake() => Instance = this;

    async void Start() {
        await System.Threading.Tasks.Task.Yield();   // wait until your managers exist
        CallJS("window.onUnityReady()");              // tell JS we can receive auth events
    }

    public static void LoadingStep(string m) {
#if UNITY_WEBGL && !UNITY_EDITOR
        SDK_LegionLoadingStep(m);
#endif
    }
    public static void LoadingEnd() {
#if UNITY_WEBGL && !UNITY_EDITOR
        SDK_LegionLoadingEnd();
#endif
    }

    // window.* helpers
    public static void UpdateRoom(string r, string p) => CallJS($"window.updateLegionRoom('{Esc(r)}','{Esc(p)}')");
    public static void PlayerJoined(string u) => CallJS($"window.legionPlayerJoined('{Esc(u)}')");
    public static void PlayerInRoom(string u) => CallJS($"window.legionPlayerInRoom('{Esc(u)}')");
    public static void FetchConnections() => CallJS("window.fetchLegionConnectionsForUnity()");
    public static void InviteFriend(string id) => CallJS($"window.inviteLegionFriendForUnity('{Esc(id)}')");
    public static void ShowAuthModal() => CallJS("window.showLegionAuthModal()");   // embedded
    public static void ShowAuthWindow() => CallJS("window.showLegionAuthWindow()"); // standalone
    public static void Logout() => CallJS("window.logoutLegion()");
    public static void Purchase(string sku, string serverBaseUrl) => CallJS($"window.legionPurchase('{Esc(sku)}','{Esc(serverBaseUrl)}')");

    static void CallJS(string js) {
#if UNITY_WEBGL && !UNITY_EDITOR
        Application.ExternalEval(js);
#else
        Debug.Log($"[LegionBridge] (editor) {js}");
#endif
    }
    static string Esc(string v) => string.IsNullOrEmpty(v) ? ""
        : v.Replace("\\","\\\\").Replace("'","\\'").Replace("\n","").Replace("\r","");
}

Assets/Scripts/LegionListener.cs โ€” JS โ†’ C# receiver (GameObject "LegionListener")

csharp
using UnityEngine;

/// MUST be on a GameObject named EXACTLY "LegionListener".
public class LegionListener : MonoBehaviour {
    public void OnLegionVolumeChanged(string s) {
        if (int.TryParse(s, out var v)) AudioListener.volume = Mathf.Clamp01(v / 100f);
    }
    public void OnLegionMusicVolumeChanged(string s) { /* "0".."100" */ }
    public void OnLegionGraphicsQualityChanged(string q) {
        int lvl = q.ToLower() switch { "low"=>0, "medium"=>1, "high"=>2, "ultra"=>3, _=>-1 };
        if (lvl >= 0) QualitySettings.SetQualityLevel(lvl, true);
    }
    public void OnLegionShowFpsChanged(string s) { bool show = s == "true"; }
    public void OnLegionCameraSensitivityChanged(string s) { /* "0.1".."5.0" */ }
    public void OnLegionChatMessageSent(string msg) { /* relay to in-game chat */ }
    public void OnLegionRespawnRequested() { /* respawn local player */ }
    // Gunblox-only avatar sync (omit if cosmetics live elsewhere):
    public void OnLegionAvatarChanged(string json) { /* parse skinId/hatId/... */ }
    public void OnLegionProportionsChanged(string json) { /* parse height/... */ }
}

AuthManager.cs & FriendsManager.cs โ€” receiver stubs

csharp
// GameObject "AuthManager"
public class AuthManager : MonoBehaviour {
    public void ProcessJsonAfterLogin(string json) { /* { accessToken, refreshToken, user{ legionId,... } } */ }
    public void Logout2() { /* clear tokens, set guest user */ }
    public void SetLoginPendingFromJS(string pending) { /* "true"|"false" (fps: SetAutoLoginPendingFromJS) */ }
}

// GameObject "FriendsManager"
public class FriendsManager : MonoBehaviour {
    public void OnConnectionsReceived(string jsonArray) { /* [{_id,username,pfp,presence{...}}] */ }
    public void OnConnectionRequestSent(string json) { /* {success,status?,error?} */ }
    public void OnInviteSent(string result) { /* "true"|"false" */ }
}
Gunblox vs fps: Gunblox adds Gems (IAPManager) and avatar sync; fps omits both, adds a synchronous window.getLegionInviteLink(), routes pointer-lock through LegionListener.OnLegionPointerLockChanged, and uses SetAutoLoginPendingFromJS. Implement only the features your game uses.

Reference

Data shapes #

typescript
interface LegionUser {
  _id: string; username: string;
  displayName?: string; email?: string; pfp?: string;
  avatar?: LegionAvatar;
}

interface LegionAvatar {
  hatId?: string | null; backId?: string | null;
  skinId?: string;                       // default "-1"
  headId?: string; armLId?: string; armRId?: string;
  legLId?: string; legRId?: string; torsoId?: string;
  hairId?: string;                       // "-1" = none; worn with a hat
  maskId?: string;                       // "-1" = none; masks, glasses, face props
  neckId?: string; chestId?: string; waistId?: string; handId?: string; shoesId?: string;
  faceId?: string; pantsId?: string; shirtId?: string; // skin overlays; see getSkinTextureUrl()
  proportions?: BodyProportions;          // each value defaults to 1.0
}

interface BodyProportions {
  height?: number; shoulderWidth?: number; armLength?: number;
  legOffsetX?: number; torsoScaleX?: number; neckHeight?: number; headScale?: number;
}

interface LegionFriend {
  _id: string; username: string;
  displayName?: string; pfp?: string; avatar?: LegionAvatar;
  presence: UserPresence;
}

interface UserPresence {
  status: 'online' | 'in-game' | 'away' | 'offline';
  lastSeen?: string; currentGame?: string; currentRoom?: string;
  currentParty?: string; gameSlug?: string; gameName?: string;
}

Settings keys #

KeyRange / valuesDefault
master_volume"0".."100""80"
music_volume"0".."100""80"
graphics_quality"Low" / "Medium" / "High" / "Ultra""High"
show_fps"true" / "false""false"
camera_sensitivity"0.1".."5.0""1"
enable_chat"true" / "false""true"
fullscreen"true" / "false" (applied by the portal)"false"
background_transparency"0.2".."1.0""0.9"
Which are gated, and the two odd ones. Five keys only appear active in the portal menu once your game listen()s for them: master_volume, music_volume, graphics_quality, camera_sensitivity, show_fps. background_transparency is always active. enable_chat is not delivered to games (drive chat visibility from the chat_message_sent event instead), and fullscreen is applied by the portal โ€” use Legion.SDK.portal.requestFullscreen() to go fullscreen from your game.

REST endpoints (standalone mode) #

Base: the told apiUrl (https://api.bloxity.io on prod, see Environments). The SDK adds Authorization: Bearer <token> when a token exists. You rarely call these directly โ€” the namespaces above wrap them โ€” but they're useful for server-side validation.

MethodPathPurpose
get/v1/auth/meCurrent user (auto-login / refresh)
get/v1/social/connectionsFriends list
post/v1/social/connections/request/{userId}Send friend request
post/v1/social/messages/{userId}/game-inviteGame invite ({ gameId })
get/v1/social/meGems balance ({ bux })
get/v1/games/{gameSlug}/iaps/{sku}Server-authoritative IAP product
post/v1/bux/purchaseExecute purchase ({ gameSlug, sku, metadata })
get/v1/avatar/equippedEquipped + owned items
post/v1/avatar/equipPersist a single equip change
get/v1/avatar/items?limit=&offset=Paginated avatar catalog (owned + buyable)
post/v1/avatar/buyBuy an avatar item with Gems ({ itemId })
post/v1/avatar/proportionsPersist body proportions (re-renders the PFP)
post/v1/bux/transferGift Gems ({ toUserId, amount }, max 10000)
get/v1/payments/packagesReal-money Gems top-up packages
post/v1/play/{gameSlug}Matchmaker โ€” resolve a live server ({ roomId, cold })
post/v1/games/{gameSlug}/loadedLoading-complete analytics (fired by loadingEnd())

CDN asset URLs #

Avatar meshes/textures live on the told staticUrl (https://static.bloxity.io on prod). Build URLs from the equipped ids (skip any id that is '-1'/''/'undefined'/null):

SlotURL pattern (base /avatars)
Base body/player.glb
Hat/items/hats/{hatId}.obj + /textures/hats/{hatId}.png
Hair/items/hats/{hairId}.obj + /textures/hats/{hairId}.png (same folders and anchor as hats)
Mask/items/hats/{maskId}.obj + /textures/hats/{maskId}.png (same folders and anchor as hats)
Neck / Chest / Waist / Hand / Shoes/items/{slot}/{id}.obj + /textures/{slot}/{id}.png (slot folders: neck, chest, waist, hand, shoes)
Back/items/back/{backId}.obj + /textures/back/{backId}.png
Skin/skins/{skinId}.png โ€” or, when a face, shirt or pants is worn, getSkinTextureUrl(). For other players: {apiUrl}/v1/avatar/skin-texture/s{skinId or 0}[_pn{pantsId}][_sh{shirtId}][_fc{faceId}].png (segments in that order, empty ones left out) โ€” the skin with them drawn on
Head / Torso/parts/head/{id}.glb, /parts/torso/{id}.glb
Arms / Legs/parts/arms/{id}_L.glb + _R.glb (same for legs)
Item icon/icons/{id}.png
Profile picture{staticUrl}/img/pfps/{n}.png?width=128&quality=85

Where each worn item attaches on player.glb. Hats and back items use a fixed local offset. The newer accessories are authored in the body's rest pose, so parent them with local matrix restInverse(bone) ร— translate(origin), where restInverse is the inverse of the bone's rest transform in model space, captured right after player.glb loads (before any animation or proportions). Do not use skeleton.boneInverses here: on the leaf bones of player.glb they are 90ยฐ off the rest pose and put hand items sideways.

SlotBonePlacement
Hat, Hair, MaskNeck1local position (0, 0.8, 0)
BackSpine2local position (0, 0, 0)
NeckSpine2origin (0, 4.8, 0)
ChestSpine2origin (0, 3.6, 0)
WaistSpine1origin (0, 2.4, 0)
Hand (a pair)ArmL2_leaf + ArmR2_leaforigin (0, 0, 0); the mesh is the left-wrist piece, and the right wrist wears a copy mirrored across x (restInverse(ArmR2_leaf) ร— scale(-1, 1, 1) ร— translate(origin)). Follow position and rotation only, so arm-length changes do not stretch it
Shoes (a pair)LegL2_leaf + LegR2_leaforigin (0, 0, 0); the mesh is the left shoe, and the right foot wears a copy mirrored across x (restInverse(LegR2_leaf) ร— scale(-1, 1, 1) ร— translate(origin)). Follow position and rotation only

Gems webhook payload #

After deducting Gems, the Boxity API POSTs this JSON to your resolved webhook URL (metadata.webhookUrl โ†’ the IAP's webhookUrl โ†’ the game's default). Respond 2xx within 10 s to confirm the grant, or the Gems are auto-refunded.

jsonc
// POST https://your-game-server.com/api/legion-webhook
// Header: x-legion-webhook-secret: <LEGION_WEBHOOK_SECRET>   (when configured)
{
  "transactionId": "txn_1718600000000_a1b2c3d4",
  "userId":        "6602f1a2b3c4d5e6f7080910",
  "username":      "coolplayer42",
  "gameSlug":      "my-game",
  "sku":           "coins_500",
  "productName":   "500 Coins",
  "productPrice":  50,
  "metadata":      { "playerId": "p_123" },
  "timestamp":     "2026-06-17T12:00:00.000Z"
}
Verify the secret. When LEGION_WEBHOOK_SECRET is set, check the x-legion-webhook-secret header before granting anything. The grant is only final once you return 2xx.
Retries & idempotency. On a network error or a 502/503/504, Boxity retries the webhook (short backoff), so the same transactionId can arrive more than once โ€” key your grant on it and no-op duplicates. A 4xx is treated as a permanent rejection (no retry) and refunds immediately; a timeout or repeated 5xx refunds after the retries are exhausted. The whole exchange is bounded by the 10 s per-attempt timeout.