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.
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:
| Function | What it does |
|---|---|
connect.hub.listQuests | Lists the quests, with the player's progress |
connect.hub.startQuest | Starts a quest |
connect.hub.verifyQuestTask | Checks a task and completes it when the condition is met |
connect.hub.answerQuestTask | Sends the answer to a task that asks a question |
connect.hub.listShop | Lists what the game may sell |
connect.hub.buy | Buys an item with the player's coins |
connect.hub.listLocker | Lists what the player owns through the game |
connect.hub.claim | Claims an item that was bought |
connect.hub.getRanks | Reads the player's rank and the game's leaderboard |
connect.hub.getBalance | Reads the coins, the streak, the perks and what the player may spend |
connect.hub.checkIn | Claims 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.
| Gate | Error | What to do |
|---|---|---|
| The community switched the loop on for your game | 409 hub_not_enabled | Hide the loop. The community's team turns it on |
| The player has consented to share their quests, rewards, shop and rank with your game | 409 sharing_needed | Send 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 server | 409 membership_required | error.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
403verified_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,
409spend_on_hold; - the game has a daily spending limit for each player: past it,
409daily_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.listShopis 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
connect.restore()when the game starts, then checkconnect.state.connect.hub.getBalance()for the coins, and forspending.maySpend, which indicates whether a Buy button should be enabled.connect.hub.listQuests()andconnect.hub.listShop(), each a page at a time.connect.hub.listLocker()for what the player owns and can claim.
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.
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.
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.
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:
code | When |
|---|---|
quest_not_found | There is no such quest |
quest_not_active | The quest is not active |
quest_not_started | The quest has not started yet |
quest_ended | The quest has ended |
quest_already_completed | The quest is done and cannot be repeated |
quest_limit_reached | The player did it as many times as it allows |
quest_requires_role | The player lacks the role it needs |
quest_requires_level | The player has not reached the level it needs |
quest_requires_season_xp | The player has not earned the season XP it needs |
quest_requires_program | The player is not in the program it belongs to |
quest_requires_quest | The quest before it is not done |
quest_full | The quest reached its completion limit |
quest_cooling_down | The player has to wait before doing it again. error.body.availableAt says when |
quest_daily_limit_reached | The 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
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.
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.
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.
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
5xxit can retry:502,503and504, and429, up tomaxRetriestimes; 409idempotency_in_progress, which is returned while the first request with the same key is still running. The client waits as long asRetry-Afterspecifies and retries, for up to 75 seconds from the first request, andmaxRetries: 0turns 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:
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.
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
| Status | code | When |
|---|---|---|
403 | verified_proof_required | The game asks for proof, and this device connected without it |
404 | item_not_found | There is no such item, or it is switched off, or this game does not sell it. These return the same response on purpose |
409 | price_changed | The price is not expectedPrice. error.body.price is the price now |
409 | insufficient_balance | The player has fewer coins than the price. error.body.balance and price give the amounts |
409 | purchase_refused | The 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 |
409 | economy_disabled | The community switched its economy off |
400 | bad_request | itemId, 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.
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.
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.
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.
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.
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.
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.
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:
| Status | code | When |
|---|---|---|
400 | bad_request | A query parameter or a field is invalid, and field names it |
401 | invalid_player_session | The player session expired. The client requests a new one, once |
401 | connection_ended | The connection has ended: the member disconnected, moved or signed this device out |
401 | wrong_key_kind | The request was sent with another kind of key than a player session |
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 use the loop |
404 | not_available | This instance does not support this yet. The client throws it as a CeejayNotAvailableError |
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 |
409 | hub_not_enabled | The community has not switched the loop on for this game |
409 | sharing_needed | The player has not given consent to share. error.body.manageUrl is where they manage it |
409 | membership_required | The Discord account is not in the server. See What has to be true first |
413 | body_too_large | The request body is over 16 KB. A 413 returned by a proxy is given this code too |
429 | rate_limited | Too many requests. retryAfter says how long to wait |
500 | internal_error | Something failed inside CEEJAY |
503 | bot_unavailable | CEEJAY's bot could not be reached, and nothing was changed. retryAfter is 5 seconds |
504 | bot_timeout | The 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.

