CEEJAYDocs

The game loop, inside a game

Show the community's quests, shop, locker, rank, daily reward and surveys in your game, for a player who is connected to their Discord account.

Show the community's quests, shop, locker, rank, daily reward and surveys in your game, for a player who is connected to their Discord account.

This page is for the game. It uses connect.hub, an object of the Connect client of @communitystudios/ceejay/connect, so read Connect, inside a game first: the player has to be connected before any function here works. The client stores the player session and renews it, so a game never handles a token.

Show the first quests
import { CeejayConnect } from '@communitystudios/ceejay/connect'

const connect = new CeejayConnect({ publicKey: 'ceejay_pk_5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f' })

const quests = await connect.hub.listQuests()
console.log(quests.data.length, 'quests on this page')

There are eleven functions, one for each request of the game loop:

FunctionWhat it does
connect.hub.listQuestsLists the quests, with the player's progress
connect.hub.startQuestStarts a quest
connect.hub.verifyQuestTaskChecks a task and completes it when the condition is met
connect.hub.answerQuestTaskSends the answer to a task that asks a question
connect.hub.listShopLists what the game may sell
connect.hub.buyBuys an item with the player's coins
connect.hub.listLockerLists what the player owns through the game
connect.hub.claimClaims an item that was bought
connect.hub.getRanksReads the player's rank and the game's leaderboard
connect.hub.getBalanceReads the coins, the streak, the perks and what the player may spend
connect.hub.checkInClaims the daily reward

What has to be true first

Three conditions must be met before a request of the loop is processed, and each has its own error. They are checked in this order.

GateErrorWhat to do
The community switched the loop on for your game409 hub_not_enabledHide the loop. The community's team turns it on
The player has consented to share their quests, rewards, shop and rank with your game409 sharing_neededSend the player to connect.manageUrl. Consent is collected when the player connects, and requested again when the game adds to what it uses
The Discord account is in the server409 membership_requirederror.body.membership says pending, left or not_member. For left and not_member, error.body.joinUrl is where to send the player

Buying and claiming need more, because they spend the player's coins:

  • the device connected with proof, when your game asks for it: otherwise 403 verified_proof_required;
  • a new connection or a new device is subject to a spending hold, so a stolen device cannot empty a wallet: until the hold ends, 409 spend_on_hold;
  • the game has a daily spending limit for each player: past it, 409 daily_spend_cap_reached;
  • the game sells only the items the community's team added to the game's list, and only of the kinds it allows. Until they add items, connect.hub.listShop is an empty list.

Reading, quests and the daily reward need neither proof nor a hold.

A function needs a connected player who is in the server. When this device has no connection, or the Discord account is not in the server, the function throws a TypeError before it sends anything. Look at connect.state and connect.attention first, and read connect.getProfile after a sharing_needed: connect.attention is then sharing.

What a game receives is limited. It never receives a Discord ID, an avatar or anything about another member. The leaderboard returned by connect.hub.getRanks contains a display name and a level for players of this game who have consented to share it, and a code attached to a purchase is never sent.

The order of a first screen

  1. connect.restore() when the game starts, then check connect.state.
  2. connect.hub.getBalance() for the coins, and for spending.maySpend, which indicates whether a Buy button should be enabled.
  3. connect.hub.listQuests() and connect.hub.listShop(), each a page at a time.
  4. connect.hub.listLocker() for what the player owns and can claim.
The order of a first screen
async function firstScreen(): Promise<void> {
  if ((await connect.restore()) !== 'connected') return

  const wallet = await connect.hub.getBalance()
  console.log(wallet.balance, wallet.currency.name, 'streak', wallet.streak, wallet.spending.maySpend)

  const [quests, shop, locker] = await Promise.all([connect.hub.listQuests(), connect.hub.listShop(), connect.hub.listLocker()])
  console.log(quests.data.length, shop.data.length, locker.data.length)
}

Lists

connect.hub.listQuests, connect.hub.listShop and connect.hub.listLocker return the full response, { success, data, page }, like ceejay.players.list. The items are in data, and page is alongside it:

Prop

Type

limit is a whole number from 1 to 100, and 50 when left out. cursor is the page.nextCursor of the previous page, sent back as it was returned. A list that changes between two pages can repeat an item or skip one, so key your list by the item's id.

Read every page of a list
async function everyQuest() {
  const found = []
  let cursor: string | undefined
  do {
    const page = await connect.hub.listQuests({ limit: 100, ...(cursor === undefined ? {} : { cursor }) })
    found.push(...page.data)
    cursor = page.page.nextCursor ?? undefined
  } while (cursor !== undefined)
  return found
}

