Inspiration
I kept coming back to the Dark Souls messaging system — the way you leave a cryptic note on the ground for a stranger you'll never actually meet, and stumble on theirs hours later. It's multiplayer that feels social without anyone being online at the same time.
That turned out to be more than a vibe. The reason most multiplayer games are hard to scale is real-time state synchronization — keeping everyone's view of the world consistent, live. If players never share a session, that whole problem disappears. For a "million-scale global app" track, async-by-design isn't a limitation — it's the unlock.
What it does
Cairn is a top-down dungeon crawler where you never see another player — you find the traces they left. Players compose short, templated marks ("Beware of the void ahead," "Hidden gold nearby") and pin them to specific tiles. Other players discover those marks later, read them, and appraise the helpful ones. Helpful marks earn their author renown (Wanderer → Pathfinder → Sage); ignored ones decay via TTL.
There's also /pulse — a live dashboard of world-wide stats (marks inscribed, appraisals, per zone) that updates as the event pipeline processes activity. It's the system made visible.
How I built it
- Frontend: Next.js (App Router, TypeScript) on Vercel, with an HTML5 Canvas renderer for the tile grid, scrolling camera, and pixel-art animation.
- Database: a single Amazon DynamoDB table (single-table design) holding marks, appraisals, player profiles, and sharded world-stat counters.
- Event-driven pipeline: an action goes API → SQS → worker Lambda → DynamoDB → SNS → SQS → aggregator Lambda. The worker persists the write; the aggregator updates renown and stats off the fan-out.
- Scale tricks: marks are chunk-partitioned so writes spread across partitions; reads are chunk-keyed and CDN-cached at Vercel's edge (a thousand players in one spot = one DB read per region); stat counters are sharded so no single item goes hot.
Challenges I ran into
The load test humbled me — in the best way.
I started small (100 events) instead of jumping to 10k, and immediately found a problem: 19% of events silently vanished. My account's Lambda concurrency cap is 10, and SNS was invoking the aggregator asynchronously — so the overflow got throttled, retried twice, and then dropped. Fix: put SQS between SNS and the aggregator so every consumer has backpressure.
That got me to 98/100 — not 100. The next culprit: I was treating every DynamoDB TransactionCanceledException as "already processed," but that exception also fires on a transient transaction conflict (two events hitting the same shard). I was swallowing real events. Fix: only no-op when the idempotency guard's condition actually failed.
Then the subtle one: DynamoDB and SNS aren't a single transaction. If a write succeeded but the publish failed, the retry saw "already written" and never published — the mark existed but never reached the dashboard or renown. Fix: always re-publish on retry, and dedupe on a deterministic event ID so re-publishing can't double-count.
Accomplishments that I'm proud of
- Exactly-once aggregation at 10,000 events, with zero loss — verified end to end, all running under a concurrency ceiling of just 10. The queue absorbs the burst; the counters land on exactly 10,000.
- Every consumer is queue-backed with a DLQ, so load becomes latency, never dropped data.
- An idempotent pipeline that survives at-least-once delivery and a non-atomic dual-write.
- A genuinely polished game that makes the architecture legible — you can watch a single in-game action ripple through the whole pipeline to the live dashboard.
What I learned
- At-least-once delivery plus non-atomic writes is a minefield of correctness traps, and most of them are invisible until you push real load through.
- Backpressure beats hope. Queue-backing every consumer turns a concurrency limit into latency instead of data loss.
- Don't swallow exceptions broadly. "Already done" and "transient conflict" look the same until you read the cancellation reasons.
- Test small first. 100 events surfaced every bug that 10k would have buried in noise.
What's next for Cairn
- More zones, hazards, and a proper daily featured zone.
- Real-time leaderboards off the existing renown aggregates.
- Request a Lambda concurrency increase to push the load test to 100k+.
- Smarter mark decay (score-weighted TTL extension) so great marks persist and noise fades faster.
- Mobile/touch controls to widen the "million players" funnel.
Built With
- amazon-web-services
- claude
- dynamodb
- elevenlabs
- lambda
- next.js
- sns
- sqs
Log in or sign up for Devpost to join the conversation.