Skip to content

Commit

Permalink
Improve buzzer: fair tiebreaks and quantization
Browse files Browse the repository at this point in the history
Buzzer ties are now broken randomly, independently for each clue. Buzzer times
are rounded to the nearest 200ms to slightly deweight buzzer racing as a game
mechanic. Lockouts should still carry a penalty, otherwise buzz-spamming
becomes optional.
  • Loading branch information
neilmovva authored and cmnord committed Jul 8, 2024
1 parent da700b1 commit 155476c
Show file tree
Hide file tree
Showing 7 changed files with 90 additions and 39 deletions.
3 changes: 2 additions & 1 deletion app/components/prompt/prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Action, Player } from "~/engine";
import {
CANT_BUZZ_FLAG,
CLUE_TIMEOUT_MS,
QUANTIZATION_FACTOR_MS,
GameState,
useEngineContext,
} from "~/engine";
Expand Down Expand Up @@ -402,7 +403,7 @@ function ReadCluePrompt({
if (
buzzUserId !== userId &&
buzz !== CANT_BUZZ_FLAG &&
buzz < deltaMs
(buzz + QUANTIZATION_FACTOR_MS) < deltaMs
) {
submitBuzz(CLUE_TIMEOUT_MS + 1);
}
Expand Down
28 changes: 17 additions & 11 deletions app/engine/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2208,16 +2208,22 @@ describe("gameEngine", () => {
});

describe("getWinningBuzzer", () => {
it("returns the buzz of the lower user ID in case of a tie", () => {
// NB: Maps iterate over keys in insertion order
let buzzes = new Map();
buzzes.set(PLAYER1.userId, 100);
buzzes.set(PLAYER2.userId, 100);
expect(getWinningBuzzer(buzzes)?.userId).toBe(PLAYER1.userId);

buzzes = new Map();
buzzes.set(PLAYER2.userId, 100);
buzzes.set(PLAYER1.userId, 100);
expect(getWinningBuzzer(buzzes)?.userId).toBe(PLAYER1.userId);
it("measures the buzzer winrate between two equally fast players over 1000 trials. Expected to be near 50-50", () => {
let player1Wins = 0;
const n_trials = 1000;
for (let trial = 0; trial < n_trials; trial++) {
const tiebreakSeed = Math.random().toString() + trial.toString();
const buzzes = new Map();
buzzes.set(PLAYER1.userId, 100);
buzzes.set(PLAYER2.userId, 100);
const winner = getWinningBuzzer(buzzes, tiebreakSeed)?.userId;
if (winner === PLAYER1.userId) {
player1Wins++;
}
}
// CDF of bin(n=1000, p=0.5) = 0.99999 @ k = 435
const k = 435;
expect(player1Wins).toBeGreaterThan(k);
expect(player1Wins).toBeLessThan(n_trials - k);
});
});
88 changes: 65 additions & 23 deletions app/engine/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { enableMapSet, produce } from "immer";

import type { Board } from "~/models/convert.server";

import { cyrb53 } from "~/utils";
import {
isAnswerAction,
isBuzzAction,
Expand Down Expand Up @@ -43,34 +44,70 @@ export const CLUE_TIMEOUT_MS = 5000;
* on this clue. */
export const CANT_BUZZ_FLAG = -1;

export function getWinningBuzzer(buzzes: Map<string, number>):
/** Buzzes within this many milliseconds of each other are treated as ties. */
export const QUANTIZATION_FACTOR_MS = 200;

function isValidBuzz(deltaMs: number): boolean {
return deltaMs !== CANT_BUZZ_FLAG && deltaMs <= CLUE_TIMEOUT_MS;
}

/** getWinningBuzzer returns undefined if there were no valid buzzes. */
export function getWinningBuzzer(
buzzes: Map<string, number>,
tiebreakerSeed?: string,
):
| {
userId: string;
deltaMs: number;
}
| undefined {
const result = Array.from(buzzes.entries())
// Sort buzzes by user ID for deterministic results in case of a tie.
.sort(([aUserId], [bUserId]) => (aUserId > bUserId ? 1 : -1))
.reduce(
(acc, [userId, deltaMs]) => {
if (
deltaMs !== CANT_BUZZ_FLAG &&
deltaMs < acc.deltaMs &&
deltaMs <= CLUE_TIMEOUT_MS
) {
return { userId, deltaMs };
}
return acc;
},
{ userId: "", deltaMs: Number.MAX_SAFE_INTEGER },
);
const validBuzzes = Array.from(buzzes.entries()).filter(([, deltaMs]) =>
isValidBuzz(deltaMs),
);

if (result.userId === "") {
if (validBuzzes.length === 0) {
return undefined;
}

return result;
if (tiebreakerSeed === undefined) {
tiebreakerSeed = "t";
console.warn(
"TiebreakerSeed is undefined, ties will be broken in a fixed user order.",
);
}
// generate 53-bit hash, discard MSBs to get 32-bit unsigned
const tiebreakSeed32 = cyrb53(tiebreakerSeed) >>> 0;

const minDeltaMs = Math.min(...validBuzzes.map(([, deltaMs]) => deltaMs));

const quantizedBuzzes: [string, number, number][] = validBuzzes.map(
([userId, deltaMs]) => [
userId,
// measure every buzz relative to the fastest one, and round to the quantization interval
Math.floor(Math.max(0, deltaMs - minDeltaMs) / QUANTIZATION_FACTOR_MS),
// random number derived from user ID and per-contest seed, to break ties
cyrb53(userId, tiebreakSeed32),
],
);

quantizedBuzzes.forEach(([userId, qDeltaMs, tiebreak], index) => {
console.log(
`User: ${userId}, Raw: ${validBuzzes[index][1]}, Quantized: ${qDeltaMs}, Tiebreaker: ${tiebreak}`,
);
});

const sortedBuzzes = quantizedBuzzes.sort(
([, qDeltaA, tiebreakA], [, qDeltaB, tiebreakB]) => {
if (qDeltaA === qDeltaB) {
return tiebreakA - tiebreakB;
} else {
return qDeltaA - qDeltaB;
}
},
);

const [userId, deltaMs] = sortedBuzzes[0];
return { userId, deltaMs };
}

/** getHighestClueValue gets the highest clue value on the board. */
Expand Down Expand Up @@ -300,13 +337,14 @@ export function gameEngine(state: State, action: Action): State {
return;
}

const winningBuzzer = getWinningBuzzer(draft.buzzes);
const board = draft.game.boards.at(draft.round);
const clue = board?.categories.at(j)?.clues.at(i);

const winningBuzzer = getWinningBuzzer(draft.buzzes, clue?.clue);
if (!winningBuzzer) {
// Reveal the answer to everyone and mark it as answered. If the clue
// was wagerable and the player didn't buzz, deduct their wager from
// their score.
const board = draft.game.boards.at(draft.round);
const clue = board?.categories.at(j)?.clues.at(i);
if (clue?.wagerable) {
const clueValue = getClueValue(draft, [i, j], userId);
const player = draft.players.get(userId);
Expand Down Expand Up @@ -382,6 +420,10 @@ export function gameEngine(state: State, action: Action): State {
}
const isLongForm = draft.type === GameState.RevealAnswerLongForm;

const board = draft.game.boards.at(draft.round);
const clue = board?.categories.at(j)?.clues.at(i);
const clueText = clue?.clue;

// Ignore the action if it was from a player who didn't answer the clue.
const key = `${draft.round},${i},${j}`;
if (isLongForm) {
Expand All @@ -390,7 +432,7 @@ export function gameEngine(state: State, action: Action): State {
return;
}
} else {
const winningBuzzer = getWinningBuzzer(draft.buzzes);
const winningBuzzer = getWinningBuzzer(draft.buzzes, clueText);
if (userId !== winningBuzzer?.userId) {
return;
}
Expand Down
3 changes: 2 additions & 1 deletion app/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ export {
ActionType,
CANT_BUZZ_FLAG,
CLUE_TIMEOUT_MS,
QUANTIZATION_FACTOR_MS,
gameEngine,
getHighestClueValue,
} from "./engine";
export type { Action } from "./engine";
export { clueIsPlayable, GameState } from "./state";
export { GameState, clueIsPlayable } from "./state";
export type { Player, State } from "./state";
export { GameEngineContext, useEngineContext } from "./use-engine-context";
export { useGameEngine, useSoloGameEngine } from "./use-game-engine";
4 changes: 2 additions & 2 deletions app/engine/use-game-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { getSupabase } from "~/supabase";
import type { Action } from "./engine";
import { gameEngine, getWinningBuzzer } from "./engine";
import { applyRoomEventsToState, isTypedRoomEvent } from "./room-event";
import { getClueValue, State, stateFromGame } from "./state";
import { State, getClueValue, stateFromGame } from "./state";

export enum ConnectionState {
ERROR,
Expand Down Expand Up @@ -55,7 +55,7 @@ function stateToGameEngine(
?.answeredBy.get(userId);
};

const winningBuzz = getWinningBuzzer(state.buzzes);
const winningBuzz = getWinningBuzzer(state.buzzes, clue?.clue);
const winningBuzzer = winningBuzz?.userId ?? undefined;

function getClueValueFn(idx: [number, number], userId: string) {
Expand Down
1 change: 1 addition & 0 deletions app/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from "./http.server";
export * from "./is-browser";
export { getRandomEmoji, getRandomName } from "./name";
export {
cyrb53,
formatDollars,
formatDollarsWithSign,
generateGrid,
Expand Down
2 changes: 1 addition & 1 deletion app/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function formatDollarsWithSign(dollars: number) {
return signFormatter.format(dollars);
}

const cyrb53 = (str: string, seed = 0) => {
export const cyrb53 = (str: string, seed = 0) => {
let h1 = 0xdeadbeef ^ seed,
h2 = 0x41c6ce57 ^ seed;
for (let i = 0, ch; i < str.length; i++) {
Expand Down

0 comments on commit 155476c

Please sign in to comment.