Skip to content

How I built a fair sweepstake app in 8 hours, starting on paper

Hattle, my World Cup sweepstake app, went from a paper sketch to shipped in 8 hours. The draw is a seeded Fisher-Yates shuffle anyone can re-run and check. Here's how it works, an illustrative code sketch, and my own Node test showing why the popular sort-with-random shuffle is quietly unfair.

Aatman (AJ) Jain8 min readUpdated 28 Sept 2026
A deck of cards printed with footballs arcs through a shuffle over a notebook page with a sketched knockout bracket, on mint green paper.
A deck of cards printed with footballs arcs through a shuffle over a notebook page with a sketched knockout bracket, on mint green paper.

I built Hattle, a fair World Cup sweepstake app, in 8 hours by planning the whole system on paper first and making the draw a seeded Fisher-Yates shuffle. Fisher-Yates gives all 48 teams an equal chance of every slot, and the seed lets anyone re-run the draw and get exactly the same result, so nobody has to take "fair" on trust.

Three numbers tell most of the story:

from a paper sketch to shipped
8 hours
World Cup teams dealt in every draw
48
how often the sort trick left the top team on top (fair is 2.08%)
6.15%

The spreadsheet had a bug

My mates ran their World Cup sweepstake on a spreadsheet. It had bugs. Two people ended up with the same team. By the group stage nobody trusted the standings, and the slagging stopped working because half the group thought the table was wrong.

That last bit is the real problem. A sweepstake is basically a month of banter with a table attached. Once people stop trusting the table, the banter goes too.

Every group chat has this spreadsheet.

Every tournament, the same broken setup. So I built the thing they actually needed.

The one idea that shaped the whole app: fair has to be something you can check. If the only proof of a fair draw is "trust me", there is no proof.

Paper first, then the brand, then 8 hours

Before I touched a keyboard, I sketched the whole system on paper. The draw, the scoring, the money tab, and how debts would settle between people.

The whole system, before any code. Redrawn for this post.

Paper is slow in a good way. You can't copy and paste a bad idea. It forces the decisions early, like how the draw works when the teams don't split evenly, or who gets to say a debt is paid.

Then the brand. I called it Football Frenzy: sunshine yellow, cream, navy outlines, red marker headlines, gold accents and hard offset shadows. Anton for the big headlines, Caveat Brush for the scribbles, Inter for the body and Space Mono for the scoreboard digits. Sticker-doodle on purpose, so it looks like a person made it. (Default AI styling was never going to survive a group chat. More on that in why AI-built apps all look the same.)

Then eight hours of focus.

Why the obvious shuffle is rigged

The tempting shortcut is one line:

js
teams.sort(() => Math.random() - 0.5); // looks random, isn't fair

It looks random. But a sort function expects a consistent answer about which item comes first, and this one flips a coin every time it's asked. What comes out depends on how the engine's sort compares things, and some orders come up far more often than others.

I didn't want to take that on faith, so I measured it. In Node 22 (v22.22.2), I shuffled a 4-item list and a 48-item list one million times each, with both methods, always from the same starting order. Both used plain Math.random(), so the only difference is the shuffle. After every shuffle I counted which slot each item landed in. A fair shuffle puts every item in every slot 1 time in n.

Start small. Here's where the first of 4 items ended up:

Where the first of 4 items lands

FairSort with Math.random()Fisher-Yates
Slot 125%35.93%25.02%Slot 225%17.23%24.93%Slot 325%15.67%25.09%Slot 425%31.17%24.97%
Where the first of 4 items lands (%)
RowFairSort with Math.random()Fisher-Yates
Slot 125%35.93%25.02%
Slot 225%17.23%24.93%
Slot 325%15.67%25.09%
Slot 425%31.17%24.97%

My own test, 28 Sep 2026, Node v22.22.2: 1,000,000 shuffles per method, each starting from the same 4-item order, plain Math.random() for both. Fair is 25% in every slot.

With the sort trick, the first item stays first 36% of the time and lands in the two middle slots far less often than it should. Fisher-Yates sits on 25% everywhere.

