Skip to content

andre-christi-kan, portfolio

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.

Rekt Game Core and the systems around itWeb and game clients call Rekt Game Core over HTTP. Core writes to PostgreSQL and reads from it. BullMQ holds jobs and never-flush state, separate from Redis cache. Game services and Envio send events to RabbitMQ. Core consumes those events. Core does not publish on-chain or game events to RabbitMQ. The only RMQ publish is profile sync to Rekt Game Chat. Mint and transfer go out through the thirdweb Engine SDK.HTTPwrites / readsjobs and statecachegame eventsindexer eventsto Coreprofile syncto ChatEngine SDKWeb and game clientsRekt Game Corethe Rekt middlePostgreSQLprimary + read replicaBullMQjobs and stateRedis cacheRabbitMQRekt Game ChatGame servicesEnvio(indexer)thirdweb Engine
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 money write, end to endIn the request, a request, message or event calls a use case. The use case takes an advisory lock and checks the idempotency key in PostgreSQL, writes a PENDING ledger entry and adds a durable BullMQ job. The HTTP response returns. In the worker, the job runs and the worker moves the entry to PROCESSING with compare-and-swap. If the row is updated, the entry becomes POSTED with the balance change and an event is emitted for other modules to react to. If the last retry fails, the entry becomes FAILED and a compensating refund job is added.Other modulesWorkerBullMQPostgreSQLUse caseRequest,message oreventIn the requestIn the workeralt[row updated][error on the last retry]calladvisory lock,idempotency checkledger entryPENDINGdurable jobresponse returnsrun the jobcompare-and-swap to PROCESSINGPOSTED with balance changeevent emitted, othermodules reactledger entry FAILEDcompensating refundjob
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.

A game runRekt Game Core schedules the run on the game-run queue, a BullMQ queue capped at 5. Its worker first calls the game simulation service over REST, then asks the streaming orchestration to prepare and start the stream over REST. The orchestration and the streaming service talk over REST and Redis events, and the streaming service sends the live video to AWS IVS. IVS stream start and end go through AWS EventBridge, which calls Rekt Game Core with a webhook, and Rekt Game Core adds STARTED and FINISHED jobs to the game queue.AWSEventBridgeAWS IVSStreamingserviceStreamingorchestrationGamesimulationBullMQworkerRekt GameCoreschedule the run(game-run queue,cap 5)1. simulate (REST)2. prepare, start(REST)start, events(REST, Redis)live videostream start, endwebhookSTARTED,FINISHED jobs(game queue)
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.

The on-chain sideEnvio reads contract events and sends fills, purchases and mint confirmations through RabbitMQ to Rekt Game Core. Mint and transfer go out as BullMQ jobs. The worker calls thirdweb Engine through the Engine SDK, which calls back with a webhook when a transaction settles. Alchemy sends deposit webhooks and is used for RPC reads. Moralis sends transfer webhooks and is used for NFT reads.fills, purchases,mint confirmationsmint, transfer jobsEngine SDKtx webhookRPC reads /deposit webhookNFT reads /transfer webhookEnvio(contract events)RabbitMQRekt Game CoreBullMQthirdweb EngineAlchemyMoralis
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.

Item metadata on S3Core writes the metadata object to S3. A BullMQ job reads that object and syncs the item row. The chain and the public fetch S3. They do not query the database.PublicWorkerBullMQS3CoreCore writes metadata to S3, enqueuessyncWorker reads S3, syncs item rowwrite metadata objectpoint token URIenqueue sync jobrun sync jobread metadata objectsync item rowfetch metadata
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.

A season rollRekt Game Core enqueues season jobs on BullMQ. A worker runs cooldown, then after a 1-hour cooldown marks the season inactive, pauses game queues, and leaderboards stop. Reward jobs allocate by tier. One transaction switches to the next season. Game queues then resume.PostgreSQLWorkerBullMQRekt GameCoreEnqueue cooldownWorker runs the roll1-hour cooldownleaderboards stopone transactionenqueue cooldown jobnotify cooldownrun cooldownmark season inactivepause game queuesallocate rewards by tierswitch next seasonresume game queues
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.

One marketplace, two railsWEB2 and WEB3 share one marketplace and one history. A WEB2 fill settles on the wallet ledger as a buyer debit, an NFT ownership change and a seller credit. A WEB3 fill arrives from Envio on RabbitMQ. A BullMQ job applies the fill. A one-way admin switch to WEB3 cancels open WEB2 orders.WEB2 fillbuyer debitchange ownershipseller creditfill eventenqueue fill jobapply the fillClientMarketplaceWallet ledgerNFT ownershipEnvioRabbitMQBullMQ
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.

Sign-up and loginSign-up creates the user, optional referral, own referral code, current season and a free character. Email verification can run. Login checks that the account is active, then 2FA if enabled, and issues a JWT. Unverified users do not get a session. SIWE, Google and Snapchat are other login paths, not drawn here. Profile sync to Chat is the path on the context diagram.PostgreSQLRekt GameCoreClientSign-upLoginemail, optional referralcreate the userown referral codegrant free characterverify emailpasswordcheck activecheck 2FAissue JWT
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.

Stripe card top-upThe client asks Rekt Game Core for a credit-topup intent. Core creates the PaymentIntent at Stripe and returns it. Stripe then 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.BullMQStripeRekt GameCoreClientIntentWebhook, thengrantask credit-topup intentcreate PaymentIntentreturn the intentStripe webhookre-fetch PaymentIntentenqueue wallet credit
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.

Battle pass purchase and claimA battle pass purchase posts on the wallet ledger. A job then inserts membership. A failed insert refunds. Claim is a job. Delivery is a ledger credit or an on-chain transfer, with the same batched grant rules as other rewards.WorkerBullMQPostgreSQLRekt GameCoreClientPurchaseMembership, thenclaimrefund on failurealt[ledger grant][chain grant]purchase battle passledger entry postedinsert membership jobinsert membershipclaim rewardclaim jobrun claimledger crediton-chain transfer
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.

Referral attach and rewardSign-up with a code attaches the referee. Core generates the new user's own referral code. A qualification job then grants the reward. Delivery uses the same handlers as battle pass: tokens, inventory-first NFTs, tickets and XP boosts.WorkerBullMQPostgreSQLRekt GameCoreClientSign-up with a codeQualify, then grantsign-up with codeattach the refereegenerate own codequalification jobgrant rewardsame handlers as battlepass
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.

Match XP and leaderboard listsA finished SpludgeWars match writes XP history on the PostgreSQL primary and applies the uncapped level curve. Boosts enqueue with that match. Public leaderboard lists for account and character, daily through all-time, read the replica, top 100. This is not the SSE notification path.ClientPostgreSQLreplicaPostgreSQLprimaryRekt GameCoreGame servicesMatch finishedLeaderboard listsmatch finishedwrite XP historyapply XP curverequest leaderboardlist accountslist characterstop 100
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