Inspiration
Every group chat has the same arguments: "He's going to be late again." "It'll snow before November." "They won't last the summer." When it happens, someone says "called it", but there's never any proof they did. Prediction markets like Kalshi and Polymarket have shown how much people enjoy putting a stake behind their opinions on world events. We wanted that same feeling for the small, personal questions friends actually argue about, without real money involved.
What it does
called it. turns friend-group debates into prediction markets.
- Groups: create a private, invite-only group or a public one anyone can find in Discover. Invite links expire after 15 minutes and preview nicely in iMessage, WhatsApp, Discord and Slack.
- Markets: any member posts a question with a deadline (Yes/No or multiple choice) and picks a trusted moderator.
- Betting: friends stake points, and the odds update live as bets come in.
- Resolution: after the deadline, the moderator picks the winner. A 5-minute grace period lets them nullify it if something went wrong, and then winners split the pot.
- Points: virtual only. Everyone starts with 1,000 and can claim 1,000 more every 24 hours.
- Leaderboards: per-group rankings and a prediction score show who really knows their friends best.
How we built it
We split into a Next.js frontend and a Go backend, joined by a single written API contract and a requirements spec with 216 pass/fail requirements, each with a stable ID like RES-16 or WAL-12. Both teams worked from those documents, so the frontend could be built against an in-memory mock API while the backend was still in progress, then switched to the real API with one environment flag.
Frontend: Next.js with TypeScript and Vitest tests. A route guard verifies login tokens using the backend's public key. Open Graph images are generated for invite links. Market pages refresh every 5 seconds while a market is live.
Backend: Go with Fiber and pgx on PostgreSQL (hosted on Supabase), with 11 versioned migrations run by golang-migrate. Auth uses short-lived access tokens and single-use refresh tokens, both Ed25519-signed JWTs in HTTP-only cookies. Refresh tokens are stored hashed and rotated atomically.
Payouts are parimutuel: there is no house and no fixed odds, and winners split everything that was staked. If you bet s points on the winning option, your payout is
$$ \text{payout} = \left\lfloor \frac{s \cdot P}{W} \right\rfloor $$
where P is the total staked on the market and W is the total staked on the winning option.
Rounding down can leave a few points unpaid, so the leftover goes to the largest winning bet. That makes the payouts add up to exactly P: points are never created or destroyed. If nobody backed the winner (W = 0), or the market is nullified, everyone gets their stake back. The multiplication uses big-integer arithmetic so s × P can't overflow.
Each user's prediction score is
$$ \text{score} = \text{round}\left( 100 \cdot \frac{\text{correct}}{\text{total}} \right) $$
with halves rounded up, 0 before any settled predictions, and refunds excluded.
Challenges we ran into
- Race conditions with points. A double-click or two open tabs could claim the daily bonus twice or pay a market out twice. We made each money-moving action a single database transaction. The daily bonus is one conditional statement (
UPDATE … WHERE next_daily_bonus_at <= now()), and payouts and nullifies lock the market row. Tests fire 8 claims at once and check that exactly one succeeds. - Payout vs. nullify. With a grace period, a moderator's nullify can race the scheduled payout. Both lock the market and re-check its status, so exactly one of them ever wins.
- Link previews behind a login wall. Chat apps' preview bots never have a login cookie, so every shared invite previewed as our login page. We opened invite pages to signed-out visitors and added a rate-limited public endpoint that reveals only a group's name, privacy and member count.
- Deployment surprises. Supabase's direct database host only has an IPv6 address, which our network couldn't reach, so we switched to its IPv4 connection pooler. Free ngrok tunnels replaced API responses with a warning page until the frontend sent a skip header. And a cookie
Domainset to the tunnel's hostname made logins silently fail. - Merging parallel work. Two teammates each wrote a migration numbered
000010on different branches. After the merge, the migration tool refused to run at all, and the shared database already had one of them applied. We renumbered the unapplied one, retested every migration up and down, and added the step to our checklist. - Keeping two codebases honest. With the frontend and backend on separate branches, the docs drifted. We made the API contract and requirements the source of truth, synced them to both branches with every change, and kept a traceability table recomputed from the requirement rows.
Accomplishments that we're proud of
- A working end-to-end product: signup, groups, invites, live markets, moderator resolution with a grace period, payouts, daily bonuses and leaderboards.
- 172 of 216 requirements built, and 152 covered by automated tests across Vitest on the frontend and Go integration tests against a real PostgreSQL database.
- A payout engine that provably conserves points, with tests down to the rounding remainder and pools large enough to overflow ordinary 64-bit math.
- Invite links that preview in chat apps and expire after 15 minutes, with a one-click "Get a new link" and a live countdown.
- Keys that load from environment variables, so the backend can deploy to hosts with no key files.
What we learned
- Write the contract first. Agreeing on API types and error codes up front let the frontend and backend move in parallel, and made integration mostly a flag flip.
- Money logic belongs in the database transaction. Correctness under concurrency came from atomic statements and row locks, not application-level checks.
- Integer math matters. Rounding down per bet and handing out the remainder is the difference between a pool that balances and one that slowly leaks or mints points.
- Test against the real thing. Mocks got the UI built fast, but bugs like a wrong database error code, a duplicate migration and an IPv6-only host only showed up against real Postgres and real hosting.
What's next for called it
- Real value for points. We believe points could eventually be exchanged for a real medium of exchange, so a good call is worth something. Doing that responsibly means treating it like a regulated product: real-money prediction markets fall under financial and gambling rules in most places, so it would need licensing, identity and age checks, and spending limits before any money changes hands.
- Scheduled payouts on serverless hosting, using a cron-triggered endpoint so payouts land on time without a long-running server.
- Notifications when a market you bet on is about to close, gets resolved or pays out.
- Fairer split bets: combine each user's bets before rounding, so splitting a stake never costs a point.
- More market types, such as numeric ranges ("How late will he be?").
Built With
- gofiber
- golang
- nextjs
- postgresql
- react
- supabase
- typescript
- vercel
Log in or sign up for Devpost to join the conversation.