An open-source CS2 case-opening platform.
Steam sign-in, provable fairness, an RTP-balanced case builder,
item withdrawal through a farm of Steam bots and an admin CRM.
Every piece of the loop is here and working: a player signs in with Steam, opens a case, watches the reel stop on a real item, sells it back or upgrades it, and requests a withdrawal that a Steam bot actually delivers. The odds are provably fair and re-verifiable in the browser, item prices come from the Steam market, and the back office computes margin per case before it goes live.
The stack and the architectural decisions are covered in docs/ARCHITECTURE.md.
The catalogue and the live drop feed. Prices come from the Steam market; the language and currency switches sit in the header.
Every case publishes its contents: rarity, current price and the exact chance of each item. The odds are the ticket ranges the server rolls against, not a marketing figure.
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: the Steam bot farm and the withdrawal queue consumer |
packages/shared |
shared types, zod schemas, translations, ticket logic and provable fairness |
Why TypeScript, and why the trading layer has to run on Node, is covered in section 1 of the architecture document.
Requires Node ≥ 22, pnpm 11 and Docker.
cp .env.example .env # no editing needed for a local run
pnpm setup # install + docker + shared build + migrations + seed
pnpm dev # api on :4000, web on :3000pnpm setup runs, in order:
pnpm install
pnpm infra:up # Postgres :5433, Redis :6380
pnpm --filter @caseforge/shared build # api and web import the built output
pnpm db:migrate
pnpm db:seed # only the demo administrator and their seed pairPorts 5433 and 6380 are deliberately non-standard so they do not clash with a locally installed Postgres or Redis.
The catalogue of 20 themed cases is populated by a separate command. It talks to Steam and therefore takes about ten minutes:
pnpm seed:casesContents are not hard-coded by name: items are picked by searching the market, so each one is guaranteed to have an image and a real price. From there a case is assembled the same way it would be by hand in the CRM — auto-balanced to a target RTP and saved through the same validation.
db:seed deliberately creates neither items nor cases. It used to, with guessed
prices, and that turned out to be a trap: after the very first sync the real
Steam prices differed by an order of magnitude, cases went into loss, and
re-running the seed silently overwrote fixes made in the CRM.
pnpm test # unit tests: provable fairness, ticket ranges, balancing, upgrade odds
pnpm typecheck # every package
pnpm test:smoke # end-to-end run against a live APItest:smoke needs the infrastructure up and the API running. It covers what
unit tests cannot: the atomicity of the balance debit, that the active server
seed never leaks, that an opening recomputes after rotation, that the balance
agrees with the ledger, that roles keep the admin panel closed, that every item
in an active case has an image and a Steam-confirmed price, and that the server
refuses a loss-making case and a case with a gap in its ticket ranges.
Item prices drift, and a case assembled at 90% can wander off. Recovery is two clicks in the builder: Fit price to RTP, then Solve the odds, then save.
Two switches in the header: RU / EN and ₽ / $.
With no stored preference the language follows the browser, and the currency
follows the language. Both choices are remembered in localStorage.
The currency switch is display only. Everything is settled in roubles: balances, item prices and case prices are stored as integer minor units of the base currency, and no ledger entry ever holds a converted amount. The dollar figure is that same number divided by the exchange rate at render time. Treating a display rate as a settlement rate is how a site ends up selling items below cost after a currency move.
The rate comes from the Central Bank of Russia's public daily feed — no key, no quota — refreshed every six hours and cached in Redis. If the feed is unreachable, the previous rate keeps being served: a stale rate beats a broken price list.
Interface strings live in one dictionary in packages/shared/src/i18n.ts.
Server errors carry a machine-readable code alongside an English message;
the interface translates the code and falls back to the message for codes a
build does not know yet, so an error added on the backend stays readable before
its translation lands.
Case names are content, not interface text, so they live in the database: each case has a base name and an optional English one, both editable in the builder. The CRM itself is English only — translating an internal tool doubles the maintenance for an audience of a few people.
- Steam OpenID 2.0 sign-in with server-side verification; the avatar and nickname are pulled without a Web API key, through the public profile XML
- Russian and English interface with a language switch, and prices in roubles or dollars with a currency switch
- balance top-up through a stub (demo mode, disabled by a flag)
- case builder in the CRM: Steam market search, import with image, rarity and price, a case image, auto-solved odds for a target RTP, a live margin verdict
- prices and images from Steam: hourly synchronisation, RTP recalculation for every active case, a warning about prices Steam never confirmed
- CS-style opening animation: a reel of items decelerating under a marker, the winner highlighted while the rest dim
- up to 10 cases opened at once: one reel per case, stopping in sequence
- instant sale of a drop straight from the results panel, per item or all at once
- upgrade: stake your skin against a pricier one, with the chance derived from the price ratio
- provable fairness: seed pairs, rotation with reveal, and re-verification in the browser by an independent Web Crypto implementation
- case opening in a single transaction with an atomic debit and nonce reservation
- site inventory and selling items back
- a live drop feed over Redis Pub/Sub, batched every 300 ms
- withdrawal requests: item locking, a BullMQ queue, idempotency by request id
- the bot worker: bot login, an inventory mirror, trade offers, hold checks, status polling
- CRM: a GGR dashboard, per-case margin, a case builder with range validation and RTP calculation, balance adjustments through the ledger, an audit log
- nightly reconciliation of balances against the transaction ledger
Stage 1 of the roadmap (section 14 of the architecture document). Not implemented: payments, item deposits, case battles, contracts, promo codes, KYC.
The canonical .env lives at the monorepo root — api, bot and prisma all read it.
What actually needs filling in before a production run:
| Variable | Why |
|---|---|
JWT_ACCESS_SECRET, JWT_REFRESH_SECRET |
openssl rand -hex 32 each |
STEAM_API_KEY |
https://steamcommunity.com/dev/apikey. Without it the profile falls back to the public XML; sign-in works either way |
BOT_SECRETS_KEY |
openssl rand -hex 32, exactly 64 hex characters. Encryption key for bot secrets |
BOOTSTRAP_ADMIN_STEAM_ID |
your SteamID64: that account gets the ADMIN role on first sign-in |
ENABLE_STUB_DEPOSITS |
true/false. The stub top-up. On by default in development, off in production, enabled only by an explicit true |
After signing in, the header shows the Steam avatar and nickname; the profile page adds a card with the SteamID and a link to the Steam profile, a trade URL field and the history.
The trade URL is checked against the account: partner in it is the low 32 bits
of the SteamID64, and somebody else's link is rejected before a withdrawal could
follow it.
Top-up is a stub. The button credits the entered amount with no payment at
all. It exists so the gameplay loop can be exercised before a payment provider
is wired in. The credit still goes through Transaction, so the nightly balance
reconciliation stays correct. The real flow will be different: an invoice at a
PSP, a redirect, and a balance change only on a payment webhook, idempotent by
payment id. In production the stub is off by default.
/upgrade — the player stakes a skin against a pricier one: win and they get
the expensive item, lose and they forfeit their own.
The chance is not set by hand but derived from prices:
chance = stake / target × 90%. The same 90% the cases run on, so the upgrade
lives in one economy with them instead of becoming a separate game with its own
maths. The expected value works out to exactly 90% of the stake at any
multiplier — there is a test for that.
Bounds: the target must be at least 1.05x pricier (below that it is a swap with a fee, not an improvement), the chance is capped at 85%, and an excessive price gap is rejected. The range of available targets is computed with the same formula as the chance, so the list never offers an option the server will refuse.
The outcome comes from the same roll as a case opening — the same seed pair and
the shared nonce counter. The upgrade needs no fairness page of its own: it is
verified exactly the same way.
/admin/cases lists the cases with their RTP and margin; /admin/cases/new
opens the builder.
How a case is assembled:
- Search for an item on the Steam market, right inside the builder. Results are cached for an hour: Steam throttles requests.
- Add it — the item is imported into the catalogue together with its image, rarity and price in the settlement currency.
- Balance — set a target RTP and press Solve the odds: ticket ranges are laid out so the expected return matches the target, the pricier the item the rarer it is. Fit price to RTP solves the inverse problem, computing the case price for the odds already set.
- Save — the server independently recomputes the RTP from database prices and refuses the case if the return exceeds 98% or the ranges do not tile the ticket space.
The balancing model: an item's weight is inversely proportional to its price
raised to k, and k is found by binary search against the target RTP. The
achievable RTP range is bounded by the cheapest and priciest item — if the
target falls outside it, the builder says what to change.
A separate check covers unconfirmed prices. An item whose price Steam never returned (no listings, a wrong name) keeps whatever number was put into it by hand, yet counts towards the RTP like any other. Such items are flagged both in the case list and in the builder.
One detail about names: knives and gloves carry a ★ prefix on the market
(★ Karambit | Marble Fade (Factory New)). Without the star Steam does not know
the item, and neither the price nor the image will resolve.
A bot is a separate Steam account with the mobile authenticator enabled.
shared_secret and identity_secret come from the maFile; without them offers
cannot be auto-confirmed.
cd apps/bot
BOT_STEAM_ID=7656119... BOT_USERNAME=... BOT_PASSWORD=... \
BOT_SHARED_SECRET=... BOT_IDENTITY_SECRET=... \
pnpm add-botSecrets are never stored in the clear: the script encrypts them with AES-256-GCM
under BOT_SECRETS_KEY, and they are decrypted only inside the bot worker.
The constraints the withdrawal logic is built around (details in section 7.4 of the architecture document): a Steam inventory holds 1000 slots, a trade hold of up to 15 days applies to a recipient without a mobile authenticator, and Valve rate-limits offer creation.
apps/
api/
prisma/schema.prisma data model, money as integer minor units
prisma/seed.ts the administrator only; the catalogue comes from Steam
src/auth/ Steam OpenID + JWT
src/cases/ case opening — the core of the project
src/upgrade/ upgrade: odds, roll, stake consumption
src/drops/ batched WebSocket feed
src/withdrawals/ withdrawal requests and queueing
src/admin/ CRM: reports, case builder, audit
src/steam/ OpenID, the Steam market, price and image sync
src/common/ config, Prisma, Redis, FX rates, roles, nightly reconciliation
test/smoke.mjs end-to-end run against a live API
bot/
src/crypto.ts bot secret encryption
src/steam-bot.ts wrapper over steam-user / steamcommunity / tradeoffer-manager
src/bot-pool.ts the farm: who can hand out which items
src/withdrawal-processor.ts idempotent request handling
scripts/add-bot.ts bot registration
web/
src/app/ home, case, upgrade, profile, CRM, Steam callback
src/app/admin/cases/ case list and builder
src/components/ drop feed, opening reel, cards, switches
src/lib/ API client, auth store, settings store, socket
packages/
shared/
src/i18n.ts interface dictionary, RU and EN
src/errors.ts error codes shared by the API and the interface
src/money.ts minor units, display currencies, formatting
src/tickets.ts ticket space, range validation, RTP
src/balancing.ts auto-solved odds for a target RTP, margin verdict
src/upgrade.ts upgrade odds and bounds
src/steam-market.ts market response parsing: prices, rarity, images
src/provably-fair.ts server-side cryptography (node:crypto)
src/verify.ts in-browser re-verification (Web Crypto)
@caseforge/shared is split on purpose: the main entry point is isomorphic and
the server-side cryptography sits behind the @caseforge/shared/node subpath.
Otherwise node:crypto ends up in the browser bundle and the front-end build
fails.
Buying cases with real money is regulated as gambling in most jurisdictions: a licence, KYC, age verification and country restrictions are all required. That has to be settled before payments go live — section 13 of the architecture document.
Issues and pull requests are welcome — see CONTRIBUTING.md for how to get the project running and what the review looks for.
MIT. Do what you like with it; the legal note above still applies to running it for real money.

