Skip to content

Cloud

Build a Live Audience Voting Game for Your Conference Talk in a Weekend (Serverless, Zero Paid Tools)

For a conference session I co-delivered, I wanted a hundred-plus people swiping live votes on their phones — without Mentimeter, without Kahoot, and without a single credential in the JavaScript bundle. The whole backend is three DynamoDB tables behind an IAM-authenticated AppSync API: a singleton GameState row drives lobby, launch, reveal and podium; Cognito guest identities sign every call with SigV4; votes are namespaced by runId so a reset hides everything without deleting anything. Total AWS bill for the event: under a dollar.

 ·  9 MIN READ


Alexandre Agius

Alexandre Agius

AWS SOLUTIONS ARCHITECT

SHARE

Earlier this month I co-delivered a conference session on AI security, and we wanted the room to play. Ten statements about securing AI agents appear as cards on your phone; you swipe “do” or “don’t”; the projector reveals what the room thinks; a podium crowns the sharpest players. A hundred-plus people, phones out, two minutes of chaos.

The obvious move is Mentimeter or Kahoot. They are good tools, but I wanted mechanics they do not offer — a presenter-choreographed reveal, a “fastest correct answer” ranking — and no third-party SaaS between me and my audience. I also had a hard personal rule: no public unauthenticated endpoint, ever, even for a toy that lives ten days.

So I built it. The entire backend is three DynamoDB tables, one of which contains exactly one row, behind an IAM-authenticated GraphQL API. The frontend is one HTML file and a vanilla JavaScript bundle. The build took a weekend’s worth of hours spread over three evenings, and the AWS bill for the whole affair — every rehearsal, every QA run, the live session — came to less than a dollar. The code is public at github.com/agiusalexandre/dodont.

This is the build log: the four problems that actually mattered, and the numbers behind each.

What the room actually needs

Strip the idea down and the requirements are stricter than “a poll”:

  • Zero friction: scan a QR code, get a browser page. No app, no login, no typing an email.
  • A lobby: people trickle in; the presenter needs a live count of connected devices before launching.
  • Synchronized play: nobody votes until the presenter hits Launch; then everyone gets the same 120-second rush through ten cards.
  • Suspense: no verdicts on the phones during the rush. The reveal happens on the projected screen when the presenter decides.
  • A podium that means something: random swiping must lose to actual thinking.
  • A real reset: I would be QA-testing until the morning of the talk, and test votes must not pollute the live game.

Everything else — animations, confetti, pseudonyms — is decoration on those six.

Three tables, one of them a single row

I built on Amplify Gen 2: schema-as-code that stands up an AppSync GraphQL API in front of DynamoDB tables in an afternoon.

GameState is a singleton: one row, id = 'live' — the entire synchronization mechanism. It carries a phase enum (lobby → question → ended), a revealed flag, the current question index, an endsAt timestamp, and a runId. Every phone renders as a pure function of that one row. The presenter console is just a hash route on the same page that mutates it: Launch stamps endsAt = now + 120s and flips the phase; Reveal flips the flag; Reset bumps the runId.

Vote is one row per swipe: question id, choice, device id, a generated alias, and elapsedMs — the client-measured milliseconds from card display to swipe. Reads always go through a secondary index on questionId, never a filtered scan: list with a filter applies the limit before the filter, silently drops matches, and full-scans the table. Any lookup by a non-key attribute gets an index, even in a toy.

Presence is one row per connected device, heartbeated by the clients. More on that below.

No Lambda anywhere: presenter console, participant app, and projector view are one static bundle reading and writing those three tables.

Anonymous users, and still no anonymous endpoint

This is the part I care most about, because the anti-pattern is everywhere: the app needs anonymous users, so someone ships an API key in the JavaScript bundle.

An AppSync API key is a static shared secret. Baked into a public bundle, it is thirty seconds of DevTools away from anyone in the room — and then it is their API too. They can script unlimited mutations from anywhere, against tables you pay for, until the key expires (seven days by default, extendable to a year). It authenticates the application, not a user: no per-device identity, nothing to rate-limit against, no revocation short of rotating the key and redeploying. For a voting game, that is a stuffed ballot box; for anything more serious, it is an incident.

The alternative costs nothing and is barely more work: a Cognito Identity Pool with guest access, and SigV4 on every call. In Amplify Gen 2 it is configuration, not code:

export const data = defineData({
  schema,
  authorizationModes: {
    defaultAuthorizationMode: 'identityPool',
  },
});

with allow.guest() on each model. On first load, the browser silently obtains a unique Cognito identity and short-lived STS credentials (an hour, auto-refreshed), and the Amplify client signs every GraphQL request with SigV4. The bundle contains only public configuration — region, pool id, endpoint. The AppSync endpoint is reachable from the internet, but an unsigned request bounces off it; there is no secret to steal because there is no secret. Each guest is bound by the IAM policy of the unauthenticated role, which grants exactly the operations the schema hands to guests — and disabling guest access on the pool kills every credential at the source. Ballot stuffing now requires minting fresh identities rather than replaying one lifted key: not impossible, but attributable, rate-limitable, and revocable.

