This guide is for developers who want to put a game or interactive app on Kettle. It covers what Kettle does for you, how to plug in a server, how clients connect over HTTP and WebSocket, and the SDK calls that wrap it all. Everything here is taken from the code as it stands; when the code changes, this file changes with it.
Why build on Kettle
Every online game rebuilds the same plumbing before it can test a single idea. Kettle ships that plumbing, tested, so a team writes only the parts that make its game different.
| You would otherwise build | Kettle gives you |
|---|---|
| Player accounts and token refresh | Anonymous sign-in in one call; 15-minute access tokens; 30-day refresh tokens that rotate on use and are stored only as hashes |
| A real-time server that scales to zero | Rooms on Durable Objects: one object runs many rooms, sleeps when idle, and costs nothing while nobody plays |
| Room codes and invites | 6-character codes without look-alike letters, valid for 30 minutes |
| Netcode basics | Handshake, seats, rate limits, round-trip measurement, batched sends, changes-only state updates |
| Fair timing for players on slow connections | Every action arrives with an estimate of when the player actually acted |
| Recovering from drops and deploys | Resume tokens, saved room state and SDKs that reconnect on their own |
| Analytics without collecting personal data | Declared events only, hashed player ids, optional Cloudflare Queues and Analytics Engine |
| Client libraries | TypeScript and Godot SDKs with the same shape |
Good fit: session-based multiplayer for 1 to 8 players per room (co-op, party, turn-based, asynchronous challenges), small studios, and games that want near-zero idle cost.
Not a fit today: fast shooters that need UDP or sub-50 ms authority, MMO-scale shared worlds, voice chat, and anything needing purchases, leaderboards or cloud saves before those modules land (see the roadmap below).
Getting started
These steps create a game server from the reference game that ships with Kettle.
- Copy
workers/api-referenceinto your project and rename it. - Replace
ClickRoomwith your ownRoomsubclass (see "Writing a room"). - List your room types in
createKettle({ roomTypes })and increateRoomHost({ ... }). - Set two secrets:
JWT_SECRETandJOIN_SECRET(in.dev.varslocally,wrangler secret putwhen deployed). - Run
pnpm dev. Your server answers onhttp://127.0.0.1:8787. - In a client, sign in, create a room, connect, and send intents (see "SDKs").
The Worker needs these bindings (from workers/api-reference/wrangler.jsonc):
{
"durable_objects": {
"bindings": [
{ "name": "LOBBY", "class_name": "LobbyDO" },
{ "name": "ROOM_HOST", "class_name": "RoomHost" }
]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["LobbyDO", "RoomHost"] }],
"d1_databases": [{ "binding": "DB", "database_name": "my-game", "database_id": "…", "migrations_dir": "migrations" }]
}Copy Kettle's migrations/ into your project (the D1 tables for accounts) and add your own after them.
Authentication
Clients start with an anonymous account. The access token goes in an Authorization: Bearer header on every authenticated call.
| Token | Lifetime | Notes |
|---|---|---|
| Access token | 15 minutes | Signed JWT (HS256 today; EdDSA planned). sub is the player id, aud the game id, iss is kettle; a token for one game is refused by every other. The SDKs refresh once and retry when a call gets 401. |
| Refresh token | 30 days | Single use: each refresh revokes it and returns a new pair, atomically. Stored hashed. Presenting a token that was already used revokes every token descended from it, so a copied token is useless once either side refreshes. |
| Join token | 60 seconds | Single use, bound to one room and one player (typ: join). Comes inside ws_url. |
| Resume token | 30 seconds after a drop | Sent in welcome; your seat waits this long for you. |
POST /v1/auth/anon → a new player and a session:
{ "player_id": "3f0c…-uuid", "access_token": "eyJ…", "refresh_token": "…", "expires_in": 900 }POST /v1/auth/refresh with { "refresh_token": "…" } → the same shape, or 401 {"error":"bad_token"}.
Keep the same player across launches. Store the refresh token wherever your game keeps secrets, and sign in with it next time:
- Godot:
sign_in(saved_refresh), saving every token fromsession_changed. - TS:
signIn(savedRefresh), then savesession.refresh_token.
The SDK rotates the token and starts a new anonymous player only if the server refuses it; with no answer (offline) it fails once and doesn't wait twice. Without this, each launch is a new player with an empty inventory.
Account linking (Apple, Google, Game Center, Play Games, email)
A refresh token lives on one device. To keep an account across devices and reinstalls, the player links a provider identity to it, then signs in with that identity on the new device. Kettle verifies each credential itself and stores only the provider's user id (for email, a keyed hash of the address).
import { createKettle, defineLinks } from "@kettle/server-core";
export const links = defineLinks({
apple: { audiences: ["com.example.game"] }, // bundle ids / services ids (aud)
google: { clientIds: ["123-abc.apps.googleusercontent.com"] },
gamecenter: { bundleIds: ["com.example.game"], maxAgeSec: 600 },
playgames: { clientId: "123-web.apps.googleusercontent.com", secretEnv: "PLAYGAMES_SECRET" },
email: { from: "My Game <codes@example.com>", perAddressPerHour: 3, perIpPerHour: 10, maxTries: 5, codeTtlSec: 600 },
});
export default createKettle({ game: "my-game", /* … */ links });| Provider | Credential the client sends | Checked |
|---|---|---|
apple | { id_token, nonce? } (Sign in with Apple identity token) | RS256 against https://appleid.apple.com/auth/keys (cached), iss https://appleid.apple.com, aud in audiences, exp. With nonce (the raw value), the token's nonce must be its SHA-256 hex, the value the app put in the request. Subject: sub. |
google | { id_token } (Credential Manager / Sign in with Google) | RS256 against https://www.googleapis.com/oauth2/v3/certs (cached per Cache-Control), iss accounts.google.com or https://accounts.google.com, aud in clientIds, exp. Subject: sub. |
gamecenter | { public_key_url, signature, salt, timestamp, player_id, bundle_id } from GameKit's fetchItems(forIdentityVerificationSignature:) (signature and salt base64, timestamp in ms, player_id = teamPlayerID) | public_key_url must be https on a host ending in .apple.com; the DER certificate is fetched and cached, its validity dates checked, and RSASSA-PKCS1-v1_5 SHA-256 verified over player_id ‖ bundle_id ‖ UInt64BE(timestamp) ‖ salt. bundle_id in bundleIds, timestamp within maxAgeSec. Limit: the certificate's chain to Apple's root isn't validated; trust rests on HTTPS from an apple.com host. Subject: teamPlayerID. |
playgames | { server_auth_code } (Play Games Services v2 requestServerSideAccess) | Exchanged at https://oauth2.googleapis.com/token with the game server's web client id and the secret in env[secretEnv], then GET https://games.googleapis.com/games/v1/players/me. Subject: playerId. |
email | { email_token } from /v1/auth/email/verify | A 6-digit code sent to the address: Resend (POST https://api.resend.com/emails) when RESEND_KEY and from are set, a custom email.send(env, to, code), or, with DEV_EMAIL_CODES=log in local dev, the code is logged (never the address). Codes are stored hashed, expire (codeTtlSec), allow maxTries wrong tries, are used once and compared in constant time. Sends are capped per address and per IP per hour. Subject: HMAC of the lower-cased address keyed by LINKS_SECRET (falls back to JWT_SECRET; set LINKS_SECRET before launch, since rotating the key orphans email links). |
Routes (errors are {error, reason?}; reason names the failed check, e.g. signature, audience, issuer, expired, nonce, public_key_url):
| Route | Auth | Body | Returns |
|---|---|---|---|
POST /v1/auth/link | Yes | { provider, ...credential, takeover? } | { provider, linked_at }. 409 linked_elsewhere {other: {player_id}} when the identity belongs to another player; takeover: true moves it to the caller. 409 already_linked when the caller has another identity for that provider (one per provider). Re-linking the same identity is a no-op. |
POST /v1/auth/signin | No | { provider, ...credential, refresh_token?, link_current? } | A session like /v1/auth/anon plus linked: true, for the linked player; other devices keep their sessions. Not linked: 404 not_linked, unless link_current: true with the device's refresh_token: then the identity is linked to that player and its token rotated, in one call (a fresh install's "Sign in with Apple"). |
GET /v1/me/links | Yes | — | { links: [{provider, linked_at}], providers: [...] }. Never subjects. |
POST /v1/auth/unlink | Yes | { provider, force? } | { ok }. The player's last link is refused (409 last_link) unless force: true: it is their only way back from another device. |
POST /v1/auth/email/start | No | { email } | { ok, expires_in } (the same whether or not the address is linked). 429 rate_limited over the caps, 503 email_unavailable with no sender. |
POST /v1/auth/email/verify | No | { email, code } | { email_token, expires_in } (10 minutes; the email credential). 401 bad_code, 401 code_expired, 429 too_many_tries. |
No merge. When an identity already belongs to another player, Kettle doesn't combine their data. The client asks: "use that account" (signin; this device's anonymous player is left behind) or "keep this one" (link with takeover; the other player loses that link). See ADR-013.
On the shared platform, put the non-secret ids in a <G>_LINKS var (the defineLinks options as JSON, e.g. {"apple":{"audiences":["com.example.game"]},"email":{"from":"My Game <codes@example.com>"}}) and the secrets in <G>_LINKS_SECRET, <G>_PLAYGAMES_SECRET and <G>_RESEND_KEY (else a shared RESEND_KEY). Secret names are fixed there, so a game never reads another's.
Tests and special deploys can replace the provider fetch: defineLinks({ fetch }) or setLinksFetch(fn) (also clears the key caches).
HTTP endpoints
All bodies are JSON. Errors return { "error": "<code>" } with the status shown.
| Method and path | Auth | Body | Returns |
|---|---|---|---|
GET /v1/health | No | — | { ok: true, game } |
POST /v1/auth/anon | No | — | Session |
POST /v1/auth/refresh | No | { refresh_token } | Session |
POST /v1/auth/link, /v1/auth/signin, /v1/auth/unlink, /v1/auth/email/start, /v1/auth/email/verify, GET /v1/me/links | See Account linking | (when links is set) | |
POST /v1/rooms | Yes | { type, config? } | { room_id, type, code, join_token, ws_url } |
POST /v1/rooms/join | Yes | { code } | { room_id, type, join_token, ws_url } |
GET /v1/rooms/<host>/<room>/ws?token= | Join token | WebSocket upgrade | The room connection (use ws_url as given) |
GET /v1/geo | No | — | { country, city, latitude, longitude }, city-level, from Cloudflare's request data |
POST /v1/t | Yes | { events: [...] } (at most 50) | { accepted, dropped, sink } |
GET /v1/inventory | Yes | — | Inventory (see Economy) |
POST /v1/inventory/exchange | Yes | { exchange, op_key, target? } | { op_key, replay, version, granted, xp?, balances } |
POST /v1/inventory/ops | Yes | { ops: [...] } (at most 50) | { results: [...], inventory } |
GET /v1/visits/discover?n= | Yes | — | { visits: [{ player_id, doc, updated_at }] }, a random few listed places, never yours (see Visits) |
GET /v1/visits/p/<player_id> | Yes | — | { player_id, doc, updated_at } |
POST /v1/visits/p/<player_id>/gift | Yes | { gift, op_key } | { replay, gift, granted } |
GET /v1/visits/gifts?since=<ms> | Yes | — | { gifts: [{ gift, from, at }] }, gifts you received |
POST /v1/visits/me | Yes | { doc, listed? } | { ok }, only when the game sets clientPublish |
GET /v1/leaderboards/<board>?period=&n=&offset=&around=me | Yes | — | { board, period, entries: [{ rank, player_id, tag, score, data, at }], me } (see Leaderboards) |
POST /v1/leaderboards/<board> | Yes | { score, data? } | { period, score, improved, rank, total }, only on clientSubmit boards |
GET /v1/guilds/names | Yes | — | { parts, emblems, roles, max_members }, what a name may be made of (see Guilds) |
POST /v1/guilds | Yes | { name, emblem?, open? } | { guild }; you lead it |
GET /v1/guilds/mine | Yes | — | { guild, role, requests, feed } (guild: null when you're in none; requests only for roles that accept) |
GET /v1/guilds/search?q=&n= | Yes | — | { guilds: [{ id, name, emblem, open, members }] }, by name prefix |
GET /v1/guilds/g/<id> | Yes | — | { guild: { id, name, emblem, open, data, created_at, members: [{ player_id, role, joined_at }] } } |
POST /v1/guilds/g/<id>/join | Yes | — | { joined, requested }: open guilds take you, closed ones get a request |
POST /v1/guilds/leave | Yes | — | { left, disbanded } |
POST /v1/guilds/members/<player>/accept, /reject, /kick | Yes | — | { ok } (needs the role's permission and a higher rank) |
POST /v1/guilds/members/<player>/role | Yes | { role } | { ok } |
POST /v1/guilds/settings | Yes | { open?, emblem? } | { ok } |
GET /v1/guilds/feed?since=<id> | Yes | — | { feed: [{ id, kind, actor, data, at }] }, newest first |
POST /v1/guilds/help | Yes | { job, need } | { help: { id, job, need, got, expires_at } }, your request (the same job's open one if any) |
GET /v1/guilds/help | Yes | — | { help: [...] }, your guild's open requests with helped |
POST /v1/guilds/help/<id>/give | Yes | — | { help } |
POST /v1/worlds/join | Yes | — | { world, plot: { player_id, world_id, x, y, doc, shield_until } }, your plot (the same one every time; see World shards) |
GET /v1/worlds/me | Yes | — | { world, plot, travels } (world: null before you join) |
GET /v1/worlds/map?x=&y=&w=&h= | Yes | — | { world, plots, sites, travels, fog }, at most 32×32 cells, only what your fog shows |
POST /v1/worlds/travel | Yes | { kind, x, y, data? } | { travel: { id, kind, from, to, start_at, arrive_at, state, data } }, only the game's client kinds |
POST /v1/worlds/travels/<id>/recall | Yes | — | { travel } |
POST /v1/trades | Yes | { to, offer: [...], ask: [...] } | { trade } (lines are { instance } or { item, qty }; see Trades) |
GET /v1/trades | Yes | — | { incoming, outgoing }, open offers |
POST /v1/trades/<id>/accept | Yes | — | { trade, moved: { toB, toA }, replay } |
POST /v1/trades/<id>/decline, /cancel | Yes | — | { trade } |
GET /v1/me | Yes | — | { player_id, age_band } (see Age bands and drop tables) |
POST /v1/me/age | Yes | { band } | { age_band } (under13, 13to15, 16to17, adult; lower only) |
GET /v1/drops/<table> | Yes | — | { table, cost, entries: [{ id, tier, weight, percent }], pity, pity_in, available, why } |
GET /v1/purchases/products | Yes | — | { products: [{ id, kind, apple, google, usd_cents, grant, can_buy, reason? }], can_buy, reason?, band, month_cap_cents, month_spent_cents, month_left_cents } (see Purchases) |
GET /v1/purchases/token | Yes | — | { token }: the player's account UUID for appAccountToken / obfuscatedAccountId |
POST /v1/purchases/check | Yes | { product } | { ok, month_left_cents }, or 409 money_cap, 403 age / age_unknown / region / region_unknown |
POST /v1/purchases/apple | Yes | { jws } | { product, transaction_id, granted, instances, balances, replay, flagged? } |
POST /v1/purchases/google | Yes | { product, purchase_token } | The same, plus acknowledged |
POST /v1/purchases/restore | Yes | { apple: [jws] } or { google: [{ product, purchase_token }] } | { results: [{ product, transaction_id, status }] } |
POST /v1/purchases/apple/notify | No (verified) | App Store Server Notifications V2 { signedPayload } | { ok, type, revoked } |
POST /v1/purchases/google/notify?token= | No (secret) | Pub/Sub push of a Play RTDN | { ok, revoked } |
GET /v1/friends | Yes | — | { friends: [{ player_id, handle, since, presence }], max } (see Friends and presence) |
GET /v1/friends/code, POST /v1/friends/code | Yes | — | { code } (POST: a new code; the old one stops working) |
POST /v1/friends/requests | Yes | { code } or { player } | { state: "sent" | "friends" } |
GET /v1/friends/requests | Yes | — | { incoming: [{ player_id, handle, at }], outgoing } |
POST /v1/friends/accept, /decline, /cancel, /remove | Yes | { player } | { state: "friends" } / { ok } |
POST /v1/friends/presence | Yes | { status?, room?, hidden? } | { status, room, hidden } |
GET /v1/config?platform=&app= | Optional | — | { version, values, flags: { name: bool } }, with an ETag (see Remote config) |
GET /v1/saves | Yes | — | { slots: [{ slot, version, bytes, updated_at }], slot_names } (see Cloud saves) |
GET /v1/saves/<slot> | Yes | — | { slot, version, updated_at, data } (404 when empty) |
POST /v1/saves/<slot> | Yes | { data, base_version } | { slot, version, updated_at }, or 409 { error: "conflict", current } |
POST /v1/saves/<slot>/delete | Yes | { base_version } | { deleted } |
GET /v1/me/standing | Yes | — | { banned: { until, reason } | null, strikes } (when moderation is set) |
GET /v1/me/export | Yes | — | { player_id, exported_at, tables, truncated } (see Privacy) |
GET /v1/me/delete, POST /v1/me/delete, POST /v1/me/delete/cancel | Yes | — | { pending, requested_at, due_at } |
| Your game routes | Your choice | Your choice | Your choice |
| Error | Status | Meaning |
|---|---|---|
unauthorized | 401 | Missing or expired access token; refresh and retry |
bad_token | 401 | Refresh token unknown, used or expired; sign in again |
unknown_room_type | 400 | type is not in the game's roomTypes |
bad_room_config | 400 | The room's onCreate rejected config |
bad_code | 400 | The code has the wrong length or characters |
room_not_found | 404 | No room with that code (codes expire after 30 minutes) |
no_code | 503 | Could not allocate a code; retry |
bad_batch | 400 | Telemetry body missing or over 50 events |
telemetry_disabled | 404 | The game declared no telemetry events |
insufficient, limit_reached, cap_exceeded, requirement_unmet | 409 | The exchange or spend can't happen now; nothing changed |
conflict | 409 | Too many writes to the same inventory at once; retry with the same op_key |
unknown_exchange, not_owned, not_found | 404 | No such exchange, item instance or reservation |
bad_op_key, not_client_consumable, bad_target, bad_slot | 400 | Fix the request |
unknown_gift | 404 | Not in the game's gift list |
doc_too_large / bad_doc / client_publish_off | 413 / 400 / 403 | The place document was refused |
bad_name, bad_emblem, bad_role | 400 | Guild names come from the game's word lists (or its check) |
name_taken, already_in_guild, guild_full, already_helped, help_done | 409 | The guild change can't happen; nothing changed |
not_in_guild / not_allowed | 404 / 403 | You're in no guild / your role can't do that |
not_joined / shielded / world_full | 404 / 409 / 503 | No plot yet / the target's shield is up / no free cell (retry) |
not_tradable / unfair / cooldown / not_open / too_new / not_same_guild | 400 / 409 / 409 / 409 / 403 / 403 | The trade was refused; nothing moved |
age_gated / unavailable | 403 | Friend requests: your age band can't send them / the other player can't get yours right now (blocked, gated or full inbox: deliberately the same answer) |
friends_full / too_soon / limit_reached | 409 / 429 / 429 | Your friend list is full / they declined you recently / too many open requests |
bad_status | 400 | Not one of the game's presence statuses |
rate_limited | 429 | Data export: a few per hour |
conflict (saves) | 409 | The slot moved on since base_version; current holds the newer save |
unknown_slot / bad_save / too_large | 404 / 400 / 413 | Not one of the game's slots / the game's check refused it / over maxBytes |
banned | 403 | A refresh, sign-in or room join by a banned player: { until } (null = permanent), reason |
friends_only | 403 | That area is the owner's and their friends' only |
/v1/geo never stores anything and needs no device permission. Use it only to suggest something nearby, and always let the player choose.
Real-time protocol
Every WebSocket frame is one JSON envelope, at most 16 KB:
{ "v": 1, "t": "intent", "id": 42, "ts": 1760486400000, "d": { } }v is the protocol version, t the message type, ts the sender's clock in ms, d the payload and id optional. Games may add their own types, namespaced as "<game>.<name>"; Kettle checks only the envelope for those.
Connection lifecycle
- Get a ticket from
POST /v1/roomsorPOST /v1/rooms/join, then open a WebSocket to itsws_urlwithin 60 seconds. - Send
hellowith your protocol version. - Receive
welcomewith your seat, the server time, a resume token and the full room state. - Answer every
pingwith apongthat echoesserver_ts. The first few pings come quickly so the server learns your round-trip time right away. - Send
intentmessages; each gets anackwith the sameseq. - Apply
snapshot(full state) anddelta(changes) to keep your copy of the room state current. - If the socket drops, request a new ticket with the same room code and say
helloagain. Within 30 seconds you get your old seat back.
Say hello once per connection (a second one gets bad_message), within 15 seconds of connecting (a socket still waiting after that is closed with 4003 when others connect). A room accepts at most 2 × maxPlayers + 4 sockets at once; more get 503 room busy. After a server restart, seats come back marked disconnected and are reclaimed after the resume window if their players don't return.
Messages
| Type | Direction | Payload d |
|---|---|---|
hello | client → server | { protocol_version, sdk, app_version, resume_token? } |
welcome | server → client | { player_id, seat, server_time, resume_token, room_state? } |
ping | server → client | { server_ts } |
pong | client → server | { server_ts }, echoed unchanged |
intent | client → server | { seq, client_ts, d }: seq rises per client; d is your game's action |
ack | server → client | { seq, ok, code? } |
snapshot | server → client | { tick, state } |
delta | server → client | { tick, changes } |
event | server → client | { tick, events: [...] } |
kick | server → client | { reason } |
error | server → client | { code, message } |
<game>.<name> | both | Your game's own payload. Client → server goes to the room's onGameMessage and shares the intent rate limit; server → client via broadcast or sendTo |
State deltas
changes is a list of operations. Each one is safe to apply twice, so applying a delta on top of a newer snapshot stays correct.
| Operation | Meaning |
|---|---|
[path, value] | Set the value at path, creating objects or arrays on the way |
[path] | Delete the key at path |
A path is an array of keys and indexes, for example ["stations", 2, "queue"]. The SDKs apply deltas for you; @kettle/protocol exports applyDelta and diff if you write your own client.
Errors and close codes
| Code | Where | Meaning |
|---|---|---|
bad_message | error | Invalid JSON, envelope or core payload, or a message the server didn't expect |
too_large | error | Frame over 16 KB |
not_joined | error | Sent something before hello |
rate_limited | ack | Over the room's intent limit (30 per second by default) |
unsupported_version | error, then close 4000 | Update the app |
room_full | error, then close 4001 | All seats taken |
| 4002 | close | The same player connected again; the older socket is closed |
| 4003 | close | No hello within 15 seconds |
| 4004 | close | An admin removed the player from the room: SDKs don't reconnect (TS onGaveUp("kicked"), Godot reconnect_failed("kicked")) |
Writing a room
A room is a class with a few hooks. It holds your game state and rules; Kettle handles everything about connections. Room files must not import Cloudflare APIs (ADR-003), so a cheaper host can run the same classes later.
| Hook | Called when | Your job |
|---|---|---|
onCreate(config) | The room is created | Validate config; throw to reject it (bad_room_config) |
onJoin(player, reconnect) | A player says hello | Seat them in your state; often startLoop() |
onIntent(player, intent, timing) | An intent arrives | Validate and apply it; return { ok: true } or { ok: false, code } |
onTick(dtMs) | Every tick while the loop runs | Advance the simulation; call broadcastState(tick) |
onLeave(player, reason) | A player's last socket closes | Hand their seat to a bot, pause, or end |
onGameMessage(player, t, d) | A namespaced message (<game>.<name>) arrives | Validate and act (chat, emotes, cosmetics); return false to reject it with bad_message |
snapshot() | Kettle needs the state clients see | Return it; trim anything private |
serialize() / restore(data) | Saving and waking up | Return and reload everything needed to continue |
| Helper | Does |
|---|---|
startLoop() / stopLoop() | Start or stop ticking; a stopped room can sleep |
broadcastState(tick) | Send changes since the last call, with a full snapshot every fullEveryMs |
broadcastSnapshot(tick) | Force a full snapshot now, for example on a phase change |
broadcast(t, d) / sendTo(seat, t, d) | Send your own message to everyone or to one seat |
players() | Connected players and their seats |
persistNow() | Save now; call it at moments players would hate to lose |
Override protected options to change the defaults:
| Option | Default | Meaning |
|---|---|---|
maxPlayers | 4 | Seats per room |
tickHz | 10 | Simulation and send rate |
resumeMs | 30,000 | How long a dropped player's seat is held |
persistEveryMs | 30,000 | Automatic save interval while ticking |
maxIntentsPerSec | 30 | Per-connection intent limit |
pingEveryMs | 2,000 | Round-trip sampling interval after warm-up |
latencyCapMs | 150 | Upper bound for actionDelayMs |
fullEveryMs | 5,000 | Longest gap between full snapshots |
Fair timing
timing carries receivedAt, rttMs and actionDelayMs = min(rttMs / 2, latencyCapMs). For anything judged on timing (hitting a beat, stopping a meter), judge the action at receivedAt − actionDelayMs rather than at arrival. In load tests this gave players at 0, 50, 150 and 300 ms round trips the same results. The cap stops a player from gaining by faking lag.
Game routes and telemetry
Raw archive (K-2.01). Bind an R2 bucket as TELEMETRY_ARCHIVE on the Worker that consumes the telemetry queue (the platform, or a game's own Worker) and every batch is also kept as NDJSON, one object per game and hour: telemetry/<game>/<YYYY-MM-DD>/<HH>/<id>.ndjson ({game, name, ts, player, props} per line; player is the same pseudonymous hash Analytics Engine gets). A failed write leaves the batch un-acked, so it comes back. Set a lifecycle rule on the bucket to delete objects after 13 months.
Add HTTP endpoints without touching Kettle. Routes match before Kettle's own and receive the signed-in player id:
createKettle({
// …
routes: [{
method: "POST", path: "/v1/runs/finish", auth: true,
handler: async (req, env, { playerId }) => Response.json({ ok: true, playerId }),
}],
telemetry: { events: {
session_start: { blobs: ["platform"] },
level_end: { blobs: ["level"], doubles: ["stars", "seconds"] },
} },
});Telemetry accepts only declared event names and fields, with strings up to 96 characters. Each event becomes one Analytics Engine row: blobs [game, name, …declared blobs], doubles [ts, …declared doubles], and a hashed player id as the index. Bind TELEMETRY_QUEUE (Cloudflare Queues) and ANALYTICS (Analytics Engine) to store events; with neither bound, events are counted and dropped. Never declare fields that could hold personal data.
Economy
Currencies, items and upgrades the server owns (ADR-004). The game declares a catalog; Kettle checks it at startup.
const economy = defineEconomy({
catalog: {
items: [
{ id: "coins", kind: "currency", stackMax: 9999 },
{ id: "ticket", kind: "stack", stackMax: 5, overflow: "clamp", clientConsume: true },
{ id: "sword", kind: "instance", equipSlots: ["hand"], levels: { maxLevel: 3, xp: [10, 30] } },
{ id: "booster", kind: "instance", expires: { ttlSec: 3600 } },
{ id: "founder", kind: "entitlement" },
],
exchanges: [
{ id: "buy_ticket", costs: [{ item: "coins", qty: 10 }], outputs: [{ item: "ticket" }], limits: { perDay: 3 } },
{ id: "upgrade_sword", costs: [{ item: "coins", qty: 5 }], target: { item: "sword" }, outputs: [{ xp: 10 }] },
{ id: "daily_chest", outputs: [{ random: [{ weight: 1, outputs: [{ item: "coins", qty: 5 }] }] }], limits: { perDay: 1 } },
],
},
policy: { iapForbiddenTags: ["gameplay"], grantAlertPerHour: 200 },
hooks: { onLevel: (owner, instance, item, level) => {}, onAnomaly: (ownerHash, n) => {} },
dayKey: (now) => myResetDay(now), // per-day limits; default the UTC date
});
export const RoomHost = createRoomHost({ shift: ShiftRoom }, { economy });
export default createKettle({ /* … */ economy });| Item kind | What it is |
|---|---|
currency | A balance, optional stackMax |
stack | A counted item; overflow: "clamp" caps instead of refusing; expires.at empties it at a fixed time |
(currency or stack) expires: { at, convert: { to, each, gives } } | Convert-on-expire (K-3.08): at at the balance turns into to (another currency or stack that outlives it), gives for every each held, rounded down and capped by to's stackMax; ledger kinds expire and convert |
instance | One item with its own id, level/xp, game attrs, equip slots and expiry (ttlSec or at) |
entitlement | A permanent 0/1 flag (purchases, founder rewards) |
Server code (game routes, rooms through this.economy) uses createEconomyStore(env.DB, economy): grant, consume, reserve → commit/release, revoke, addXp, exchange, equip, transfer, give, apply, inventory, sweep, and totals(owner, { sinceMs, reasons? }) (what was granted since a time, per item, optionally by reason, counting grants, game ops and gifts received: build daily earning caps on it), and spendLeft(owner) (the player's daily spend caps: {item: {per_day, spent, left}}).
Spend caps by age band. policy.spendCaps: { <item>: { under13?, "13to15"?, "16to17"?, adult?, unknown? } } caps how much of a currency or stack a player may spend per day (dayKey), by the age band they declared (unknown until they do; a band left out has no cap). Every spend path counts (client consume, exchanges, game ops, gifts, sales, holds; a released hold counts as unspent), so rooms and game routes can't get round it. Over the cap, the write fails with spend_cap (HTTP 409 on Kettle's routes) and nothing changes. GET /v1/inventory adds spend_caps (the same shape as spendLeft) when the player has any.
Game ops (apply(owner, op, meta)) are for a game's own rules on the server: read the inventory, decide, then write the whole outcome in one atomic step: { ifVersion?, consume?, grant?, update?: [{ instance, level?, attrs? }], remove?: [instanceIds] }. With ifVersion (the version you read), the write fails with stale if anything changed in between: read again and decide again. Instance attrs are capped at policy.maxAttrsBytes (4 KB) and checked by hooks.validateAttrs. A town builder keeps each building as an instance (level, attrs = position, build timer, production clock) and checks the timers against server time in its own route. give(from, to, { consume?, grant }, meta) makes one owner pay and another receive in one step (gifts). Every write takes an opKey: the same key never applies twice and returns the first answer. A room should hold what a run uses (reserve with holder: "room:<id>") and commit at the end, passing { holder, owner } so only that room's hold for that player can be settled (a mismatch answers not_found); if the room dies, the hold lapses after ttlMs and the items come back. Quantities and levels must be whole numbers from 1 to 1,000,000 on every call (bad_request otherwise), so a game route that passes a client's number through can't reverse a transfer or mint.
Clients can read, run declared exchanges, spend items marked clientConsume, and equip. They can never grant. Random outputs are allowed only in free exchanges, and items tagged in iapForbiddenTags can't be marked for real-money sale.
{ "ops": [
{ "op_key": "1792110727661-0603f281", "op": "consume", "item": "ticket", "qty": 1 },
{ "op_key": "1792110727662-9a1b2c3d", "op": "equip", "slot": "hand", "instance": "i_…" }
] }Ops run in order and each answers on its own (ok, replay, or error). Expired items and lapsed holds are swept on every read and write, and by scheduled() if you add a cron trigger.
Visits and gifts
Players can look at another player's place (a home, a town, a shop: the game decides what its public document holds) and leave a gift. There are no messages: a gift is an id from the game's list.
export const visits = defineVisits({
gifts: [{ id: "wave", grant: [{ item: "coins", qty: 2 }] }, // free for the visitor
{ id: "flowers", cost: [{ item: "seeds", qty: 1 }], grant: [{ item: "fans", qty: 3 }] }],
limits: { perVisitorPerDay: 10, perHostPerDay: 30, perPairPerDay: 1 }, // the economy's dayKey
validateDoc: (doc) => isMyLayout(doc), // no names or free text
});
createKettle({ ..., economy, visits });The game's server publishes documents with createVisitsStore(env.DB, visits, economyStore, economy.dayKey).publish(playerId, doc, { listed }), usually right after its own rules changed the place, so a document always shows what really exists. Unlisted documents are reachable by id only. Set clientPublish: true only for documents that are pure decoration; it requires validateDoc. Gift lines are whole numbers, and every gift item must exist in your catalog (Kettle checks when it starts). A gift runs give (the visitor pays cost, the host receives grant) with the caller's op_key, so a resend is harmless. Gifts received count in the host's totals. Documents are capped at 32 KB (maxDocBytes).
Guilds
Players form guilds (crews, clubs, organizations: the game names them). A player is in at most one guild.
export const guilds = defineGuilds({
maxMembers: 100,
roles: [{ id: "leader" }, { id: "officer", can: ["accept", "kick", "promote", "settings"] }, { id: "member" }], // highest first
names: { parts: [["Red", "Blue", "Golden"], ["Foxes", "Owls", "Rockets"]] }, // or { validate: (name) => ... }
emblems: ["star", "moon", "bolt"],
feedKinds: ["milestone"], // kinds your server posts
help: { maxNeed: 10, perHelperPerDay: 30, ttlMs: 24 * 3600_000 },
});
createKettle({ ..., guilds }); // or createGameWorker({ ..., guilds }) on the platform- No free text. A name is one word from each list in order (any case; stored with the list's spelling), or passes your
validate. Emblems come from your list. Feed entries carry your declared kinds with ids and numbers only. - Roles. The first role leads (one per guild, may do everything); the last is a new member's. A role's
canlistsaccept(join requests),kick,promoteandsettings. You can only kick or re-role members ranked below you. The leader hands over leadership withrole: "<leader id>"and steps down one rung; when the leader leaves, the highest-ranked, longest-standing member leads; the last member out disbands the guild. - Joining. Open guilds take you up to
maxMembers(checked in the same statement as the insert, so a rush can't overfill a guild); closed ones collect requests for a role withaccept. - Help. A member asks for help with one of their jobs (
job: an id your game understands,need≤maxNeed). Others give one help each, up to the need and withinperHelperPerDay. Kettle only counts: your server readscreateGuildsStore(env.DB, guilds).helpFor(playerId, job), applies what help means in your game (a build finishing sooner), thencloseHelp. - Server-only data.
setData(guildId, data)(≤ 2 KB public JSON: a level, held sites) andpost(guildId, kind, actor, data)for feed entries. Clients can't call either. - Guild-owned items. The economy's owners are generic:
guildOwner(id)gives a guild its own balances and instances (shared research, a joint roster). Members contribute withgive(player, guildOwner(id), ...)from your routes.
Apply migration 0006_guilds. See ADR-009.
World shards
Shared grid maps (call them regions, servers or realms in your game) of player plots, neutral sites and timed travels.
export const worlds = defineWorlds({
grid: [200, 200], playersPerWorld: 300,
sites: (worldId) => mySites(worldId), // neutral sites of a new world: [{site_id, kind, x, y, data}]
spacing: 3, // free cells around plots and sites
fog: { chunk: 8, revealRadius: 12 }, // fog of war in 8×8 chunks; 0 = no fog
travel: { clientKinds: ["scout", "challenge"], blockedByShield: ["challenge"], maxActive: 3, msPerCell: 60_000 },
});
createGameWorker({ ..., worlds }); // or createKettle({ ..., worlds })- Joining gives a player one plot for good: in the newest world with room, at a free random cell at least
spacingfrom other plots and sites (checked in the insert itself). When a world is full the next one opens with yoursites(worldId). - Fog. A new plot uncovers
revealRadiuscells around it; your server uncovers more withreveal(playerId, x, y, r)(a scout arriving). Map reads show only plots, sites and travels in uncovered chunks (and always your own). - Travels start from the player's plot: clients may start your
clientKinds(and pass yourvalidate), your server any kind with an optional duration. A player has at mostmaxActivemoving;blockedByShieldkinds can't target a shielded plot. Kettle only times them: your server readsdue(playerId)(ordueInWorld(id)from a cron), applies your rules (a challenge match, a scouting report) and records the outcome once withresolve(id, result). Players mayrecalla moving travel. - Server-only:
setDoc(playerId, doc)(≤ 1 KB public summary of a plot: a level, a banner id),setShield(playerId, until),setSite(world, site, owner, data)with owner{ kind: "player" | "guild", id }.claimSite(world, site, owner, expect, data?)changes the owner only while it is stillexpect(null= unowned) and answers whether it did: first come, first served (a one-of-a-kind building, a contested venue).claimStatements(...)gives the same guard and update as statements for your game op'sextra, so a payment and the claim land in one write (when someone else got there first, the op answersconflict).syncSites(world)adds sites yoursites(worldId)gained in a game update to a world opened before it (existing sites keep owner and data).
Apply migration 0007_worlds.
Trades
Players swap items: one offers, the other accepts, and both sides move in one economy batch (store.swap), or nothing moves. Only items marked tradable: true in your catalog can change hands; instances keep their level and attrs.
export const trades = defineTrades({
maxLines: 4, ttlMs: 24 * 3600_000,
limits: { perDay: 5, minAccountAgeMs: 7 * 86_400_000, sameGuild: true, instanceCooldownMs: 3 * 86_400_000, openPerPlayer: 5 },
value: { of: (lines) => myValue(lines), maxRatio: 2 }, // sides within 2:1; an empty side is worth 0, so no one-way funnels
eligible: (playerId, inv) => (levelOf(inv.balances.xp ?? 0) >= 5 ? null : "level"), // your rule; refusals answer 403
});
createGameWorker({ ..., economy, trades }); // needs economy (and guilds for sameGuild)- Atomic, not escrowed: items aren't locked while an offer is open. Accepting checks both sides again (ownership, cooldowns, values, both players' daily caps) and swaps; the trade's state and the caps are asserted inside the same batch, so a trade completes once even when accepted twice at the same time (the second answer is a
replayornot_open). - Limits are yours: keep trades from becoming a real-money market (
valuebands, daily caps, account age, same guild, per-instance cooldown). - Your server can also call
economy.swap(a, b, aGives, bGives, { opKey })directly. Apply migration0008_trades.
Handles
A handle is a player's display name, built from your word lists: one word from each list, in order, plus an optional number. Nobody types a name, so a game for teens can show who made or traded something without moderating free text. Handles are unique per game, in any letter case.
export const handles = defineHandles({
parts: [["Quiet", "Bright", "Lucky"], ["Fox", "Owl", "Crane"]], // "Bright Owl"
number: { min: 1, max: 99 }, // optional: "Bright Owl 42"
changeCost: { item: "coins", qty: 50 }, // optional, needs economy
});
createGameWorker({ ..., economy, handles });GET /v1/handles/parts, GET /v1/handles/me, GET /v1/handles/suggest (a free one at random), POST /v1/handles {handle} (refusals bad_handle, handle_taken, insufficient), GET /v1/handles/lookup?h= ({player_id, handle}), GET /v1/handles?ids=a,b (up to 50). The first pick is free; a change is charged in the same batch as the update, so a lost race spends nothing. Server code can call createHandlesStore(env.DB, handles, economy) and canonicalHandle(handles, text). Apply migration 0010_handles.
Counters
Likes, plays and other counts on your own ids (ADR-011): defineCounters({ kinds: { like: { onePerPlayer: true }, play: { perPlayerPerDay: 3 }, score: { client: false } } }), passed as counters.
POST /v1/counters/<kind>/<target> (body {on: false} takes a like back) → {count, mine}; GET /v1/counters/<kind>?targets=a,b → {counts, mine}; GET /v1/counters/<kind>/top?n=&days= (days 0 = all time). A double tap or retry counts once. Server code: createCountersStore(env.DB, counters).count/get/top. Apply migration 0011_counters_moderation.
Place vitality (K-ext.26)
A shared value per place (0..max) that players' play raises and a daily tick lowers, with named states by threshold, so a game can change a place for everyone as it thrives or struggles. Nobody can lower a place. defineVitality({ places: ["a", "b"], states: [{id: "low", min: 0}, {id: "ok", min: 40}], decayPerDay: 6, kinds: {visit: 1, help: 4}, perPlayerPerDay: 10, activeRef: 50, boostMax: 4, start: 50, max: 100 }), passed as vitality (createKettle or createGameWorker). GET /v1/vitality → {places: [{id, value, state}], states}. Server code contributes from the game's own routes: createVitalityStore(env.DB, vitality).contribute(place, playerId, kind) → {added, value, state}: a kind's points, times a quiet-day boost (activeRef / players who contributed yesterday, between 1 and boostMax, so a small region behaves like a busy one), capped by perPlayerPerDay per place. The Worker's cron runs tick() (the day's decay, once per day; missed days catch up). SDKs: Godot vitality(), TS vitality(). Apply migration 0022_vitality (vitality_contrib is player data: exported and erased).
Events (timed windows, scoped standings)
Recurring windows your game declares (a weekly region season, a weekend cup), with points per window, scope and entrant, ranks, and rewards claimed once (K-4.03).
export const events = defineEvents({ events: [
{ id: "region_season", periodMs: 7 * 86_400_000, anchorMs: 4 * 86_400_000 }, // Mondays 00:00 UTC
{ id: "weekend_cup", periodMs: 7 * 86_400_000, anchorMs: 9 * 86_400_000, lengthMs: 2 * 86_400_000 },
]});
createGameWorker({ ..., events });GET /v1/events→{now, events: [{id, window, start, end, open, previous: {window, start, end}}]}.- Points and standings go through your routes, because only your game knows a player's scope (their world, their guild):
createEventsStore(env.DB, events)givesadd(event, scope, {kind, id}, points)(only while the window is open;nullwhen closed),addStatements(...)for theextraof the op that earned the points,standings(event, window, scope, kind, {n, offset})(ties share a rank),entry(event, window, scope, entrant)→{score, rank, total}. - Rewards once:
claimStatements(event, window, playerId, key)in the economy op that grants the reward; a second claim fails the batch (the op answersconflict).claimed(...)tells you first;claims(event, window, playerId)lists every key claimed (a pass track's tiers). - Entrants have a kind (
player,guild, or your own), so a player's points and their guild's tally can sit side by side. The cron prunes windows older thankeepWindows(default 8). Apply migration0012_events.
Push notifications and the inbox
Game-declared notices (no free text from players), queued by your server now or for later, delivered to an in-game inbox and to devices through providers your deploy plugs in (K-ext.18).
export const push = definePush({
kinds: {
build_done: { title: "{building} is finished", body: "Come and see it." },
raided: { title: "Your concessions were raided!", body: "{who} got away with {coins} coins." },
weekly_digest: { title: "Your week", body: "...", default: false }, // off unless the player turns it on
},
perPlayerPerDay: 12,
providers: (env) => [/* your APNs / FCM / web push senders: { platform, send(token, msg) } */],
});
createGameWorker({ ..., push }); // the cron delivers due notices and prunes- Routes:
POST /v1/push/devices {platform, token},POST /v1/push/devices/remove {token},GET|POST /v1/push/prefs,GET /v1/push/inbox?n=,POST /v1/push/inbox/read {ids | all}. The inbox also delivers whatever is due for its reader, so it works without a cron. - Server:
createPushStore(env.DB, push)gives:notify(playerId, kind, data, {at, collapse}), ornotifyStatements(...)for a game op'sextraso the notice is queued with what caused it.- A
collapsekey replaces a queued notice (a build hurried along re-times its notice).cancel(playerId, collapse)drops one. - Data values are numbers or strings of at most 64 characters.
- Providers:
send(token, msg)answersok,invalid(Kettle forgets the token) orretry. Without providers, notices still reach the inbox. Players switch kinds off, andperPlayerPerDaycaps device pushes (the inbox gets the rest). - Apply migration
0014_push. APNs and FCM senders aren't built in yet: they need your keys (APNs token auth, FCM HTTP v1 service account).
Reports and blocks (moderation v1)
defineModeration({ kinds: ["beat"], reasons: ["spam", "rude", "stolen"], hideAt: 3 }), passed as moderation (ADR-011). Players report a target with a reason id (no free text): POST /v1/reports {kind, target, reason} → {hidden}; one report per player per target, reportsPerDay per player. At hideAt distinct reporters the target is hidden. Your server decides what hidden means and can takedown / restore (that decision sticks): createModerationStore(env.DB, moderation).hidden(kind, targets). Blocks: POST /v1/blocks {player}, POST /v1/blocks/remove, GET /v1/blocks; blocked(a, b) on the server keeps two players apart. Staff review reports in the admin console's Reports tab (K-3.07): open, hidden and decided targets with their reasons, and Take down / Restore for game admins (audited; a console decision sticks the same way). For games on the shared platform the console writes through the platform's admin entrypoint, whose only write is POST /games/<g>/moderation {kind, target, hidden, by} (decideModeration).
Strikes and bans (K-3.05, migration 0021). defineModeration({ ..., strikes: { windowDays: 90, banAt: [{ strikes: 3, days: 1 }, { strikes: 5, days: 30 }, { strikes: 8 }] } }): staff (the console) or your server give strikes with one of your reason ids (or staff); strikes in the window ban automatically at the thresholds (no days: permanent; a longer ban in force is never shortened). A banned player gets 403 {error: "banned", until, reason} on refresh, provider sign-in and room, area and matchmaking joins, so their access ends within 15 minutes. GET /v1/me/standing tells the player. Server: createBansStore(env.DB, { ...moderation.strikes, reasons: moderation.reasons }) → strike, ban, unban, forgive, standing.
Listings (a player market)
Players sell tradable instances at fixed prices in one soft currency (ADR-012):
export const listings = defineListings({
currency: "cash", feePct: 10, maxOpen: 10, ttlMs: 7 * 86_400_000,
price: (item, attrs) => (item === "record" ? bandFor(attrs) : null), // {min, max}; null = can't list
eligible: (playerId, inv) => (levelOf(inv.balances.xp ?? 0) >= 5 ? null : "level"),
// Optional: items that stand for moderated player content (hidden ones can't be listed or bought).
moderated: (item, attrs) => (item === "print" ? { kind: "post", target: String(attrs?.post) } : null),
});
createGameWorker({ ..., economy, listings });POST /v1/listings {instance, price}, GET /v1/listings?item=&attr=&value=&sort=cheap|new&n=&after=, GET /v1/listings/mine, POST /v1/listings/<id>/buy, POST /v1/listings/<id>/cancel. Atomic at buy: economy.sale(seller, buyer, instance, price, fee) pays, keeps the fee out of the economy and moves the instance in one batch; a listing sells once, and closes if the seller no longer has the item. With moderated, an item whose moderation target is hidden (a report threshold, a takedown in the game or the console) can't be listed, drops out of browse, and buying it answers 404 hidden; restoring the target brings its listings back. Apply migration 0013_listings.
Age bands and drop tables
export const drops = defineDrops({ tables: [
{ id: "club_pack", cost: [{ item: "pack_token", qty: 1 }], entries: [...], pity: { every: 30, tiers: ["epic", "legend"] } },
{ id: "star_pack", cost: [{ item: "stars", qty: 100 }], entries: [...], pity: { every: 30, tiers: ["epic", "legend"] },
gates: { minAge: "adult", blockCountries: EU_COUNTRIES } },
]});
createGameWorker({ ..., economy, age: true, drops });- Age bands: players declare one (
POST /v1/me/age); it can be lowered but never raised by the client.getAgeBand(db, id)/ageAtLeast(band, min)for your own rules. Unknown = too young. - Country: game routes get
ctx.country(Cloudflare's view of the caller; signed through the platform). Unknown fails closed on tables with country rules. - Odds:
GET /v1/drops/<table>shows exact percentages from the weights the server draws with, the pity countdown, andavailable/why(age,region,region_unknown). - Drawing: in your route,
createDropsStore(env.DB, drops).decide(playerId, table, opKey, ctx.country)returns the entry,consumeandextrastatements; put them in yourstore.applywith what the entry means in your game, so a draw is paid, counted (pity) and granted once. For tables whose entries carrygrant,open(economyStore, ...)does it all. Retries:logged(playerId + ":" + opKey).
Apply migration 0009_age_drops. See ADR-010.
Purchases (App Store and Google Play)
Real-money purchases, checked on the server and granted through the economy. The client never says what it bought: Kettle reads it from Apple's signed transaction or from Google's API.
export const purchases = definePurchases({
products: [
{ id: "gems_500", apple: "com.example.gems500", google: "gems_500", grant: [{ item: "gems", qty: 500 }], usdCents: 499 },
{ id: "season_pass", apple: "com.example.pass1", google: "pass_1", grant: [{ item: "pass_s1" }], usdCents: 999 },
{ id: "kit_bundle", apple: "com.example.kits", google: "kits", grant: [{ item: "kit", qty: 3 }], usdCents: 299, kind: "non_consumable" },
],
moneyCaps: { "13to15": 2000, "16to17": 5000 }, // US cents per UTC calendar month
apple: { bundleIds: ["com.example.game"], environments: ["Production", "Sandbox"] },
google: { packageName: "com.example.game" },
});
createGameWorker({ ..., economy, age: true, purchases }); // or createKettle({ ..., purchases })- Catalog: every granted item must exist and be
iap: true(policy.iapForbiddenTagsstill vetoes); Kettle checks at startup.kinddefaults toconsumablewhen a line is a currency or stack, elsenon_consumable;subscriptionis also accepted.gates: { minAge, blockCountries }work as on drop tables. - Who may buy:
under13and players with no declared band are refused by default (refuseBands).moneyCapscountsusdCents, the game's own list price, never the store's local price, of granted and not refunded purchases in the current UTC month. ShowGET /v1/purchases/products(or callPOST /v1/purchases/check) before the store sheet. - Account token:
GET /v1/purchases/tokengives a stable UUID per player (HMAC of the player id withPURCHASE_SECRET). Pass it as StoreKit 2appAccountTokenand Play BillingsetObfuscatedAccountId; a purchase carrying another player's token is refused (wrong_account).requireAccountToken: truealso refuses purchases without one. - Apple: send
Transaction.jwsRepresentationtoPOST /v1/purchases/apple. Kettle verifies it offline: thex5cchain (leaf, intermediate, root) must end at Apple Root CA - G3 (pinned by SHA-256), each certificate is signed by the next and valid at the transaction'ssignedDate, the intermediate is a CA, Apple's marker OIDs are present, and the JWS is ES256 signed by the leaf. ThenbundleId,environment,productIdandappAccountTokenare checked and arevocationDaterefuses it. Finish the StoreKit transaction once Kettle answers. - Google: send
{ product, purchase_token }toPOST /v1/purchases/google. Kettle signs in as the service account (GOOGLE_SERVICE_ACCOUNT), readspurchases.products(orpurchases.subscriptionsv2), requirespurchaseStatepurchased and a matchingobfuscatedExternalAccountId, grants, then consumes consumables and acknowledges the rest. If Google doesn't answer the consume,acknowledged: falsecomes back and the next resend tries again. - Once per transaction: the grant and the purchase row are one economy write keyed
iap:<store>:<transaction id>. A resend by the same player replays (replay: true); the same transaction from another player answers 409owned_elsewhere. - Over a cap anyway: a verified purchase that arrives past the cap or a gate (a race between two devices, an old client that skipped the check) is still granted, because the store took the money. Its row is flagged (
flagged: "money_cap",flagsinpurchases) for review, and the game may refund it in the store. - Restore:
POST /v1/purchases/restoretakes up to 50 transactions. Ones never delivered are granted; for non-consumables and subscriptions the player already owns, entitlements and instances they lost are granted again (never currencies, never twice, and an instance traded to someone else doesn't count as lost). A transaction owned by another player answersowned_elsewhere(account linking is how a player gets back to the account that bought it). - Refunds: point App Store Server Notifications V2 at
https://<host>/<game>/v1/purchases/apple/notify;REFUNDandREVOKErevoke the grant throughstore.revoke(signed payloads verified the same way). Point a Pub/Sub push subscription for Play real-time developer notifications at.../v1/purchases/google/notify?token=<RTDN_TOKEN>; avoidedPurchaseNotificationrevokes. Revocation is best effort: currency already spent can't be taken back, and what wasn't is stored inrevoke_missing_json. A refunded purchase stops counting toward the month's cap. - Testing: transactions from an Xcode StoreKit configuration file (environment
Xcode) are signed by a local certificate and can't chain to Apple's root. Kettle accepts them only withenvironments: [..., "Xcode"]andallowXcode: true, and then checks nothing but the payload. Use it in dev and tests only, never in production. Sandbox transactions are verified like production ones. Tests can pin their own root withapple.rootSha256and injectfetchfor Google.
Secrets live where the routes run (the game's own Worker, or the game Worker on the shared platform): PURCHASE_SECRET (account tokens), GOOGLE_SERVICE_ACCOUNT (the service account's JSON key), RTDN_TOKEN (the push URL's token). Apply migration 0016_purchases. See ADR-014.
Leaderboards
export const leaderboards = defineLeaderboards({ boards: [
{ id: "daily_score", period: "daily", clientSubmit: true, minScore: 0, maxScore: 1000, validate: (s) => Number.isInteger(s) },
{ id: "season_wins", period: "all", keep: "latest" }, // only your server writes
]});
createKettle({ ..., leaderboards }); // or createGameWorker({ ..., leaderboards }) on the platform- Periods: a period is a UTC day (
2026-10-16), an ISO week (2026-W42) orall. Reads takeperiod=current(the default),previousor a key. - Scores: each player keeps their best score per period (
keep: "best", higher is better unlesssort: "asc"), or their last one (keep: "latest"). - Ranks: competition ranks, so ties share a rank.
around=melists the entries around the caller, andmeis the caller's{ rank, score, total }, ornull. - Friends (K-4.01):
?friends=1ranks the board among the caller and their friends only (blocked ones left out);me.totalis how many of them have a score. Needs the friends tables (migration 0017);defineFriendsisn't required. - Who writes: the game's server writes with
createLeaderboardStore(env.DB, leaderboards).submit(board, playerId, score, data)from a room's end or a route. Clients mayPOSTonly toclientSubmitboards, which needmaxScoreor avalidatehook. Use those for casual scores only; anything worth cheating for should be posted by your server. - No names: entries show an anonymous
tag(P-XXXXX) and yourdataobject (at most 256 bytes: a character id, a flag), never names or free text. - Retention: the Worker's cron prunes periods older than
keepPeriods(daily boards 14, weekly 8). Apply migration0005_leaderboards. See ADR-007.
Matchmaking
export const matchmaking = defineMatchmaking({ roomType: "duel", modes: ["duel"] });
createKettle({ ..., matchmaking }); // or createRoomHost(types, { matchmaking }) on the platformPOST /v1/match {mode, profile}queues you (one ticket per player) or pairs you at once with the oldest waiting player of that mode:{ticket, state: "matched", join: {room_id, type, join_token, ws_url}}.GET /v1/match/<ticket>?mode=polls (about once a second keeps the ticket alive; it goes stale afterstaleMs, default 30 s):{state: "waiting" | "matched" | "gone", join?, waited_ms}.DELETEthe same path cancels.- The room is made with config
{mode, matched: true, players: [{player, profile}, ...]}; your room validates the profiles inonCreate(throw to refuse) and can start once both have joined. First come, first served: no ratings yet. See ADR-015.
Areas (live presence)
Players in the same area of your world (a street, a map, a chunk: the game names it with a key) see each other move and emote, live. There is no free text: emotes are ids from your list, and a player's look is a small document of plain values (numbers, short ids, colours) that your game also checks.
export const areas = defineAreas({ emotes: ["wave", "cheer"], maxPerRoom: 30, validateLook: (l) => isMyAvatar(l) });
export const RoomHost = createRoomHost({ match: MatchRoom, area: AreaRoom }, { economy });
createKettle({ ..., areas });Friends-only areas (K-4.01). defineAreas({ ..., friendsOnly: ["home."] }): a key home.<player id> belongs to that player, and only they and their friends (not blocked) may join; anyone else gets 403 friends_only. Use it for a player's home, base or room. It rides on the room host manifest, so it works on the shared platform too.
Area rooms are created only by POST /v1/areas/join, with your defineAreas settings: listing "area" in roomTypes does not let clients create one through POST /v1/rooms (they get unknown_room_type). Area settings are clamped (tickHz 1–20, maxPerRoom 1–100, maxLookBytes ≤ 4096), and look keys are lowercase words (no __proto__).
POST /v1/areas/join {key} (auth) returns a room ticket (room_id, shard, join_token, ws_url) for the first area room with space (maxPerRoom, then up to shards overflow rooms; area_full after that). Connect with the usual room WebSocket. Intents: {k: "pose", x, y, fx, fy, a} (up to 16 a second), {k: "emote", e}, {k: "look", look} (≤ 512 bytes, plain values only). State, at tickHz as deltas: {p: {<player_id>: [x, y, fx, fy, anim, emote, emote_at, look]}}; an emote shows for 3 s. Nothing is stored: leaving removes you. Godot: join_area(key), then connect_room(ticket), send_intent, state_changed.
Skirmish areas. Some areas can hold a friendly contest the server judges (tag, snowball fights): list their keys and Kettle adds hearts, blasts and knock-outs to those area rooms. Hits are decided by the room from its own copy of each player's pose; clients only say which way they blast.
export const areas = defineAreas({
emotes: ["wave"],
skirmish: {
keys: ["arena:main", "zone_*"], // exact keys, or prefixes ending in "*"
hearts: 3, range: 6, arcDeg: 70, // these are the defaults
cooldownMs: 500, outMs: 3000, graceMs: 2000, maxSpeed: 9,
lineOfSight: (key, ax, ay, bx, by) => !wallBetween(key, ax, ay, bx, by), // optional
},
});POST /v1/areas/joinadds the numbers to the room's config when the key matches (skirmishFor(areas.skirmish, key)). Rooms clamp them (hearts1–20,range0.5–100,arcDeg1–360,cooldownMs50–60000,outMsandgraceMs0–60000,maxSpeed0.5–1000) and keep them across hibernation. On the platform they travel in the room host's manifest;lineOfSightstays on theAreaRoomclass in the game's Worker (likevalidateLook), so deploy the game Worker before the platform.- Intent
{k: "blast", fx, fy}: a facing (normalised by the room). Refusals:not_skirmish(an ordinary area),bad_blast(zero or non-finite facing),out,cooldown. A blast counts for the blaster, then hits every other player who isn't out or in grace, withinrangeof the blaster, and inside thearcDegcone or within 0.8 units (point blank), and in sight whenlineOfSightis given (a check that throws blocks the hit). A hit takes one heart and records the blaster; at 0 hearts the player is out foroutMs, then comes back with full hearts and can't be hit forgraceMs. Each knock-out adds one to the blaster's knocks. - State gets one more key in skirmish areas (only there;
pis unchanged):s: {<player_id>: [hearts, hits, blasts, out, knocks, last_by]}.outis 0 or 1,hitsandblastsonly grow (watch them change to play effects, likeemote_at),last_byis the last blaster's player id or"". Deltas carry only what changed. - Poses there can't move faster than
maxSpeedunits a second (time counted up to 1 s since the last pose); a faster pose is moved part of the way. The first pose after joining places the player anywhere, and while out a player may jump to their respawn point. - Joining starts at full hearts; leaving forgets the player's row.
Busy areas: a join claims a seat on its shard (players plus tickets handed out in the last 15 s that haven't connected), so a burst of joins spreads over the shards instead of over-filling the first one.
Shared platform Worker
createKettlePlatform() (workers/platform) serves every game listed in KETTLE_GAMES (comma-separated ids) under /<game>: POST https://<platform>/<game>/v1/auth/anon, /<game>/v1/rooms, and so on. Each request runs through an ordinary createKettle handler with basePath: "/<game>", so room WebSocket URLs carry the prefix. GET /v1/health lists the games.
Forward tokens. Requests the platform passes to a game Worker carry x-kettle-forward, a 30-second token (typ: fwd) bound to the method, path, query string and a hash of the body. It's signed with <G>_FORWARD_SECRET when set (the game Worker's FORWARD_SECRET), else with the join secret; give each game its own.
Usage by game. Bind an Analytics Engine dataset as USAGE and Kettle writes one point per request: blobs [game, route class, status class] (route class: auth, rooms, ws, areas, t, inventory, visits, game, …; status class: 2xx, 4xx, 5xx, 101), doubles [1, latency ms]. On the platform it's written once per request, after any forward to the game Worker; a game's own createKettle Worker writes it too. The admin console reads it to split a shared Worker's requests and errors by game.
Binding (per game, G = id upper-cased, - → _) | What |
|---|---|
<G>_DB | D1 database with Kettle's migrations (the game's players and tokens) |
<G>_ROOMS | Durable Object binding to the game's room host class, in the game's own Worker (script_name) |
<G>_JWT_SECRET | Access-token secret (a token from one game is refused by another) |
<G>_JOIN_SECRET | Room join-token secret; the room host Worker's JOIN_SECRET has the same value |
<G>_APP (optional) | Service binding to the game's Worker: /<game>/v1/* routes Kettle doesn't serve are forwarded to it |
<G>_ADMIN (optional) | Service binding to the game Worker's KettleGameAdmin entrypoint (createGameAdmin): the console's grants, config and deletions |
<G>_LINKS (optional var) | Account linking ids as JSON (defineLinks options); secrets <G>_LINKS_SECRET, <G>_PLAYGAMES_SECRET, <G>_RESEND_KEY |
LOBBY (shared) | LobbyDO; object names are prefixed with the game id, so room codes never cross games |
The platform holds no game data: room types, shard count, telemetry declarations and area settings come from the room host (createRoomHost(types, { name, shards, telemetry, areas, economy }), read with RoomHostDO.manifest(); name is the game's display name in the admin console). Deployment configs live in deploy/<name>/ (outside Kettle's code). Local dev runs both Workers in one session: wrangler dev -c deploy/realm8/wrangler.jsonc -c <game>/server/wrangler.jsonc.
Economy, visits and game routes run in the game's own Worker, with its catalog, hooks and database:
// The game Worker (bound by the platform as <G>_ROOMS by script name and <G>_APP as a service).
export const GameRooms = createRoomHost({ shift: ShiftRoom, area: AreaRoom }, { economy, areas, telemetry, shards: 64 });
export default createGameWorker({ economy, visits, friends, config, privacy, routes: [...myRoutes] });
export const KettleGameAdmin = createGameAdmin({ economy, config, privacy }); // bound as <G>_ADMINThe platform still signs players in. It forwards /<game>/v1/inventory*, /v1/visits/* and any other /v1/* path it doesn't serve to <G>_APP, with the caller in a short-lived token signed with the game's join secret (x-kettle-forward, bound to the method and path); createGameWorker refuses requests without one, and route handlers get ctx.playerId as before. The game Worker binds the same D1 database as the platform's <G>_DB and runs the economy sweep on its own cron. Timed activities (builds, upgrades, production) are economy instances whose attributes hold server times, so a client that restarts reads the same finish times back. One telemetry queue serves every game: the platform's consumer files each event under its game's declarations.
Adding a game to the platform (the convention). The platform's KETTLE_GAMES is the one list of platform games; the admin console reads it (ADR-008). In the game's repo, give the room host a display name: createRoomHost(types, { name: "My Game", shards, telemetry, areas, economy }). In the platform deployment, add the KETTLE_GAMES entry, <G>_DB, <G>_ROOMS, <G>_APP and the secrets. Deploy, and the game shows up in the console within 5 minutes, for owners at once and for anyone else once invited. Don't add it to the admin deployment. Add an ADMIN_GAMES entry there only to count the room host's Durable Objects and its D1 in the game's cost (scripts, d1), or for a game that runs Kettle in its own Worker.
Adopting the October 2026 modules (each game). Friends, remote config, privacy, cloud saves, strikes and bans, and the console's writes are opt-in per game: apply migrations 0017–0021 (games that keep their own copy of the migrations copy them first), export KettleGameAdmin (createGameAdmin) from the game Worker and have the platform bind it as <G>_ADMIN, then turn on the modules the game wants. Until then the console is read-only for that game. The full checklist is in docs/runbooks/deploy.md ("For game repos").
Moving a game from its own Worker to the platform: keep the room host export; turn createKettle({...}) into createGameWorker({ economy, visits, routes }); move telemetry, areas and the shard count into createRoomHost's options and add its name; in the platform deployment add the game id to KETTLE_GAMES, <G>_ROOMS (script_name), <G>_APP (service), <G>_DB (the game's existing D1 database keeps its players) and the secrets <G>_JWT_SECRET (the game's current JWT_SECRET, so existing sessions stay valid) and <G>_JOIN_SECRET (= the game Worker's JOIN_SECRET); then point clients at https://<platform>/<game>. Keep the game's Worker on workers_dev: false so it's reachable only through the platform.
Friends and presence
defineFriends({ max: 200, statuses: ["online", "in_room"], presenceTtlSec: 120, minBand: "13to15" }), passed as friends (K-4.01, migration 0017_friends). Every player has a friend code (8 characters without look-alikes; POST /v1/friends/code replaces it). A request goes by code or by player id (from a handle look-up, a leaderboard row, a trade); asking someone who already asked you makes you friends at once. Friendships are mutual and capped on both sides (max, default 200), checked inside the same write so racing accepts can't overfill. A declined request can't be sent again for declineCooldownDays (7); unanswered ones expire after requestTtlDays (30; the cron prunes). Blocks (moderation) hide friends and refuse requests in either direction. With minBand set, only players of at least that age band send or receive requests, and a player without a declared band can't; refusals caused by the other side answer unavailable, so a player can't tell a block from an age gate.
Presence. While playing, the client sends POST /v1/friends/presence {status, room?} every minute or so: status is one of the game's ids (no free text; null = offline) and room an optional room code friends can join. Friends see it in GET /v1/friends for presenceTtlSec after the last heartbeat. hidden: true opts out (kept until changed), and friends then see nothing. Each heartbeat is one D1 write.
On the server: createFriendsStore(env.DB, friends) gives friendIds(player) and areFriends(a, b) for the game's own rules (gifts only to friends, a friends-only place).
Remote config and feature flags
defineConfig({ defaults, flags, check? }), passed as config (K-2.02, ADR-016, migration 0018_config). defaults holds every value the game reads (any JSON); flags names the features it can switch. Admins publish documents in the console's Config tab:
{ "values": { "shop": { "discount": 15 } },
"flags": { "new_shop": { "on": true, "pct": 25, "platforms": ["ios"], "min_app": "1.4.0", "countries": ["DE", "FR"], "bands": ["adult"] } } }A document must have the defaults' shape (only known keys, the same JSON types, nested objects checked the same way) and pass the game's check(values). Values are merged over the defaults key by key. A flag is on when on is true and every rule given matches: pct is a stable share of players per flag (signed-out callers only get 100 %), platforms (ios, android, web, windows, mac, linux, console, other), min_app compares dotted numbers, countries uses Cloudflare's view of the request, bands the player's declared age band.
Clients call GET /v1/config?platform=ios&app=1.4.2 (signed in for percentages and bands) and get { version, values, flags } with an ETag; sending it back as If-None-Match answers 304. Every publish is a new numbered version kept in the game's D1; making an older version (or 0, the defaults) live is a rollback. Publishing names the version the author saw, so two admins can't overwrite each other (stale). On the server, createConfigStore(env.DB, config).resolve(...) gives a room or route the same values.
Cloud saves
defineSaves({ slots: ["main", "settings"], inlineBytes: 16_384, maxBytes: 512_000, bucket: "SAVES", validate }), passed as saves (K-2.04, ADR-018, migration 0020_saves). Each player has the game's named slots, each holding one JSON document (an object or array) and a version.
GET /v1/saveslists the slots;GET /v1/saves/<slot>reads one.POST /v1/saves/<slot> {data, base_version}writes when the slot is still atbase_version(0for the first save). If another device saved since, the answer is409 {error: "conflict", current}with the newer save: merge by your game's rule and write again withcurrent.version. Kettle never merges.- Saves up to
inlineBytes(default 16 KB) live in D1; larger ones (up tomaxBytes, at most 900 KB) in the R2 bucket bound underbucket, written before the row moves and deleted when replaced. Without a bucket the cap is 64 KB. validate(slot, data)is your shape check (an error message refuses it withbad_save).- Never put currencies, items or entitlements in a save: they live in the economy, where only the server grants.
- Export includes saves (R2 ones too) and deletion erases them, with
privacyset.
Maps, 3D terrain and maps that change
Kettle serves map tiles from R2 through its CDN Worker (K-2.03, ADR-019): vector tiles (a Protomaps basemap extract) and height tiles for 3D terrain, Terrarium-encoded PNGs (metres = R×256 + G + B/256 − 32768; MapLibre reads them as raster-dem with encoding: "terrarium"). A game can change its maps during play: craters, sinkholes, a volcano's collapsed cone, a flattened building site.
Base archives are PMTiles files in the CDN bucket at tiles/<set>.pmtiles, built and uploaded with tools/tiles:
pnpm tiles extract --bbox -118.70,33.70,-117.90,34.35 --maxzoom 15 --out la.pmtiles # vector, newest Protomaps build
pnpm tiles dem --bbox -118.70,33.70,-117.90,34.35 --minzoom 8 --maxzoom 13 --out la-dem.pmtiles # heights, AWS Terrain Tiles
pnpm tiles upload --file la-dem.pmtiles --set la-dem --bucket kettle-cdn-stagingextract needs the pmtiles CLI; upload uses wrangler (files up to 300 MB; larger ones with rclone over R2's S3 API). Treat a set as immutable: the CDN caches TileJSON for an hour and tiles for a day, so upload changed data under a new set name. Credit the data where the map is shown: OpenStreetMap contributors for the basemap, the Terrain Tiles sources for heights (both are in the TileJSON attribution).
CDN routes (public, cached at the edge on the custom domain):
| Route | What |
|---|---|
GET /tiles/<set>.json, GET /tiles/<set>/{z}/{x}/{y}.<mvt|png> | A base archive's TileJSON and tiles |
GET /maps/<space>/<map>/<instance>.json?v= | An instance: version, each layer's TileJSON and tile template, overlay URL |
GET /maps/<space>/<map>/<instance>/<dem|vector>/{z}/{x}/{y}.<png|mvt>?v= | The instance's tile: its patch if edited, else the base tile |
GET /maps/<space>/<map>/<instance>/overlay.json?v= | Vector features the game added on top (GeoJSON) |
Declaring maps: defineMaps({ bucket: "MAPS", space: "<game id>", maps: { world: { dem: "la-dem", vector: "la", demZooms: [10, 13], attribution } }, maxTilesPerEdit: 256 }), passed as maps to createGameWorker / createKettle (and to createGameAdmin for the console), and to createRoomHost(types, { maps }) so rooms can edit (this.maps). The Worker that edits binds the CDN bucket as MAPS and sets CDN_URL (the CDN origin) in its vars. Migration 0023_maps (no player data).
Instances are created on first use (any id of lower-case letters, digits, _, -): one per world shard, season or match. Each has a version that every change bumps.
Call (server: createMapsStore, this.maps in a room) | What |
|---|---|
terrain(map, instance, { kind: "stamp", lat, lon, radiusM, deltaM, profile, rimM? }) | Raise or dig around a point. profile: bowl (crater), flat (steep walls: a sinkhole), cone, gauss (a smooth hill, reaches 1.5× the radius); rimM adds a lip at the edge |
terrain(map, instance, { kind: "level", polygon: [[lon, lat], …], heightM }) | Set the ground inside a polygon to one height |
overlay(map, instance, { add: [Feature], remove: [id] }) | Vector changes drawn over the base (features need a string id; adding an existing id replaces it) |
putTile(map, instance, { layer, z, x, y }, bytes) | Replace one tile outright (a hand-made height or vector tile) |
reset(map, instance) | Back to the base map |
Each returns {version, tiles: [{layer, z, x, y}]}. An op is applied at every zoom in demZooms (each pixel evaluated at its geographic centre), and only tiles whose heights changed are written. More than maxTilesPerEdit tiles → edit_too_large; one edit at a time per instance (map_busy, retry). Broadcast the result from the room (a game message such as <game>.map with {map, instance, version, tiles}) so clients refetch just those tiles with the new ?v=.
Clients: GET /v1/maps/<map>/<instance> → {version, tilejson, layers: {dem, vector?}, overlay, attribution, dem_zooms} (tile templates on the CDN with ?v=); GET /v1/maps/<map>/<instance>/changes?since=<version> → {version, reset, edits: [{version, kind, tiles}]} for a client that was away (reset: true: refetch everything, e.g. after a reset or once the log, kept 30 days, no longer reaches back).
Content packs
Files a game downloads after install (levels, art, audio, Godot .pck packs), published as immutable versions and announced in a signed manifest (K-4.05, ADR-020, migration 0024_content). defineContent({ bucket: "MAPS", space: "<game id>", packs: ["levels", "music"] }), passed as content to createGameWorker / createKettle and createGameAdmin; the Worker binds the CDN bucket and sets CDN_URL, and holds the signing key as the secret CONTENT_KEY (keyEnv).
pnpm content keygen --out ./keysmakes an RSA-2048 key: putcontent-key.pemin the game Worker (wrangler secret put CONTENT_KEY), bakecontent-public.peminto the game.pnpm content upload --file levels.pck --space <game> --pack levels --version 1.2.0 --bucket kettle-cdn-stagingstores it atcontent/<space>/<pack>/<version>and prints its SHA-256.- In the console's Content tab, register the version (Kettle reads the object and checks the hash; add a minimum app version and platforms if needed), then roll it out to a share of players (stable per player) or make it live for everyone; roll back or turn a pack off the same way.
GET /v1/content/manifest?platform=&app= (signed in) → {manifest, signature, key_id}: manifest is JSON text {space, generated_at, packs: [{id, version, url, sha256, size, min_app}]}, signature the base64 RSASSA-PKCS1-v1_5 / SHA-256 signature of exactly that text. A client checks the signature with its baked-in public key before parsing, downloads from url (the CDN; Range works for resuming) and checks the SHA-256 before using the file. Players outside a rollout share, or whose platform or app version a new version excludes, get the previous version. Versions are dotted numbers and never change once registered.
Privacy: data export and account deletion
definePrivacy({ graceDays: 7, tables, onDelete }), passed as privacy (K-4.06, ADR-017, migration 0019_privacy). Kettle keeps one inventory of every table that holds a player's rows (KETTLE_PLAYER_DATA; docs/privacy.md is the readable version); a test fails when a migration adds a player column it doesn't cover.
GET /v1/me/export: the player's rows from every table, as JSON (a few times an hour; tokens and other secrets left out; at most 5,000 rows per table,truncatedlists the rest).POST /v1/me/deleteschedules deletion aftergraceDays(default 7, at most 25, so it's done within 30 days);POST /v1/me/delete/cancelundoes it until then;GET /v1/me/deleteshows the date. The game's cron (thescheduledhandler ofcreateKettleorcreateGameWorker) erases due accounts in one D1 batch:- rows that are only the player's are deleted (account, sessions, links, age band, friends, blocks, inventory, scores, pushes, places, plots);
- rows other players or money records need are kept with the player id replaced by a random
deleted:…id: ledger lines and operations, store purchases, trades, listings, gifts, guild feed, paid draws; - open trades and listings are closed first, and a guild the player led passes to the next member (pass
guildstodefinePrivacy's store, ascreateKettledoes).
- Your own tables:
definePrivacy({ tables: [{ table: "g_saves", column: "player_id" }, { table: "g_scores", column: "player_id", erase: "anonymize" }] });onDelete(db, playerId)returns extra statements for the same batch (or does other cleanup, such as R2 blobs).
The 7-day default grace period and the split between deleted and anonymized rows were approved by the platform owner on 2026-10-23. The raw telemetry archive (R2, K-2.01) holds only the pseudonymous player hash and is not rewritten on deletion; it ages out under the bucket's 13-month lifecycle rule (also approved). Games selling through the App Store or Google Play should turn privacy on: both stores require in-app account deletion.
A deleted player's access token keeps working until it expires (at most 15 minutes); the refresh token is gone, so the session ends there. Staff can export, schedule, cancel and (owners) erase at once from the console's Players tab.
SDKs
Both SDKs keep state current from welcome, snapshot and delta, answer pings, track round-trip time and reconnect when a room code is known, with exponential backoff and full jitter (a random wait up to 0.25 s, 0.5 s, … 8 s; 10 tries), so a deploy's dropped clients don't all come back at the same instant. After the last try the TS SDK calls room.onGaveUp("attempts") and Godot emits reconnect_failed("attempts").
TypeScript (@kettle/client-ts)
const kettle = new KettleClient("https://api.example.com/my-game", "web/1.0", "1.0.0");
await kettle.signInAnonymously();
const ticket = await kettle.createRoom("click"); // or kettle.joinRoom("K7PX3M")
const room = await kettle.connect(ticket);
room.on("delta", () => render(room.state));
room.sendIntent({ action: "click" }); // returns seq| Member | Purpose |
|---|---|
signInAnonymously(), signIn(savedRefresh?), resume(refreshToken), createRoom(type, config?), joinRoom(code), connect(ticket) | Account and room setup. Save session.refresh_token and pass it to signIn next time to stay the same player. |
link(provider, credential, takeover?), signInWith(provider, credential, linkCurrent?), links(), unlink(provider, force?), emailStart(email), emailVerify(email, code) | Account linking; link and signInWith resolve to the refusal ({error, other?}) instead of throwing |
room.state, room.seat, room.rttMs | Live room state, your seat, round-trip estimate |
room.on(type, handler) | Listen for any message type; returns an unsubscribe function |
room.sendIntent(d), room.close() | Act, leave |
room.sendMessage(t, d) | Send a game message (<game>.<name>); listen for replies with room.on(t, …) |
room.reconnects, room.reconnectMs, room.bytesIn | Connection statistics |
room.onGaveUp, room.simulateDrop(); client.headers | Reconnecting stopped ("attempts" or "kicked"); drop the socket as a deploy would (tests, loadbot); extra headers on every HTTP call |
inventory(), exchange(id, opKey, target?), inventoryOps(ops) | Economy |
leaderboardSubmit(board, score, data?), leaderboard(board, { n, period, aroundMe }) | Leaderboards |
handleParts(), myHandle(), setHandle(h), lookupHandle(h), handlesOf(ids) | Handles |
tradeOffer(to, offer, ask), tradesOpen(), tradeAccept(id), tradeDecline(id), tradeCancel(id) | Trades |
count(kind, target, on?), counts(kind, targets), counterTop(kind, n, days), report(kind, target, reason), block(player), unblock(player) | Counters, reports, blocks |
listItem(instance, price), listings({item, attr, value, sort, n}), buyListing(id), cancelListing(id) | Listings |
purchaseProducts(), purchaseToken(), purchaseCheck(product), purchaseApple(jws), purchaseGoogle(product, token), purchaseRestore({apple, google}) | Store purchases |
friends(), friendCode(renew?), requestFriend({code} | {player}), friendRequests(), acceptFriend(id), declineFriend(id), cancelFriendRequest(id), removeFriend(id), setPresence({status, room, hidden}) | Friends and presence |
config(platform?, app?) | Remote config: values and flags; sends the last ETag and returns the cached copy on 304 or when offline (null before any) |
exportMyData(), deletionStatus(), requestDeletion(), cancelDeletion() | Privacy |
saves(), loadSave(slot), writeSave(slot, data, baseVersion) (resolves to {conflict} on 409), deleteSave(slot, baseVersion) | Cloud saves |
standing(); leaderboard(board, { friends: true }) | Moderation standing; a board among friends |
contentManifest(publicKeyPem, platform?, app?), downloadPack(pack) | Content packs: the manifest after its signature checks out (throws otherwise); a pack's bytes after the SHA-256 check |
mapInfo(map, instance), mapChanges(map, instance, since); tileUrl(template, {z, x, y}), decodeTerrarium(pixels, channels?) | Maps: tile templates for an instance's version, what changed since a version; height pixels to metres |
Godot (addons/kettle)
Add KettleClient as a node and set base_url.
| Member | Purpose |
|---|---|
await sign_in_anonymously(), await sign_in(saved_refresh), await refresh_session(), await create_room(type, config), await join_room(code), connect_room(ticket) | Account and room setup. session_changed(refresh_token) fires on every new token: save it and pass it to sign_in next launch to stay the same player (and keep the inventory). |
await link(provider, credential, takeover := false), await sign_in_with(provider, credential, link_current := false), await links(), await unlink(provider, force := false), await email_start(email), await email_verify(email, code) | Account linking. sign_in_with sets the session and emits session_changed; refusals return {error} (linked_elsewhere carries other.player_id), {} offline |
send_intent(d), leave(), is_open() | Act, leave, check the socket |
send_message(t, d) | Send a game message (<game>.<name>); replies arrive on the message signal |
room_state, seat, room_code, rtt_ms | Live state and connection info |
await request(path, body, auth := true, keep_errors := false) | Call one of your game routes with the access token. With keep_errors, a refusal comes back as {"error": code} and {} means no answer (offline) |
await request_get(path, auth := true, keep_errors := false) | The same for GET routes (a game's GET route, /v1/visits/discover?n=5) |
track(name, props), await flush() | Telemetry, buffered offline (up to 500 events) |
await fetch_inventory(), inventory, await exchange(id, op_key, target) | Economy: cached inventory, exchanges (refusals return {error}) |
await leaderboard_submit(board, score, data), await leaderboard(board, n, period, around_me) | Leaderboards (refusals return {error}, {} offline) |
await handle_parts(), await my_handle(), await suggest_handle(), await set_handle(h), await lookup_handle(h), await handles_of(ids) | Handles (refusals return {error}) |
await trade_offer(to, offer, ask), await trades_open(), await trade_accept(id), await trade_decline(id), await trade_cancel(id) | Trades (refusals return {error}) |
await count(kind, target, on), await counts(kind, targets), await counter_top(kind, n, days), await report(kind, target, reason), await block(id), await unblock(id), await blocks() | Counters, reports, blocks |
await list_item(instance, price), await listings(item, attr, value, sort, n), await my_listings(), await buy_listing(id), await cancel_listing(id) | Listings |
await purchase_products(), await purchase_token(), await purchase_check(product), await purchase_apple(jws), await purchase_google(product, token), await purchase_restore(apple, google) | Store purchases (refusals return {error}, {} offline: keep the store transaction and retry) |
await friends(), await friend_code(renew), await request_friend_by_code(code), await request_friend(id), await friend_requests(), await accept_friend(id), await decline_friend(id), await cancel_friend_request(id), await remove_friend(id), await set_presence(status, room, hidden) | Friends and presence (refusals return {error}) |
await fetch_config(platform, app_version), remote_config, flag(name) | Remote config: the last config is kept in remote_config (save it to start offline with it) |
await export_my_data(), await deletion_status(), await request_deletion(), await cancel_deletion() | Privacy |
await saves(), await load_save(slot), await write_save(slot, data, base_version), await delete_save(slot, base_version) | Cloud saves ({"error": "conflict", current} when another device saved first; {} offline) |
await standing(); await leaderboard(board, n, period, around_me, friends_only) | Moderation standing; a board among friends |
await map_info(map, instance), await map_changes(map, instance, since), relay_map_change(d), tile_url(template, z, x, y), await fetch_bytes(url), terrarium_heights(png) -> Image | Maps: an instance's tile templates, what changed, relay a room's edit message (emits map_changed(map, instance, version, tiles)), and height tiles as Image.FORMAT_RF metres |
await content_manifest(public_key_pem, platform, app), await download_pack(entry, path) | Content packs: the manifest ({"error": "bad_signature"} if it doesn't verify with the baked-in key); a pack saved to path only when its SHA-256 matches |
KettleTerrain (kettle_terrain.gd, a Node3D) | 3D terrain from height tiles: setup(kettle, info, zoom, origin_x, origin_y), await load_tiles([[x, y], …]); connect map_changed to on_map_changed and edited tiles rebuild (mesh + HeightMapShape3D collision; step, vertical_scale, material) |
queue_inventory_op(op), await flush_inventory_ops(), pending_inventory_ops, new_op_key() | Spend or equip now or offline; save pending_inventory_ops with your save and flush when online |
http_timeout_s (4 s) | HTTP calls give up after this and fail with code network, so a stalled network falls back to offline play |
Signals: joined, state_changed, message, ack, failed, disconnected, reconnecting, reconnect_failed(reason), inventory_changed | React to the room and inventory |
Admin console
workers/admin (deployment deploy/realm8/admin.wrangler.jsonc, ADR-006) is one Worker serving three things:
- the public landing page at
/; - this guide at
/docs/; - the console at
/console/, with its API at/admin-api/*.
Signing in. Admins sign in with an emailed link, sent through Estra Mail (MAIL_API_KEY), with Resend as the fallback (RESEND_API_KEY). There are no passwords. A session is a __Host- cookie (HttpOnly, Secure, SameSite=Strict). Writes also need the console's Origin and the x-kettle-admin header.
Roles:
- Platform owners (
OWNER_EMAILS, a secret) see every game and invite admins. - Game admins manage alerts and members, export data, grant and revoke items, publish config and schedule account deletion.
- Viewers read only.
A game you have no role in answers 404, the same as a game that doesn't exist.
Home (every signed-in admin). The console opens on an activity chart for all the games you can see: one line per game over 30 days (active players, new players or requests), each game in its own fixed colour (the same dot as in the top bar), with a crosshair tooltip, a legend that highlights a line and a table view. Below it, a tile per game with its latest number and trend; owners also get the month's cost overview there. API: GET /admin-api/activity?metric=active_players|signups|worker_requests|players_total&days=7..90 → {metric, days, series: [{id, name, values}], trend: {<game>: {now, prev, week}}, trend_all} (null for a day without numbers).
24-hour comparisons. The main numbers (new players, active players, requests, error rate, cost) carry a small arrow and % against the 24 hours before: accent when better, red when worse, grey under 1%. Estimated cost also shows the average list-price cost per hour over the last 24 hours (before free allowances), against the 24 hours before and the same 24 hours a week earlier. Player counts come from each game's own database; requests and cost come from hourly snapshots the collector keeps for 9 days, so those arrows appear after the first day of data and "vs last week" after eight. Each trend is {now, prev, week}, null where there is no data yet; GET /admin-api/games/<g>/dashboard returns one per metric under trend, and /admin-api/overview gives each game's cost_hour. Each trend also has usual, the median of the 7 complete days before; a number at least twice or at most half of it gets a "2.4× usual" badge (only above a small floor, so tiny games aren't flagged).
Game page, deeper views. Under the main numbers: monthly active players, stickiness (the last 24h's players ÷ the month's), requests per player and list-price cost per player per day, each against the 24h before. Last 24 hours, hour by hour draws the last 24 hours against the 24 before and the same hours a week earlier (active players, requests or cost per hour); a heatmap shows when players play over the last 7 days, in the viewer's local time. Players has new vs returning players per day and a retention table: of each day's new players, the share seen again on day 1, 2, 3, 7, 14 and 30. Cost by resource stacks the list-price cost per day of Workers, Durable Objects and D1. Player activity is read from the game's database (a refresh-token row is written on every sign-in and refresh, so a player who plays is seen about every 15 minutes); requests and cost per hour come from the collector's snapshots. APIs: GET /admin-api/games/<g>/hourly → {start, hours, active, requests, cost} (one value per hour, oldest first, null without data); GET /admin-api/games/<g>/players-insight → {retention_days, retention: [{day, size, back}], days: [{day, fresh, returning}], mau: {now, prev}}; the dashboard's cost_daily → {groups, days}.
Collector health (owners). Below the cost overview, one tick per hour for the last 48 hours: every source answered, some failed (hover for which), or no run recorded at all. GET /admin-api/collector → {start, slots: [{hour, runs, failed}]}. A run that crashes is recorded with a collector failure instead of leaving no trace. A player has a link of their own: #game=<g>&tab=players&player=<id>.
On-call (owners). On the home page: pager channels (an ntfy topic, Pushover, or a JSON webhook) with a test page each; alert rules can tick Page to reach them as well as email. Built-in health alerts page once a day per problem: the collector missed hours, a game's error rate is ≥ 3× its usual day, a burst of errors in the last hour, or cost per hour ≥ 2× usual. The panel also lists weekly D1 exports to R2 (opt-in) and recent health alerts. Runbooks: docs/runbooks/on-call.md, docs/runbooks/backups.md. API: GET|POST /admin-api/pager, POST /admin-api/pager/<id>/test, DELETE /admin-api/pager/<id> (owners).
What a game's page shows:
- Dashboard: signups and active players day by day, Cloudflare usage (Worker requests and errors, Durable Object requests and time, D1 rows and size), and the estimated cost this month with a projection. Cost is estimated from analytics and published prices. The account's included amounts are shared across games in proportion to use.
- Data browser: read-only browsing of the game's D1 tables, with sort, filter, paging and a "New signups" preset.
refresh_tokensand internal tables are never shown. CSV export is limited to admins, capped at 50,000 rows, audited, and neutralises spreadsheet formulas. - Alerts: email when cost (month to date or projected), usage, signups or active players, or the error rate crosses a threshold. Each alert sends at most once per day, or once per month for cost.
Which games it shows. Every game on the shared platform Worker appears on its own: bind the platform's KettlePlatformAdmin entrypoint as PLATFORM (a service binding with entrypoint: "KettlePlatformAdmin"; named entrypoints are reachable only through service bindings) and set PLATFORM_USAGE_DATASET to the platform's USAGE dataset. The console re-reads the platform's game list every 5 minutes and on each hourly run, takes names from createRoomHost's name (else the id), and reads a game's database through the platform, which runs only single SELECT statements. ADMIN_GAMES adds games that run elsewhere and the Worker scripts and D1 ids behind a game's cost (Durable Objects, D1); where both list a game, ADMIN_GAMES wins field by field. A <G>_DB binding on the console, when present, is used instead of the platform. New games show up for owners at once; admins and viewers still need to be invited.
Reports (review queue, K-3.07). The Reports tab lists what players reported (moderation, above): Open (reported, nobody has decided; auto-hidden ones first, then the most reported), Hidden and Decided, each target with its reason counts, and the individual reports in a side panel. Viewers read; game admins Take down or Restore, which marks the target decided (new reports no longer change it) and is written to the audit log. The console decides through the game's own <G>_DB when bound, else through the platform's single moderation write. Find the reported content itself in the data browser (the game's own tables). API: GET /admin-api/games/<g>/reports?state=open|hidden|decided&offset=, GET /admin-api/games/<g>/reports/<kind>/<target>, POST /admin-api/games/<g>/reports/<kind>/<target> {action: takedown|restore}.
Players (K-3.07). Find a player by id, the first characters of an id, or handle; see their account (created, last sign-in, age band, linked providers by name only), inventory, friends, guild, reports, purchases and a pending deletion, and page through their ledger with each line's reason and who made it. Game admins grant and revoke items from the game's catalog with a required note (both audited; the form's key makes a double submit apply once; revoke takes what's there and reports the rest), download the player's data and schedule or cancel deletion. Only platform owners erase at once. API: GET /admin-api/games/<g>/players?q=, GET …/players/<id>, GET …/players/<id>/ledger?before=, POST …/players/<id>/grant|revoke {items, note, op_key}, GET …/players/<id>/export, POST …/players/<id>/delete {action: schedule|cancel|now}, GET …/catalog.
Standing, saves and purchases (K-3.05, K-3.07). A player's page also shows their ban and strikes (game admins strike, ban for some days or for good, and unban, each with a note; audited), their save slots, and their store purchases. The Purchases tab lists every purchase newest first, with month totals (list-price revenue and refunds) and filters for refunded and flagged ones (granted past a money cap or age gate). API: POST …/players/<id>/strike|ban|unban {reason, days?, note}, GET …/moderation/reasons, GET …/players/<id>/purchases, GET /admin-api/games/<g>/purchases?state=granted|revoked&flagged=1&offset=.
Config (K-2.02). The versions with notes and authors, the live document and the game's defaults; admins edit a new version as JSON, Check it against the game's rules, see what changes against the live one, Publish, or make any earlier version (or the defaults) live again. All audited. API: GET /admin-api/games/<g>/config, GET …/config/<v>, POST …/config/check {doc}, POST …/config {doc, note, if_version}, POST …/config/activate {version, if_version}.
The game's admin entrypoint. Grants, config and deletion need the game's own code (its catalog, defaults and tables), so the game's Worker exports one more class and the console reaches it through a service binding:
export const KettleGameAdmin = createGameAdmin({ economy, config, privacy, guilds, moderation, saves });A platform game is bound on the platform as <G>_ADMIN ({ "binding": "<G>_ADMIN", "service": "<game worker>", "entrypoint": "KettleGameAdmin" }); the platform's KettlePlatformAdmin forwards /games/<g>/admin/* to it. A game with its own Worker is bound the same way in the admin deployment. Without it the console reads as before and answers writes with game_admin_unbound. Named entrypoints are reachable only through service bindings (docs); the console checks roles and writes the audit log, and by (console:<admin id>) lands on the economy operation as its ref.
Free allowance (owners). The console home shows how far the Cloudflare account is into each included amount of the Workers Paid plan this billing cycle: Workers requests and CPU time, Durable Object requests, duration, SQLite rows read and written, and stored data, D1 rows read and written and storage, and KV, Queues and R2 once they're used. Each category has a bar with a tick per 10%, the projection to the reset (used so far plus the last 7 full days' daily average times the days left; storage stays at its latest size), how much over that would be and what it would cost, each game's share, and the projected bill ($5 base plus overage). The numbers are account-wide (every Worker, Durable Object and database in the account, the admin Worker too), since that's what Cloudflare bills. Included amounts reset on the subscription renewal date, read from the account subscriptions API (CF_API_TOKEN with Billing: Read), else from the BILLING_DAY var (1–28), else the calendar month with a warning. Each time a category passes another 10% (and the first time it's on pace to go over), owners get one email per collector run, and the step appears on the Milestones timeline: one lane per category across the cycle, a dot where each step was recorded (steps recorded within hours of each other share a dot), today's line, and the reset at the right; hover, focus or tap a dot for the day, time, steps and the amount used then. The steps start again each cycle. For Workers requests, CPU time and Durable Object requests, an expanded row also names the account's top Worker scripts and the game that owns each, so use outside the games' own scripts (the platform Worker, room hosts, the admin Worker) can be traced. API: GET /admin-api/allowance. Per-game cost estimates and cost alerts use the same billing cycle.
Data sources. An hourly cron collects from each game's D1 (<G>_DB or through the platform), the GraphQL Analytics API (CF_API_TOKEN with Account Analytics: Read and Billing: Read, plus CF_ACCOUNT_ID) and the USAGE dataset. Game ids appear only in the deployment's KETTLE_GAMES, ADMIN_GAMES and bindings.
Versioning, security and limits
- Protocol versions. Servers accept the current protocol version and the one before it. Clients that are too old get
unsupported_versionand should ask players to update. - Pre-1.0. Until Kettle reaches 1.0, APIs can change between versions. Pin the commit you build against.
- Server authority. Clients send intents; only the server changes state. Never trust a client for scores or currency.
- Secrets.
JWT_SECRETandJOIN_SECRETstay in Worker secrets, never in code or clients. - Privacy. Kettle stores a player id and hashed refresh tokens. Location is city-level and never stored. Telemetry rejects undeclared fields. Players can export their data and delete their account (
privacy);docs/privacy.mdlists what is stored, why, and what deletion does.
| Limit | Value |
|---|---|
| WebSocket frame | 16 KB |
| Intents per connection | 30 per second (configurable per room) |
| Players per room | 4 (configurable per room) |
| Telemetry batch | 50 events, 20 props each, strings ≤ 96 characters |
| Inventory ops per request | 50 (configurable: policy.maxClientOps) |
Client op_key | 1–64 characters A-Z a-z 0-9 _ . : - |
| Room code lifetime | 30 minutes (expired codes are swept by an alarm) |
| JSON body (Kettle's routes) | 64 KB; visit documents up to 4 × maxDocBytes; 1 MB for anything the platform forwards to a game |
| Saved rooms | Deleted after 24 h without use |
| Sockets per room | 2 × maxPlayers + 4, hello within 15 s |
| Gift caps | Checked inside the same atomic write as the gift, so parallel gifts can't pass them |
Rate limits. Bind two Workers Rate Limiting namespaces and Kettle uses them: RATE_AUTH (keyed by client IP: POST /v1/auth/anon, /v1/auth/refresh, /v1/auth/signin, /v1/auth/email/*) and RATE_PLAYER (keyed by player: rooms, room codes, areas, telemetry). Over the limit, the route answers 429 {"error":"rate_limited"}. Keys carry the game id, so on the shared platform one binding serves every game. Without the bindings nothing is limited (local dev, tests).
"ratelimits": [
{ "name": "RATE_AUTH", "namespace_id": "8001", "simple": { "limit": 20, "period": 60 } },
{ "name": "RATE_PLAYER", "namespace_id": "8002", "simple": { "limit": 60, "period": 60 } }
]Not available yet
These are planned but do not exist in code today. Don't build against them yet.
| Module | Status |
|---|---|
| Steam sign-in; built-in APNs / FCM senders; subscription renewals | Next |
| Moderation for player-made names and text | Planned |
| Published npm packages and a hosted Kettle for other teams | Not scheduled |