Selected work / 02
IdleForge
An idle-game economy backend with offline earnings, Redis leaderboards, and BullMQ jobs.
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
/player/me/leaderboardmixed load test
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
- Push the load test beyond 10 concurrent readers and measure where Postgres row contention starts to dominate.
- Exercise Redis failover in a repeatable test environment instead of only reasoning about the outage path.
- Add fault injection around worker retries and scheduled jobs to verify the duplicate-delivery guarantees under real failure timing.
- Tighten the retry and backoff policy so transient upstream failures do not turn into long-lived queue pressure.