One honest footnote: Cognito insists on at least one login method, so the app technically supports email login that no human has ever used. And my presenter console is a hash route with no additional auth — any participant reading the bundle could have found it and hit Reset mid-game. For a quiz I accepted that; anything that matters gets a Cognito user group in front of the mutations.

Synchronization without subscriptions

AppSync has real-time GraphQL subscriptions over WebSockets, and my first sketch used them. What I shipped is a 2.5-second poll of the singleton row.

The math convinced me. A point-read of one row by id every 2.5 seconds, from 130 devices, is 52 requests per second — trivial for AppSync and DynamoDB, and well inside the default per-API rate limit of roughly 2,000 requests per second. Worst-case staleness is 2.5 seconds on a phase change, imperceptible when a presenter is talking over the transition anyway. In exchange I dropped an entire class of state — connection lifecycle, reconnect logic, phones sleeping through subscription messages. A phone that wakes polls once and is correct.

The capacity review found one borderline spot: the end of the rush, when 130 timers fire simultaneously and every client fetches results — about 1,950 near-simultaneous requests, kissing the rate limit. The realistic room size made it a non-issue and I left it, a call I would not make for a product.

Presence rides the same philosophy. Each device upserts its own Presence row every 20 seconds; the lobby counter is the number of rows with lastSeen fresher than 60 seconds. At 130 devices that is 6.5 writes per second. One trap: AppSync’s timestamp type is epoch seconds in a 32-bit signed integer. Date.now() returns milliseconds — about 1.76 trillion, which does not fit in 2.1 billion. Divide by 1,000 at the boundary or watch your heartbeats fail validation.

A reset that hides everything and deletes nothing

I rehearsed with this app for days. QA scripts swiped through complete games; every run wrote real votes into the real table. The classic fix is a cleanup script you must remember to run — and the classic failure is a podium topped by a bot named “test-7”.

Instead, votes are namespaced by run: the vote’s key value is ${runId}#${questionId}, and runId lives in the GameState singleton. Reset bumps the runId. Every previous vote — QA bots, rehearsals, the curious early scanner who found the QR code — becomes instantly invisible to every query, because aggregation only ever asks the index for the current run’s keys. Nothing is deleted; nothing needs to be. The pre-session ritual was three actions: open the console, hit Reset, project the lobby.

Defeating the random swiper

A binary swipe game has a degenerate strategy: swipe fast at random, finish first, look great. Two mechanics kill it.

First, speed never beats correctness. The leaderboard sorts by number of correct answers, then by cumulative answer time — time only breaks ties among equally-correct players. A random swiper converges on 5/10 and ranks beneath everyone who thought. A missing timing measurement is charged a 30-second penalty.

Second, the per-question “fastest” honor shown at each reveal is fastest correct answer — computed only over the players who got that card right.

The rush itself is silent: no per-card verdict on the phone, so nobody can calibrate mid-game. Your phone shows your score, your corrections, and your rank only after the presenter ends the game. And the pseudonyms on the podium are generated with a reroll button rather than free-text input — a projected screen in front of a live audience is exactly where you do not want to moderate creative usernames in real time.

The day, and the bill

Feature freeze a week before the talk. On the day it just worked: lobby filled, two minutes of swiping, reveals landed, podium animated, laughs in the right places.

The bill matched the architecture. AppSync charges about $4 per million query/mutation operations; a 40-minute session at the polling rates above is roughly 125,000 requests — fifty cents. DynamoDB on-demand writes for a few thousand votes and heartbeats are pennies. Cognito identity pools are free. Amplify Hosting served a bundle measured in kilobytes. Everything included, the event cost under a dollar.

One aftermath worth admitting: within days, the repository greeted me with nine Dependabot alerts, five of them high severity. Disposable infrastructure is still infrastructure. Tear it down when the talk is done, or it starts billing you in attention instead of dollars.

Honest recommendation

If all you need is a bar chart of poll answers, use Mentimeter or Kahoot and spend your weekend rehearsing the talk — the talk matters more than the toy. Build your own when the mechanics are the point (choreographed reveals, fastest-correct scoring, your own look on the projector) or when you refuse to route your audience through someone else’s tracking.

If you do build: take the boring synchronization. A 2.5-second poll of a single-row point-read is dumb, debuggable, and self-healing; subscriptions earn their complexity at scales and tempos a conference room will never reach. Namespace ephemeral data by runId from the first commit — a reset that hides instead of deletes will save you on the day you demo. And steal the auth pattern even if you build nothing else this way: Identity Pool guest access with SigV4-signed calls is the drop-in answer every time you catch yourself about to ship an API key inside a public JavaScript bundle. That reflex is worth more than the game.

ABOUT THE AUTHOR

Alexandre Agius

Alexandre Agius

AWS Solutions Architect

Passionate about AI & Security. Building scalable cloud solutions and helping organizations leverage AWS services to innovate faster. Specialized in Generative AI, serverless architectures, and security best practices.

ONE LETTER A MONTH · NO TRACKER · UNSUBSCRIBE ANYTIME

CONTINUE READING

Related dispatches

Comments

Sign in to leave a comment