Ankit Sinha All projects

Selected work / 02

IdleForge

An idle-game economy backend with offline earnings, Redis leaderboards, and BullMQ jobs.

TypeScript Node.js Express PostgreSQL Drizzle Redis BullMQ Docker

The problem

Idle games are full of small economy mutations: collect what was earned while offline, buy an upgrade, claim a reward, update a leaderboard. Those operations are easy to write once and surprisingly hard to keep correct when clients retry, jobs run twice, or two requests touch the same player at the same time.

IdleForge is built around the idea that the economy should stay consistent even when the request layer is not. The database is the source of truth; Redis and the queue are there for speed and scheduling, not to quietly become the ledger.

How it fits together

Client
  │  authenticated requests
  ▼
Express API ───────────────── PostgreSQL
  │                            ├─ players, balances, upgrades
  │                            ├─ guarded SQL + row locks
  │                            └─ persisted idempotency records
  │
  ├── Redis
  │     ├─ sorted-set leaderboard
  │     └─ BullMQ queues
  │
  └── BullMQ workers
        ├─ scheduled economy events
        └─ duplicate-delivery protection

The API and workers run as separate processes. Both write through the same data model, so a worker retry and an HTTP retry hit the same guarantees.

Design decisions

Offline collection and upgrades are transactions

Collection and spending are guarded by SQL and row locks. A request either credits the correct amount and commits, or it fails without leaving a half-applied balance change. Two concurrent upgrades cannot spend the same currency twice.

Retries are expected, so idempotency is persisted

Client retries use persisted idempotency records instead of an in-memory cache. Scheduled events get duplicate-delivery protection at the BullMQ layer, so a job that runs more than once does not become a second payout.

Postgres owns the truth; Redis owns the fast view

The leaderboard is a Redis sorted set reconciled from Postgres. If Redis is unavailable, the API has defined behavior rather than silently serving a stale ranking. Rebuilding the sorted set is treated as a recovery path, not the primary write path.

Separate API and worker processes

HTTP traffic and background work have different failure modes, so they have separate lifecycles. Both emit correlation logs and expose health checks, and both handle graceful shutdown so in-flight work is not cut off mid-transaction.

At a glance

114 msp95, authenticated
/player/me
33 msp95,
/leaderboard
0errors in the
mixed load test
246mixed req/s at
10 concurrent readers

The interesting part is not the happy path. It is what happens when the same economy mutation arrives twice.

What I'd do next