Rektstudios, part-time, then full-time
Rekt Game Core
- Ongoing
- Web client
- Game client
- Backend team of 3
The main backend of a competitive blockchain game: accounts and sign-up, an in-game wallet with Stripe card top-up from $5 to $500, a store, a marketplace with WEB2 and WEB3 rails, game runs, seasons, XP and level progression, battle pass, referral reward and a realtime leaderboard. One NestJS process. HTTP to clients. Core consumes game and indexer events from RabbitMQ. Core publishes only profile sync to Chat on RabbitMQ. Mint and transfer go out through the Engine SDK. BullMQ on Redis runs workers. Anything that moves money goes through a wallet ledger guarded by Postgres advisory locks, compare-and-swap status changes and idempotency keys, so a retried request cannot move value twice.
- Where
- Rektstudios (opens in a new tab), part-time employment, full-time from Nov 2024
- Role
- One of the founding backend engineers in a backend team of 3. We built Rekt Game Core from scratch together. I review code within the team and coordinate with the game and frontend teams.
- Serves
- Web client and game client
- Stack
- NestJS 11, TypeScript 6, PostgreSQL 16, Prisma 6, Redis, RabbitMQ, BullMQ, AWS
- registered players
- 10k+
- SpludgeWars matches played
- 17k+
- players joined through a referral
- 1.7k+
- p95 API response in production
- <120 ms
- durable job queues
- 28
- CryptoRun games at a time
- 5
- request rate limit
- 100/s
- test coverage threshold
- 100%
We built Rekt Game Core from zero. The players, matches, referrals and response time are production numbers from October 2026.
01Context and my role
I am one of the founding engineers in a backend team of three. We built Rekt Game Core together from the first module: ports and adapters with one use case per class, the money rules and the job model. I work with a blockchain developer who owns the smart contracts and Envio. Rekt Game Core is the off-chain side of every on-chain flow. I wired Claude Code into this repo (AGENTS.md, rekt-* skills and agents) so an agent adds a queue or a use case the same way I do.
02Focus
Rekt Game Core is the middle of the Rekt system: one NestJS process that the rest of Rekt talks to. Web and game clients, Rekt Game Chat, game services, Envio and the on-chain providers all meet here. Wallet, store, marketplace, games, seasons, battle pass, referral reward, Stripe top-up and items live in this monolith, so a new feature lands in this process.
- A retried request cannot move value twice.
- HTTP returns. Settlement, games, mail, mint, transfers and metadata sync run on BullMQ.
- WEB2 and WEB3 share a marketplace and one history.
- Sign-in with Ethereum (SIWE). Contract events arrive from Envio over RabbitMQ, async.
- Item metadata lives on S3. The chain and the public read S3, not our database. Core only syncs.
- Stripe card top-up: Core re-fetches the PaymentIntent, then credits the wallet.
03Features
Wallet ledger
Off-chain credit and custodial ERC-20 per user and asset. Every balance change is an entry. A refund is a new compensating entry. When an on-chain store payment fails or is refunded, Core credits the custodial ledger back. The wallet is asset-agnostic. An amount is an asset value object in minor units. The figure is static. It does not track a market price.
Durable jobs
28 BullMQ queues with retry and backoff. Money, game runs, seasons, mail, mint, transfers and metadata sync run as jobs so they survive a crash. CryptoRun runs at most 5 games at a time. Cache sits in a separate Redis database from the queues.
Marketplace
WEB2 and WEB3 share one marketplace and one history. The marketplace ran on WEB2 with card top-ups first. A WEB2 fill settles on the wallet ledger as buyer debit, NFT ownership change and seller credit. A WEB3 fill arrives from Envio on RabbitMQ, and a job applies it. A one-way admin switch to WEB3 cancels open WEB2 orders.
Game runs
CryptoRun waits on its own capped queue. A worker calls simulation and streaming over REST, AWS IVS carries the live video, EventBridge comes back, and a job finishes the game. SpludgeWars is the other game type. SpludgeWars match XP is written on the primary after a match finishes. CryptoRun uses the run queue, not that XP history.
Web3
Sign-In with Ethereum (SIWE). Mint and transfer go out as jobs through thirdweb Engine, which calls back with a webhook. Envio watches the chain and publishes fills, purchases and mint confirmations over RabbitMQ. Custodial deposits arrive from Alchemy Notify. Robot NFT transfers arrive from Moralis Streams. Several grants to one user go out as one chain transfer of the total, to save gas. Partner quests on Snag Solutions and Immutable can grant tokens on chain.
Item metadata on S3
Token metadata is an object on S3, the source of truth. The chain and the public cannot read our database, and we do not serve the database to them. An update is written to S3 first. Core only syncs the item row from that object.
Battle pass
Purchase posts on the wallet ledger. A job then inserts membership. A failed insert refunds. Claim is a job. Delivery uses the same handlers as referral reward: an idempotent ledger credit or an on-chain transfer. A retry cannot grant twice. Two grants of 5 stay two rows of 5. The chain transfer is 10 once. Both rows store the same queue id.
Referral user
Sign-up with a code attaches the referee. Core generates the new user's own code. XP share for 90 days, chain depth 2, XP boost at most 12%. 1.7k+ players joined through a referral.
Referral reward
A qualification job then a grant. Tokens, inventory-first NFTs, tickets and XP boosts go through the same handlers as battle pass.
Auth
Sign-up can sit behind a feature flag. It takes email and username, an optional referral code if referral is on, creates the user, generates their own referral code, joins the current season and grants a free character. Email verification OTP can run. Login checks password, blocked, inactive and an inactive role, then 2FA if enabled. Unverified users do not get a session. A JWT is issued on success. Active is a flag checked on the primary and the replica. Inactive cannot log in. It is not a monthly count. JWT, 2FA, email and phone verification, SIWE, and OAuth (Google, Snapchat).
Stripe top-up
The client asks Core for a credit-topup intent. Stripe sends a webhook into Core. Core re-fetches the PaymentIntent from Stripe and only then enqueues a wallet credit grant. It never grants from the webhook body alone. Card top-ups from $5 to $500.
Level progression
A finished SpludgeWars match writes XP on the primary and enqueues boosts. The curve is uncapped: level 1 needs 1,500 XP, then 5% more up to 80, 2.5% up to 120, 1% more from 121.
SSE notifications
We built the notification stream ourselves with Server-Sent Events. It is one-way. A transaction event reaches the frontend so it can redirect or run a trigger. Email and SMS stay on jobs.
Realtime leaderboard
After XP writes on the primary, public lists for account and character are read from the replica, daily through all-time, top 100. This is not the SSE path. Seasons last 3 months.
04How it works
A state-changing call from the client hits HTTP, then one use case. The use case takes an advisory lock, checks the idempotency key, writes a PENDING ledger entry when money moves, and enqueues a BullMQ job. The HTTP response returns. A worker compare-and-swaps the entry to PROCESSING then POSTED, or FAILED on the last retry, and a compensating job undoes it. In-process events fan out work that may be lost, and they keep modules from calling each other. RabbitMQ carries facts between services. Redis holds BullMQ jobs, never-flush state, and cache on three databases.
- Read replica: The realtime leaderboard and listings go to a Postgres read replica. Replica lag is accepted. Actions and workers use the primary.
- Redis three databases: one for BullMQ jobs, one for state that must never be flushed (nonces, locks, idempotency markers, pub/sub), and one for cache that is safe to flush, for example at a season roll.
- Money as big-number strings: no floating point. Amounts are a value object over a big-number type with 78 decimal places and cross every boundary as strings in minor units.
- Batched on-chain rewards: Grants for one user and one asset are summed into a single ERC-20 transfer. Each grant row keeps its own amount. The Engine queue id is written on every row in that batch.
- SSE notifications: The client holds an SSE stream. Core pushes a one-way event when a transaction settles. The frontend uses that as a trigger. It is not a bidirectional socket.
- Asset amounts: Every money figure is an asset value object. The same process works for any asset. The numeric value is configured, not a live market rate.
System context
Rekt Game Core and the systems around it. Game services and Envio reach Core through RabbitMQ. Mint and transfer go out through the Engine SDK. Solid boxes are ours. Dashed boxes belong to other teams or outside providers.
A money write, end to end
The request records intent and returns. A worker settles it, and a failure is undone by a new entry, never by an edit.
A CryptoRun game run
Games wait in a queue capped at 5. The finish comes back as a stream event, and a BullMQ job settles the result.
The on-chain side
Indexer events arrive on RabbitMQ. Mint and transfer go out as jobs through the Engine SDK. Webhooks complete them. Several grants go out as one transfer.
Item metadata on S3
Write the JSON object to S3. Point the token URI at that object. Enqueue a metadata sync job. The worker reads S3 and updates the item row. The chain and marketplaces read S3. They never query Core's database.
A season roll
Uncapped XP, realtime leaderboard. Seasons last 3 months. Queue jobs run cooldown, prepare and release. After cooldown the season is marked inactive, game queues pause, and leaderboards stop. Reward jobs allocate by tier. The switch to the next season is one transaction. Game queues then resume.
One marketplace, two rails
WEB2 and WEB3 share one marketplace. A WEB2 fill settles on the wallet ledger. A WEB3 fill arrives from Envio on RabbitMQ, and a job applies it.
Sign-up and login
Sign-up creates the user, optional referral, free character and verification. Login checks active and 2FA, then issues a JWT.
Stripe card top-up
Core creates the intent, re-fetches Stripe after the webhook, then enqueues the wallet credit.
Battle pass purchase and claim
Purchase posts on the ledger, membership insert, claim job, then grant.
Referral attach and reward
Sign-up with a code attaches the referee, then qualification and grant.
Match XP and leaderboard lists
A finished SpludgeWars match writes XP on the primary. Leaderboard lists read the replica. Not SSE.
05Decisions and trade-offs
Business
Compensating entries instead of edits
- Why
- The ledger never loses history, so every balance can be rebuilt and every refund traced to what it undid.
- Cost
- The current balance is the fold of entries, not one column. Queries and ops are longer.
Sagas for money, request first
- Why
- Money is always async and needs its checks, so the request only records intent. A saga with compensating jobs is how those steps finish.
- Cost
- Every step needs an explicit compensation. A failure is several jobs, not one database rollback.
Marketplace, WEB2 then WEB3
- Why
- The marketplace ran on WEB2 with card top-ups first, then the marketplace switches to WEB3. History stays.
- Cost
- The switch is one way. Open WEB2 orders cancel. Only one rail is active at a time.
One chain transfer for several grants
- Why
- Gas is paid once. Two rewards of 5 become one transfer of 10.
- Cost
- One Engine job and one queue id cover several rows. A failed transfer fails the batch together. The ledger still shows 5 and 5.
Asset-agnostic amounts, static figures
- Why
- One value object and one ledger process work for every asset. No floating point. No live oracle on the hot path.
- Cost
- The number does not follow the market. An exchange or a token price change is not applied automatically.
Infrastructure
Jobs for work that must happen, events to decouple modules
- Why
- A BullMQ job is the durable async path for work that must finish after the request: money, games, seasons, mail, mint. An in-process event is async too, and it stops modules calling one another.
- Cost
- An in-process event is gone if the process dies. Work that still sits on an event instead of a job can be lost.
One process, three transports
- Why
- HTTP is the client call. RabbitMQ is service to service. Redis is the worker queues. One process listens on all three, so the domain stays in one place.
- Cost
- They share one event loop. Workers are not a separate deploy.
Read replica for listings
- Why
- The realtime leaderboard and listings need fast reads. Replica lag is accepted.
- Cost
- A read right after a write can be stale.
Redis split into three databases
- Why
- Cache sometimes has to be flushed in one shot, for example at a season roll. Checking keys one by one is painful. Three databases split jobs, never-flush state, and cache.
- Cost
- Three connections. Flush the wrong database and jobs or nonces go.
Item metadata on S3, Core only syncs
- Why
- The chain and the public cannot read our database, and we will not serve the database to them. S3 is what a token URI can fetch. Core writes the object, then syncs the row.
- Cost
- A metadata change is not on chain until S3 holds the new object and Core has synced. Editing the database row alone does not change what the public sees.
SSE instead of a socket
- Why
- The frontend only needs to hear that a transaction finished, then redirect or trigger. One-way is enough.
Other
Advisory lock per aggregate instead of row locks everywhere
- Why
- One lock primitive is easy to audit. Combined with compare-and-swap it also covers two workers picking up the same job.
- Cost
- A lock on the wrong key serialises too much or leaves a race. The timeout is 10 s, and the caller waits.
One use case per class
- Why
- Fat services gather dependencies, then circular deps, then a god service that is hard to read. One execute method per file keeps the graph honest. That is also how we do ports and adapters here.
- Cost
- Many files. A small feature still has four layers.
06Testing
- Tests: Jest 30 with the NestJS testing module and mocks. 1,530 spec files, about 13,800 test cases, and a 100% global coverage threshold (branches, functions, lines, statements) over entities, value objects, use cases, repositories, mappers, processors, handlers and controllers.
- Local quality gates: Husky, commitlint, lint-staged, ESLint, Prettier, cspell, ts-prune (dead exports).
07Stack
- Runtime and language
Node.js 24
TypeScript 6
SWC
Yarn
- Framework
NestJS 11 (HTTP, microservices, event emitter, schedule)
Swagger and OpenAPI
- Data
PostgreSQL 16 (primary and read replica)
Prisma 6 with read replicas
Redis (ioredis, Keyv, cache-manager)
- Messaging and jobs
RabbitMQ (amqplib, amqp-connection-manager)
BullMQ
- Auth and access
JWT
Passport
TOTP (otplib)
SIWE
Google
Snapchat
OAuth
CASL with Prisma
- Web3
ethers.js 6
Alchemy SDK
Moralis
thirdweb Engine
Envio
- Money and payments
- bignumber.js value objects
Stripe
- Validation
- Joi
- Cloud services
AWS S3
SES
Pinpoint (SMS)
IVS (video streaming)
EventBridge
- Observability
Sentry
Pino
- Testing
Jest 30
- @swc/jest
- Supertest