Hartii developer docs

Games

Public API

Base URL for the hub: https://hartiigames.com. Base URL for Biome: https://hartiibiome.com. Examples below are trimmed real responses captured live against production, except where marked "from source": those are written from the code before the release was captured live.

JSON request limits

JSON body limits count raw UTF-8 bytes, including JSON structure and any byte-order mark. Multibyte characters consume their encoded size, so character counts are not a safe size estimate. A Content-Length header is not required for the server to enforce the limit; an oversized stream is rejected before parsing.

Limits remain endpoint-specific: username claim accepts up to 2,048 bytes; ranked preparation, entry, status and payment attachment each accept up to 4,096 bytes; username resolution and studio mint up to 8,192 bytes; studio creation up to 32,768 bytes; replay submissions use their existing larger limits. Count a client payload with new TextEncoder().encode(JSON.stringify(body)).byteLength before submitting. Each endpoint retains its existing failure response, including an empty result for username resolution. Wallet-signature checks remain required where documented.

Global state

GET /api/config

Public client config: the kill switch and split constants the client trusts. Since 1.19.0 the example is written from source (functions/api/config.js).

json
{
  "paidPlay": false,
  "paidGames": [],
  "entryAsset": "QUAI",
  "playerAllocationBps": 7500,
  "reserveAllocationBps": 2500
}

paidPlay: false means ranked play is globally disabled, and the client hides all stake UI when it is false. PAID_PLAY_ENABLED is "false" in wrangler.toml. Cache-Control: public, max-age=30.

Changed in 1.19.0:

  • paidGames is []. It was ["hash-wars"]. It is the server's one list of games that can take a paid entry (PAID_GAME_IDS in functions/api/_lib/gameRoster.js), and no game is on it. The client reads it too, so no game shows a stake screen even if the global switch were turned on.
  • The campaign block that 1.18.0 returned (a visual-only QuaiAxe event) is gone, along with the banner that showed it.

GET /api/pools

json
{ "paidPlay": false, "pools": {}, "note": "paid play is not yet enabled" }

When paidPlay is true, pools becomes { [gameId]: poolAddress } for each game in PAID_GAME_IDS. That list is empty since 1.19.0 (it was hash-wars), so pools is {} even with the switch on, and the response is { "paidPlay": true, "pools": {} }. The shape is unchanged.

GET /api/health

json
{ "ok": true, "rpc": true, "price": true, "d1": { "ok": true, "missing": [] }, "at": 1790174499542 }

GET /api/transparency

Public accounting summary — proceeds split, entry-fee split, live pool balances, cosmetics creator-economy rules.

json
{
  "chain": { "id": 9, "name": "Quai mainnet, Cyprus-1" },
  "proceedsSplit": { "playersPct": 50, "buybacksPct": 50 },
  "entry": { "paidPlay": false, "paidGames": [], "playerAllocationBps": 7500, "reserveAllocationBps": 2500 },
  "pools": [{ "game": "hash-wars", "address": "0x0056C742a880eC0A55b87Db3a893F2a92E94dD89", "archived": true, "balanceQuai": 0 }],
  "creators": { "rule": { "tradeFeeBps": 100, "creatorShareBps": 5000, "maxTradeFeeBps": 300 } }
}

proceedsSplit.buybacksNote and buybacks.note say what the buyback half does today. QAXE buybacks were stopped on 28 Sep 2026, and the ones that ran stay listed in /data/buybacks.json with their transaction hashes. The treasury holds the HBOME it buys with treasury funds. Of an HBOME buyback paid from collected fees, half is burned and half is held. Nothing else is burned unless the owner orders a burn (the first was 1,111,111 HBOME on 9 Oct 2026). No buyback paid from fees has run yet. The page at hartiigames.com/transparency says the same, and its "Still running" row for QAXE buybacks reads "No".

GET /api/gas

One cached gas quote for the client's txs — measured, not guessed (source comment: a real enterRanked() used 24,771 gas, 2026-07-05).

json
{
  "gasPriceWei": "29482625609348",
  "usdPer21k": 0.00636966,
  "usd": { "entry": 0.00758, "approve": 0.01395, "buy1155": 0.03640, "claim": 0.01820 },
  "elevated": false
}

GET /api/quai-price

{ usd, source, at } — MEXC QUAIUSDT primary, CoinGecko fallback, ~2 min edge cache.

Ranked play

POST /api/ranked/enter

Verify an on-chain enterRanked() (or bare-transfer) payment and issue a run seed.

Body: { gameId, tier, wallet, txHash, ack: true, signature?, ref? }. ack (18+/eligibility acknowledgement) is required and checked server-side. Checks performed, in order: paid-play kill switch, jurisdiction gate (cf-ipcountry, edge-verified — not client-suppliable), rate limit (360/hour), gameId must be a game that takes paid entries (403 for any other game, which is every game in 1.19.0; 400 for an unknown id), tx not already used (single-use, exactly-once via a D1 unique constraint — not just a racy KV check), one active run per wallet per game, the tx's to matches that game's pool and from matches the wallet, the paid value is within −12% / +50% of the tier's USD quote at the current QUAI price (tight lower bound against underpaying, wide upper bound to absorb price movement between quote and confirmation — any overpayment simply donates to the pool the player is competing for).