Now every item and slot pair (16 for 4 items, 2,304 for 48). The table shows the lowest and highest from one run. Repeat runs moved them by a tenth of a point at most.

ShuffleItemsFair chance per slotLowest seenHighest seen
sort(() => Math.random() - 0.5)425.00%9.34%39.13%
Fisher-Yates425.00%24.95%25.04%
sort(() => Math.random() - 0.5)482.08%0.29%6.15%
Fisher-Yates482.08%2.04%2.14%

Look at the 48-team rows, because that's a real World Cup. With the sort trick, the team at the top of the list stayed at the top 6.15% of the time, about three times its fair 2.08%. The rarest team and slot combination came up just 0.29% of the time, roughly seven times less often than it should. Fisher-Yates stayed within a whisker of fair.

The exact numbers depend on the engine's sort, so another browser could give different figures. Different, not fair. In a sweepstake, that's a rigged draw nobody meant to rig.

Don't take my word for it. Run both shuffles thousands of times and watch the bias show up, then type a seed to replay the same draw:

Interactive demo

Shuffle lab: sort-shuffle vs Fisher-Yates

Runs the one-line arr.sort(() => Math.random() - 0.5) shuffle and a Fisher-Yates shuffle thousands of times on a small deck of teams, then shows how often each team lands in each position, with a bias score for each method.

Fisher-Yates spreads every team evenly across every slot; the sort-based shuffle piles some teams into some slots far more often than chance. A seeded mode hashes a seed like frenzy-rocket-4821 (FNV-1a into mulberry32 into Fisher-Yates) so the same seed always replays the same order.

Fisher-Yates, with a seed

Fisher-Yates is simple. Walk backwards through the list. At each position, pick a random spot from the start up to and including where you are, and swap the two. One pass, and every possible order is equally likely.

The other half is where the random numbers come from. Math.random() can't be replayed. Whatever it did, it did. A seeded generator takes a starting value, the seed, and produces the same sequence every single time you give it that seed.

In Hattle, every draw gets a seed made of football words and four digits, like frenzy-rocket-4821. The seed is hashed into a number, a small mulberry32-style generator turns that into a sequence, and the sequence drives the Fisher-Yates shuffle that deals the teams.

Same seed in, same draw out.

Two details make it repeatable, and they're easy to miss:

  • Sort the inputs first. Hattle sorts the teams by FIFA code before shuffling, and the players by ID before dealing. If the starting order can change, the same seed gives a different draw.
  • Deal like cards. The shuffled teams go round the players one at a time, so 48 teams spread out as evenly as the maths allows. With 5 players, three get 10 teams and two get 9.
Round-robin, straight from Hattle's FAQ.

An illustrative sketch of the draw

Here's the idea in code. This is an illustrative sketch I wrote for this post, not Hattle's code. It follows the same steps, small enough to read in one go. The hash is FNV-1a because it's short. Any stable string hash works.