The IDs of quests, tasks, items and purchases are whole numbers. The functions take them as they were returned, as a number or as a string of digits, and reject anything else before they send.

connect.hub.listQuests

API reference: List the quests

Lists the quests the player can see, in the community's order, with the player's progress in each.

Takes params, then options.

Prop

Type

Returns the full response. Each quest in data:

Prop

Type

locked is null and lockCheck is checked_on_start when the quest needs a role and CEEJAY has not seen the member in its cache of the server. Show the quest as one that can be started, and let connect.hub.startQuest make the final check.

Each task in tasks:

Prop

Type

A task that goes to the community's team for review has the action discord: the answer is reviewed there and the result is sent in Discord, so a game cannot complete it.

connect.hub.listQuests
const quests = await connect.hub.listQuests({ limit: 20 })

for (const quest of quests.data) {
  const bar = quest.progress ? `${quest.progress.current}/${quest.progress.target}` : 'no bar'
  console.log(quest.title, quest.status, bar, quest.locked === true ? quest.lockedReason : '')
  for (const task of quest.tasks) console.log('  ', task.title, task.action ?? 'by itself', task.statusLine)
}

It can return 400 bad_request for a limit or a cursor it cannot parse. The errors every request of the loop can return are listed under Errors.

connect.hub.startQuest

API reference: Start a quest

Starts a quest for the player. It takes questId, then options.

Returns questId, status (in_progress) and alreadyStarted. A quest that is already open returns alreadyStarted: true, which is also what a repeat after a dropped response returns, so the client retries automatically when no response was received.

connect.hub.startQuest
const started = await connect.hub.startQuest(12)
console.log(started.questId, started.status, started.alreadyStarted ? 'was open already' : 'started now')

A quest that cannot be started is a CeejayError with a code that gives the reason. All return 409 except quest_not_found, which returns 404. The sentence to show the player is error.body.message:

codeWhen
quest_not_foundThere is no such quest
quest_not_activeThe quest is not active
quest_not_startedThe quest has not started yet
quest_endedThe quest has ended
quest_already_completedThe quest is done and cannot be repeated
quest_limit_reachedThe player did it as many times as it allows
quest_requires_roleThe player lacks the role it needs
quest_requires_levelThe player has not reached the level it needs
quest_requires_season_xpThe player has not earned the season XP it needs
quest_requires_programThe player is not in the program it belongs to
quest_requires_questThe quest before it is not done
quest_fullThe quest reached its completion limit
quest_cooling_downThe player has to wait before doing it again. error.body.availableAt says when
quest_daily_limit_reachedThe player reached the community's daily quest limit

connect.hub.verifyQuestTask

API reference: Verify a quest task

Checks the condition a task asks for (a role, a badge, a balance, a follow) and completes the task when the condition is met. It takes questId, taskId, then options. Leave taskId out, or pass null, for a quest created before tasks existed.

Returns what happened:

Prop

Type

connect.hub.verifyQuestTask
import { CeejayError } from '@communitystudios/ceejay/connect'

try {
  const result = await connect.hub.verifyQuestTask(12, 41)
  console.log(result.tasksDone, 'of', result.taskCount, result.questCompleted ? result.rewards : null)
} catch (error) {
  if (error instanceof CeejayError && error.code === 'quest_task_not_done') {
    console.log('Not yet:', (error.body as { message?: string }).message)
  } else {
    throw error
  }
}

Errors: the quest codes of connect.hub.startQuest, and 409 quest_task_not_done when the check failed, with error.body.message giving the reason. 409 quest_task_unavailable has a reason in error.body: already_done, in_review, not_for_games (a task whose action is discord), wrong_action (a task that wants an answer, not a check) or needs_connect (error.body.platform is the account the member has to link in Discord). An unknown taskId is 404 quest_not_found. Safe to retry: a task that is already done returns quest_task_unavailable with already_done.

connect.hub.answerQuestTask

API reference: Answer a quest task

Sends the player's answer to a task that asks a question. It takes questId, taskId, answer, then options. answer is text of up to 1,000 characters, a number, or for a rating a whole number from 1 to the task's scale. Pass null as taskId for a quest created before tasks existed. The task's answer.kind says which form it wants.

Returns the same as connect.hub.verifyQuestTask.

connect.hub.answerQuestTask
const result = await connect.hub.answerQuestTask(12, 44, 'Rugby Manager, every weekend')
console.log(result.questCompleted, result.tasksDone)