json
{ "runId": "…", "seed": "ranked-hash-wars-…", "gameId": "hash-wars", "tier": "micro" }

The example shows the shape. Hash Wars was the only paid game, and no game returns this response in 1.19.0.

Errors are 400/403/404/409/451 (blocked jurisdiction)/503 (kill switch off, storage unavailable) with { error }.

POST /api/ranked/submit

Body: { runId, score, inputLog }. The server replays the run itself using the seed it issued and rejects on any mismatch — a client's claimed score is never trusted. On success it places the score on the tier's weekly and monthly leaderboards and returns promotion info.

json
{ "accepted": false, "reason": "replay mismatch", "replayScore": 0 }

GET /api/ranked/status, POST /api/ranked/prepare, POST /api/ranked/attach-payment

Supporting steps in the same flow (status polling, a provisional pre-payment run context, and attaching a late-arriving payment to a provisional run) — see functions/api/ranked/*.js.

POST /api/ranked/prepare applies the same roster rule as enter: a game that is not in PAID_GAME_IDS gets 403 with { "error": "paid ranked play is not available for this game" }, and no provisional run is written.

GET /api/claimable/:wallet

Reads claimableBalances(wallet) across all ten MonthlyGamePayouts pools, one after another (a pure read — the actual claim is the wallet's own claimPayout() transaction, this endpoint never moves funds).

json
{ "wallet": "0x0031b09e63592b713768761cddacc603974c10bd", "pools": [], "totalQuai": 0, "rewards": null, "hartiiClaimable": 0 }

The Hash Wars pool is still read, so a prize from it can still be claimed. Hash Wars is back as a free game (1.42.0); the pool stays archived. hartiiClaimable is the wallet's accrued legacy HARTII rewards, read from the rewards pool named in rewards (null when none is configured). The Me sheet shows a Claim button for it when it is above zero.

Leaderboards, activity, tournaments

Seasons

Since 1.18.0 the free and daily boards are kept per season. A season is one rules version of one game. The version travels in the run's seed, as a token after the game id:

text
<kind>-<game>-v<N>-<rest>        kind: free, ranked, daily or qaxe

A seed with no -v<N>- token uses the game's classic rules. The server replays every submitted run under the rules named in its seed, so a run cannot claim a season it was not played under. The current season is one config line per game (SEASONS in apps/hub/games/_shared/seasons.js). This is what 1.23.0 ships:

Gameseason values you can readDefault (current)
pulse-snake, shatter-stack, nova-storm, neon-drift, flux-hop, gravity-flip, orbit-lock, piano-keys1 = Season 1, classic rules (archive). 2 = Season 2, rules v2.2
hex-breaker, blade-pong1 = Season 1, classic rules (archive). 2 = Season 2, rules v2 (archive). 3 = Season 3, rules v3 (deterministic trig).3
alien-siege1 = Season 1, classic rules (archive). 2 = Season 2, rules v2 (archive). 3 = Season 3, rules v3 (hold to fire).3
hash-wars (back on the roster in 1.42.0, free play)2 only. Rules v2 is its classic rules, so its one board counts as Season 1. The archived board is still readable.2

alien-siege and piano-keys went from 1 only to 1 and 2 in 1.19.0, when they came back on the roster. alien-siege added 3 in 1.23.0: its ship fires only while fire is held, and its daily seed is daily-alien-siege-v3-<date>. Hash Wars rules v3 to v5 were practice only and have no board. Since 1.42.0 its free runs use rules v5 practice, which is not posted to the rules v2 boards. Ranked seeds carry no version token except for Blade Pong and Hex Breaker: since 1.45.0 their ranked seeds are ranked-<game>-v3-<hex> (for example ranked-blade-pong-v3-9f2c...), so new ranked entries run rules v3. Every other game's ranked seed is still ranked-<game>-<hex> and uses its classic rules.

Replay determinism (1.45.0). The server accepts a run only if its replay reproduces the claimed score exactly, so the rules code must give identical bits in every browser engine. ECMAScript does not pin Math.sin, cos, atan2 or hypot, and Safari (WebKit) replayed Blade Pong and Hex Breaker to different scores than Chromium, so its scores were rejected. Rules v3 of both games (same gameplay as v2) use the house trig in packages/engine/src/det-math.js (dSin, dCos, dAtan2, dHypot), built only from + - * / and sqrt in a fixed order, so V8, JavaScriptCore and SpiderMonkey agree bit for bit. Old v1 and v2 runs and seeds still replay and verify unchanged under their frozen rules, and CI replays the golden runs in Chromium and WebKit.

GET /api/leaderboard/:game?board=free|daily|ranked&tier=…&period=weekly|monthly

json
{ "game": "hash-wars", "board": "free", "key": "lb:free:hash-wars",
  "season": 2, "seasons": [{ "version": 2, "season": 1, "label": "Season 1 (classic rules)", "current": true, "archive": false }],
  "entries": [{ "wallet": "0x00700bd1…", "score": 408832, "at": 1789420663494, "name": "Swimgood" }] }

season and seasons are new in 1.18.0 and are written here from source (functions/api/leaderboard/[game].js); the rest of the example is the earlier live capture. They are returned for board=free and board=daily only. The example is a Hash Wars board. Hash Wars left the roster in 1.19.0, but GET /api/leaderboard/hash-wars still returns its archived boards. Every other part of the request works as shown for the eleven roster games.

season (optional) picks which season's board to read:

  • It is a rules version: an integer from the table in Seasons. It applies to board=free and board=daily, and is ignored for board=ranked.
  • Omitted or empty: the game's current season.
  • Anything that is not a rules version the game has a board for, such as season=3, season=abc, or season=1 for Hash Wars: 400 with { "error": "unknown season" }.
bash
curl -s "https://hartiigames.com/api/leaderboard/hex-breaker?board=free"            # current season (rules v3)
curl -s "https://hartiigames.com/api/leaderboard/hex-breaker?board=free&season=1"   # Season 1 archive (classic rules)

Response fields for free and daily boards (season and seasons are new):

FieldMeaning
seasonThe rules version this response is for. It is the number you would pass as ?season=.
seasonsEvery board the game has, oldest first.
seasons[].versionThe rules version. Pass it as season.
seasons[].seasonThe season number players see. 1 is the classic rules.
seasons[].labelSeason 1 (classic rules) or Season 2 (rules v2).
seasons[].currenttrue for the season served when season is omitted.
seasons[].archiveThe opposite of current.
keyThe board's storage key. Season 1 keeps its original keys (for example lb:free:<game>). Later seasons add a prefix (for example lb:v2:free:<game>).

For the eleven roster games other than Hash Wars, season and seasons[].season are the same number. For Hash Wars they are not: season is 2 (the rules version) while its one entry is season number 1.

GET /api/daily/:game

Today's shared daily seed for one game, the same for every player (UTC date). Roster games only: an id that is not on the roster returns 404 with { "error": "unknown game" }. Cache-Control: public, max-age=300.

json
{ "game": "hex-breaker", "date": "20261009", "seed": "daily-hex-breaker-v2-20261009", "rulesVersion": 2 }

The shape above is written from source (functions/api/daily/[game].js); rulesVersion is new in 1.18.0. The daily always runs on the game's current rules, so the seed format depends on the game:

GameDaily seedrulesVersion
the eleven roster games other than Hash Warsdaily-<game>-v2-<YYYYMMDD>, for example daily-hex-breaker-v2-202610092
hash-wars (back since 1.42.0)daily-hash-wars-<YYYYMMDD>, with no version token, because its current rules are its classic rules2

Before 1.18.0 every daily seed was daily-<game>-<YYYYMMDD>. A daily run submitted to POST /api/play/track must use the seed this endpoint returns today, or it is refused with 400 (wrong daily seed). An old unversioned daily seed no longer scores for any roster game. The reply to POST /api/play/track now includes rulesVersion, the version the run was scored under. A seed whose rules version has no board, such as -v3- for a game that stops at rules v2, is refused with 403. A run for a game that is off the roster, such as hash-wars, is refused with 400 (unknown game).

GET /api/dailies[?wallet=0x…]

Today's shared-seed daily challenge summary per game, plus (with a wallet) that wallet's server-computed streak. There is one row for each roster game (twelve since 1.42.0, Hash Wars included). top and players count the game's current-season daily board.

json
{ "date": "20261009", "games": [{ "id": "hex-breaker", "top": 0, "players": 0 }], "streak": null, "streakPlayedToday": null }

Trimmed to one row and written from source (functions/api/dailies.js). streak and streakPlayedToday are null without a wallet, and null (never 0) when the store cannot be read.

GET /api/activity

Recent verified runs / claims / ranked entries, newest-first, edge-cached (s-maxage=10, stale-while-revalidate=20) so a burst of viewers costs one origin hit.

json
{ "items": [] }

GET /api/tournaments?status=active|past|all

json
{ "items": [], "status": "active", "at": "2026-09-23T14:50:45.939Z" }

Always returns an honest empty list rather than an error if the sync hasn't run — never a bare {}.

GET /api/profile/:wallet

XP, level (1 + floor(sqrt(xp/50)), capped at 100), Bronze→Diamond rank tier, per-game bests, daily streak. byGame keeps bests per season: Season 1 under the plain game id, later seasons as <game>:v<N> (for example hex-breaker:v2).

Cosmetics, marketplace, token

  • GET /api/cosmetics/catalog: shop catalog (type, name, rarity, price, image) plus the live HartiiCosmetics contract address. price is the legacy HARTII price. The Shop never reads it, because skins are priced in HBOME since 1.19.0.
  • POST /api/cosmetics/equip, GET /api/cosmetics/equipped/:wallet, GET /api/cosmetics/owned/:wallet
  • GET /api/marketplace/listings — active cosmetics resale listings, self-heals from chain if its KV index is empty.
  • POST /api/marketplace/record: indexes a listing/sale after the client's own on-chain tx. The hub only cancels listings now. It no longer buys or lists on the HARTII market, so it calls this after a cancel only.
  • GET /api/token/config
json
{ "token": "0x00356B9bc20Ea80C654D77655Da53ca07547c216", "sale": "0x00483bE6EAaA8015130b216e06d8f70723dF822D",
  "cosmetics": "0x007fDe17CbA1Fe1750906F08f64fEf44195702eA", "market": "0x0034D51cc6162E27bCfDce5E276d92Eb563bb73C",
  "rate": 1000, "decimals": 18, "symbol": "HARTII" }

The legacy fields above are unchanged, and the response is still cached with Cache-Control: public, max-age=60. Since 1.19.0 it also carries an hbome block, written from source (functions/api/token/config.js and functions/api/_lib/hbome.js):

json
{
  "hbome": {
    "token": "0x001fA4fdb848BB8C98B0d6Cd85257EfcCAe3b425",
    "symbol": "HBOME",
    "decimals": 18,
    "marketplace": null,
    "getUrl": "https://hartiilabs.com/token/0x001fa4fdb848bb8c98b0d6cd85257efccae3b425"
  }
}
FieldMeaning
token, symbol, decimalsThe HBOME token (HartiiBiomeInc), "HBOME" and 18. Checksum token with quais.getAddress(token.toLowerCase()) before you pass it to an SDK.
marketplaceThe HBOME marketplace that Shop checkout runs through, or null. It stays null until the HBOME_MARKETPLACE setting holds a well-formed address, and the Shop keeps Buy disabled while it is null. No marketplace address is set in 1.19.0.
getUrlWhere a player gets HBOME: the token's page on HartiiLabs.

GET /api/token/hbome/:wallet

New in 1.19.0. The wallet's HBOME balance. It feeds the HBOME chip in the top bar and the balance in the Shop and Me. The call is read-only. The server makes an ERC-20 balanceOf call on the HBOME token through the shared Quai read RPC (the gateway first, QuaiScan as the fallback). It holds no keys and sends nothing.

bash
curl -s https://hartiigames.com/api/token/hbome/<wallet>

A 200 response, written from source (functions/api/token/hbome/[wallet].js):

json
{
  "token": "0x001fA4fdb848BB8C98B0d6Cd85257EfcCAe3b425",
  "symbol": "HBOME",
  "wei": "25500000000000000000",
  "decimals": 18,
  "hbome": 25.5,
  "available": true
}
FieldMeaning
tokenThe HBOME token contract. Checksum it with quais.getAddress(token.toLowerCase()) before you pass it to an SDK.
symbol"HBOME".
weiThe exact balance in the token's smallest unit, as a decimal string. Use it for any arithmetic.
decimalsAlways 18. The endpoint does not read decimals() from the chain: HBOME's decimals are fixed, so a request costs one RPC call, not two.
hbomewei divided by 10 ** decimals, as a JSON number. It is for display and loses precision on very large balances.
availabletrue on every 200.

A wallet with no HBOME gets "wei": "0" and "hbome": 0.

Caching and errors:

  • The response is Cache-Control: no-store, so a browser never keeps it. The server keeps each wallet's answer for 10 seconds per edge location, so two requests inside that window can return the same balance. Add ?fresh=1 to skip the stored copy and read the chain (the Shop does this right after a purchase).
  • 400 with { "error": "bad wallet" } when :wallet is not 0x followed by 40 hex digits. Any letter case is accepted.
  • 502 with exactly { "error": "balance read failed" } when the RPC read fails. An empty or short answer (anything that is not a full 32-byte word) is also a 502. The detail goes to the server log, not the response. A failed read is a 502, never a zero.
  • No CORS headers are set, so a browser page on another origin cannot read it. Call it from a server.

Identity

  • GET /api/username/:wallet → { wallet, name }, CORS-open, name: null if unclaimed. For a linked wallet (see below) name is its link display and the answer adds linkedTo, the primary's name: { wallet, name: "ibo_1", linkedTo: "ibo" }.
  • POST /api/username/claim, GET /api/username/check, GET /api/username/resolve. check also reports a name as taken when it equals a link display. resolve takes wallets (GET, comma-separated) or wallets (POST body) and returns names by wallet; add linked=1 (GET) or "linked": true (POST) to get an extra _linked map { wallet: primaryName } for the linked wallets. Without that flag the shape is unchanged.

Linked wallets (Games/Biome 1.44.0)

A second wallet can show under a Hartii username as the name plus a number: ibo_1, ibo1 or ibo+1. The number is 1 to 99 and an account holds at most 20 linked wallets. Both wallets prove control by signing one exact text with personal_sign. No transaction is sent and no gas is paid. The data lives in D1 table username_links (migration 0024_username_links.sql).

Rules:

  • The primary wallet must already hold a claimed name, and the name in the signed text must still be its current one.
  • The suffix is _N, N or +N with N from 1 to 99. The display is the primary's name followed by the suffix.
  • A wallet that holds its own claimed name, or is already linked, cannot be linked. A linked wallet cannot be a primary.
  • Collisions are refused both ways: a link display cannot equal any claimed name, and a name cannot be claimed if it equals a link display. A linked wallet cannot claim a name of its own until it is unlinked.
  • Renaming the primary carries its links with it (every display is rebuilt from the new name). If any rebuilt display would collide, the whole rename is refused and nothing changes.
  • Either side can unlink: the primary or the linked wallet signs.

POST /api/username/link, body { primary, linked, suffix, nonce, primarySig, linkedSig }. The nonce is the primary's single-use value from GET /api/auth/nonce?wallet=<primary> (valid for 5 minutes, consumed on use). Both wallets sign this same text, with checksummed addresses and As set to the display:

text
HARTII LINK WALLET
Account: <primary's claimed name>
Primary: <primary, checksummed>
Linked: <linked, checksummed>
As: <display, for example ibo_1>
Nonce: <nonce>

Success: { ok: true, display, name, primary, linked }. Failures: 400 bad wallet, same wallet twice, or bad suffix (422); 401 a missing or wrong nonce or signature; 409 when the name, the wallet or the 20-link limit changed; 429 over 10 requests per IP per 5 minutes; 503 when storage is unavailable.

POST /api/username/unlink, body { linked, by, nonce, sig }, where by is the primary or the linked wallet. nonce is by's own single-use value from /api/auth/nonce. by signs:

text
HARTII UNLINK WALLET
Linked: <linked, checksummed>
By: <by, checksummed>
Nonce: <nonce>

Success: { ok: true, linked }. Failures: 404 when the wallet is not linked, 403 when by is neither side, 401 for a bad nonce or signature.

GET /api/username/links/:primary, public and CORS-open: { primary, name, max, suggest, links: [{ linked, short, display, suffix, linkedAt }] }. max is 20. suggest is the lowest free number for each style. An unclaimed primary answers name: null and an empty list.

Arena standings (GET /api/arena/standings) rows carry a name field: the claimed name, else the link display, else null.

Studio (creator NFT minting) and vault (IPFS document pinning)

functions/api/studio/** (create/mint a creator-submitted art type, eligibility check) and functions/api/vault/** (nonce, pin, public, list/:wallet — a public, paginated IPFS document directory) exist and are live but are write-gated by wallet signature per-route; not detailed further here beyond GET /api/vault/public?limit=&cursor=, a plain paginated read:

json
{ "items": [{ "cid": "QmWYLKGZSCFHTpv8sgRR4oNCACf5ZQ7DTafcBrcaR1Qmo8", "name": "poetry.txt",
  "textBytes": 3555, "at": 1789494765339, "url": "https://ipfs.qu.ai/ipfs/QmWYLKGZ…" }] }

QuaiAxe Arena

Added in 1.42.0 and changed to QAXE in 1.43.0 (hartiigames.com). The Arena is QuaiAxe's 1v1v1 product. Practice against two bots runs without a wallet on quaiaxe.com and hartiigames.com. There are no free online matches. Paid Weekly-pool and Instant matches run only on quaiaxe.com, and their entries and prizes are in QAXE. This API checks Weekly-pool entries, holds those tickets and publishes weekly standings; Instant tickets and claims live on QaxeInstantArena. Online matches are run by a separate match server (quaiaxe-arena), whose protocol is not part of this API. Its public lobby counts are in GET /lobbies below.

Lobbies. The tier ids are slot ids, not amounts. t10 is lobby L1 (5,000 QAXE), t50 is L2 (15,000 QAXE) and t250 is L3 (40,000 QAXE).

Ticket states (arena_entries.status): paid (a ticket waiting for a match), matched (inside a match that is starting or running), scored (the match ended and its result row was written). A match that cannot start returns its ticket from matched to paid. The retired solo states started, burned and expired remain only on old rows.

Money: each entry is an approval of the exact lobby amount, then one enter(uint256 amount, bytes32 nonce) call on QaxeArenaPot. The amount is exact: 5,000 QAXE (L1), 15,000 QAXE (L2) or 40,000 QAXE (L3), in wei with 18 decimals. The receipt must hold the pot's Entered log and a QAXE Transfer from the payer to the pot for the same amount.

Week: each tier has a weekly window that starts at UTC Monday 00:00. weekEnd in the response is the exact end. A week's standings are final 10 minutes after it ends.

GET /api/arena/config

Reads no storage. Cached 60 seconds. enabled is false until paid Arena entry is switched on. potAddress, token and buyIn are null while it is off. matchUrl is null until the match-server address is set. Source: functions/api/_lib/arena/arenaPot.js (getArenaConfig).

json
{
  "enabled": true,
  "potAddress": "0x…",
  "token": "0x…",
  "unit": "QAXE",
  "tiers": [
    { "id": "t10", "qaxe": "5000", "wei": "5000000000000000000000", "label": "Rookie" },
    { "id": "t50", "qaxe": "15000", "wei": "15000000000000000000000", "label": "Smith" },
    { "id": "t250", "qaxe": "40000", "wei": "40000000000000000000000", "label": "Forgemaster" }
  ],
  "weekStart": "2026-10-26T00:00:00.000Z",
  "weekEnd": "2026-11-02T00:00:00.000Z",
  "prizeSchedule": [30, 18, 12, 9, 7, 5, 3.5, 2.5, 1.5, 1.5],
  "prizeShareOfEntries": 90,
  "threshold": 200000,
  "prizesPaidIn": "QAXE",
  "buyIn": { "router": "0x…", "wquai": "0x…", "path": ["0x…", "0x…"] },
  "feeBps": 100,
  "quaiAxe": "0x…",
  "treasury": "0x…",
  "mode": "online",
  "playersPerMatch": 3,
  "matchUrl": "wss://quaiaxe-arena.hartii.workers.dev",
  "rankedOnlyOn": "https://www.quaiaxe.com/arena/"
}

The example shows the shape; the addresses are placeholders. threshold is the pot's payout level in whole QAXE. buyIn tells the page which route quotes a QUAI buy-in of QAXE (path is WQUAI then the QAXE token). tiers[].wei is the exact amount in wei.

POST /api/arena/enter

Verifies an on-chain entry and creates a paid ticket. CORS is allowed only for https://www.quaiaxe.com and https://quaiaxe.com. The body is at most 4096 bytes.

Body: { tier, wallet, nonce, txHash, ack: true }, plus an optional signature.

  • tier: t10, t50 or t250.
  • wallet: the payer's address (checksummed or lowercase).
  • nonce: 0x plus 64 hex characters, chosen by the client. It is unique per payer: the entry key is keccak256(wallet, nonce).
  • txHash: the enter transaction hash.
  • signature (optional since Games 1.43.2; quaiaxe.com 1.22.0 sends none): personal_sign by wallet over this text, with the pot address as the client knows it (checksummed or lowercase), the nonce and the transaction hash in lowercase:
text
QUAIAXE ARENA ENTRY
Pot: 0x…
Tier: t10
Nonce: 0x…
Transaction: 0x…

The receipt already proves the payer (the Entered log plus the payer's own QAXE Transfer), and an entry can only belong to that wallet, so no signature is needed. A signature that is sent must still be valid (401 otherwise). Proof of wallet control for joining a match is checked by the match server, with the ticket secret (keccak256(secret) equals the nonce).

  • ack: must be true. It confirms 18+ and eligibility. No birth date is collected.

Checks, in order: entries switched off (503), blocked jurisdiction (451), storage unavailable (503), rate limit of 120 requests per IP per hour (429), body shape (400), a signature that was sent and is invalid (401), a used entry key (200 with the same ticket when the transaction, wallet and tier match, otherwise 409 "Arena entry already used"), receipt not found or not yet final (409), a reverted transaction (422, nothing was entered), no matching Entered log from the pot for this wallet, nonce and amount (400), no QAXE Transfer log from the wallet to the pot for the exact tier amount in the same receipt (400 "Arena entry QAXE transfer does not match its payment"), fewer confirmations than ARENA_MIN_CONFIRMATIONS (the code default is 3; the deployed value is 1 since 1.43.2, so an entry is accepted once its receipt is in one block; 409 "entry is awaiting confirmations"), chain RPC failure (502 "chain data unavailable, try again").

json
{ "entryId": "3f1c…-uuid", "tier": "t10", "status": "paid" }

A new ticket has status paid. It waits in the queue until a match takes it on quaiaxe.com. Errors are { "error": "…" }.

GET /api/arena/standings?tier=t10|t50|t250&week=current|previous

The weekly standings for one lobby. week defaults to current. Cached 60 seconds.

  • Each wallet ranks by its best single match score in the week. Ties go to the earlier match end, then to the wallet address. Practice never appears.
  • The week is the one the match started in. Unplayed tickets are in no week.
  • Prizes: the top 10 get 30, 18, 12, 9, 7, 5, 3.5, 2.5, 1.5 and 1.5 % of the pool. prizePoolQaxe is 90% of entriesQaxe. QuaiAxe pays the prizes from its own wallet, in QAXE, not from the contract.
json
{
  "tier": "t10",
  "weekStart": "2026-10-26T00:00:00.000Z",
  "weekEnd": "2026-11-02T00:00:00.000Z",
  "final": false,
  "finalAt": "2026-11-02T00:10:00.000Z",
  "entries": 4,
  "entriesCounted": "paid tickets that played a match this week",
  "entriesQaxe": 20000,
  "prizePoolQaxe": 18000,
  "rows": [
    { "rank": 1, "wallet": "0x…", "name": "ibo_1", "score": 1700, "matches": 2, "prizePct": 30 }
  ]
}

The example shows the shape; the numbers are not real. Errors: 400 for an unknown tier or week, 503 when paid entry is off or storage is unavailable.

POST /api/arena/start and POST /api/arena/submit

Retired. Both answer 410 with { "error": "Arena rounds are played online — use Find match on quaiaxe.com" } before reading the body, the flag or any storage. A paid ticket can only be spent by the match server.

GET https://quaiaxe-arena.hartii.workers.dev/lobbies

Public lobby counts from the QuaiAxe match server. It drives the lobby popup on quaiaxe.com (every page except the tracker) and the hartiigames.com notice on every hub route. It reads no wallet and returns no entry, IP or label.

json
{
  "asOf": "2026-10-31T12:00:00.000Z",
  "lobbies": [
    { "id": "t10", "label": "L1", "qaxe": "5000", "mode": "pooled", "waiting": 1, "need": 3, "oldestWaitSec": 42 },
    { "id": "t50", "label": "L2", "qaxe": "15000", "mode": "pooled", "waiting": 0, "need": 3, "oldestWaitSec": 0 },
    { "id": "t250", "label": "L3", "qaxe": "40000", "mode": "pooled", "waiting": 2, "need": 3, "oldestWaitSec": 7 },
    { "id": "i10", "label": "Instant L1", "qaxe": "5000", "mode": "instant", "waiting": 1, "need": 3, "oldestWaitSec": 90 },
    { "id": "i50", "label": "Instant L2", "qaxe": "15000", "mode": "instant", "waiting": 0, "need": 3, "oldestWaitSec": 0 },
    { "id": "i250", "label": "Instant L3", "qaxe": "40000", "mode": "instant", "waiting": 0, "need": 3, "oldestWaitSec": 0 }
  ]
}

The example shows the shape; the counts are not live.

  • waiting is the number of players queued in that lobby. need is 3, the players per match. qaxe is a string.
  • oldestWaitSec is how many seconds the longest-waiting player in that lobby has waited (0 when nobody waits, null when waiting is null; added in 1.23.2). mode (pooled or instant) is also present on each row.
  • waiting: null means unknown (the lobby did not answer). It is never a fake 0. If every lobby failed, the last good copy is served with its own older asOf, or every waiting is null. The status is still 200.
  • Cache: Cache-Control: public, max-age=5.
  • CORS: exact allow-list https://www.quaiaxe.com, https://quaiaxe.com and https://hartiigames.com. Other origins get the data without an allow-origin header, so browsers block it. Only GET is allowed; other methods answer 405.
  • The client treats null, a malformed answer or an asOf older than two minutes as "show nothing".
  • hartiigames.com polls this every 20 seconds while the tab is visible, and hides the notice during a round.

Growth counter

GET /api/qaxe/growth

A public Hash Wars counter, added in 1.42.0. The numbers count from D1 migration 0019_qaxe_growth.sql. Nothing before that is back-filled, so there is no launch baseline. The counts are estimates. They are not unique people. The response shape is written from source (functions/api/qaxe/growth.js), not from a live capture.

FieldMeaning
visitorsStartedBrowser codes that started Hash Wars. The browser reports them, and only the first code per browser counts. Estimate.
completedRunsVerified complete runs. The server replays each run before it counts.
walletsCompletedDistinct wallets with at least one verified complete run whose signature was checked. A wallet counts once, however many runs it has. Wallets are not unique people.
definitionThe wallet count's definition, as a text string returned on every response.
measurementText stating the measurement epoch: Since migration 0019; excludes historical runs.
asOfServer time the response was generated, in ISO 8601 UTC.

The response is 200 with Cache-Control: public, max-age=60, s-maxage=300: 60 seconds in the browser and 300 seconds at the edge. If the database or the schema is missing, the route returns 503 with { "error": "Hash Wars counters unavailable" }. It never returns a zero in place of a missing count.

Automatic counting in the game client stops when the browser sends Do Not Track or Global Privacy Control. An explicit signed save still sends the run the player chose.

POST /api/qaxe/growth

Used by the game client only. It is same-origin, accepts a body up to 1.5 MB, and allows 30 submissions per IP per hour (counted in D1). It takes three kinds of request:

  • { "kind": "start", "visitor": "<random browser code>" }: the first browser code only, stored as a hash.
  • { "kind": "complete", "record": { seed, score, inputLog, ticks } }: an exact rules-5 replay that the server verifies.
  • The same completion plus wallet, runId and sig: attributes the completion to one wallet after the signature check.

Out of scope here

functions/api/qaxe/** integrates with the separate QuaiAxe product (documented elsewhere), except GET /api/qaxe/growth, which is documented above under Growth counter. functions/api/sink/**, admin/settlements.js and auth/nonce.js are internal plumbing / operator-only — no public contract documented here beyond "exists and is gated." daily/nonce (the nonce for a wallet's signed daily submission) is not documented here either.

Biome endpoints

Base: https://hartiibiome.com. The routes below are GET, read-only, no wallet/money logic. The only other route on this list is the analytics page's own counting beacon, POST /api/t (see analytics traffic).

GET /api/chain

json
{ "blockNumber": 10250128, "gasPriceWei": "29484720446206", "gasPriceGwei": 29484.72 }

quai_blockNumber / quai_gasPrice against the public RPC, max-age=45. Fields are null (never a fabricated value) when the RPC is unreachable.

GET /api/prices

json
{ "quaiUsd": 0.010354, "changePct": 6.44, "volumeUsd": 93962.9, "qiQuai": 105.025, "qiUsd": 1.087, "spark": [0.009965, "…24 hourly closes"] }

MEXC 24h ticker + quai_qiToQuai RPC read for the Qi↔QUAI protocol rate. ~2 min edge cache.

GET /api/ecosystem

Fans out server-side to Games (/api/tournament), Gallery (/api/collections) and Labs, cached 5 minutes per colo, only caches a response if all three sibling fetches succeeded (x-ecosystem-complete: 1) — a partial failure is never pinned into the cache. While paid play is off, Games' monthly standings (/api/tournament) count each game's current-season free board only, so scores from different rules never mix. gamesWithPlay counts the roster games that have at least one score this month, so it is at most twelve since 1.42.0.

json
{ "games": { "month": "202609", "gamesWithPlay": 10,
  "standings": [{ "wallet": "0x00700bd1…", "points": 5772, "games": 6, "name": "Swimgood" }] } }

GET /api/analytics

Added in 1.22.0. One cached answer for the analytics page: Labs, Games, Gallery, the two buyback ledgers, the contract counters and the event-log counts. It is read server-side because Games' API is closed to browsers and the log counts need many paged calls to Quaiscan. It is cached 5 minutes per colo when every source answered (x-analytics-complete: 1). A partial answer is kept for 30 seconds only, so a failing source is asked once and not once per visitor.

json
{ "at": 1791571072164, "complete": true,
  "sources": { "labsStats": { "ok": true, "at": 1791571069162, "status": "ok" },
               "ledgerHbome": { "ok": false, "at": 1791571069244, "status": "not_published" }, "…": "…" },
  "labs": { "launched": 90, "active": 85, "graduated": 5, "holders": 300, "isPartial": false,
            "volume24hWei": "1000000000000000000000", "volume7dWei": "…", "volumeAllWei": "…",
            "burns": [{ "address": "0x…", "symbol": "ABC", "burnedWei": "1000000000000000000" }] },
  "games": { "version": "1.22.0", "healthy": true, "month": "202610", "gamesWithPlay": 0, "paidPlay": false },
  "gallery": { "collections": 45, "minted": 5000, "mintedUnknown": 19, "creators": 6, "creatorsUnknown": 30,
               "activeListings": 10, "partial": false, "version": "…", "healthy": true },
  "ledgers": { "qaxe": { "asOf": "2026-09-17T16:37:55.189Z", "tokensBurned": 1000, "burnCount": 2, "purchases": 3,
                         "lastBuybackAt": "2026-09-17T16:37:55.189Z" },
               "hbome": null },
  "tools": { "profiles": { "names": 10 },
             "claim": { "transactions": 18, "campaigns": 2, "claims": 14, "atLeast": false },
             "otc": { "transactions": 6, "offers": 3, "filled": 2, "cancelled": 1, "atLeast": false },
             "airdrop": { "transactions": 4, "batches": 4, "recipients": 80, "atLeast": false } } }

The numbers above are illustrative, not a live reading. null means unknown, never 0. Every source reports { ok, at, status }, where status is ok, not_published (the HBOME buyback ledger does not exist yet, which is expected and still counts as a complete answer) or error. Wei values are strings. A group is null when every source behind it failed.

  • gallery.minted and gallery.creators add up only the collections that report a count or a creator, and mintedUnknown and creatorsUnknown say how many do not. A non-zero value means the figure is a floor. Both figures are null when no collection reports one.
  • labs.burns has one row per token. Labs' cross-token burn total is not passed on. Token symbols are cleaned: control characters, direction marks and zero-width marks are removed and a symbol is cut to 32 characters.
  • tools.*.atLeast: true means the log list reached its page cap (20 pages) or its 8 second deadline, so the count is a lower bound. tools.*.transactions is Quaiscan's all-time transaction counter for the contract and counts every call.
  • ledgers.hbome holds, when the file is published, asOf, tokensAcquired, treasuryFundedTokensAcquired, feeFundedTokensAcquired, tokensBurned, pendingBurn, burnCount, purchases, ownerBurned and ownerBurnedFromBuybacks. It is null until then. See the note under Burns.

GET /api/analytics/traffic

Added in 1.22.0. Aggregates of the visits counted by this site (see Privacy), never a single visit. The query is ?range=24h|7d|30d: range is one of the three values, and anything else is read as 7d. It is cached 5 minutes per colo when every query answered (x-traffic-complete: 1) and 30 seconds when one failed.

json
{ "ok": true, "counting": true, "range": "7d", "at": 1791571072164, "views": 120, "visitorDays": 45, "visitorDaysAtLeast": false,
  "pages": [{ "name": "/", "n": 50 }],
  "referrers": [{ "name": "Direct or hidden", "n": 20 }, { "name": "Other (under 3)", "n": 2 }],
  "countries": [{ "name": "US", "n": 30 }], "devices": [{ "name": "mobile", "n": 70 }],
  "series": [{ "day": "2026-10-07", "n": 40 }],
  "events": { "hpay_link_created": 7, "hpay_send_confirmed": 3, "claim_confirmed": 2, "search_address": 9 },
  "toolViews": { "/hpay": 40, "/claim": 12 } }

The numbers above are illustrative, not a live reading. While counting is switched off, which is how it ships, the answer is { "ok": false, "reason": "counting_off" }. With counting on but the read credentials not set it is { "ok": false, "reason": "not_configured" }, and if no query could be answered { "ok": false, "reason": "unavailable" }. All three come with Cache-Control: no-store. The reader asks the same switch the beacon does, so a credential set early cannot make an empty or old dataset look live.

  • Countries and referrers with fewer than 3 visits are folded into Other (under 3). A visit with no referrer is Direct or hidden, an unknown country is Unknown, and a visit from one page of this site to another is not a referrer. The lists hold at most 10 pages, 25 referrers, 25 countries and 10 device kinds.
  • visitorDays is the sum over days of distinct daily visitor codes. visitorDaysAtLeast is true when some views carried no code. visitorDays is null when views exist and none carried a code, and 0 only when there are no views.
  • series[].day is a UTC calendar day such as 2026-10-07, or the start of an hour such as 2026-10-07 14:00 when the range is 24h. A day or hour between the first and last counted one with no views is 0.
  • events lists every action name with 0 where none was counted (hpay_link_created, hpay_send_confirmed, claim_link_opened, claim_confirmed, otc_offer_created, otc_offer_filled, airdrop_batch_sent, search_block, search_hash, search_address), and toolViews lists the page views of /hpay, /claim, /otc and /airdrop. A part that could not be read is null.

POST /api/t

The page's own counting beacon. It is not an integration point and takes no input you should build on. It always answers 204 with an empty body, whatever happened, and counts nothing while counting is switched off. It stores only what Privacy lists.

Quai Network mainnet · chain 9 · Cyprus-1. Figures marked "read on" a date were read from the chain that day; re-read before relying on them.