js
// Illustrative sketch for this post, not Hattle's code.
// 1. Turn a seed like "frenzy-rocket-4821" into a 32-bit number (an FNV-1a hash).
function hashSeed(seed) {
  let h = 2166136261;
  for (const ch of seed) {
    h ^= ch.codePointAt(0);
    h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}

// 2. mulberry32: a tiny seeded generator. Same seed, same numbers, every time.
function mulberry32(a) {
  return function () {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// 3. Fisher-Yates: walk backwards, swap each slot with a random earlier one (or itself).
function shuffle(items, rand) {
  const a = [...items];
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(rand() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

// 4. Sort both lists first, shuffle the teams, then deal them out like cards.
export function draw(seed, teams, players) {
  const rand = mulberry32(hashSeed(seed));
  const deck = shuffle([...teams].sort((x, y) => x.code.localeCompare(y.code)), rand);
  const seats = [...players].sort((x, y) => x.id.localeCompare(y.id));
  const hands = Object.fromEntries(seats.map((p) => [p.id, []]));
  deck.forEach((team, i) => hands[seats[i % seats.length].id].push(team.code));
  return hands;
}

I ran this sketch before putting it here. Same seed with the inputs in a different order: identical draw. Change one digit of the seed: a different draw. Test your own version properly before you trust it with anyone's bragging rights.

The verify page: proof instead of trust

Every draw gets a seed, and anyone can paste it into a public verify page to re-run the exact same draw, team for team. Nobody, me included, can rig who gets which team.

Seeded, verifiable, locked.

The seed is fixed before anyone sees the draw, so it can't be swapped afterwards. That part matters more than the maths. A verifiable draw with a seed you can change after the fact is just a slower way to cheat.

No one, including us, can rig who gets who. Once it's drawn, it's locked.

Hattle's FAQ

The money tab: two sides, zero cash

Hattle never touches the cash. No payments, no payouts, no holding money. The site says it straight: "Hattle is a social scoreboard, not a betting or payment service."

What it does do is keep the tab. Your group sets a stake (a curry, bragging rights, whatever you agree) and Hattle tracks who owes who. The tab is two-sided: one person marks a debt paid, and the other confirms it.

Play for whatever. Hattle just keeps score.

That pattern is worth stealing for anything where two people have to agree something happened. Neither side can close it alone, and the record shows both of them said yes.

Real fixtures, free data

The standings follow real results. Every match comes from openfootball (opens in a new tab), a free public-domain (CC0) dataset. No API bills, no licence headaches. Just good data that someone kindly gave away.

What fitted in 8 hours

Brand, landing page, sign-in, the draw, fixture sync, scoring, the money tab and multiplayer pools, all shipped in one eight-hour stretch in May 2026. It was my entry for Base44's Vibe Sprint: Championship Edition.

Hattle runs on Base44, with React and Tailwind CSS on the front. Base44 builds in the database, user management and hosting, so the 8 hours could go on the product itself.

That list only fits in a day because the thinking happened on paper first. Fast because it was planned is very different from fast because corners got cut. (It's one of the big lessons in what shipping 20+ client apps at 15 has taught me.)

Real groups ran their World Cup sweepstakes on it through the tournament. The spreadsheet did not survive.

Checklist: a draw people can trust

  1. Use Fisher-Yates

    Never sort(() => Math.random() - 0.5).

  2. Seed it

    Drive the shuffle with a seeded generator, and save the seed with the draw.

  3. Fix the seed first

    Before anyone sees the result, with no way to swap it afterwards.

  4. Sort the inputs

    Put teams and players in a fixed order before shuffling.

  5. Deal round-robin

    So nobody gets more than one extra team.

  6. Publish a verify page

    Anyone can re-run the draw from the seed.

  7. Lock the draw

    Once it's made, it's made.

  8. Keep secrets out of it

    Seeded randomness never goes near tokens or passwords.

FAQ

What's the fairest way to do a sweepstake draw online?

Use a Fisher-Yates shuffle driven by a seeded random generator, and publish the seed. Every team then has an equal chance of every slot, and anyone can re-run the draw from the seed to check it. Fix the seed before anyone sees the result so it can't be swapped.

Why is sort with Math.random a bad way to shuffle?

Because the sort algorithm expects consistent comparisons, and a random comparison skews the result. In my own test in Node 22 with 48 teams, the first team stayed first 6.15% of the time instead of the fair 2.08%. Fisher-Yates stayed within a whisker of fair.

What is a seeded shuffle?

A seeded shuffle uses a pseudo-random generator that starts from a seed, so the same seed always gives the same order. That makes a draw repeatable and checkable. It's great for sweepstakes, and wrong for anything secret like passwords.

Is Hattle a gambling app?

No. Hattle never handles, holds or pays out money. It runs the draw and keeps the score, and any stake is settled between the group, off the platform.

Want to see the whole build, stickers and all? The Hattle case study has the lot.

Keep reading

More posts on the same things.

Newsletter

Liked this one?

Get my Friday email: new posts, what I built and the Base44 and AI news worth knowing.

No spam. Unsubscribe whenever.

Got an app in mind?

I build and fix Base44 apps, and I'm looking for a remote internship. Tell me what you're working on.