All articles

Engineering

Proving a prize draw was fair

A live draw on a big screen with the entrants in the room. Asserting it was fair is worthless if a loser cannot check. Commit and reveal, an unbiased shuffle, and the venue constraints that shaped every other decision.

Jithin Roy, Full Stack Developer

By

Full Stack Developer, Kerala, India

10 min read

A few thousand people entered a contest. Tonight, at an event, five names come out of it on a projector, in front of the people who entered. One of them wins.

That setting changes the engineering completely. Randomness stops being a library call and becomes a claim you have to defend, in the room, to somebody who just lost. Every failure is public and none of them can be fixed afterwards. Most writing about random selection is theoretical; a room full of people watching a screen turns each abstract concern into a hard requirement.

Fair is not the requirement. Verifiable is.

You can implement a perfectly unbiased draw and still have nothing to say when somebody asks how they can check. Trust me is not an answer, and it is the only answer most systems have.

The fix is commit and reveal, which is old and underused. Generate a random seed. Publish its hash before the first round runs, while nobody knows anything. Publish the seed itself after the last round. Because the whole draw is a deterministic function of that seed, anyone can now recompute every round and confirm the names that came out are the names that had to come out.

export function createSeed(): string {
  return randomBytes(32).toString("hex")
}

export function hashSeed(seed: string): string {
  return createHash("sha256").update(seed).digest("hex")
}

The enforcement is one line in the API, and it is the line the entire scheme rests on:

{
  seedHash: session.seedHash,                          // always visible
  seed:     session.completedAt ? session.seed : null, // only after the last round
}
Publishing the hash early is what makes the seed meaningful later. Either half alone proves nothing.

It is worth being precise about what this buys, because it is easy to overclaim. Commit and reveal does not prove the seed was random. It proves the seed was fixed before the draw ran, so nobody chose it after seeing who entered, and nobody reran the draw until they liked the answer. That is exactly the accusation you need to be able to answer, and it is the one you cannot answer any other way.

Publishing the hash costs nothing and changes the conversation entirely. Without it, a challenged result is your word against theirs. With it, it is arithmetic, and anyone can do it.

The shuffle everybody writes is biased

This is the section that earns its place, because it is a real mistake that real systems ship.

The intuitive shuffle walks the array and swaps each element with a randomly chosen index from the whole array. It looks obviously fair, it is not, and the reason is countable. That procedure has n to the power n equally likely execution paths, but there are only n factorial possible orderings, and n factorial does not divide n to the power n. Some orderings therefore come up more often than others. The bias is not a rounding artefact; it is structural.

Fisher-Yates picks from the remaining unshuffled range instead, which produces each permutation exactly once:

export function seededShuffle<T>(items: readonly T[], seed: string, label: string): T[] {
  const out = items.slice()
  const randInt = createRandInt(seed, label)
  for (let i = out.length - 1; i > 0; i -= 1) {
    const j = randInt(i + 1)        // from the remaining range, not the whole array
    ;[out[i], out[j]] = [out[j], out[i]]
  }
  return out
}
The difference from the biased version is the argument to randInt. That is the whole fix.

There is a second bias hiding one level down, and it is the more commonly missed of the two. Having got a random 32 bit integer, the obvious way to reduce it to a range is modulo. But unless the range divides evenly into two to the power thirty two, the low values of the range get one extra chance each. Rejection sampling removes it:

// Taking a plain modulo would bias the low end of the range, which on a pool
// of a few thousand is a real, measurable tilt in who survives round one.
return function randInt(max: number): number {
  if (max <= 1) return 0
  const limit = Math.floor(0x100000000 / max) * max
  let value = nextUint32()
  while (value >= limit) value = nextUint32()
  return value % max
}

Discard anything at or above the largest exact multiple of the range, then take the modulo of what remains. The loop looks unbounded and is not: it rejects less than half the time in the worst case, so it terminates immediately in practice.

Neither of these bugs is visible. A biased draw produces perfectly plausible looking winners every single night, and the only way to find the tilt is to know it is there.

Where the randomness comes from