Errors: as connect.hub.verifyQuestTask. An incorrect answer is quest_task_not_done, and a missing, too long or too short one is 400 bad_request with field: "answer". Nothing is ever sent to the team for review from a game.

connect.hub.listShop

API reference: List the shop

Lists the items in the shop that this game may sell, in the shop's order, each with the player's own state.

Takes params (limit and cursor), then options.

Returns the full response. Each item in data:

Prop

Type

memberState.canBuy is a hint for a Buy button: connect.hub.buy re-validates. When spending is switched off for the game, every item reads canBuy: false. The error 409 economy_disabled means the community switched its economy off.

connect.hub.listShop
const shop = await connect.hub.listShop()

for (const item of shop.data) {
  const state = item.memberState.canBuy ? 'can buy' : (item.memberState.reason ?? 'cannot buy')
  console.log(item.name, item.price, item.deliveryMode, state)
}

connect.hub.buy

API reference: Buy a shop item

Buys an item with the player's coins. It takes itemId, expectedPrice, then options. expectedPrice is the price the player was shown, in coins. When the price has changed, nothing is bought and the response is 409 price_changed: read the shop again.

In addition to signal, timeoutMs and maxRetries, the options take two more:

Prop

Type

Returns the purchase:

Prop

Type

A purchase awaiting the team's action has status: "pending" and waitingFor: "team". A delivery that failed and was refunded has the status refunded, and balance already reflects the refund.

connect.hub.buy
const shop = await connect.hub.listShop()
const item = shop.data[0]

if (item?.memberState.canBuy) {
  const bought = await connect.hub.buy(item.id, item.price)
  console.log(bought.itemName, bought.status, bought.waitingFor, 'balance', bought.balance)
  if (bought.claimNeeded) console.log('Claim it with purchase', bought.purchaseId)
}

Idempotency keys

The key is what makes a retry safe. The client sends every attempt of a call with the same key, and if CEEJAY has already processed a request with that key, it returns the original response with duplicate: true instead of buying twice. So the client retries automatically after:

  • no response at all, or a 5xx it can retry: 502, 503 and 504, and 429, up to maxRetries times;
  • 409 idempotency_in_progress, which is returned while the first request with the same key is still running. The client waits as long as Retry-After specifies and retries, for up to 75 seconds from the first request, and maxRetries: 0 turns this off.

idempotency_key_reused (the key was used for another item), idempotency_outcome_unknown and idempotency_unavailable are never retried: they stay the same however long the wait. Without a key nothing is ever retried.

If the call still fails, the generated key is discarded. To retry a purchase after an error, generate the key yourself when the player presses Buy, store it, and pass it again:

connect.hub.buy, one key
const key = crypto.randomUUID()

async function pressBuy(itemId: number, price: number): Promise<void> {
  try {
    const bought = await connect.hub.buy(itemId, price, { idempotencyKey: key })
    console.log(bought.duplicate ? 'You already bought this' : 'Bought', bought.itemName)
  } catch (error) {
    // The same key on the next press is the same purchase.
    console.log('Not sure it went through. Press again to check.', error instanceof Error ? error.message : '')
  }
}

Use a new key for each new purchase. Two purchases of one item in a row need two keys.

When the player cannot spend

409 spend_on_hold and 409 daily_spend_cap_reached are thrown as a CeejaySpendBlockedError, a CeejayError of its own, so a game can show "come back later". Nothing was bought and nothing was taken.

Prop

Type

error.body contains the rest, such as cap, spentToday and price.

connect.hub.buy, a blocked spend
import { CeejaySpendBlockedError } from '@communitystudios/ceejay/connect'

try {
  await connect.hub.buy(7, 500)
} catch (error) {
  if (error instanceof CeejaySpendBlockedError) {
    console.log(error.code === 'spend_on_hold' ? `Come back after ${error.holdUntil}` : `Daily limit reached. It resets at ${error.resetsAt}`)
  } else {
    throw error
  }
}

Other errors

StatuscodeWhen
403verified_proof_requiredThe game asks for proof, and this device connected without it
404item_not_foundThere is no such item, or it is switched off, or this game does not sell it. These return the same response on purpose
409price_changedThe price is not expectedPrice. error.body.price is the price now
409insufficient_balanceThe player has fewer coins than the price. error.body.balance and price give the amounts
409purchase_refusedThe item cannot be bought now. error.body.reason is unavailable, not_on_sale, not_eligible, out_of_stock or limit_reached, and error.body.message is a sentence to show
409economy_disabledThe community switched its economy off
400bad_requestitemId, expectedPrice, idempotencyKey or answer is invalid, and field identifies which

