API Reference
Here is all the cool stuff[^1] a game running in CUE Portal can call. The SDK is a small client library. You import it, initialise it, and it handles all the talking to the portal for you. LIKE MAGIC.
Overview
Your game runs inside the portal page, but in its own protected box (an <iframe>, a page inside a page). It never touches the portal's data directly. Instead it asks, and the portal decides.
The SDK is how you ask. It gives you a small set of functions plus the <cue-card> element for drawing cards on screen.
Every SDK call returns a 'promise', which basically means the answer arrives a moment later. Put await in front of the call and your code waits for the answer before moving on.
// At a glance
const cue = createCueClient({ gameId: 'my-game' });
await cue.init(); // connect to the portal. Call this first!
await cue.player.me(); // who's playing
await cue.player.collection(); // the card IDs they own
await cue.player.decks(); // the decks they've built
await cue.cards.get(ids); // card details and art urls
await cue.storage.get(key) / set(key, val);// save and load your game's stateAsking, not writing
SDK calls are requests, not writes. You are asking the portal to do a thing, and the portal checks its own rules first. Only then does anything change. The upside for you: a bug in your game can never hand out cwhat it shouldn't, so you can experiment freely.
Read-only calls (player.me, player.collection, player.decks, cards.get) simply return data. Calls that change something (economy.*) return the portal's decision, and the answer can be no. AND NOTE: currently the answer on rewards is always no as we haven't decided how this should work yet.
So - Design your game so a "no" is handled gracefully.
Initialise
Creates the client object. This sets things up locally but does not talk to the portal yet.
Connects to the portal and resolves once the connection is ready. Call this before anything else. Any other SDK call made earlier simply waits in a queue until it completes. Returns { portalVersion, player }.
import { createCueClient } from '@cue/sdk';
const cue = createCueClient({ gameId: 'tripeaks' });
const { player } = await cue.init();
console.log(`Hello, ${player.name}`);Identity & collection
Some calls hand back a plain bundle of fields with a name ending in DTO, like PlayerDTO or CardDTO. This refers to the 'shape' of the answer you get back - fields are listed in a table right below it.
The signed-in player. Each unique player has a single identity across every game on the portal.
The ids of the cards this player owns. Pass them to cards.get() to get the full card details.
The decks this player has made, in the portal or the CUE app. The ones they played most recently come first. You can read them but you can't change them.
The deck marked active is their main deck, the one they picked to play with. If they never picked one, it's the most recent deck they can play with. Players who aren't signed in have no decks, so you get an empty list.
Pass a deck's cards to cards.get() to get the card details. Check playable before you use a deck: it's false when the deck isn't finished yet or has cards the player no longer owns.
PlayerDTO
| Field | Type | Notes |
|---|---|---|
id |
string | Stable player id |
name |
string | Display name |
avatarUrl |
string | Absolute URL |
cardBackUrl |
string? | The card back this player picked for their deck (in the portal collection or the CUE app). If it's missing that just means "use the default back" |
sleeveUrl |
string? | Their deck sleeve art, same idea as above |
currency |
{ coins, gems, xp } | Read-only info |
DeckDTO
| Field | Type | Notes |
|---|---|---|
id |
string | Stable deck id |
name |
string | The name the player gave it |
cards |
string[] | Card ids, in deck order. Pass them to cards.get() |
active |
boolean | True for their main deck. Only one deck has it |
playable |
boolean | True when the deck has 18 different cards and the player owns all of them |
cardBackUrl |
string | The card back picked for this deck (the normal back if none) |
sleeveUrl |
string | The sleeve picked for this deck, same idea |
Cards
Turns card IDs into full card details, including art URLs served by the portal. IDs the portal doesn't recognise are left out of the result. Pass opts.imageOmit to get card images without some parts (see below).
Browse the card catalog (owned or not). Filters are optional - call it with none to get everything.
Filters: album (name, case-insensitive), collection (name, case-insensitive), rarity, acquireType, rankMin / rankMax, foil, ownedOnly (just the player's collection), and hasQuiz (only cards that come with quiz questions). imageOmit works here too. Use it to build games around specific parts of catalog itself, like a quiz about Space cards.
const spaceEpics = await cue.cards.query({ album: 'Space', rarity: 'EPIC' });
const myHighCards = await cue.cards.query({ ownedOnly: true, rankMin: 10 });Card images normally show the whole card: frame, name, rarity, energy, power and the ability box. If your game draws its own stat display then you can (and should!) ask for images with some parts left off using imageOmit. You can remove 'power', 'energy' and/or 'ability', to make your version of the card that bit easier to parse. NB. frame, name and rarity always stay, for consistency.
// Card images without the stat badges, for a game with its own HUD:
const cards = await cue.cards.get(hand, { imageOmit: ['power', 'energy'] });
// Or from a catalog browse:
const clean = await cue.cards.query({ album: 'Space', imageOmit: ['ability'] });
A lil' bit of context for y'all: currently there are about 6,700 cards across 7 albums (and not an 'Our Planet' in sight...) with roughly 250 of them as shinies (aka in foil). Distribution of energy amounts is... erm... widely varied. So there are two 13s, but thousands of 6s and 7s. [Yes 6s and 7s. No, don't do that thing with the hands. You're better than that.].
A brand-new player is only guaranteed to own 18 starter cards - but frankly if they're playing your game they've probably gone a bit further than that.
CardDTO
| Field | Type | Notes |
|---|---|---|
id |
string | The CUE card code, e.g. EAG001 |
name |
string | Card name |
rank |
number | The card's energy. Most cards are 1 to 13, which makes it handy as a play rank, but some are 0 and a few are 14 or more |
power |
number | Card power |
rarity |
Rarity | COMMON → MYTHIC, FUSION, ULTRAFUSION, plus the rare WORLD and FABLED tiers |
acquireType |
string | How the card is obtained in CUE: normal (the everyday base pool), limited (from time-limited events), fusion, crafted or level |
album |
string | e.g. "Space", "Oceans & Seas" |
collection |
string | The collection inside the album, e.g. "Ocean Mammals" |
foil |
boolean | Shiny card |
foilRank |
number | How shiny: 0 means not foil, 1 is the normal foil, 2 is the rarer extra-shiny tier. foil is just shorthand for foilRank > 0 |
tags |
string[]? | Themed groups the card belongs to, like "Turtle", "Moon" or "Knight of the Round Table". Great for building themed pools - see below. Only included on relevant cards. |
imageUrl / backUrl |
string | Absolute art URLs for <cue-card>. backUrl is the back the player playing your game picked (or the normal back if they didn't), so their face-down cards automatically look like theirs. To show an opponent's back instead, set the element's back attribute to their entry in the match start event's cardBacks |
dyk |
string? | "Did you know" trivia, if present |
abilityTitle |
string? | The ability's name, e.g. "Marching Bootes". Only on cards with an ability |
abilityText |
string? | What the ability does, as plain text, one clause per line, with the card's icons as tokens like :power: (see below). Only on cards with an ability |
quiz |
QuizQuestion[]? | Trivia quiz questions about this card, if any exist (see below) |
borrowed |
boolean? | True if the portal lent this card to the player for your game - function not to be used currently |
Tags: ready-made themes
About a third of the catalog carries tags: theme labels like "Octopus", "Winged Mythical" or "Knight of the Round Table". And we plan on adding a load more for the purposes of the portal. They are perfect when you want a card pool that feels hand-picked rather than random. There is no tag filter on cards.query(), but filtering the result yourself is one line:
const all = await cue.cards.query();
const knights = all.filter((c) => c.tags?.includes('Knight of the Round Table'));A card can have more than one tag, and most cards have none, so always check with ?. like above.
Ability text
abilityText is the wording printed in the card's ability box, with no formatting. A card with more than one ability has one line per clause, so split('\n') gives you each one. Where the card shows an icon, the text has a token instead:
| Token | Icon |
|---|---|
:play: |
When played |
:draw: |
When drawn |
:return: |
When returned to the deck |
:turnstart: |
At the start of a turn |
:power: / :energy: |
Power / energy |
:power/turn: / :energy/turn: |
Power / energy each turn |
:burn: |
Burn |
:lock: |
Lock |
const [card] = await cue.cards.get(['HEV003']);
card.abilityText;
// ":draw: For every Holiday 19 card in your deck, your Holiday 19 cards in-hand gain +2:power: until played.
// :play: Your Holiday 19 cards gain +5:power: this turn.
// :return: Your Plant Life cards, wherever they are, gain +5:power: permanently."Swap the tokens for your own icons or words when you show the text. To make the abilities actually do something in your game, use the rules engine instead of reading the words.
Quiz questions
Most cards carry ready-made trivia questions about their subject, so you can build a quiz game without writing a single question yourself. They arrive on the quiz field of every card from cards.get() and cards.query(). A card has one to three questions; some cards have none, so deal from cards.query({ hasQuiz: true }) when your game needs them.
QuizQuestion
| Field | Type | Notes |
|---|---|---|
difficulty |
number | 1 (easiest) to 4 (hardest) |
question |
string | The question text |
options |
string[] | Always 4 answers to choose from |
answer |
number | The position of the correct answer in options, counting from 0 |
randomize |
boolean | True: shuffle the options before showing them. False: they are an ordered scale (like "5, 6, 7, 8"), show them in the order given |
const pool = await cue.cards.query({ album: 'Space', hasQuiz: true });
const card = pool[Math.floor(Math.random() * pool.length)];
const q = card.quiz[0];
ask(q.question, q.randomize ? shuffle(q.options) : q.options);
// the right answer is q.options[q.answer], so remember it BEFORE you shuffle
Always shuffle when randomize is true. In the raw data the right answer usually sits in the middle two positions, and players notice patterns like that fast. And since the questions and answers are data your game receives, a quiz is a fun game, not a secure exam :)
Rendering cards with <cue-card>
The card renderer is a custom HTML element and will look like a CUE card in every game. Register with defineCueCard(), then create and place it like any other element. It works with any framework (or none!) because it is just part of the page.
If you're drawing your game on a canvas instead, use @cue/2d, which draws the same card with the same five states (face down, shiny, selected, matchable, disabled). Both are fine; pick by whether your game is a layout or a spectacle.
import { defineCueCard } from '@cue/sdk';
defineCueCard();
const [card] = await cue.cards.get(['EAG001']);
const el = document.createElement('cue-card');
el.card = card; // fills the art, name and foil from the card in one line
document.body.append(el);el.card = card is the easy way: hand it a card from cue.cards.get() and it shows the art, name and foil. The art is the full card face, with the energy, power and ability text printed on it, so there is nothing extra to draw. If you want finer control you can still set each attribute yourself (see the table below). cue.cards.get() remembers the cards you ask for, so you can call it on each redraw without slowing down, meaning there is no need to keep your own list of cards.
Try it, live
The last three buttons are imageOmit (see Cards) in action.
Attributes
| Attribute | Effect |
|---|---|
image |
Front art URL |
back |
Card-back URL (shown when face-down). Optional: leave it off and the shared CUE card back is used |
name |
Card name, used as the art's alt text |
face="down" |
Show the back instead of the front |
foil |
Animated iridescent sheen |
selected |
Lifted, with a violet ring |
matchable |
Green "playable now" glow |
playable |
Pointer cursor and a hover lift |
disabled |
Dimmed and desaturated |
--cue-card-w |
CSS variable that sets the width (aspect stays 5:7) |
What it looks like
If you're building without being able to see it live (using an AI assistant, for example), this is a description of what you'd be seeing...
The card is a rounded rectangle (default width 92px, always 5:7) with the art filling it edge to edge over a near-black backing.
The art is the finished card face with the name, energy number, power and ability text as part of the image itself. A face-up card is fully readable with no overlays.
Face-down shows the back image (if you do not set one, it falls back to the default CUE card back). <cue-card face="down"> always looks right with no extra work. While a face-up card's art is still downloading the card shows a soft grey shimmer in its place, not the back, so a table of loading cards should not look like a deck being turned over.
foil is a diagonal glint that slides across the art.
selected lifts the card slightly and gives a violet glow,
matchable adds a green glow,
disabled greys it out, and
playable gives it a pointer cursor and a hover lift.
Save state
A simple key-value store, kept separately for each game and each player. Values are strings, so JSON.stringify your state on the way in and JSON.parse it on the way out. Saves are stored with the player's account, so they survive reloads and follow the player to any browser on any device, and no other game can read or write them. Sensible limits apply: keys up to 128 characters, values up to 64KB, 100 keys per game.
await cue.storage.set('save', JSON.stringify(state));
const raw = await cue.storage.get('save');
const state = raw ? JSON.parse(raw) : newGame();Multiplayer
A real-time match server. The platform handles rooms, seats and matchmaking; your game decides what a "turn" or a "move" means.
The easy way is one call. cue.match.lobby() shows a ready-made lobby (a Play button, a "Play with a friend" code screen, and a waiting screen) and hands your game a live match once it starts. You write no menu of your own.
const match = await cue.match.lobby();
if (!match) return showMyTitleScreen(); // the player closed the lobby without starting
// match.room is your live Room. match.seat is your own seat number.
// match.roster is every player's name, in seat order.
startPlaying(match);
Shows the lobby and resolves once a match begins. Every options field is optional: solo adds a "Play the computer" button, title and subtitle change the heading, and dismissable: false hides the close button so the player can't back out. If the player does close the lobby it resolves null, so you can show your own screen again.
The Match you get back has everything you need to begin: room (your live Room), code (the room's shareable 4-letter code), seat (your own seat number), roster (every seat's name, in order), each seat's chosen look in that same order (avatarUrls, cardBacks, sleeves), decks (each player's main deck), and seed (the shared match seed, see below).
decks lists each player's main deck as card ids, in the same order as roster. A player with no playable deck gets null. Everyone in the match can see everyone's deck.
The lobby's waiting screen shows the room code and an Invite portal friends button. The portal draws the friend list and sends the invites itself (your game doesn't get the data directly). An invited friend gets a notification with a Join button, and when they tap it your game opens with the lobby already joining that room. You get all of this for free by using lobby(); if you build your own menu with the calls below, invited players still land in your game, they just start from your menu like anyone else.
You can make the lobby your game's whole front screen so a player is one tap from playing, with no menu in front of it. Pass actions to add your own buttons (a practice mode, a tutorial) beside Play, and set dismissable: false since there is nowhere to close to. If the player taps one of your buttons, lobby() resolves with { action: 'the-id-you-gave' } instead of a match, and you do whatever that button means:
const r = await cue.match.lobby({
title: 'Card Runner',
dismissable: false,
actions: [{ id: 'practice', label: 'Practice run' }],
});
if (r.action === 'practice') startPractice(); // your own button
else startMatch(r); // r is a Match: r.room, r.seat, r.roster …Set your game's Max players (and, if you like, Min players) in Studio → Details. Max is how many seats a room has. Set Min below it and a match can start early once that many people have joined. The player who opened the room gets a Start button, and a short countdown starts it for them if they wait too long. Leave Min the same as Max to always wait for a full room.
Build the lobby yourself
Want your own menu and waiting screen instead? The lobby is built on these four calls:
host() creates a room and returns a 4-character code to share. join(code) joins one. quick() pairs you with whoever else is waiting for the same game. solo() starts a match against the computer, so a player never has to wait for someone else.
solo(), and the lobby's "Play the computer" button, only has an opponent to play if your game runs its match on the server (like the CUE card game does). For those games the second seat is played by the computer, and everything else, including the cards a player brings, works exactly like a two-player match.
Room
const { code, room } = await cue.match.host();
showCode(code); // share this with the other player(s)
room.on('start', (m) => {
// m.seat is your seat index. m.roster is one entry per SEAT, in order, with null for
// every empty one — so m.roster.length is the seat COUNT, not the player count. To
// count players: m.roster.filter(Boolean).length
// m.avatarUrls, m.cardBacks and m.sleeves are each seat's chosen look, in the
// same order (null where a player uses the defaults). The portal fills these
// from each player's account, so nobody can wear someone else's look.
// Example: show the other player's card back on their face-down cards:
// oppCardEl.setAttribute('back', m.cardBacks[oppSeat] ?? '');
// m.decks is each seat's main deck (card ids), or null if that player has no
// playable deck.
// m.seed is the match seed: a server-made random number, the SAME for every
// player in this match and different next match. Feed it to a seeded random
// function and every client deals the same shuffle with no messages needed:
// let s = m.seed;
// const rand = () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
});
room.on('peer-action', (m) => {
// m.from is the seat that sent it; m.type / m.data are whatever they sent
});
room.on('seat-left', (m) => { /* m.seat left the game - everyone else plays on */ });
room.on('ended', (m) => { /* the match itself stopped - too many players left (m.reason) */ });
room.on('disconnected', (m) => { /* YOUR connection dropped (network/server), m.reason */ });
room.send('move', { x: 1, y: 2 }); // every OTHER seat gets a peer-action for thisThese events (start, peer-action, seat-left, ended, disconnected) are received by all games, and they're typed.
Streaming positions: room.state()
room.send() delivers every message you give it, to every other seat. That is what you want
for events: a goal, a pickup, a hit, a score. It is the wrong tool for something you send
over and over, like where your player is standing, because in a big room the messages multiply:
with 32 players each sending 15 times a second, the room has to push more than 14,000 messages
a second and it falls over.
For that, use room.state(). You hand it your latest state, as often as you like. The portal
keeps only the newest one per player and sends everybody's together, about 30 times a second,
as a single states event:
// Every player publishes where they are, 15 times a second.
setInterval(() => room.state({ x: me.x, y: me.y, facing: me.facing }), 66);
// Everyone else's latest state, all in one go. Keyed by seat number.
room.on('states', (m) => {
for (const seat in m.seats) {
if (Number(seat) === mySeat) continue; // your own is in there too, skip it
const s = m.seats[seat];
others[seat].moveTo(s.x, s.y, s.facing);
}
});If a newer frame turns up before the old one has gone out, the old one is dropped, which is exactly right for a position (you only ever want the current one) - so use state() for that. It's exactly wrong for a score, so for that you'd use send().
A seat that has not published since the last tick is simply missing from m.seats. Keep showing
its last known state.
If you're making a 3D game with @cue/3d then room.state() is not yours to call. The engine uses it to stream everyone's position (as you'd expect), and the portal keeps only the newest state per player. Calling it yourself would throw your own character's position away. Positions are already handled for you - just use room.send() for your own messages.
By default the match server is a relay, not a referee. It enforces the seat count and delivers your send() calls to every other connected seat as peer-action, but it does not check what's inside them. That is enough for casual multiplayer (turn-taking, shared state) but not for anything where a modified client could cheat by sending a fake action.
Turn-based matches
Multiplayer where nobody has to be online at the same time. A player asks for a match, the portal pairs them with the next players who ask, and everyone takes turns over hours or days. When you finish your turn, the portal sends the next player a notification with a Play button that opens your game right back on that match. Think chess by post, with CUE cards.
Turn it on in Studio → Details: switch on Turn-based matches, pick Players per turn match (2 to 8) and a Turn time (1 hour to 2 weeks, 72 hours by default). A player who runs out of turn time is out of the match, and the last player left standing wins.
The whole loop looks like this:
import { createCueClient } from '@cue/sdk';
const cue = createCueClient({ gameId: 'my-game' });
await cue.init();
// Did a notification bring the player here? Then resume that match.
const ctx = await cue.turn.context();
if (ctx) {
showMatch(await cue.turn.get(ctx.matchId));
} else {
showMenu(await cue.turn.matches()); // their matches, active first
}
// Your menu's Play button:
const res = await cue.turn.play();
if (res.status === 'matched') showMatch(res.match); // someone was waiting
else tellThem("You're in the queue. We'll send a notification when an opponent turns up.");
// Taking a turn:
const updated = await cue.turn.submit(match.matchId, {
state: JSON.stringify(myGameState), // your own data, up to 64KB
move: cardId, // what you did, shown to the next player
}, match.turn);
showMatch(updated);The portal stores your match between turns and tells the next player it is their move. It never looks inside state or checks that a move is legal. That is your game's job, the same deal as room.send() in a live match.
Ask for a match. If enough players are already waiting you get matched and the match starts now (the longest waiter is seat 0 and moves first, so it may not be your move yet). Otherwise you are queued: the player can close your game, and the portal notifies them when the match fills. Each player can have up to 10 matches going in your game.
The player's matches in your game, active first. Build a "your games" screen from it: each match says whose move it is (currentSeat vs yourSeat), the names in seat order (roster), and how it ended (status, winnerSeat, endReason).
One match, fresh. state is the blob the last turn saved (an empty string before the first turn), lastMove is what the previous player did, seed is a random number fixed when the match was made (both players see the same one, so use it to deal the same cards on both sides), and deadlineAt is when the current player times out.
Take your turn. turn.state (up to 64KB) replaces the match state and turn.move (up to 8KB) tells the next player what you did. Pass turn.result = { winnerSeat } to end the match (null means a draw), and the portal tells everyone. By default the turn passes to the next player still in the match; set turn.nextSeat to your own seat to take several moves in a row (no notification goes out until the turn leaves you).
expectedTurn must be the turn number from the match you rendered. If the match changed since (the player moved on another device, or tapped twice), the call rejects with the code ETURN_CONFLICT and nothing is applied. The error carries the fresh match on err.data, so catch it and re-render from that.
Give up. In a 2-player match the other player wins. With more players the match carries on without the leaver (their seat shows up in resigned, and turns skip them) until one player is left standing.
The match this launch should resume, when the player arrived from a turn notification. Read it once at boot, before showing your menu: null means a normal launch. It only answers once per launch.
Leave the matchmaking queue. Safe to call even when not queued.
Testing needs two accounts. Matches pair different players, so you can't play yourself. Get a teammate: ask a friend to sign up, add them as a portal friend, and invite them to your team in the Studio. Then the Details tab grows a Turn match button that starts a real match between the two of you, and it works before your game is published. Your teammate gets the "wants to play" notification, and its Play button opens your game's test version on the match.
Shared docs
Save state (cue.storage) is private, so each player sees only their own. Shared docs are the opposite. A player makes something in your game (a tier list, a bracket, a level), publishes it, and every other player can open it. The portal stores it, so it survives reloads, works on any device, and never has to be squeezed into a share code.
A doc starts private: only its author can read it, so it works as a draft. Publish it with visibility: 'public' and anyone signed in can open it. Each player can keep up to 20 docs per game, each up to 64KB of JSON.
// Save the player's tier list as a draft, then make it public.
let saved = await cue.docs.publish({ title: 'Best dinosaurs', doc: myList });
saved = await cue.docs.publish({
docId: saved.docId,
title: saved.title,
doc: myList,
version: saved.version,
visibility: 'public',
});
// Get a link the player can send to a friend.
const url = await cue.docs.link(saved.docId);The link looks like .../#/play/your-game/abc123.... When someone opens it, the portal launches your game and hands you the doc's id. Check for it at boot, before showing your menu:
const ctx = await cue.docs.context();
if (ctx) {
const doc = await cue.docs.get(ctx.docId);
showTierList(doc); // straight to the shared thing, no menu
}
Create a doc (leave docId out) or update one of yours (pass docId and the version you last saw). doc is any JSON value up to 64KB; title is required and the portal cleans it up (no markup, no contact details). visibility is 'private' (the default for a new doc) or 'public'; leave it out on an update to keep what it was.
If the doc changed since you read it (another device, a double tap), the call rejects with the code EDOC_CONFLICT and nothing is applied. The error carries the fresh doc on err.data, so catch it and re-render from that.
One doc: any public doc in your game, or the caller's own private one. The result has docId, title, doc (your JSON back), visibility, version, author (a display name), yours (true for the author), and updatedAt.
Your game's public docs, newest first (up to 50 per page; pass before: lastDoc.updatedAt for the next page). With { mine: true } it lists the caller's own docs instead, drafts included, which is how you build a "your lists" screen.
The doc's shareable address on the portal. Show it in your own copy-this-link UI. Anyone who opens it lands in your game with the docId waiting in docs.context(). Only useful for public docs: a private doc's link would show the visitor nothing.
Delete one of the caller's docs, for good.
The doc this launch should open, when the player arrived through a shared link. Read it once at boot. null means a normal launch. It only answers once per launch.
Use card codes inside your doc (the id from cards.get), never anything else, so a list still means the same cards after the catalog updates.
Errors
When the portal says no to a request, the call throws a CueRequestError with a .code:
| Code | Meaning |
|---|---|
ECON_EXHAUSTED |
Economy budget or limit reached, so the grant was denied |
EBADREQ |
Malformed params (e.g. unknown card id) |
EMETHOD |
Method not allowed by the portal |
ETIMEOUT |
No response within 10s |
ETURN_CONFLICT |
A turn submit lost a race (or it was not your turn); err.data carries the fresh match |
EDOC_CONFLICT |
A doc publish lost a race (it changed since you read it); err.data carries the fresh doc |
EINTERNAL |
Portal-side error |
Under the hood
You don't need this to build a game, but here is how the boundary works. The SDK and the portal pass small messages to each other (the browser's postMessage mechanism), and both sides check exactly who they are talking to before trusting anything:
// request (game -> portal)
{ cue: '1', dir: 'req', id: 'c-42', method: 'player.me', params: {} }
// response (portal -> game)
{ cue: '1', dir: 'res', id: 'c-42', ok: true, result: { /* ... */ } }- Your game only ever trusts messages from the portal; the portal only trusts your game's own address and the exact frame it launched.
- Every message names its intended receiver explicitly. Nothing is broadcast.
- Save data lives with the player's account on the portal's server (a sandboxed game can't keep its own browser storage), which is why
cue.storageis the only way to persist anything.