The shuffle needs numbers that are deterministic given the seed, so the draw can be recomputed, and unguessable without it, so a participant cannot predict round two while round one is still running. A counter mode stream over HMAC-SHA256 gives both:

function createRandInt(seed: string, label: string): (max: number) => number {
  let counter = 0
  let block = Buffer.alloc(0)
  let offset = 0

  function nextUint32(): number {
    if (offset + 4 > block.length) {
      block = createHmac("sha256", seed).update(`${label}:${counter++}`).digest()
      offset = 0
    }
    const value = block.readUInt32BE(offset)
    offset += 4
    return value
  }
  // ...rejection sampling as above
}

The label is the detail worth copying. Each round derives its stream from the round index, so round two never replays round one's ordering, and a round stays reproducible even if the operator changed the target sizes between rounds. One seed, independent streams, no shared mutable position to get out of step.

And the whole thing runs on the server. The big screen is told what was drawn; it never decides. A draw picked in the browser is one devtools console away from being chosen by hand, and at an event the machine is sitting on a table that anybody can walk past.

Rounds are theatre, and the arithmetic has to survive small contests

One shuffle would be enough to pick a winner. It would also be terrible to watch. The draw runs in three rounds because a room needs the tension: keep a tenth, then a fifth of those, then pull five finalists. Ten thousand entrants becomes one thousand, then two hundred, then five.

Percentages break on small numbers, and small numbers are exactly what an unpopular contest produces. Ten percent of forty entrants is four, which cannot yield five finalists, and the software would be asked to produce a winner and four runners-up from a pool of four. So every round is clamped:

// Never thin the pool below the number of finalists still to come, or a small
// entry list leaves two people standing and a prize with no runners-up behind it.
target = pool <= finalists
  ? pool
  : Math.max(finalists, Math.ceil(pool * round.keepRatio))

target = Math.min(Math.max(1, target), pool)

One more subtlety, because it is the kind of thing that produces a bug report from a venue at seven in the evening. Operators can override any round's target from the console. Those overrides have to be resolved against the running pool rather than a precomputed chain: if round one is raised from five hundred to a thousand, round two's default has to become two hundred rather than stubbornly staying at one hundred. Recompute forward, do not cache.

The venue is a hostile environment

This is where the requirements stop resembling normal web work.

The setup is two windows on one laptop: an operator console the person running the draw looks at, and a fullscreen presentation window on the projector. They talk over a BroadcastChannel, which is same machine only and never touches the network.

/**
 * The projector window.
 *
 * It never calls the API. Everything it shows arrives from the operator console
 * over a same-machine BroadcastChannel, which means two things worth having: a
 * network problem cannot freeze a reveal halfway through, and a screen left
 * open on its own displays nothing at all.
 */

Venue wifi is bad. That is not a pessimistic assumption, it is the base case. A design where the projector fetches its own state means a dropped connection freezes the reveal in front of the audience, and there is no recovering that moment. Moving the boundary so the screen is a pure renderer of state the console owns takes the network out of the critical path entirely.

Two failure modes still had to be handled inside that channel. A screen opened late or reloaded mid draw would otherwise sit on the waiting card forever, so it announces itself and the console replays the current state. And because a re-broadcast triggered by that hello is not ordered against normal traffic, every message carries a sequence number so a late arriving older state cannot step on a newer one:

channel.onmessage = (event) => {
  const message = event.data
  if (message.type === "state" && message.seq > seq.current) {
    seq.current = message.seq
    setState(message.state)
  }
}
// Announce ourselves so a screen opened late, or reloaded mid-draw,
// catches up instead of sitting on the waiting card.
channel.postMessage({ type: "hello" })

State that has to survive a dead laptop lives on the draw record rather than in the browser. The title the room reads is editable from the console and stored server side, so a machine that dies at eight o'clock comes back up configured the way it was rehearsed rather than reset to a default nobody agreed.

Make the invariants unforgettable

Two things must be on the projector continuously: the sponsor lockup, and the rehearsal watermark when a rehearsal is running. Both are the kind of requirement that gets remembered for the phases that existed when it was written and forgotten by whoever adds the fourth phase in a hurry.