connect.hub.listLocker

API reference: Read the locker

Lists purchases made through this game, plus purchases of its listed items made elsewhere. Newest first.

Takes params, then options.

Prop

Type

Returns the full response. Each purchase in data:

Prop

Type

When claim.needsInput is true, ask the player for each of claim.fields (key, kind, label and max). For a field whose kind is role or giveaway the player picks one of claim.options.roles or claim.options.giveaways, up to 25 of them. A role the CEEJAY bot cannot see is left out.

connect.hub.listLocker
const locker = await connect.hub.listLocker({ status: 'open' })

for (const purchase of locker.data) {
  console.log(purchase.itemName, purchase.statusLabel, purchase.claim.needsInput ? purchase.claim.fields.map((field) => field.label) : 'nothing to ask')
}

connect.hub.claim

API reference: Claim an item

Claims an item the player bought. It takes purchaseId, input, then options. input contains what claim.fields asked for: an object of up to 10 keys with text values of up to 200 characters, one key for each field, named by the field's key. For a choice, the value is the id of the pick, as text. Leave it out for an item that asks for nothing.

Returns purchaseId, claimed (always true), alreadyClaimed, itemName, status, waitingFor and delivery. An item that is already claimed returns alreadyClaimed: true, so the client retries automatically when no response was received.

connect.hub.claim
const claimed = await connect.hub.claim(4242, { title: 'Harbour RFC' })
console.log(claimed.itemName, claimed.alreadyClaimed ? 'was claimed already' : 'claimed now', claimed.delivery.state)

A claim is subject to the same holds as a purchase: 403 verified_proof_required and 409 spend_on_hold (a CeejaySpendBlockedError). 404 purchase_not_found is also returned for a purchase that is another member's, or that is not this game's. 409 not_claimable says the purchase is in a state that cannot be claimed, and error.body.status is that state. 400 bad_request with field: "input" means the item asks for something: error.body.fields lists it.

connect.hub.getRanks

API reference: Get the ranks

Reads the player's own rank, and the top players of this game.

Takes params, then options. top is how many players the list contains, from 1 to 20, and 10 when left out.

Returns me and top:

Prop

Type

me.rank and the rank of each entry are positions on the leaderboard of this game's players. They are counted from 1. me.communityRank is the position among everyone in the community, or null when it is not known. The leaderboard contains players of this game who have consented to share it, who are in the server and have XP. It never contains an ID, an avatar or a Discord name beyond the display name.

connect.hub.getRanks
const ranks = await connect.hub.getRanks({ top: 5 })

console.log('You are level', ranks.me.level, 'place', ranks.me.rank ?? 'not ranked')
for (const entry of ranks.top) console.log(entry.rank, entry.displayName, entry.level, entry.xp)

connect.hub.getBalance

API reference: Get the balance

Reads the player's coins, streak, perks and XP boost, and what they may spend.

Takes options only.

Returns the wallet:

Prop

Type

spending.reason is null when maySpend is true. Otherwise it is spend_on_hold, verified_proof_required or spending_off. It uses the rules that connect.hub.buy uses, so a game can grey a Buy button before the player presses it. A dailyCap of 0 means spending is switched off for the game. 409 economy_disabled means the community switched its economy off.

connect.hub.getBalance
const wallet = await connect.hub.getBalance()

console.log(wallet.balance, wallet.currency.emoji ?? '', wallet.spending.remainingToday, 'left today')
if (!wallet.spending.maySpend) console.log('Buying is not open:', wallet.spending.reason, wallet.spending.holdUntil)

connect.hub.checkIn

API reference: Check in

Claims the daily reward. It takes options only.

Returns reward, streak, balance, nextCheckInAt and savedByStreakSaver, which is true when a streak saver kept the streak alive for a missed day.

A claim made too soon is 409 checkin_cooldown, a CeejayError whose error.body contains nextCheckInAt, and retryAfter is the number of seconds until then. Treat it as "already claimed today". It is also what a repeat returns after a dropped response, so the client never retries a check in automatically.

connect.hub.checkIn
import { CeejayError } from '@communitystudios/ceejay/connect'

try {
  const claimed = await connect.hub.checkIn()
  console.log(`+${claimed.reward} coins, streak ${claimed.streak}`, claimed.savedByStreakSaver ? '(saved by a streak saver)' : '')
} catch (error) {
  if (error instanceof CeejayError && error.code === 'checkin_cooldown') {
    console.log('Already claimed. Next at', (error.body as { nextCheckInAt?: string }).nextCheckInAt)
  } else {
    throw error
  }
}

