DEV Community

Joe Lin for BeGoodTool.com

Posted on

Random pairing gets surprisingly tricky once you try to avoid repeats

I thought a "random pairing" tool would be a half-hour job. Shuffle a list, take names two at a time, done.

The annoying part only showed up when I tried to make it usable for recurring things like coffee chats, book clubs, and gift exchanges. "Random" is easy. "Random, but please stop giving me the same pairings as last month" is where the implementation stops being trivial.

The input cleanup is doing more than it looks like

The first thing the component does is normalize the pasted list into a deduplicated array:

const namesList = computed(() => {
  const tokens = rawNames.value
    .split(/[\n,,;;]+/g)
    .map((s) => s.trim())
    .filter(Boolean);
  return [...new Set(tokens)];
});
Enter fullscreen mode Exit fullscreen mode

I like this because it fixes the boring real-world mess up front. People paste one name per line, or comma-separated, or full-width Chinese punctuation, and the tool accepts all of it. Trimming plus filter(Boolean) also prevents blank lines from turning into fake participants.

The more opinionated part is new Set(tokens). That means exact duplicate names are silently collapsed before any pairing happens. For a lot of office or classroom lists that's the right default, because duplicates are usually copy-paste mistakes. But it also means two different people both named Alex would be treated as one person unless the user distinguishes them manually.

The pairing logic avoids the classic "one person left over" bug

The shuffle itself is the standard Fisher-Yates pattern:

function shuffle(arr) {
  const a = [...arr];
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}
Enter fullscreen mode Exit fullscreen mode

That part is conventional. The more interesting bit is what happens after the shuffle:

function buildPairingUnits(shuffled) {
  const units = [];
  const n = shuffled.length;
  let i = 0;
  let idx = 1;
  while (i < n) {
    if (n - i === 3) {
      units.push({ id: idx++, members: shuffled.slice(i, i + 3), isTrio: true });
      i += 3;
    } else {
      units.push({ id: idx++, members: shuffled.slice(i, i + 2), isTrio: false });
      i += 2;
    }
  }
  return units;
}
Enter fullscreen mode Exit fullscreen mode

Instead of producing pairs until one unlucky person is stranded, it watches for the "exactly 3 left" case and turns that into a trio. That's a small rule, but it's the difference between a usable recurring tool and a toy demo. Nobody gets dropped, and the UI can explicitly label the result as a three-person unit.

Grouping by size is really balancing by remainder

The grouping mode has a subtle implementation detail I didn't expect at first. Even when the user picks "group by size", the code doesn't just keep slicing fixed chunks and dump leftovers into a tiny final group:

if (groupSizeMode.value === "byCount") {
  const k = Math.max(1, Math.min(n, Math.floor(groupCountInput.value) || 1));
  const base = Math.floor(n / k);
  const remainder = n % k;
  sizes = Array.from({ length: k }, (_, i) => base + (i < remainder ? 1 : 0));
} else {
  const s = Math.max(1, Math.floor(groupSizeInput.value) || 1);
  const k = Math.max(1, Math.round(n / s) || Math.ceil(n / s));
  const base = Math.floor(n / k);
  const remainder = n % k;
  sizes = Array.from({ length: k }, (_, i) => base + (i < remainder ? 1 : 0));
}
Enter fullscreen mode Exit fullscreen mode

The key idea is that both modes end up distributing the remainder across groups as evenly as possible. So if you have 10 people and ask for groups of 3, you don't get 3,3,3,1; you get something closer to 4,3,3. That feels much fairer in practice, especially for workshops or discussion groups where a lonely one-person leftover would be obviously wrong.

"Avoid repeats" is implemented as a search problem, not a hard rule

The smartest part of the tool is that it doesn't store only exact past groups. It breaks every result into pairwise co-occurrences:

function pairKey(a, b) {
  return [a, b].sort().join("___");
}

function coOccurrencePairsOfUnits(units) {
  const keys = [];
  for (const u of units) {
    const members = u.members;
    for (let a = 0; a < members.length; a++) {
      for (let b = a + 1; b < members.length; b++) {
        keys.push(pairKey(members[a], members[b]));
      }
    }
  }
  return keys;
}
Enter fullscreen mode Exit fullscreen mode

That design matters a lot. In grouping mode, the history is still tracking "who has already been together," not just "was this exact four-person group repeated." That's a much better definition of fairness for recurring rotations.

Then, instead of pretending repeats can always be avoided, the component tries up to 300 random draws and keeps the one with the fewest historical conflicts:

const MAX_ATTEMPTS = 300;
let best = null;
for (let i = 0; i < MAX_ATTEMPTS; i++) {
  const attempt = generateAttempt();
  if (!best || attempt.conflicts < best.conflicts) best = attempt;
  if (attempt.conflicts === 0) break;
}
Enter fullscreen mode Exit fullscreen mode

I like this tradeoff. It's honest about the math: with a small fixed roster, eventually you run out of unseen pairings. So the goal becomes "minimize repeats" rather than "guarantee perfection." For a client-side tool, 300 attempts is also cheap enough to feel instant.

The gotchas are real

There are a few limitations here that are worth saying out loud:

  • History is stored in localStorage, so it's tied to one browser on one device. Clear browser data or switch laptops, and the pairing memory is gone.
  • saveHistoryStore() intentionally fails silently if storage is blocked or full. That's good for not breaking the UI, but it also means users may think history was saved when the browser refused it.
  • The "best of 300 attempts" approach is a heuristic, not a proof of optimality. It usually finds a low-conflict result, but it isn't solving the pairing problem exhaustively.
  • Exact duplicate names are deduplicated. That's nice for accidental repeats, but not for two distinct humans with the same displayed name.

I turned that into a small free tool here: Fair Random Pairing & Grouping Generator.


Available in other languages

Top comments (0)