So they are not a convention, they are the type. Every phase that puts something on screen extends a shared base carrying those fields, and a new phase that omits them does not compile.

/**
 * Carried by every phase that puts something on the projector. Factored out so
 * a new phase cannot quietly ship without the sponsor lockup or the rehearsal
 * watermark, both of which have to be on screen continuously.
 */
interface StageBase {
  mode: SessionMode
  title: string
  prize: DrawPrize | null
  partner: Partner | null
}

A rehearsal that is not unmissably marked is a disaster waiting for its moment. Somebody photographs the screen, the picture reaches a group chat, and a practice run becomes a published result. Watermarking the whole presentation surface, and making it structurally impossible to render a phase without it, is cheap insurance against a problem you cannot take back.

Failed claims move the prize without rewriting history

Winners do not always claim. They gave a wrong number, they left early, they were never eligible. The prize has to move to the next rank, and the record of what was actually drawn on the night has to stay intact, because that record is the thing the seed lets anyone verify.

So ranks are never rewritten. Which rank currently holds the prize is derived:

export function currentWinnerRank(finalists: readonly FinalistLike[]): number | null {
  const eligible = finalists
    .filter((f) => f.claimStatus !== "invalid")
    .sort((a, b) => a.rank - b.rank)
  return eligible[0]?.rank ?? null
}

Promoting the runner up by editing their rank to one would destroy the only thing making the draw checkable. Derive the current holder, store the drawn order forever. That panel is operator side only and never reaches the projector, because the room does not need to watch someone being marked invalid.

Take less to the venue

The draw application receives an entrant's first name, last name and booking reference. Not their email, phone, employer, or answers to any custom question the contest asked. Those exist in the main system and never reach the machine on stage.

This is data minimisation as an ordinary engineering decision rather than a compliance checkbox. A laptop at a venue is left unlocked, plugged into unfamiliar equipment, and sometimes operated by a contractor. If it only ever holds names and reference numbers, the worst case is embarrassing rather than reportable.

The access control around it makes the same trade in the other direction, and the comment in the source is more honest than most:

/**
 * Draw operators have no second factor: an authenticator prompt in front of a
 * live audience is a failure mode, and that was a deliberate trade. This is what
 * pays for it: five wrong passwords from one address and that address gets
 * fifteen minutes off, which turns an offline-speed guessing attack into an
 * unusable one.
 */

Dropping the second factor is a real reduction in security, taken deliberately because the alternative fails in a way that is worse in this specific context, and compensated with a control that costs nothing on stage. That is what a security decision actually looks like. Operator permissions are also read from the database on every request rather than carried in the token, so revoking access takes effect immediately instead of whenever a token happens to expire.

What to take from this

  1. Publish the hash of your seed before you run, and the seed after. Verifiability is a different property from fairness and only one of them survives being challenged.
  2. Use Fisher-Yates picking from the remaining range. The whole-array version is structurally biased, not approximately unbiased.
  3. Reject and resample instead of taking a modulo. The low end of your range is otherwise favoured, invisibly.
  4. Derive each round's stream from a label, so rounds are independent and stay reproducible when parameters change.
  5. Select on the server. A selection made in a browser was made by whoever had the keyboard.
  6. Clamp every round against the pool in front of it. Percentages produce nonsense on small inputs, and small inputs happen.
  7. Take the network out of anything that happens live in front of people.
  8. Give re-broadcast state a sequence number, and let a late joiner ask for the current state rather than assuming it saw everything.
  9. Encode non-negotiable screen elements in the type, so a new phase cannot omit them.
  10. Never rewrite the drawn order. Derive who holds the prize now.
  11. Send the minimum data to the least trusted machine.

The thread running through all of it is that a live draw has no undo. Every other system gets to fix things after the fact: reissue the refund, correct the record, apologise by email. This one gets one attempt in front of the people who care most about the outcome, and the software's job is to make sure that when somebody asks whether it was rigged, the answer is a seed and a hash rather than an assurance. The wider platform this belongs to is written up in the Synerggy Events case study.