connect.hub.listSurveys

API reference: List surveys

Lists the surveys the community shows in this game that the player can answer now. It takes params with limit and cursor, as the other lists do, and options. The response is returned whole: the surveys are in data, and page sits beside it.

A survey can wait for an event your game logs, such as the end of a match three times: it appears in the list once the event has been logged that often. Call listSurveys after the moments you log, and show a survey when it fits.

Returns for each survey its id, title, description, questions, closesAt and rewards. Each question has a key, a label and a type that says how to ask it, with its options, its limits and, for a rating or a Net Promoter Score, the words at each end in scale.

connect.hub.listSurveys
const surveys = await connect.hub.listSurveys()

for (const survey of surveys.data) {
  console.log(survey.title)
  for (const question of survey.questions) {
    console.log(' ', question.label, question.type, question.options.map((option) => option.label).join(' / '))
  }
}

connect.hub.answerSurvey

API reference: Answer a survey

Sends the player's answers. It takes surveyId, the survey's id from listSurveys, the answers by question key, and options. An answer is text, the value of an option, or a list of values for a checkbox question. Leave out a question the player skipped.

Returns surveyId, updated (true when earlier answers were updated, and nothing was granted again) and rewards: xp, roles and the names of the reward list's rewards, or null for an update.

An answer its question doesn't take is 400 bad_request with field: "answers" and the question's key in error.body.question: show error.message beside that question. A survey that cannot be answered now is 409 survey_unavailable, and error.body.reason and error.body.message say why. A survey that was removed is 404 survey_not_found. The request is safe to repeat.

connect.hub.answerSurvey
import { CeejayError } from '@communitystudios/ceejay/connect'

try {
  const saved = await connect.hub.answerSurvey(41, { k_00000000000000a1: '4', k_00000000000000a3: 'More maps, please' })
  console.log(saved.updated ? 'Answers updated' : `Thanks! +${saved.rewards?.xp ?? 0} XP`)
} catch (error) {
  if (error instanceof CeejayError && error.code === 'bad_request') {
    console.log('Check the question', (error.body as { question?: string }).question, error.message)
  } else if (error instanceof CeejayError && error.code === 'survey_unavailable') {
    console.log((error.body as { message?: string }).message)
  } else {
    throw error
  }
}

Errors

An error is a CeejayError, as on every page of the Connect client: status, code, message, retryAfter, requestId and body. Check the code, not the message text. A sentence meant for the player, such as why a quest cannot be started, is error.body.message; error.message is the API's own text.

The client renews a player session that expired early, once, and retries the request: an expired session is rejected before any work is done. A connection that has ended (connection_ended) is discarded immediately and connect.state becomes not_connected.

Errors every request of the loop can return:

StatuscodeWhen
400bad_requestA query parameter or a field is invalid, and field names it
401invalid_player_sessionThe player session expired. The client requests a new one, once
401connection_endedThe connection has ended: the member disconnected, moved or signed this device out
401wrong_key_kindThe request was sent with another kind of key than a player session
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 use the loop
404not_availableThis instance does not support this yet. The client throws it as a CeejayNotAvailableError
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
409hub_not_enabledThe community has not switched the loop on for this game
409sharing_neededThe player has not given consent to share. error.body.manageUrl is where they manage it
409membership_requiredThe Discord account is not in the server. See What has to be true first
413body_too_largeThe request body is over 16 KB. A 413 returned by a proxy is given this code too
429rate_limitedToo many requests. retryAfter says how long to wait
500internal_errorSomething failed inside CEEJAY
503bot_unavailableCEEJAY's bot could not be reached, and nothing was changed. retryAfter is 5 seconds
504bot_timeoutThe bot did not respond in time, and the request may or may not have been done

After bot_timeout a request is safe to retry: the reads change nothing, starting a quest, completing a task and claiming an item each report that they were already done, and a purchase carries its key. The client retries automatically for all of them except connect.hub.checkIn.

A player session can make 120 reads and 20 writes a minute in all, and buying and claiming together are limited to 60 an hour for one member in one game. A game should read retryAfter and wait.

What the client sends and retries

Two headers and no other: Authorization and, with a body, Content-Type, as everywhere in the Connect client. After a 429, a 5xx or no response the client waits and retries, up to maxRetries times, for the five reads and for connect.hub.startQuest, connect.hub.verifyQuestTask, connect.hub.answerQuestTask and connect.hub.claim. connect.hub.checkIn is never retried automatically. connect.hub.buy is retried as described in Idempotency keys.

On this page

Community StudiosPrivacyTerms