CEEJAYDocs

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.

Create the Connect client
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.

How a player connects
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.

Where the client stores things
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.

  • A browser game: open a window in the click itself and set its URL when connect.start returns. 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 at qrUrl.

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.

Opening the link
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.stateWhat it meansWhat the button shows
not_connectedThis device has no connection and no open sessionConnect Discord
waitingA session is openThe link or the code of connect.session, and a way to cancel
connectedThis device has the player's connectionConnected
needs_attentionConnected, and the player must take actionWhat connect.attention says

connect.attention indicates what the player must do, and is null in every other state:

connect.attentionWhat it meansWhere to send the player
not_memberThe Discord account never joined the serverconnect.joinUrl
leftThe Discord account left the serverconnect.joinUrl
pendingThe account is in the server, and Discord's membership screening is still openNowhere. It passes when the member finishes the screening in Discord
sharingThe 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.

connect.onChange
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:

  1. An open session: the state is waiting, and nothing is requested yet. Call connect.waitForResult to continue polling. A session that expired long ago is discarded.
  2. Otherwise a refresh token: a new player session is requested. The state is connected or needs_attention, or not_connected when the connection has ended.
  3. 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 is not_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.

connect.restore
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.

connect.start
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:

connect.start with a connect pass
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.

connect.startWithCode
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-After specifies 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, and connect.onChange notifies you.
connect.waitForResult
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:

connect.waitForResult for a server session
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.

connect.getResult
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.

connect.confirm
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.

connect.cancel
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.

connect.getStatus
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.

connect.getStatus with getProof
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.

connect.playerSession
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.

connect.getProfile
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.

connect.disconnect
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:

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

StatuscodeWhen
400bad_requestThe body cannot be parsed, or a field has the wrong type
401invalid_public_keyCEEJAY does not know this public key
401key_retiredThe public key was retired. Ship the new key before the old one is retired
401key_suspendedThe public key was suspended
401wrong_key_kindThe request was sent with another kind of key than it takes
403origin_not_allowedThe browser game is served from an origin that is not on the game's allowed list
403community_not_activeThe community removed CEEJAY
403audience_not_allowedThe game is directed at children, and such a game cannot connect players
404not_availableThis instance does not support Connect yet
404game_not_foundThe game was removed
405method_not_allowedThe request was sent with the wrong method. The client never does
409game_pausedThe game is paused
429rate_limitedToo many requests. retryAfter says how long to wait
500internal_errorSomething failed inside CEEJAY

Errors of connect.start and connect.startWithCode:

StatuscodeWhen
400bad_player_idplayerId contains a character that is not allowed, or is too long
400bad_proofproof is not an object with a type and a token
400unknown_returnreturnTo is not the key of a return URL saved for the game
400age_answer_requiredThe game is set up for a mixed audience, and underAge was not sent
401proof_rejectedThe 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
403proof_requiredThe game only connects players it has proven, and no proof was sent
403under_ageYou sent underAge: true. Nothing is stored
404code_not_foundNo open session has the code the player typed
409session_lockedThe code belongs to a session that was locked by this failed attempt, its fifth. The member starts again in Discord
429too_many_attemptsToo many attempts with a code that is not open. retryAfter says how long to wait
503proof_service_unavailableThe 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:

StatuscodeWhen
401invalid_session_secretCEEJAY does not know the session. The client forgets it
404session_not_foundThe session is gone
409nothing_to_confirmA confirmation was sent, and the session is not awaiting one right now
409session_changedThe session changed since it was read
409player_blockedThe community blocked this player
409player_takenThe player is connected to another Discord account, at the verified level
410result_goneThe 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:

StatuscodeWhen
401connection_endedThe connection has ended: the member disconnected, moved to another player or signed this device out
401refresh_reusedThe refresh token was replaced and then used again. The device is signed out
401invalid_refresh_tokenCEEJAY does not know the refresh token
401invalid_player_sessionThe 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.

On this page

Community StudiosPrivacyTerms