I created this piece of content for the purposes of entering the H0: Hack the Zero Stack hackathon (#H0Hackathon).
TL;DR
- Live esports dashboard: MQTT telemetry + parimutuel staking on play money
- DynamoDB → IoT Core for fan-out; Aurora DSQL + sharding for stakes
- Load-tested: ~45 stakes/sec sustained, 121 msg/s MQTT, saturation at 300 stakes/sec target
The problem
Imagine an esports final where a stake panel opens automatically: viewers back an outcome with platform-wide virtual credits mid-match, the crowd rides toward the clutch moment, and a winning stake pays out as the play lands. That layered engagement is what HypeMarket is for. At million-scale, there are three hard problems at once:
- Ingestion: game telemetry arrives every few hundred milliseconds.
- Broadcast: the same update must reach thousands or millions of viewers without melting your database or opening a WebSocket per client in custom server code (slow).
- Concurrent stakes: thousands of viewers may stake on the same outcome in the same second; pool totals, wallet debits, and settlement must stay transactional and auditable.
HypeMarket is my entry for Track 3 (Million-scale Global App) of the hackathon: a live-event prediction arena where viewers spend free, non-redeemable Hype Credits on match outcomes while live telemetry drives a separate momentum indicator. Odds are parimutuel (implied by the crowd), never set by the house. This is a prediction simulation, play money only, not real-money gambling.
Live demo: HypeMarket
Two pipelines instead of one
Early on I wanted to use a single Database to handle game telemetry and stake transactions, but quickly realized that they have different requirements, so I split HypeMarket into two paths:
- Telemetry: write once to DynamoDB, fan out via IoT Core (map, feed, momentum strip).
-
Market: stake to Aurora DSQL via sharded pool counters and a wallet ledger, materialize pool totals in the background, serve odds through edge-cached
GET /api/markets, then lock and resolve with inline parimutuel settlement.
Telemetry: producer.js → DynamoDB → Stream → Lambda → IoT Core → Browser
Market: Browser → placeStake (Vercel) → DSQL shards + wallet
→ Aggregator Lambda → poll_totals → GET /api/markets (CDN)
→ Admin resolve → parimutuel payout txn
The stack at a glance
| Layer | Technology | Role |
|---|---|---|
| Frontend | Next.js on Vercel + Vercel v0 | Esports second-screen dashboard (v0-scaffolded UI, live AWS wiring) |
| Telemetry store | Amazon DynamoDB | Source of truth for match events |
| Event bridge | DynamoDB Streams + Lambda | Reacts to new rows, publishes to MQTT |
| Real-time fan-out | AWS IoT Core | One-to-many broadcast to all viewers |
| Browser auth | Cognito Identity Pool | Temporary credentials with no IAM keys in JS |
| Market state | Aurora DSQL | Sharded pool counters + wallets + ledger + edge-cached /api/markets
|
Telemetry: why DynamoDB, then IoT Core
I needed somewhere to absorb kills and positions every few hundred milliseconds and capable of dealing with high traffic spikes. So I used DynamoDB with: single-table rows keyed by match, TTL to drop old events after 24 hours, capable of dealing with traffic jumps. A mock producer (telemetry_mock_data/producer.js) writes every 500ms so I can demo without a real game server.
The harder question was how viewers get updates. My first instinct was to poll an API backed by DynamoDB. That would mean every browser hammering reads on the same table, stale UI between polls, and costs that scale with viewers, not events. I wanted DynamoDB as the ledger (source of truth and replay) and a separate path for live delivery and came up with the following pipeline:
- Producer writes to DynamoDB only.
- DynamoDB Stream emits a change log on every insert.
-
Lambda (
EsportsTelemetryFanout) reads new rows and publishes JSON to MQTT topicesports/telemetry/M-1001. - IoT Core fans that single publish out to every subscribed browser.
- Next.js on Vercel subscribes over MQTT WebSocket and renders the map and feed.
I also looked at API Gateway WebSocket. For one-to-many broadcast, you often end up looping in Lambda to push the same payload to every connection (expensive and slow). The best way I found to handle one-to-many broadcast is by using IoT Core and Lambda where it publishes once to a topic, and the broker fans out to the viewers.
Subscribing from the browser without leaking keys
I could not put AWS access keys in frontend JavaScript. The fix was a Cognito Identity Pool with guest access. The browser gets short-lived credentials scoped to subscribe on telemetry topics only, then aws-iot-device-sdk-v2 opens a signed WebSocket to IoT Core. The NEXT_PUBLIC_* endpoint and pool ID are fine to expose it is instead the guest IAM role is that actually limits threats.
Reconnect and hydrate
Once the live map worked, I tested on slow intermittent Wi‑Fi and found a bug where after reconnect, player dots and the kill feed were wrong. MQTT only delivers messages published after you subscribe, so anything that happened while you were offline never arrived over the wire.
Because DynamoDB already had every event, I made a catch-up event so that on reconnect the client calls GET /api/telemetry, pulls the latest ~80 MatchTelemetry rows, and patches the map and feed before MQTT takes over again. I also added exponential backoff on disconnect and wired up the browser offline / online events so the connection badge matches what is actually happening. Hydrate needs infrastructure/iam/vercel-dynamodb-telemetry-read-policy.json on the Vercel IAM user in production.
Market: why Aurora DSQL, sharding, and a ledger
Telemetry and stakes do not behave the same way. Kills are append-only and identical for every viewer. A stake debits your wallet and increments a shared pool that thousands of people may hit in the same second. I had to keep auditability high, and the answer was not a NoSQL counter that might stay consistent.
Amazon Aurora DSQL uses optimistic concurrency control (OCC): concurrent updates to the same row can fail and retry. During a hype spike, one hot row holding "Team Alpha's entire pool" would melt. So I sharded the row into 32 counter rows per outcome (shard_id 0–31), where each stake picking a random shard. It differs from per-user wallet rows as they stays simple because only you touch yours.
Each viewer gets an anonymous UUID in localStorage (uge-viewer-id). On first user visit I create a wallet with 1,000 Hype Credits and write a grant row to the wallet ledger. I added the ledger because a balance alone is not enough to debug settlement so every stake and payout gets its own row (grant / stake / payout), and I can sum them to check that viewer_wallets.balance still adds up.
Viewer stakes 100 credits on Team Alpha
→ placeStake() Server Action (HTTPS to Vercel)
→ Vercel generates IAM auth token, connects to Aurora DSQL
→ Debit viewer_wallets, INSERT wallet_ledger (txn_type='stake')
→ INSERT vote_events (amount, settled=false)
→ UPDATE vote_shards SET staked_amount += amount (random shard)
→ Async invoke UgePollTotalsAggregator Lambda
→ Lambda SUMs shards → UPSERT poll_totals (also every 1 min on schedule)
→ UI polls GET /api/markets (edge-cached, reads poll_totals + computed odds)
Parimutuel odds
I did not want HypeMarket to act like a bookmaker setting lines. Therefore, I use Parimutuel pools since they fit the product better with the crowd moving the odds by using their Hype Credits. The math is a pure function of pool sizes:
implied_prob(outcome) = staked_total(outcome) / SUM(staked_total)
decimal_odds(outcome) = SUM(staked_total) / staked_total(outcome)
payout(stake) = floor(stake.amount / winning_pool * total_pool)
Stake 100 on Alpha when pools are 150 / 50 (total 200) and Alpha shortens to 1.33. If Alpha wins, payout = floor(100/150 * 200) = 133 credits. One migration lesson: DSQL does not allow NOT NULL/DEFAULT on ALTER TABLE ADD COLUMN, only on CREATE TABLE, so I added columns nullable and backfilled in seed scripts.
Aggregate reads instead of summing shards live
useMarket() polls every 2 seconds. If every poll summed 32 shard rows per outcome (64 across both sides in the Map 3 demo), read load would scale with viewers. I split write path from read path so the work happens in the background:
-
UgePollTotalsAggregatorLambda materializespoll_totalsfrom shard sums (every minute plus after each stake). -
GET /api/marketsserves pools and implied odds through Vercel's edge cache (s-maxage=1), so many viewers collapse into a few origin hits. -
useMarket()reads that cached endpoint instead of opening a DSQL connection per browser.
placeStake in app/actions/markets.ts runs wallet debit, stake row, random shard increment, and ledger write in one transaction with OCC retry.
Building the dashboard with Vercel v0
| Region | What viewers see |
|---|---|
| Sticky header |
TopBar with brand, live ConnectionBadge, and theme picker |
| Left column |
StreamTheater (Twitch/IVS embed) + MomentumStrip + ArenaIntel (map + feed, hover-linked) |
| Right rail |
WalletCard + PredictionMarket + PoolPulse + PositionsList + ActivityFeed
|
| Mobile |
MobileTabs for watch / predict / arena |
Early in the UI I had telemetry and odds in the same mental bucket, which is misleading since in a parimutuel market a kill streak does not move the pool. I split two signals:
-
Crowd odds move when people stake (parimutuel pools from
/api/markets). -
Momentum strip reacts to kills and objectives in a 5m telemetry window (
lib/telemetry/momentum.ts). It tracks match flow, not where the crowd put credits.
v0 saved me days on layout, motion, and the five-skin theme. I wired useTelemetryStream and useMarket() underneath, then fixed a few mismatches where my map coordinates run 0–100 (v0 assumed 0–1000), event times come from the DynamoDB sort key not TTL, and Framer Motion needed initial={false} so idle dots stopped jumping back to the corner on every batch.
Optimistic UI for stakes
Telemetry updates the moment MQTT delivers an event. A stake goes through a Server Action, DSQL, the aggregator, and a cached API. On a slow connection the odds bar could sit still for a few seconds after you clicked even when the write succeeded, which made staking feel broken next to the live map.
I added optimistic UI so the click counts immediately while all the required processes (debit wallet, aggregation, etc) run in the background. I keep floor values so totals do not drop while the CDN is stale, skip the cache for 30s after a stake, and roll back if the Server Action hangs past 20 seconds.
Settlement: paying winners
During a dry run I realized moving pools was not enough. If credits never came back after a result, judges would only see counters tick up. I added admin lock and resolve following the process of settlement reading pool sizes from the shard sums (not poll_totals, in case the aggregator lags), paying winners with floor(amount / winning_pool * total_pool), crediting wallets, and writing payout rows to the ledger in one transaction. ResolvedBanner shows what you won while useMarket() refreshes the wallet without firing the banner again if you reload a market that is already settled.
Load testing: proving the story I was telling
Track 3 is "million-scale," and I did not want to claim that on Devpost without numbers from my own deployment. I exercised three paths: edge-cached market reads, sharded stake writes, and IoT fan-out. Reads were validated in smoke and in a combined saturation run; stakes and MQTT each got dedicated runs too.
| Path | Tool | What it hits |
|---|---|---|
| Market reads | k6 poll-read.js
|
GET /api/markets (edge-cached, 2s interval) |
| Stake writes | k6 poll-vote.js
|
POST /api/load-test/vote → same placeStake as the dashboard |
| Telemetry fan-out | Node subscriber-soak.mjs
|
IoT Core MQTT over WSS with Cognito guest creds |
Smoke (production, Jun 2026): market reads 100% checks; 60 stakes in 30s at 100% success, p95 ~453ms, 0 rollbacks; 50 MQTT subscribers at ~121 msg/s with producer.js running.
Stress (local, production, Jun 2026): run-stress.sh combined mixed ~800 readers with stake writes — ~28% stake success (~12.7k accepted) when paths compete. Follow-up write-only stress put 13,392 real stakes through DSQL (~44/sec); a sustained 45/s profile held 99.9% success, p95 638ms, 4 OCC rollbacks. Peak runs failed thresholds on purpose; that is saturation data, not a broken app.
k6 Cloud geo (Jun 27, 2026): ./load-tests/scripts/run-k6-cloud-geo-all-zones.sh poll-vote from US, EU, and AP at 45 stakes/sec for 2 minutes per zone. Same placeStake path as the dashboard; artifacts in load-tests/results/cloud-geo-poll-vote-amazon-*-20260627-*.log and Grafana Cloud dashboards. Global here means where the load originated, not multi-region DSQL (the cluster stays in us-east-2).
Experiment I reverted: I tried pg.Pool with max: 1 per serverless instance because smoke tests looked faster (~256ms vs ~349ms). Under sustained load it serialized concurrent stakes and latencies blew out to ~10s, so I went back to per-request pg.Client connect/disconnect, which sustained ~44 stakes/sec at 99.9% success.
Run ./load-tests/scripts/run-all-smoke.sh for a quick check. Run ./infrastructure/dsql/reset-demo-market.sh after stress before recording a demo.
What I would tell myself earlier
Decouple write and broadcast. The producer only talks to DynamoDB, which made replay, hydrate, and testing much simpler.
Browser SDK ≠ Node SDK. IoT in the browser needed a custom Cognito credentials provider.
Measure sustained rate, not smoke latency. Pooling taught me that the hard way.
Takeaway
A stake panel that opens right before a crucial moment, platform-wide credits, and a payout that lands with the clutch play. Everything else was done working backward from problems I actually hit. The map went stale after Wi‑Fi drops, so I hydrate from DynamoDB. Stakes fought over one pool row, so I shard their entry. Every viewer polling shards would crush DSQL, so I aggregate and edge-cache. Clicks felt slow next to MQTT, so I added optimistic UI. Odds moved but credits never returned, so I built settlement. Track 3 asked for scale, so I load-tested production and wrote down where it saturates instead of pretending one number means a million users.
You do not need a custom message broker to ship this. The stack is DynamoDB plus IoT for shared live state, DSQL plus sharding for transactional stakes, and Vercel for the front-end. Smoke passed at 100%, local write stress put 13k+ real stakes through DSQL, sustained runs held at ~45/s with 99.9% success, MQTT fan-out reached 121 msg/s across 50 subscribers, and k6 Cloud geo runs hit the same ~45/s profile from US, EU, and AP.
Links
- Live app: https://hypemarket-v0-aws.vercel.app/ - (Open the live demo, stake on Map 3, watch the map update from MQTT, then check load-tests/README.md to reproduce the numbers)
- Hackathon: https://h01.devpost.com/
- Source + load-test scripts: GitHub: HypeMarket (
load-tests/README.mdfor k6 + MQTT soak)
Hashtags: #H0Hackathon #AWS #DynamoDB #AuroraDSQL #IoT #Vercel #v0 #esports #serverless





Top comments (0)