CeejayConnect
Link the player of your game to their Discord account in the community, from the game itself.
This page is for the game: a browser game, or a game whose own code runs JavaScript. It
uses the second way into the package, @communitystudios/ceejay/connect, which works with the game's
public key. A public key ships inside the game and is no secret. A secret key must
never be inside a game: the client rejects one.
A studio whose own server signs its players in can do part of this, or all of it, from that server. See Connect.
Once a player is connected, the game can show the community's quests, shop, locker, rank and
daily reward with connect.hub. That is on The game loop, inside a game.
import { CeejayConnect } from '@communitystudios/ceejay/connect'
const connect = new CeejayConnect({ publicKey: 'ceejay_pk_5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f' })The client draws nothing. Your game shows the button, the link, the code and the QR image.
How a player connects
The player presses your button. The game calls connect.start and gets a link, a code and the URL of a QR image.
The game opens the link in a browser, or shows the code. The player approves with their Discord account on CEEJAY's connect page.
The game calls connect.waitForResult, which polls for the result every 3 seconds. Once the player has approved in Discord, the client returns the Discord name to your game. The game shows it with Confirm and Cancel buttons, and the client sends the player's choice.
Both parties have approved. The client stores the tokens, and connect.state is connected.
Nothing is saved until both the Discord member and the game have approved. This step matters: a code shown on a stream can be approved by anyone who reads it, so the player can reject a name that is not theirs.
declare function showWaiting(link: string | null, code: string | null, qrUrl: string | null): void
declare function askThePlayer(discordName: string | null): Promise<boolean>
async function connectDiscord(playerId: string): Promise<void> {
const session = await connect.start({ playerId })
showWaiting(session.url, session.code, session.qrUrl)
const result = await connect.waitForResult(null, {
onConfirm: (member) => askThePlayer(member.displayName),
})
if (result.status === 'approved') {
console.log(`Connected to ${result.member.displayName}`)
} else {
console.log(`Not connected: ${result.status}`, result.reason)
}
}A member can also start in Discord, where they are given a code to type into the game.
The game then calls connect.startWithCode in place of connect.start.
Rewards and roles the community has set for connecting are granted by CEEJAY's bot within seconds of
approval. A response has a reward: its status is pending for a new
connection and changes to paid, or to the reason no reward was granted, within seconds. Poll
connect.getResult or connect.getProfile if you want to show it.
Create the client
Prop
Type
connect.baseUrl is the base URL the client sends requests to.
The origin a browser game is served from has to be on the allowed list that is saved for the
game when it is set up. A request from a browser at an origin that is not on the list is
rejected with the code origin_not_allowed. A game that is not in a browser sends no such
origin, and is not checked this way.
Where the client stores things
Two things must persist after the game is closed, and both are private to the device:
- the refresh token, which represents this device's part in the connection;
- an open session, with the secret that reads its result. A phone often closes a game while its player is in the browser, and without the session the player would have to sign in with Discord again.
Pass a storage that writes to the device's secure store: the Keychain on an iPhone, the
Keystore on Android, and localStorage for a browser game. The three functions may return a value
immediately or a promise. The client uses one key in it. Without a storage everything
is stored in memory, and is lost when the game closes.
import { CeejayConnect, type ConnectStorage } from '@communitystudios/ceejay/connect'
const storage: ConnectStorage = {
get: (key) => localStorage.getItem(key),
set: (key, value) => localStorage.setItem(key, value),
remove: (key) => localStorage.removeItem(key),
}
const connect = new CeejayConnect({ publicKey: 'ceejay_pk_5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f', storage })Use one client for one storage. Two copies of the game that renew from the same storage at the same time can sign the device out.
Opening the link
- A browser game: open a window in the click itself and set its URL when
connect.startreturns. A window that is opened later is blocked by the browser. The connect page cannot be shown inside a frame. - A game on a computer: the system's browser.
- A phone: the browser, or the system's in-app web sign-in.
- A console or a television: open nothing. Show the code, the dashboard's URL
followed by
/connect, and the image atqrUrl.
Never open the link in a web view inside the game: Discord blocks sign-in there. The client never reads the window your game opened. It polls for the result.
const button = document.querySelector('button')!
button.addEventListener('click', async () => {
// Opened in the click, before anything is waited for.
const popup = window.open('about:blank', 'ceejay-connect', 'popup,width=480,height=720')
const session = await connect.start({ playerId: 'p_19' })
if (popup && session.url) popup.location.href = session.url
})The four states of the button
connect.state is one of four values. It is known once connect.restore has run.
connect.state | What it means | What the button shows |
|---|---|---|
not_connected | This device has no connection and no open session | Connect Discord |
waiting | A session is open | The link or the code of connect.session, and a way to cancel |
connected | This device has the player's connection | Connected |
needs_attention | Connected, and the player must take action | What connect.attention says |
connect.attention indicates what the player must do, and is null in every other state:
connect.attention | What it means | Where to send the player |
|---|---|---|
not_member | The Discord account never joined the server | connect.joinUrl |
left | The Discord account left the server | connect.joinUrl |
pending | The account is in the server, and Discord's membership screening is still open | Nowhere. It passes when the member finishes the screening in Discord |
sharing | The game shares something new since the player last gave consent. A game that has the loop on also needs the player's consent for it (hub in sharing.needed) | connect.manageUrl |
connect.manageUrl is the page where the member manages their connections with nothing
but their Discord account: what is shared, which devices are signed in, and Disconnect.
connect.session is the open session, to show it: sessionId, url, qrUrl, code and
expiresAt. It is null when no session is open.
connect.onChange
Calls your function with the new state whenever the state, what needs attention or the open session changes. It returns an unsubscribe function.
declare function drawButton(state: string, attention: string | null): void
const stop = connect.onChange((state) => drawButton(state, connect.attention))
// Later, when the panel closes:
stop()connect.restore
API reference: Start connect, Get the result, Confirm connect, Get connection status, Renew a player session
Call it once when the game starts. It reads the storage and restores the state, in this order:
- An open session: the state is
waiting, and nothing is requested yet. Callconnect.waitForResultto continue polling. A session that expired long ago is discarded. - Otherwise a refresh token: a new player session is requested. The state is
connectedorneeds_attention, ornot_connectedwhen the connection has ended. - Otherwise, with
getProof: CEEJAY is asked whether this player is connected. A player who connected on another device is signed in on this one with no Discord step, when the connection was made with the member's own approval and both are at the verified level. The client starts a session for that. Where the session would need a Discord step after all, the client ends it, and the state isnot_connected: nothing is left open that the player did not request.
It returns the state. When CEEJAY cannot be reached it throws a CeejayConnectionError
and discards nothing: call it again later.
declare function askThePlayer(discordName: string | null): Promise<boolean>
const state = await connect.restore()
if (state === 'waiting') {
// The game was closed while its player was in the browser. Go on where it stopped.
const result = await connect.waitForResult(null, { onConfirm: (member) => askThePlayer(member.displayName) })
console.log(result.status)
}connect.start
API reference: Start connect
Starts a connect session for the player. The session is stored at once.
Takes one object, then options.
Prop
Type
Returns the session, typed as ConnectSession. The session secret is not in it: the
client stores sessionSecret itself.
Prop
Type
A start is never retried by the client: a second start creates a second session. A start with verified proof ends every earlier open session for the same player.
const session = await connect.start({ playerId: 'p_19', playerName: 'Nova', returnTo: 'game' })
console.log('Open', session.url, 'or type', session.code, 'before', session.expiresAt)With a connect pass your own server created, the session is at the verified level:
declare function passFromMyServer(): Promise<string>
const session = await connect.start({
playerId: 'p_19',
proof: { type: 'pass', token: await passFromMyServer() },
})
console.log(session.proof)connect.startWithCode
API reference: Start connect
For a member who started in Discord and was given a code there. The game sends the code
the player typed, and the player is attached to that member's session. The response names
the member in member.
Takes one object, then options: the fields of connect.start without returnTo,
and code. Capitalisation, spaces and hyphens in the code are ignored. A code can be used once.
Only a code the player generated themselves should be typed. Say so beside the field.
declare function askThePlayer(discordName: string | null): Promise<boolean>
const session = await connect.startWithCode({ playerId: 'p_19', code: 'K7M-2QX' })
console.log(`This code was made by ${session.member?.displayName}`)
// The player says yes in the game first. The member then confirms in Discord.
const result = await connect.waitForResult(null, { onConfirm: (member) => askThePlayer(member.displayName) })
console.log(result.status)connect.waitForResult
API reference: Get the result, Confirm connect, Start connect
Polls for the result of the open session until it ends, and returns how it ended.
Takes the session, then options.
Prop
Type
There is no default. Without onConfirm the function throws before it makes any
request, and a return value that is not true or false is rejected.
Returns one of two things, told apart by status.
A connection, typed as ConnectedResult, with status at approved:
Prop
Type
The response from CEEJAY also contains playerSession, refreshToken and refreshExpiresAt.
The client stores those three and omits them from what it returns.
No connection, typed as EndedResult:
Prop
Type
While it waits, the client:
- polls every 3 seconds, and waits as long as
Retry-Afterspecifies when it polled too often; - keeps retrying, with a growing delay, while CEEJAY cannot be reached or fails, for as long as the session can still succeed;
- reads the session again when the confirmation was rejected because the session changed
(
session_changed,nothing_to_confirm) or cannot be completed (player_blocked,player_taken): the session then reports how it ended; - starts the session again, once, when its result can no longer be read (
result_gone). An existing connection is found by the new start. Otherwise there is a new link and a new code to show, andconnect.onChangenotifies you.
declare function askThePlayer(discordName: string | null, alreadyConnected: boolean): Promise<boolean>
const closed = new AbortController()
const result = await connect.waitForResult(null, {
onConfirm: (member, { alreadyConnected }) => askThePlayer(member.displayName, alreadyConnected),
signal: closed.signal,
})
if (result.status === 'approved') {
console.log(result.member.displayName, result.member.level, result.connection.proof)
} else if (result.reason === 'player_already_connected') {
console.log('Another Discord account holds this player')
}A session your own server started is passed with the secret CEEJAY returned to the server:
declare function sessionFromMyServer(): Promise<{ sessionSecret: string; url: string | null; code: string | null }>
declare function askThePlayer(discordName: string | null): Promise<boolean>
const fromServer = await sessionFromMyServer()
const result = await connect.waitForResult(fromServer, { onConfirm: (member) => askThePlayer(member.displayName) })
console.log(result.status)connect.getResult
API reference: Get the result
Polls once for the status of the open session, for a game that runs its own loop. It takes the
session, which can be left out for the open one, then options.
Returns the session's status, by status: pending or needs_confirmation with
expiresAt; needs_game_confirmation with expiresAt, member and alreadyConnected;
or one of the two results connect.waitForResult returns. An approved result is stored the
way connect.waitForResult stores it.
const now = await connect.getResult()
if (now.status === 'needs_game_confirmation') {
console.log(`Connect to ${now.member.displayName}?`)
}connect.confirm
API reference: Confirm connect
Sends the game's confirmation or decline for the open session, after the player was shown the
Discord name. It takes the session, accept, then options. accept is true or
false: the player's choice.
Returns the session's current status, as connect.getResult does. A confirmation completes a
session the game started. For one a member started in Discord it advances the session,
and the member confirms there.
const after = await connect.confirm(null, true)
console.log(after.status)connect.cancel
API reference: Confirm connect
Ends the open session: the game's own Cancel. It takes the session, which can be left out,
then options. It returns the session's status, or null when no session was open. The
session is discarded whatever CEEJAY responds.
await connect.cancel()
console.log(connect.state)connect.getStatus
API reference: Get connection status
Reports whether a player is connected, for a game that can prove the player. It takes
playerId, the proof, then options. The proof is a token of Firebase Authentication or Unity Player Authentication that was issued in the last 10 minutes, or a fresh Steam ticket. A connect pass is not accepted here: it can be used once.
Returns connected, true or false, and nothing else. No member data is
returned. A game that cannot prove its player cannot call this: its state is the refresh token it
stores.
declare function tokenFromFirebase(): Promise<string>
const status = await connect.getStatus('p_19', { type: 'firebase', token: await tokenFromFirebase() })
console.log(status.connected)With getProof, connect.restore calls this automatically. On Steam, getProof must get a new ticket each time it is called: connect.restore calls it again before Start connect.
import { CeejayConnect } from '@communitystudios/ceejay/connect'
declare function signedInPlayer(): Promise<{ id: string; firebaseToken: string } | null>
const connect = new CeejayConnect({
publicKey: 'ceejay_pk_5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f',
getProof: async () => {
const player = await signedInPlayer()
return player ? { playerId: player.id, proof: { type: 'firebase', token: player.firebaseToken } } : null
},
})
console.log(await connect.restore())connect.playerSession
API reference: Renew a player session
The player session to send with a request made for the connected player, or null when
this device has no connection, or the Discord account is not in the server.
A player session lasts 15 minutes. The client renews it shortly before it expires, with the
refresh token, one renewal at a time. CEEJAY returns a new refresh token with each
renewal: the client stores it before the new player session is used. It also reads
membership, joinUrl, sharing and manageUrl from that response, which are the values
connect.state and connect.attention are derived from.
The requests of the game loop are made with a player session too, and the client sends them
for you: see connect.hub on The game loop, inside a game. Call
connect.playerSession yourself only for a request of your own.
const token = await connect.playerSession()
if (token === null) {
console.log('No player session:', connect.state, connect.attention)
}connect.getProfile
API reference: Get the player's profile, Renew a player session
Reads the connection and the member card of the connected player. It takes options only.
Returns the profile, typed as PlayerProfile, or null when this device has no
connection or the Discord account is not in the server: connection, member, sharing,
reward and manageUrl, as in the result of connect.waitForResult, and hub. hub is an
object with enabled, which is true when the community switched the game loop on for the
game: show the loop's screens only then.
The card is what CEEJAY stores, and it is checked on every request: a disconnect, a move, a
ban and a device that was signed out take effect immediately. The client then discards its
tokens, and connect.state is not_connected.
const profile = await connect.getProfile()
if (profile) {
console.log(profile.member.displayName, profile.member.level, profile.member.coins)
for (const role of profile.member.roles) console.log(role.name)
if (profile.hub.enabled) console.log('Show the quests and the shop')
}connect.disconnect
API reference: Disconnect
Ends the player's connection, on every device, and discards the tokens. It takes options
only. CEEJAY responds with disconnected, and the function returns nothing.
It works when no player session is held, so a person who never joined the server can still disconnect. It is safe to retry. When CEEJAY cannot be reached it throws, and nothing is discarded.
await connect.disconnect()
console.log(connect.state)Errors
An error is a CeejayError, with status, code, message, retryAfter, requestId
and body, as in the server client. No response at all is a CeejayConnectionError. Both
are exported from @communitystudios/ceejay/connect. Check code, not message.
An instance that does not offer Connect yet returns not_available. The client throws it as
a CeejayNotAvailableError, which is also a CeejayError, so a game can hide its button:
import { CeejayNotAvailableError } from '@communitystudios/ceejay/connect'
try {
await connect.restore()
} catch (error) {
if (error instanceof CeejayNotAvailableError) {
console.log('This community does not offer Connect yet')
} else {
throw error
}
}A 413 returned only by a proxy in front of CEEJAY, in plain text, is given the code
body_too_large, as the server client does. The errors of the game loop are on
The game loop, inside a game. A purchase or a claim that is blocked by a spending hold is a
CeejaySpendBlockedError, which is also a CeejayError.
No error ever shows a session secret, a refresh token, a proof or a player session.
Errors for any request:
| Status | code | When |
|---|---|---|
400 | bad_request | The body cannot be parsed, or a field has the wrong type |
401 | invalid_public_key | CEEJAY does not know this public key |
401 | key_retired | The public key was retired. Ship the new key before the old one is retired |
401 | key_suspended | The public key was suspended |
401 | wrong_key_kind | The request was sent with another kind of key than it takes |
403 | origin_not_allowed | The browser game is served from an origin that is not on the game's allowed list |
403 | community_not_active | The community removed CEEJAY |
403 | audience_not_allowed | The game is directed at children, and such a game cannot connect players |
404 | not_available | This instance does not support Connect yet |
404 | game_not_found | The game was removed |
405 | method_not_allowed | The request was sent with the wrong method. The client never does |
409 | game_paused | The game is paused |
429 | rate_limited | Too many requests. retryAfter says how long to wait |
500 | internal_error | Something failed inside CEEJAY |
Errors of connect.start and connect.startWithCode:
| Status | code | When |
|---|---|---|
400 | bad_player_id | playerId contains a character that is not allowed, or is too long |
400 | bad_proof | proof is not an object with a type and a token |
400 | unknown_return | returnTo is not the key of a return URL saved for the game |
400 | age_answer_required | The game is set up for a mixed audience, and underAge was not sent |
401 | proof_rejected | The proof was not accepted. error.body contains reason: the signature, the time, the player or the project did not match, or a pass was used before |
403 | proof_required | The game only connects players it has proven, and no proof was sent |
403 | under_age | You sent underAge: true. Nothing is stored |
404 | code_not_found | No open session has the code the player typed |
409 | session_locked | The code belongs to a session that was locked by this failed attempt, its fifth. The member starts again in Discord |
429 | too_many_attempts | Too many attempts with a code that is not open. retryAfter says how long to wait |
503 | proof_service_unavailable | The proof could not be checked right now. Retry |
connect.getStatus can return bad_player_id, bad_proof, proof_rejected,
proof_required and proof_service_unavailable as well.
Errors about an open session, which connect.waitForResult handles as described above:
| Status | code | When |
|---|---|---|
401 | invalid_session_secret | CEEJAY does not know the session. The client forgets it |
404 | session_not_found | The session is gone |
409 | nothing_to_confirm | A confirmation was sent, and the session is not awaiting one right now |
409 | session_changed | The session changed since it was read |
409 | player_blocked | The community blocked this player |
409 | player_taken | The player is connected to another Discord account, at the verified level |
410 | result_gone | The result can no longer be read: more than 10 minutes passed since the session was approved, or the member disconnected since |
Errors about the tokens. After the first three the client forgets its tokens, and
connect.state is not_connected:
| Status | code | When |
|---|---|---|
401 | connection_ended | The connection has ended: the member disconnected, moved to another player or signed this device out |
401 | refresh_reused | The refresh token was replaced and then used again. The device is signed out |
401 | invalid_refresh_token | CEEJAY does not know the refresh token |
401 | invalid_player_session | The player session has expired. The client requests a new one, once |
What the client sends
Two headers and no other: Authorization and, with a body, Content-Type. Any other header triggers a CORS preflight request, which CEEJAY would reject.
After a 429, a 5xx or no response the client waits and retries, up to maxRetries
times, for every request except Start connect. Retrying is safe: reading a result changes nothing, a
confirmation already given does not change, a refresh token whose response was lost is accepted once more,
and an ended connection stays ended.
The requests of the game loop follow the same rule, with two exceptions. Check in is never
retried automatically: a repeat returns checkin_cooldown. Buy a shop item is retried
only with its idempotency key, the same key every time, and the client also waits and retries
while the first request with that key is still running. That is described in
The game loop, inside a game.
Runtimes
A browser, Node 20 and later, Deno, and a game's own JavaScript runtime. The client uses
fetch and nothing else.

