This document answers the question "what do sites like this actually run on" and records the decisions taken for this project.
None of them publish source, but the stack reads clearly from HTTP headers, front-end bundles, WebSocket frame formats, and from which libraries exist for the job at all. The picture across the industry:
| Layer | What is usually there | Why that |
|---|---|---|
| Front end | Next.js / Nuxt, occasionally a plain Vue SPA | SEO on case pages plus a fast TTFB; after that the app lives as a WebSocket-driven SPA |
| API | Node.js (NestJS/Express) or Go, occasionally legacy PHP | see the note on Steam libraries below |
| Realtime | WebSocket + Redis Pub/Sub | the drop feed, battles and balances are pushed, not polled |
| Database | PostgreSQL | transactions and money; MySQL turns up in older projects |
| Cache/queues | Redis + BullMQ (Node) / Asynq (Go) | throttling, locks, the trade-offer queue, idempotency |
| Steam bots | always Node.js | see below |
| Infrastructure | Docker, Nginx/Traefik, Cloudflare | Cloudflare here is not "for speed" but a mandatory anti-DDoS layer |
| Payments | local PSPs plus crypto | card acquirers dislike these projects; crypto is almost always present |
This is the main technical fact that decides the stack. Item trading does not go
through the official Web API — that can only read inventories and history — but
through emulating the Steam client and calling internal steamcommunity.com
endpoints. Exactly one living, maintained set of libraries exists for that: the
DoctorMcKay ecosystem on Node.
steam-user— signing into Steam as a client, sessions, refresh tokenssteamcommunity—steamcommunity.comcookies and session, mobile confirmationssteam-tradeoffer-manager— creating, tracking and confirming trade offerssteam-totp— Steam Guard codes and confirmation keys fromshared_secretglobaloffensive— a CS2 game-coordinator connection: float, pattern, stickers, inspection
Python counterparts (steam, steampy) exist but lag noticeably: they are fixed
less promptly after Valve changes something, and their coverage of mobile
authenticator confirmations is weaker. For a project where the bot farm is the
till, being a week behind Valve's latest change means a week without withdrawals.
Conclusion: the trading layer has to be Node. Hence the decision to write the whole backend in TypeScript rather than carry two languages, two data models and a bridge between them.
apps/web Next.js 15 (App Router), React 19, Tailwind, Zustand, socket.io-client
apps/api NestJS 11 on Fastify, Prisma 6, Postgres 16, Redis 7, BullMQ, socket.io
apps/bot Node worker: consumer of the withdrawal queue — market purchases and the Steam bot farm
packages/shared shared types, zod schemas, translations, provable fairness (used by both API and front end)
One language across the project, shared types between front and back, a single tsconfig.
By composition this project is a CRM plus a game core: dozens of modules, roles, guards, validation, queues, websockets, cron. Nest supplies DI and modularity out of the box, which over a year saves more than its overhead costs. It runs on Fastify, so the RPS difference against plain Fastify is a few percent.
Go would win on RPS for battles and websockets, but: the bot farm would still be Node, meaning two languages; the barrier to entry is higher; and for MVP-scale load (up to ~5k concurrent sockets per instance) Node on Fastify is not the bottleneck — Postgres is. If the game core ever does saturate, it can be split out into Go as a separate service later; the module boundaries allow it.
The key entities (full schema in apps/api/prisma/schema.prisma):
- User — keyed by
steamId64, holds the balance, role and ban status - Item — the CS2 item catalogue:
marketHashName, rarity, type, price - CaseCategory — a shelf in the catalogue ("free cases", "by rarity", "collections"). Data rather than an enum, because grouping is merchandising: an operator adds a shelf for a holiday and removes it afterwards, and neither should be a deploy
- Case — a case: price, image, active flag, base and optional English name, a description in both locales, the shelf it sits on, and — for a free case — the top-up threshold and the opening limit that ration it in place of a price
- CaseItem — an item inside a case with its ticket range (
rangeFrom..rangeTo) - CaseOpening — a recorded opening: seed pair, nonce, roll, item dropped
- InventoryItem — an item on a user's account, with a state machine
- Transaction — the ledger of every balance movement, the single source of truth about money
- Upgrade — a staked upgrade: stake, target, chance, roll, outcome
- Contract — several items traded for one: stake, solved outcome table, roll, reward
- DailyBonus — one spin of the wheel: slice, roll, and whether it is still unspent
- Battle / BattleCase / BattlePlayer — a case battle: the agreement, its case
list with frozen prices, and its seats. What actually dropped lives in the
CaseOpeningrows that point back at the battle - Giveaway / GiveawayEntry — a skin given away to one entrant, drawn on the same seed machinery as a case opening. The seed hash is published while entries are open and the seed itself only afterwards; the client seed is a hash of the entrant list, so the effect of the seed cannot be known before entries close
- PromoCode / PromoRedemption — a top-up promotion and each use of it
- Referral — who invited whom, written once and never re-pointed
- ReferralEarning — one commission accrual, off the ledger until it is claimed
- Setting — a runtime setting, keyed by the shared registry
- Payment — one attempt to put money on a balance. The row exists before the player reaches a checkout and survives whatever happens there, so a provider that confirms twice — or confirms something the site never heard of — meets a record that already knows its own state
- ItemDeposit / ItemDepositItem — skins handed to the site for balance: the mirror of a withdrawal, with the valuation frozen on the request so a market move while the offer waits in Steam cannot change what was promised
- KycApplication / KycDocument — an identity check and its papers. One row per player rather than one per attempt: what matters operationally is the current standing. The document bytes are deliberately not in Postgres — a scan in a column is a scan in every backup, replica and slow-query log
- Withdrawal — a withdrawal request, carrying the channel that fills it
- WithdrawalItem — one line of a request: what it asked for, recorded once
- MarketAccount — one market.csgo.com key the site buys through
- MarketPurchase — one skin bought on market.csgo.com for one withdrawn item
- SteamBot — a farm bot: status, inventory capacity, limits
- ServerSeed / ClientSeed — provable fairness
- AuditLog — every operator action
Every amount is an integer in minor units (Int), never a Float. Each
balance change creates a Transaction row; the balance on User is a
denormalised cache that must agree with the sum of transactions. Reconciliation
runs as a nightly cron; a mismatch is an alert.
Debiting is a conditional update rather than read-compute-write:
UPDATE users SET balance = balance - $1 WHERE id = $2 AND balance >= $1Zero affected rows means insufficient funds. That removes the race between two concurrent openings from two tabs — the classic way to drive a site negative.
The industry-standard scheme, verifiable by the user by hand.
- The server generates a
serverSeed(32 random bytes) and publishes onlysha256(serverSeed). The seed stays active until the user rotates it. - The user sets a
clientSeed(or receives a random one) and may change it at any time. - The seed pair has a
noncecounter, incremented on every opening. - The roll:
hmac = HMAC_SHA256(key = serverSeed, message = `${clientSeed}:${nonce}`)
roll = parseInt(hmac.slice(0, 8), 16) % 1_000_000
- The
CaseItemwhose range coversrollis the drop. - Rotating the server seed reveals the old one in full — any past roll can then be recomputed and checked against the published hash.
The ticket space is exactly 1_000_000. The ranges of a case's items must cover
[0, 999999] with no gaps and no overlaps; that is checked when a case is saved
in the admin panel and by a dedicated test. This form — ranges rather than
weights — was chosen because it is trivial to verify by hand and does not depend
on the order in which items are iterated.
Important: serverSeed must not depend on the item or on the user, and the drop
decision has no business looking at the player's balance. Any nudging breaks
verifiability — if the economy needs tuning, it is tuned through ranges, not by
hacking the roll.
A case's RTP = Σ(item chance × item price) / case price. It is computed from the
ticket ranges and current item prices and shown in the admin panel on every edit.
The working corridor is 85–95%; above 100% a case loses money, below 80% nobody
buys it. The hard ceiling is 98%: above that the server simply refuses to save
the case.
"The case must be profitable" is one equation with N unknowns — infinitely many solutions. That needs a model, and the one adopted is the natural one for cases: the pricier the item, the rarer it is.
Formally: an item's weight is w_i = price_i^(-k) and its chance is
s_i = w_i / Σw. At k = 0 every item is equally likely; as k grows the cheap
ones crowd out the expensive ones. Expected return decreases monotonically in
k, so the right exponent is found by binary search against the target RTP
(packages/shared/src/balancing.ts).
The achievable RTP range is hard-bounded by the prices of the extreme items: no distribution returns less than the cheapest item or more than the priciest. If the target lies outside that band, the builder does not silently fudge it — it says what to change: the case price or the contents.
The inverse problem — "here is a loot table, what should the case cost" — is
trivial: price = expected drop value / target RTP. That is also how a case is
assembled in practice: contents first, price second.
Item prices move. A case assembled at 92% RTP can be at 105% a month later after a knife's price jumps. Therefore:
- an hourly job pulls prices from the Steam market for items in active cases,
- after every re-pricing the RTP of all active cases is recomputed,
- leaving the corridor is logged as a warning; exceeding 98% as an error.
A log line is an alert, not a repair, and drift is silent until somebody reads
it. pnpm --filter @caseforge/api rebalance-cases is the repair: it recomputes
every case's RTP from today's prices and re-solves the ones that have left the
corridor. It moves the odds and never the price — a price is an operator's
decision that a player may have seen yesterday, while the odds are a derived
quantity that was already wrong. A case it cannot solve at the current price is
reported rather than repriced, because that is a decision about what the case
is. It saves through the same admin path the CRM uses, so ranges are re-checked,
the audit entry is written and the catalogue cache is dropped.
Free cases are exempt from all of this. RTP is what comes back per unit staked, and nothing is staked — the ratio is a division by zero. A free case is rationed by a top-up threshold and an opening limit over a rolling 24 hours instead, both checked when it is opened rather than when it is saved, and it carries no cached RTP at all rather than a zero that would read as a case that pays nothing.
Unconfirmed prices are tracked separately: an item whose price Steam never returned carries an invented number yet counts towards the RTP like any other. Those items are flagged in the CRM.
The same job backfills missing images: items created outside the import path (seed, manual insert) have none, and a missing image makes the showcase look broken. A case without its own image borrows the picture of its priciest item — that is what sells the case anyway. An explicitly set image is left alone.
The player stakes a skin against a pricier one. The chance is derived from prices
rather than assigned: chance = stakeValue / targetValue × UPGRADE_RTP, with the
same RTP the cases use. Under that definition the expected value of an upgrade
equals the stake times RTP at any multiplier — the upgrade economy automatically
matches the case economy and needs no separate balancing.
The outcome comes from the same roll as a case opening:
roll < chance × TICKET_SPACE, over the same seed pair and the shared nonce
counter. That is not code reuse for its own sake but a product property: the user
has one fairness page for every mechanic, and an upgrade is verified the same way
a drop is.
The stake is consumed regardless of the outcome — it is a stake, not a deposit.
The consumption is a conditional updateMany on status = AVAILABLE: if the item
has meanwhile gone to a sale or a withdrawal, the upgrade does not happen rather
than granting the item twice.
The bounds beyond which an upgrade is refused: a multiplier below 1.05 (a swap with a fee dressed up as an improvement), a chance above 85% (a near-certain swap), and a price gap wide enough to push the chance below 0.5%.
A contract takes between 3 and 10 items and returns exactly one. Unlike an
upgrade there is no winning and losing branch: a contract always pays out, the
only question is what. The reward is drawn from a pool of catalogue items priced
between 0.1x and 5x the staked sum, and the outcome is the same roll a case
opening uses, over the same seed pair and shared nonce counter.
The pool is not weighted by hand. Hand-tuning it would make the contract a
second economy sitting next to the cases with maths of its own, and every
repricing of the catalogue would silently move the margin. Instead the weights
are solved so that the expected reward equals stakeValue × CONTRACT_RTP, with
the same RTP the cases and the upgrade run on.
Weights follow an exponential tilt, w_i ∝ exp(-alpha × u_i), where u_i is the
outcome's price on a log scale normalised to [0, 1] across the pool. Alpha is
found by bisection: the expected value falls monotonically in alpha — its
derivative is minus the variance of the tilted distribution — so a single
bracket converges. Alpha = 0 would be a uniform pool; a positive alpha leans on
the cheap outcomes, which is where it lands, since the target sits below the
middle of the band.
The consequence worth stating: the margin is a constant of the system, not a property of the catalogue. Whatever items happen to be priced inside the band, the solver places the expected value on the same number.
Fractional weights become whole tickets by largest remainder, so the ranges tile
[0, TICKET_SPACE - 1] exactly — the same invariant validateTicketRanges
enforces on a case. An outcome that would round down to zero tickets cannot be
won, and drawing it on the reel would be a lie, so it is dropped and the pool
re-solved over what is left.
The solved table is snapshotted onto the contract row as JSON, with each outcome's item id, price and ticket range. The pool is derived from a live catalogue, so it cannot be rebuilt later from prices that have since moved: without the snapshot the roll would still be reproducible but would no longer mean anything.
The staked items are consumed regardless of the outcome, through the same
conditional updateMany on status = AVAILABLE an upgrade uses — an item that
has meanwhile gone to a sale or a withdrawal fails the claim and the contract
does not happen.
A contract is refused rather than played on bad odds when the catalogue is too thin inside the band, or when it holds nothing above the payout the table has to average to.
One spin every 24 hours, for money, a discount, a free opening, or a skin.
The wheel is a ticket table like a case, rolled from the same seed pair and the
same shared nonce counter. This is the one table in the project that really is
authored by hand rather than solved, and the reason is that a solver needs a
single number to optimise: with prizes as different as a rouble credit and a
knife, there is none. What is bounded instead is the cost — wheelGrantCost()
sums the slices that hand something over, and a test fails if re-tuning ever
pushes it past the ceiling.
The slices are drawn from the very ticket ranges they are rolled against, so a three-percent prize occupies three percent of the rim. A wheel drawn in equal wedges over unequal odds is the oldest lie in the genre.
A rolling day, not a calendar one: a calendar reset hands whoever lives in the right timezone two spins a few hours apart, and turns midnight into a load spike nobody asked for.
It is claimed with a conditional UPDATE on users.lastBonusAt rather than read
and then written. Between a read and a write two requests fired together both
pass the check and the player spins twice on one day; zero affected rows means
somebody else already took it. The column is denormalised for exactly this
reason — deriving the last spin from the bonus table would be tidier and would
not be claimable in one statement.
Money and skins are settled inside the spin's transaction and the row is born
already consumed. A balance credit goes through Transaction like every other
movement, so the nightly reconciliation still adds up.
A discount and a free opening are vouchers: the row stays open until an opening
spends it. Spending happens inside the opening's own transaction, before the
debit — resolving it earlier would let a concurrent opening take the voucher in
between and charge this one a discounted price for nothing. The claim is the same
conditional updateMany the inventory uses, so a voucher cannot be spent twice.
When several vouchers are open, the opening takes whichever saves most on that basket. First-in-first-out would be simpler and worse: it burns a half-price voucher on the cheapest case in the catalogue while a free opening waits behind it. The chosen voucher comes back on the response, because a reward that vanishes with the price quietly changing is indistinguishable from a bug.
A FREE_ITEM slice has to turn into a specific skin, and that choice is made
from the same roll that picked the slice rather than from a second, hidden draw —
the candidate list is ordered by id, which is stable in a way that ordering by a
drifting price is not. If the catalogue holds nothing inside the ceiling the
slice is honoured in money instead: paying nothing at all is the one outcome the
wheel must never have.
Several players buy the same list of cases and open it side by side; one of them keeps every item that dropped. In the standard mode that is the biggest total, in the crazy mode the smallest — the same cases, the opposite goal.
Everybody pays the full list price of the list. Nobody is charged less for playing against somebody else, which means the site's margin is exactly what it would have been had the same cases been opened solo: the expected return is the weighted RTP of the cases in the battle, and the pot only moves between the players. A battle is a redistribution, not a second economy.
Nowhere new. Every drop in a battle is an ordinary CaseOpening row, rolled from
the opening player's own seed pair over their own nonce block, exactly as a
solo opening is. The rows carry battleId and battleRound and nothing else
that a solo opening does not have.
A battle-wide seed was the obvious alternative and is the wrong trade. The site already publishes a commitment per player and reveals it on rotation; a second scheme alongside it would be a second thing to get wrong, and it would not buy anything a player cannot already do — each of them verifies their own drops in their own profile, with the same arithmetic as any other drop, and the totals are then addition over numbers that are already on the record.
It also means battles need no special case anywhere else: the RTP report, the per-case margin, the live drop feed and the player's own opening history all pick them up without knowing that battles exist.
A seat is claimed with a conditional UPDATE on a denormalised counter:
UPDATE battles SET "filledSlots" = "filledSlots" + 1
WHERE id = $1 AND status = 'WAITING' AND "filledSlots" < slots
RETURNING "filledSlots"RETURNING hands back the player's own 1-based seat number, and no rows means
somebody else took the last seat. Counting the seats and then inserting one lets
two players fired together both see the same seat free. The debit that pays for
it is the same conditional UPDATE an opening uses, in the same transaction, so
a battle that fills up while a player is joining rolls their debit back rather
than refunding it afterwards.
Filling the last seat plays the battle out inside that same transaction. Four seats over thirty rounds is a hundred and twenty openings, a hundred and twenty inventory rows and a seed reservation per player — the largest write the site makes, and the reason its timeout is raised. It is not splittable: a battle that could pay some players and not others has no meaning.
The ids of the openings and of the inventory items are generated up front so each
side goes in with one createMany rather than a round trip per row while the
transaction holds locks. Seeds are reserved in a fixed order across players,
because two battles filling at the same instant can share a player and a
consistent order is what keeps their transactions from taking each other's locks
the wrong way round.
Every item is then created in the winner's inventory while still pointing at the
opening that rolled it. The opening's userId stays whoever rolled it, and
that gap between roller and owner is precisely what a battle is; recording only
one of the two would lose either the provenance or the ownership.
Two seats opening the same cases can land on the same total, and with cheap cases it happens often enough to need a rule. The rule is sudden death on the single best drop — the single worst in the crazy mode — and, if even that matches, the earlier seat wins. Both steps are functions of drops already on the record, so the outcome stays reproducible from the stored rolls; a fresh random draw would not be.
A battle waiting for a second player holds the host's money, and the host may
well have closed the tab. A sweeper cancels anything that has waited longer than
the configured window and refunds every seat in full, through the ledger. The
transition is claimed with a conditional UPDATE from WAITING, which is what
stops a refund racing a settlement — a battle that filled a moment ago is being
played, and the sweeper must not be able to give its entries back underneath it.
Switching battles off in the settings makes the same sweeper clear the lobby
outright, which is what flipping that switch during an incident is asking for.
Steam does not support OAuth — only OpenID 2.0. The flow: redirect to
steamcommunity.com/openid/login, come back with parameters, then a mandatory
server-side verification through check_authentication (without it the
parameters are trivially forged). The response yields steamId64, and we issue
our own JWT pair.
Important: OpenID reports only the SteamID64 — neither nickname nor avatar is in the response, and both have to be read separately. There are two sources:
GetPlayerSummaries (Web API) |
/profiles/<id>?xml=1 |
|
|---|---|---|
| Key | needs STEAM_API_KEY |
none |
| Returns | nickname, avatar, registration date, status | the same |
| Limits | 100k requests per day per key | like an ordinary page |
The Web API is the primary path, the XML the fallback. The XML is not a
development shim: if the key expires or the Web API is down, the user still sees
their own nickname and avatar instead of user_123456. Parsing the XML needs
care — the profile embeds nested friend lists with their own <steamID> and
<avatarFull>, and naively taking the first match substitutes somebody else's
avatar.
The key point: Steam never returns an e-mail address by either route. It will never be known unless the user types it in. Every account-recovery path is built around the Steam account.
The JWT pair is short access token plus long refresh token: the access token carries the user id and role and is checked on every request without touching the database, so it has to expire quickly for a ban or a role change to take effect. The refresh token lives in an httpOnly cookie for a month and is the only thing that can mint a new one.
That split only works if something spends the refresh token. The browser does it transparently: a 401 from any call triggers one refresh and one replay of the original request, and a failed refresh is what ends the session — not a normal 15-minute expiry. Concurrent 401s share a single in-flight refresh, because a page load fires several calls at once and each starting its own refresh would rotate the cookie repeatedly, leaving the losers of the race replaying with a token that had already been superseded. That failure mode is indistinguishable, from the player's side, from being logged out at random.
The websocket reads its token during the handshake only, so it takes the token through a callback and reconnects when the token changes; pinning it at page load would refuse every reconnect made after the first expiry.
An item on an account is a row with a status, and the row is never deleted:
AVAILABLE → SOLD | WITHDRAWN | LOCKED | UPGRADED | CONTRACTED
Only AVAILABLE can be acted on. LOCKED belongs to a withdrawal in flight;
the other three are terminal and make up the history the interface shows on its
own tab. Deleting the row instead would be smaller, and wrong twice over: a
player who sold a knife still wants to see what they got for it, and support
cannot investigate a complaint about an item that disappeared from the
interface at the moment it was disposed of.
Every transition is a conditional updateMany on status = AVAILABLE, which is
both the check and the claim. An item that has meanwhile gone to a sale, a
withdrawal or a contract simply falls out of the affected rows, and the mismatch
between the affected count and the number of ids requested is what the caller
rejects on. Nothing here relies on having read the row first.
Selling everything resolves its own set inside the transaction from the filter the interface was showing, rather than from a list of ids the browser had loaded. Ids would cap the operation at the page the player happened to have open and would race with anything that changed in between; the filter cannot. The total comes back from the server for the same reason — the confirmation dialog can only ever quote an estimate of it.
Withdrawing from the inventory starts a purchase. The item goes to LOCKED
and a request is queued; it becomes WITHDRAWN only when the market confirms
that a seller handed the skin over, and returns to AVAILABLE if the purchase
fails. The interface therefore cannot say "withdrawn" at the moment of the
press — it says a request was made, which is the only thing that is true yet.
The mechanism is section 7.6.
Filters. Two axes: the tab (available, withdrawing, history, all) and a price band. The band edges are fixed in the base currency rather than in whatever the player is displaying. A band that moved with the exchange rate would re-sort the inventory every time the rates refreshed, and an item could sit in one band on Monday and another on Tuesday without its price having changed at all. The labels are rendered through the usual money formatter, so the edges still read in the player's chosen currency — round in the base currency, converted elsewhere.
Prices and images come from the Community Market, which offers two endpoints with different properties:
market/search/render |
market/priceoverview |
|
|---|---|---|
| Returns | name, image, rarity, listing count | the price only |
| Currency | always dollars, the currency parameter is ignored |
honours currency |
| Query | keywords | an exact market_hash_name |
Hence the split: search is used to pick items and read metadata, while the price
in the project's currency always comes from priceoverview.
Three things that are easy to get burned by:
- Search does not accept a
market_hash_name. The|character and the exterior parentheses return zero results even for items that are definitely listed. The name has to be normalised into keywords (toSearchQuery). - Knives and gloves carry a
★prefix.Karambit | Doppler (Factory New)does not exist as far as Steam is concerned;★ Karambit | Doppler (Factory New)does. - Take the median, not the minimum.
lowest_priceswings with individual dumped listings, and a case priced off it undervalues its items.
Request frequency is capped at roughly 20 per minute, beyond which comes a 429 and a ban of several minutes. So requests are serialised with a 3.5 second interval, search results are cached for an hour and prices for half an hour, and a 429 puts the service to sleep for five minutes. Negative results are cached too: an item with no listings would otherwise hit Steam on every sync.
Storage and settlement stay in the base currency. The currency switch converts at render time only, using a rate from the Central Bank of Russia's public daily feed — no key, refreshed every six hours, cached in Redis, and falling back to the previous value when the feed is unreachable. Nothing is ever re-priced and no ledger row holds a converted amount: treating a display rate as a settlement rate is exactly how a site ends up selling items below cost after a currency move.
The user pastes their trade URL; partner and token are parsed out of it. It is
validated by shape, and in practice by the first offer that uses it.
Two, chosen by the withdrawals.provider setting and stamped on the request
when it is created. Reading the setting again at processing time would mean an
operator flipping the switch could leave requests half-filled by one channel and
polled by the other.
MARKET (the default). The site holds no inventory. Each withdrawn item is
bought on market.csgo.com, and the buying account names the player's
partner/token as the recipient so the seller delivers directly. What the site
carries is a price rather than a warehouse: no capital tied up in skins, no slot
ceiling, and nothing that cannot be handed out because no bot happens to own it.
There is a pool of accounts rather than one, and the reason is the rate limit: the market deletes an API key that exceeds five requests a second. That is not a throttle to back off from — it is the key ceasing to exist, taking with it the ability to ask about any purchase it made. So the client stays well under the limit, which caps a key at about four requests a second, and a single withdrawal costs a search, a buy and a poll every few seconds until the seller delivers. Extra keys raise the ceiling because the limit is counted per key, so each account gets its own client with its own queue — one shared queue would throttle them collectively and give back exactly the ceiling the second key was bought to remove.
Accounts are not interchangeable after the fact, which is the part that shapes
the code. get-buy-info-by-custom-id answers for the key that made the purchase
and reports every other purchase as unknown, and the money came out of that
account's balance. So a purchase is bound to its account before buy-for is
called, polling is grouped by account, and a retry goes back to the key that
made the first attempt. Asking a different key would get a truthful "never heard
of it" about a purchase that had been paid for — and the retry would then buy
the skin a second time.
Keys are encrypted under BOT_SECRETS_KEY and decrypted only in the worker, the
same as the Steam bots' secrets. The back office never holds one: the worker
writes balance and health snapshots, and the panel reads those.
BOTS (section 7.5.1). A farm of Steam accounts holding the inventory. Kept because an operator already running one should not be forced to migrate, and because an account with its own inventory is the only way to deliver something the market is not selling.
One bot is one Steam account with the mobile authenticator enabled and with
shared_secret/identity_secret available (needed to auto-confirm offers). The
constraints the whole withdrawal design is built around:
- a Steam inventory holds 1000 slots, so several bots are needed along with routing a request to whichever bot actually holds the item;
- a 7-day trade hold applies if the recipient has no mobile authenticator or enabled it recently — checked BEFORE sending the offer, otherwise the item sits in escrow;
- a bot that recently changed its password or device goes on hold by itself;
- Valve rate-limits offer creation, so a throttled queue is mandatory.
Bot secrets (shared_secret, identity_secret, passwords) are not stored in the
database in the clear: an external secret store in production, encrypted under an
environment key in development.
request → validation (trade URL, limits, anti-fraud)
→ items → LOCKED, one Withdrawal row, one BullMQ job
→ MARKET: one MarketPurchase per item
search-item-by-hash-name → buy-for(partner, token, price ceiling)
poll get-list-buy-info-by-custom-id → stage 2 or 5
→ BOTS: pick a bot holding every item → offer → auto-confirm → poll
→ delivered → WITHDRAWN, failed → back to AVAILABLE
Idempotency. On the bot channel the job is idempotent by withdrawalId: a
retry does not create a second offer. On the market channel that is not enough,
because the unit of money is the purchase, not the request. Each MarketPurchase
row is unique on its inventory item and travels as the market's custom_id, so a
retry after a lost reply first asks the market what happened to that id. Without
it, a dropped connection between the charge and the response buys the skin twice
and pays for both.
A request is no longer atomic. Three items are three sellers, each able to
fail alone, so the request's status is computed from its lines every time a
purchase moves — hence PARTIAL. The lines are a table of their own rather than
InventoryItem.withdrawalId, because that column says which request holds an
item now and is cleared on a refund: reading history from it would make a
request that gave everything back list nothing at all, and one that refunded a
single item report itself COMPLETED. The rule the computation enforces is one-directional:
an item returns to the player's inventory only when the purchase for it is known
not to have happened. A purchase that is merely unfinished keeps its item locked,
and a purchase that was paid for is never written off on a timer. The money is
already gone and the seller may still deliver; the site says so to an operator
rather than guessing, and the guess that would be cheap to make — refund the item
— is exactly the one that hands out a skin twice.
Price. The ceiling per item is max(current catalogue price, credited price)
raised by withdrawals.market.maxOverpayBps. The current price rather than the
credited one because the player owns an item, not an amount, and a skin that has
doubled must still be withdrawable; the credited price as a floor so an item an
operator has marked down does not become impossible to take out. Past the ceiling
the purchase is refused and the item comes back — a refusal, not a loss.
Units. The market quotes roubles in kopecks but dollars and euros in thousandths, and reports balances and amounts paid as floats in whole units. All three conversions live in one module with tests, and an account whose currency differs from the settlement currency is refused rather than converted through the display rate (see 7.3).
Up to ten cases at once. The whole batch is one transaction: either every case is
paid for and every item granted, or nothing happens at all, including the nonce
counter.
The key detail is reserving the nonce. Incrementing it one at a time in a loop is
not an option: a concurrent upgrade or an opening from another tab would slot into
the middle and take a number out of our batch, and the nonce has to be contiguous
or the history stops recomputing in order. So a block of numbers is claimed in a
single UPDATE ... SET nonce = nonce + N RETURNING nonce, and the batch occupies
the last N numbers before the returned value.
One ledger row is written per batch rather than ten: reconciliation looks at the sum, and ten rows for a single click would only clutter the history.
The rate limit counts cases rather than requests — one "×10" button press is ten openings, and script protection has to see it that way.
socket.io with the Redis adapter, so several API instances share rooms.
Channels:
drops:live— the global drop feed (rare items only, otherwise it is a flood at peak)user:{id}— personal: balance, withdrawal status, inventorybattles:events— battle changes, fanned out to everybody
Battle events are deliberately not batched and not per-battle rooms. They are rare — a creation, a seat, a settlement — and the lobby is one shared view, so a seat has to leave every other browser at once; three hundred milliseconds of buffering would only make a taken seat look free. A battle page filters the broadcast by id and re-reads the battle when it hears about its own.
At peak the drop feed is the hottest channel. It is aggregated: events collect in a buffer and are broadcast in batches roughly every 300 ms rather than one by one. That is the difference between 50k and 2k messages per second for an identical user experience.
Traffic for sites like this is sharply uneven: an ordinary day, then a 20x spike
during a streamer's giveaway. Capacity has to be planned off the spike, and the
plan has to be runnable — everything below is in the repository rather than in a
future ticket, behind pnpm infra:up:scale and measured by pnpm test:load.
PgBouncer in transaction mode. Node keeps a connection pool per process, so
Postgres's max_connections is spent by the number of API instances times their
pool size, and it runs out long before the database is actually busy. Transaction
pooling ties a server connection to a transaction rather than to a client, which
turns a thousand idle application connections into a couple of dozen real ones.
Two things have to be right for Prisma to live behind it. Prepared statements
are per-session and a transaction pooler hands out a different session every
time: pgbouncer has tracked them since 1.21, so max_prepared_statements is set
on the pooler and ?pgbouncer=true on the URL as the belt to that braces. And
migrations must not go through it at all — they open long sessions, create types
and take locks that transaction pooling cannot carry across statements — so the
schema declares directUrl, which is where DIRECT_DATABASE_URL points. It
defaults to DATABASE_URL, so a single-server deployment configures nothing.
connection_limit in the URL is worth setting explicitly behind a pooler: a
small pool per instance is the entire point, and Prisma's default of
cores * 2 + 1 per process is not.
A streaming standby, and a second Prisma client that points at it. Where
REPLICA_DATABASE_URL is unset the read client is the primary client, so
nothing branches on configuration and one box needs none.
The rule for using it cannot be expressed as a type, so it is a rule: the replica serves what an operator or a spectator reads, never what the player asking has just written. Replication lag is small but real, and a read across it shows a player an inventory without the item they just won. What reads the replica: every CRM report and listing, the public catalogue, the battle lobby, the drop feed. What stays on the primary: anything inside a transaction, the inventory, the balance, and a single battle — the player who just took a seat is handed that very battle back.
pnpm infra:replica:init prepares a running primary for a standby: a stock
postgres container has neither a replication role nor a pg_hba line that
admits one, and both have to be added without recreating a database that already
holds a seeded catalogue. pg_hba needs an explicit replication entry —
all in the database column is the one place where all does not mean all.
The catalogue is the most requested read on the site and its answer is identical for everybody, so it is cached in Redis. Invalidation is by version counter rather than by deleting keys: the counter is part of every key in the namespace, bumping it orphans the namespace at once, and because the counter lives in Redis every API instance sees the bump. An operator saving a case, an RTP recalculation and the hourly price sync all bump it; the TTL is only the backstop for a bump that never arrived.
The opening path does not read that cache. It loads the case inside its own transaction, because a stale price there is a stale amount of money.
The drop feed keeps its own backlog as a Redis list, written as drops happen. Every socket that connects asks for the recent drops, and answering that from Postgres is a four-table join per connection — during the reconnect storm after a deploy, the most pointless load on the database on the whole site. The database is read only to warm the list.
Settings are cached in-process for fifteen seconds, and a write clears the cache locally, so an operator sees their own change immediately and other instances within the TTL.
The ones that matter are on the ticket and price reads, not on the ledger: a
contract's reward pool and the wheel's free-skin slice both ask for active items
inside a price band that can span fiftyfold, and without (isActive, marketPrice) the planner walks the whole catalogue on every contract preview.
case_openings carries its own composite indexes for the report aggregates and
one for the battle view.
GET /api/health is what a balancer polls and what an operator opens first. It
reports the reachability and latency of Postgres and Redis, and the replica's
replay lag in seconds — because behind a balancer the interesting failure is not
a dead process, which stops answering by itself, but an instance that answers
happily while its replica is an hour behind. "Degraded" travels in the body
rather than as a status code: a lagging replica is a reason to look, not a reason
to take the instance out of rotation.
pnpm test:load is a dependency-free generator in the repository, with the
site's own scenarios rather than a generic "hit / with 100 VUs":
| Scenario | What it does |
|---|---|
browse |
public reads only — catalogue, case page, lobby, config, health |
open |
the write path: one case opened per request, per throwaway player |
battles |
two throwaway players create and fill a two-round battle |
mixed |
four parts browse to one part open |
It reports p50/p90/p99 and every non-2xx status separately, which matters more than it sounds: a run where a third of the requests are 429 is a rate-limit measurement, and reading it as latency is how people conclude a site is fast when it is refusing work. The throwaway players and everything they did are deleted afterwards.
What the numbers are not: capacity. The generator shares the machine with the API and the database and competes with what it is measuring, so they are comparative — before and after a change — and a real figure needs load offered from somewhere else.
Deferred until there are real numbers: sharding, splitting the game core out into
Go, partitioning case_openings (needed somewhere past 50–100 million rows).
A separate section of the application behind role-based access (ADMIN, SUPPORT,
ANALYST).
- Dashboard — GGR, deposits, withdrawals, online, conversion, per-case margin
- Cases — the builder: Steam market search with images and prices, a case image, auto-solved odds for a target RTP, a suggested case price, a live margin verdict, ticket-range coverage validation
- Items — the catalogue, prices, manual price overrides
- Users — balance, history, manual balance adjustment (always through
Transaction+AuditLog), bans - Withdrawals — the request queue, manual intervention, re-sending an offer
- Market — the buying account's balance and checks, purchase counts, what delivery has cost against what was authorised, and purchases paid for but not delivered. Read-only: the worker is the only thing that spends money
- Bots — farm status, inventories, login errors, holds
- Reports — exports by period, cohorts, top players
- Settings — feature flags, limits, copy, promo codes
Two rules with no exceptions: every operator action is written to AuditLog with a
before/after snapshot, and none of them changes a balance outside Transaction.
The panel is English only. Translating an internal tool doubles the maintenance for an audience of a few people.
Everything an operator can change without a deploy is declared once, in
packages/shared/src/settings.ts, with its group, its type, its bounds and its
default. The admin panel renders its form from that registry rather than from a
field written per setting, so adding a knob is a line in one file and nothing
else. The alternative — a bespoke form control, a validator and a reader per
setting — is three places to keep in step and the reason most back offices end
up with settings that exist in the database and nowhere in the interface.
Values are cached in the API for fifteen seconds. The settings are read on nearly every request — the rate limit, the sell fee, the wheel — and a query apiece for data that changes a few times a month is pure waste. A write updates the writing instance at once, so an operator sees their change take hold while they are still looking at the panel; other instances pick it up within the TTL.
A stored value that fails to parse reverts to its default and is logged rather than throwing. The panel validates on the way in, which is where a bad value should be rejected loudly; by the time it is being read, refusing to serve the site is not an improvement on serving it with a default.
TICKET_SPACE, the seed algorithm and the shape of a roll. These are not tuning
parameters but the terms of a promise: every past opening was published against
them, and they are what a player recomputes when they check one. An operator who
could edit them could make yesterday's drops stop verifying — which is not a
setting but a way of breaking the only claim the site makes.
The case ticket ranges are the same kind of thing and are edited where they belong, in the case builder, through validation that refuses a case whose ranges do not tile the space.
A code adds to a top-up rather than discounting it. Once a real payment provider sits behind the button the charged sum has to match the sum the provider was told about, and a code that changed it would put the two out of step; adding on top keeps the promotion entirely on our side of the transaction.
The credit is a second ledger row rather than a larger deposit row. What the player paid and what the promotion gave them are different kinds of money, and a report that cannot separate them cannot say what the promotion cost.
Two limits, enforced differently because they race differently. The per-player
limit is a count of redemption rows inside the deposit's transaction. The global
limit is a conditional UPDATE on a denormalised counter: counting rows and then
inserting lets two top-ups fired together both see the last use available and
both take it. The rows remain the source of truth; the counter exists so the cap
can be claimed in one statement.
Codes are deactivated, never deleted. The redemptions point at them, and a player asking why their balance moved deserves a row that still explains it.
A player hands out a link, whoever follows it is bound to them on sign-up, and a share of what that person then spends is credited to the inviter.
The invite arrives as ?ref=CODE on any page and the visitor is not signed in
yet — they are about to be sent to Steam and back, which discards everything the
page was holding. So the code is parked in the browser and redeemed by a call
that lands moments after sign-in.
That leaves the endpoint open at any later point, which is why the rule that protects it is not timing but history: a code is refused once the account has any activity on it. A player who has already opened a case or moved money is not somebody's fresh recruit, and without that rule an established account could be attributed to whoever asked last. On top of it, a player cannot use their own code, and a sign-up from the inviter's own address is flagged on the binding — not refused, because households and mobile carriers share addresses, but left where an operator can see it.
One inviter per player, enforced by a unique refereeId rather than by a check:
two claims fired together both pass a check, and only the constraint decides.
Commission accrues into ReferralEarning rows that are not on any balance and
not in the ledger until they are claimed. A commission credited as it is earned
would be a Transaction row per opening per inviter, and the ledger is what the
nightly reconciliation walks; accruing into rows of their own and paying out in
one entry keeps the ledger proportional to the number of payouts rather than to
the number of drops.
The rate is stored on each row. It is a setting, and an operator lowering it must not rewrite what was already earned.
A refunded battle seat takes its accrual back with it, or creating and cancelling battles would be a free way to pay an inviter. Only pending rows are removed: an accrual that has already been paid out is money on somebody's balance, and clawing that back would be a debit they never agreed to.
The pending rows are claimed with an UPDATE ... RETURNING and the floor is
checked against what came back, not against a SUM read beforehand. The other
order pays out a different number from the one it checked whenever an accrual
lands in between — and for an inviter with active recruits, that is the normal
case. A refusal throws, which rolls the claim back and leaves the pot as it was.
The payout itself is a single REFERRAL ledger entry: it is the one point at
which accrued commission becomes money.
What the site is called, what colour it is, which sections exist and what the banner says are settings, served to the browser at runtime. The alternative — editing Tailwind classes and redeploying — makes the look a developer task, which is not what it is.
It works because the stylesheet was already built on tokens. Every component
reads --accent, --surface-raised, --radius; the appearance settings are
converted to those custom properties by one function in the shared package and
rendered into the document root by the server. Nothing in a component knows that
a colour is configurable, which is exactly why every component responds to it.
Two rules keep it from becoming a second way to break the site:
Empty means the shipped default. A blank headline is not a blank headline on the page: the front end falls back to the translated string in the dictionary. So an operator fills in only what they want to differ, a site nobody has configured looks exactly as it ships — in both languages — and a half-filled form never produces a half-empty page.
Appearance cannot reach the odds. The palette and the banner live in their own group. The ticket space, the RTP and the wheel live in theirs, where a change is a change to the game rather than to its paint.
The colours are stored as hex, which is what a colour input produces and what a
brand guide is written in, and converted to the H S% L% triple the stylesheet
needs. That conversion is a tested function rather than a format the panel
happens to write: the tokens are bare components precisely so a rule can say
hsl(var(--accent) / 0.14), and a value in the wrong shape would take the whole
palette down.
/admin/appearance renders the same settings the generic form would, grouped
the way somebody thinks about a page, with two previews. The panel beside the
fields is drawn from the draft and responds to the colour picker immediately;
the iframe below it shows the saved state, because that is the honest thing
for it to show — the site as a visitor would get it. Presets exist for the same
reason a starting point always does: a dozen colour pickers and no default is
how a site ends up unreadable.
The metadata follows too. The title and description are generated from the settings on the server, so a renamed site is renamed in every link anybody shares afterwards rather than only in the header.
The split that everything else follows from: the database is already the analytics store for everything that moved money. Openings, deposits, battles, referrals and withdrawals are rows with amounts and timestamps, and a report over them is a query. Re-emitting them as analytics events would create a second set of numbers that can disagree with the ledger — and when a report and the ledger disagree, the report is the one nobody trusts again.
So the event stream carries only what the database cannot answer: that somebody arrived, where from, what they looked at, and where they stopped. Six event types, no more, and each one is something no table records — a page view, the press of the Steam button, the top-up dialog opening, a promo code being tried, the click that starts a battle, an invite link being followed.
The two meet in the funnel: an event-based top, a ledger-based bottom.
visitors → signups → deposited → played → withdrew
(events) (users) (ledger) (openings) (withdrawals)
No IP address and no user agent. The stream is about behaviour, not identity,
and neither answers a question about behaviour that a coarse desktop / mobile / tablet does not — while both would turn a traffic report into a store of
personal data. The device class is derived from the request header and stored
instead of it.
Daily metrics are summarised once a night into one row per metric per day, which makes a month of history cheap to read and keeps it after the raw events are pruned. Today's row is refreshed on demand, because an operator watching a promotion cannot wait until tomorrow.
The headline numbers are not read from those rows. A sum of daily uniques is not a unique: somebody who visited on Monday and Tuesday is two daily visitors and one visitor, and a KPI that added the chart up would overcount every returning player. So the tiles are computed over the whole range with proper distinct counts, and the charts come from the rollups. Two paths, each correct for the question it answers.
pnpm test:smoke:analytics pins the seam that matters: that wagered in a
report equals the sum of case_openings.casePrice exactly, and that a rollup
recomputed twice does not double anything.
A roadmap is decided on GGR per feature, and that cannot be counted in clicks: cases and battles are the stake minus what dropped, an upgrade is the stake minus the targets that were won, a contract is what went in minus the reward, and the wheel only ever costs money — that is what it is for. Each row is measured in its own terms and the table says so, because a single formula applied to all five would be wrong four times.
Today the "Top up" button is backed by a stub — the server credits the entered
amount with no payment at all. It exists so the gameplay loop can be exercised
before payments are wired in, and it is gated by ENABLE_STUB_DEPOSITS; in
production it is off by default, because it is literally a "draw yourself some
money" button.
A real top-up works differently, and the difference matters:
- an invoice is created at the payment provider and the user is sent to its page;
- the balance changes only on a webhook confirming payment, never on the user returning to the site — a return is trivially forged;
- webhook handling is idempotent by payment id: PSPs routinely deliver the same webhook several times;
- the webhook signature is verified before anything is written to the database.
What is already right and will survive replacing the stub: the credit goes through
Transaction rather than a direct balance UPDATE, so the nightly reconciliation
stays correct and a deposit shows up as a deposit in reports.
Injection. Every query goes through Prisma. The three dozen raw ones are
tagged templates, which Prisma sends as a parameterised statement, so a value
can never become syntax. The unsafe variants ($queryRawUnsafe,
$executeRawUnsafe, Prisma.raw) appear nowhere in the repository, and that is
worth keeping true: they are the only way to get a string concatenated into SQL
here.
Input. Every body is parsed by a zod schema before a handler sees it, every
:id goes through ParseUUIDPipe, and pagination is capped at 100 rows a page.
Amounts are read from the database, never from the request: the client sends an
id and a count, and what something costs is looked up.
Volume. A ceiling over the whole API, counted per address in Redis so it
means the same thing on every instance: RATE_LIMIT_MAX requests per
RATE_LIMIT_WINDOW_SEC seconds, 300 a minute by default. Health checks are
exempt, and a Redis outage lets requests through rather than refusing them — a
guard rail that fails closed is an outage. Underneath it sit the tighter
per-feature limits: case opening per account (limits.openPerWindow in the back
office) and analytics per address.
Body size. One megabyte everywhere, which is already generous for an API
whose largest ordinary payload is a settings object. The single exception is
POST /api/kyc, raised to twelve, because identity documents arrive as base64
and a passport photographed on a phone is several megabytes before encoding adds
a third. Raising it globally would let any caller make the server buffer twelve
megabytes at a time.
Headers. Helmet on every response: default-src 'none' and
frame-ancestors 'none' (the API renders no pages and is never meant to be
framed), nosniff, HSTS, no-referrer. CORS is an explicit allowlist from
CORS_ORIGINS, not a reflection of whatever Origin arrived.
Addresses. TRUST_PROXY is off by default. Exposed directly, the API is the
only thing writing X-Forwarded-For, so believing the header would let any
caller choose their own address — a fresh rate-limit bucket per request, and
audit entries under addresses they invented. Turn it on once a reverse proxy is
terminating connections and setting the header itself.
Sign-in. Steam OpenID and nothing else: there is no password anywhere in the
system, so there is no password to guess. The callback is not trusted on sight —
it is posted back to steamcommunity.com with check_authentication and only a
confirmed is_valid:true counts, after which the claimed identifier has to match
https://steamcommunity.com/openid/id/<17 digits> exactly.
Sessions. The access token is a 15-minute HS256 bearer token; the refresh
token is an httpOnly, SameSite=Lax, Secure-in-production cookie. Every other
endpoint reads the bearer header, which a cross-site page cannot set, and the one
cookie-bearing route is a POST, which SameSite=Lax does not send cross-site —
so there is no CSRF surface to protect. A ban is read from the database on every
action that moves value rather than from the token, so it takes effect at once;
a role travels in the token, so a demotion takes up to fifteen minutes.
Ownership. Every route that takes an id resolves it against the caller —
findFirst({ where: { id, userId } }) — or sits behind the roles guard. Identity
documents are the strictest case: served through the API rather than off a static
path, behind ADMIN/SUPPORT, with the path on disk resolved from the row (so
there is no path in the request to traverse with), written 0600, and answered
Cache-Control: no-store.
Money in. The payment webhook verifies an HMAC over the raw body before the JSON is parsed, and crediting is a conditional update, so a replayed callback credits nothing.
Markup. No dangerouslySetInnerHTML, no innerHTML, no eval — every
player-supplied string (a nickname, a promo code) goes through React, which
escapes it.
Secrets. Both JWT secrets are required and must be at least 32 characters;
neither has a default. .env is ignored by git and only .env.example is
tracked.
The limiter counts requests inside the process, which is the right place to stop
a script and the wrong place to stop a flood: traffic large enough to saturate
the link has already arrived by the time Node sees it. A CDN or a proxy in front
is what absorbs that — and once there is one, set TRUST_PROXY=true so the
limiter counts real addresses instead of the proxy's.
Two things are deliberately not capped. WebSocket connections: the gateways have no client-to-server handlers at all, so a socket can only receive, but it still costs memory. And the catalogue response, which is a couple of megabytes of JSON; it is cached in Redis, so the cost is bandwidth rather than database work.
- multi-accounting: correlation by address, fingerprint, trade URL and payment instrument
- withdrawal limits: daily, plus a rule for the first withdrawal after a first deposit
- chargeback risk: holding withdrawals until a deposit has settled
- Steam account age and profile level, which filter out throwaway accounts
- referral self-dealing beyond what is already checked (a code binds only to an account with no history, and a sign-up from the inviter's address is flagged)
Separately: the legal side. Buying a case with real money is regulated as
gambling in most jurisdictions, and the requirements (licence, KYC, age
verification, country restrictions) depend on where the company is registered and
which countries the traffic comes from. That has to be settled before payments go
live, not after — geo-blocking and KYC are designed in up front (the verification
fields already exist on User).
- Stage 1 (done). Monorepo, docker-compose, database schema, Steam OpenID, provable fairness, case opening with animation and batch opening, upgrades, contracts, inventory, drop feed, RU/EN localisation with a currency switch, a CRM with a case builder and Steam price synchronisation.
- Stage 2 (done). The bot farm, real withdrawals through market.csgo.com and through bots of our own, and deposits of skins the same way round.
- Stage 3 (done). Case battles, promo codes, a referral system.
- Stage 4 (done, with three named limits). Payments behind a provider port,
operator-reviewed identity checks, CRM reports and exports. What it does not
include is worth stating rather than leaving to be discovered:
- no live payment provider — the port ships with a signed demo adapter, and a real one is an adapter plus a merchant account, not a redesign;
- no third-party identity verification — a provider is a contract, a data-processing agreement and a per-check fee, which a codebase does not decide on its own;
- no encryption at rest for identity documents and no retention schedule — both properties of the volume and the jurisdiction.
- Stage 5 (done). Load testing, PgBouncer, a read replica, caching and health checks — section 10.
Outside the roadmap, because they turned out to be needed before stage 4: the appearance builder (section 11d) and the analytics contour (section 11e). Added later for the same reason: catalogue shelves with per-case descriptions, free cases rationed by a top-up threshold, the drop strip above the header, public player profiles, and a catalogue importer that turns a survey file into a working catalogue.
Nothing on the roadmap. What remains is a short list of things the roadmap never covered, none of which blocks running the site:
- No admin page for the catalogue shelves. They are created by the importer and by the API, and a case can be moved between them in the case builder, but renaming or reordering a shelf has no interface. The translations for that page already exist, which is the tell.
- Item names are stored in one language.
Item.nameholds whatever the import that created the row was reading — English for the demo catalogue, Russian for everything the survey importer brought in. The case grid showsmarket_hash_nameand is unaffected; the inventory is where the mix shows. weaponTypeis null for anything imported in Russian. The field is written and nothing reads it, so this costs nothing today and would need fixing before anything does.- Thirteen cases sit below the working corridor. Not a loss — they are too
stingy rather than unprofitable — and
rebalance-casesfixes them in one run. Left alone because raising a case's payout is an operator's decision.