A while back I wanted to build the classic "paste a list of names, get N even groups" tool — the kind of thing every teacher and event organizer has needed at some point. I assumed it would basically be a Fisher-Yates shuffle plus Array.slice in a loop, maybe an hour of work.

Then I actually thought about what people ask for when they say "randomly group my class." They don't mean random. They mean "randomly, except don't put the two kids who fight in the same group" or "spread the interns evenly across every table" or "the three team leads all need to end up in different groups." The moment you add a single constraint, true randomness becomes the enemy, not the feature. So the tool I ended up shipping has no Math.random() anywhere in the grouping logic at all. It's a deterministic, rule-aware placement algorithm dressed up as a "smart" shuffle.

Parsing a pasted list is messier than it looks

Before anything can be grouped, the raw textarea has to become a clean list of unique names. People paste from Excel, from a Google Doc, from a group chat — so the delimiter could be a newline, a comma, a full-width Chinese comma, a semicolon, or just whitespace:

function parseInput() {
  const tokens = rawInput.value
    .split(/[\n,,;\s]+/g)
    .map((s) => s.trim())
    .filter(Boolean);

  // Remove duplicates within the same input session
  const uniqueTokens = [...new Set(tokens)];

  for (const name of uniqueTokens) {
    if (peopleIndex.has(name)) continue; // Skip if already exists
    const id = newId();
    peopleIndex.set(name, id);
    peopleList.push({ id, name, tags: new Set() });
  }
}

Enter fullscreen mode Exit fullscreen mode

Two dedup passes happen here, not one: new Set(tokens) collapses repeats within the current paste, and peopleIndex (a name -> id map) stops you from re-adding someone you already parsed in an earlier paste, so you can top up a list incrementally without doubling anyone. The obvious gotcha: dedup is by exact string match. Two different people who happen to both be named "John" get silently merged into one entry. There's no separate identity field to disambiguate them — for this tool, the name is the identity.

There's no shuffle — it's a scored placement loop

Instead of randomizing order and slicing, the algorithm sorts people by how "constrained" they are, then greedily places the most-constrained people first:

const peopleOrder = [...peopleList].sort((a, b) => {
  // 1. Priority tags first
  const pa = Array.from(a.tags).filter((id) => priorityTagIds.has(id)).length;
  const pb = Array.from(b.tags).filter((id) => priorityTagIds.has(id)).length;
  if (pb !== pa) return pb - pa;

  // 2. People with more rules (hard + soft) should be placed first
  const ta = totalRuleDegree(a.id);
  const tb = totalRuleDegree(b.id);
  if (tb !== ta) return tb - ta;

  // 3. Rare tags first (harder to place later)
  const ra = a.tags.size === 0 ? Infinity : [...a.tags].reduce((s, id) => s + (tagFreq.get(id) || 0), 0);
  const rb = b.tags.size === 0 ? Infinity : [...b.tags].reduce((s, id) => s + (tagFreq.get(id) || 0), 0);
  return ra - rb;
});

Enter fullscreen mode Exit fullscreen mode

The intuition is the same one you'd use packing a suitcase: place the awkward, oddly-shaped items first while you still have room to negotiate, and let the easy stuff fill in the gaps at the end. Someone tagged for a priority rule, or tied up in three "must be separate" rules, gets seated before someone with no constraints at all.

For each person, pickBestGroupFor scores every group and keeps the best one — and the way it scores a hypothetical placement is the part I find genuinely clever (or slightly unhinged, depending on your mood): it actually places the person, recomputes every rule violation for real, reads off the damage, then undoes it:

function deltaScoreIfPlace(pid, gid) {
  const g = groups.find((x) => x.id === gid);
  const snapshot = takeLightSnapshot();

  g.members.push(pid); // temporarily place for testing

  const vlist = computeViolationsInternal();
  const hardViolations = vlist.filter((v) => v.type === "HARD");
  const softViolations = vlist.filter((v) => v.type === "SOFT");

  const score = hardViolations.length > 0 ? -Infinity : -softViolations.length * 10;

  applyLightSnapshot(snapshot); // restore before trying the next group
  return score;
}

Enter fullscreen mode Exit fullscreen mode

No incremental "would this specific placement create a conflict" check — it just mutates real state, re-runs the full violation scan across every rule and every group, reads the count, then rolls it back with a snapshot/restore pair. It's simple to reason about and hard to get subtly wrong, at the cost of re-scanning the whole ruleset once per candidate group per person. Fine for a class of 30. I wouldn't paste in a list of 5,000.

What happens when the list doesn't divide evenly

With n people and k groups, the base size is Math.floor(n / k) and there's a remainder of n % k people left over. How that remainder gets handled actually depends on a checkbox, and not in the way I expected when I first wired it up:

const baseCap = Math.floor(n / k);
const remainder = n % k;
const capacities = groups.map((_, i) =>
  allowPlusMinusOne.value ? baseCap : baseCap + (i < remainder ? 1 : 0),
);

Enter fullscreen mode Exit fullscreen mode

With "allow ±1" turned off, the remainder is distributed deterministically — the first remainder groups get exactly one extra seat, everyone else gets baseCap, and that's a hard ceiling. But turn "allow ±1" on and that capacities array is computed and then effectively ignored: the actual capacity check inside the placement loop switches to a flat avgCap + 1 for every group instead of reading capacities[gIndex]:

if (!allowPlusMinusOne.value) {
  if (g.members.length >= capacities[gIndex]) continue;
} else {
  if (g.members.length >= avgCap + 1) continue;
}

Enter fullscreen mode Exit fullscreen mode

So in ±1 mode, there's no planned assignment of "who gets the extra seat" — it's just whichever group hasn't hit avgCap + 1 yet when a person's turn comes up in the sorted order. Usually that lands close to even because of how the sort feeds people in, but it's a side effect of the loop order, not a designed distribution.

Limitations worth knowing about

  • Same-name collisions are real: paste two "Alex"es and the second silently merges into the first person's tag set instead of becoming a second person.
  • "Auto Fix" for a hard violation isn't an optimizer — it tries moving the person to each group in order, then tries swapping with each other person in order, and stops at the first combination that clears all hard violations. It's not looking for the best fix, just a working one.
  • The whole thing runs client-side (there's no server round-trip for any of this), which is great for privacy but means the violation-rescan approach above scales with your browser's single thread, not a server.
  • Soft rules are genuinely soft: the algorithm will happily accept a placement that breaks three "prefer separate" rules if breaking zero of them isn't achievable, and it won't tell you which combination of soft rules it chose to sacrifice unless you read the violation list afterward.

I ended up cleaning this up into a small free tool if you want to skip building your own constraint-scoring loop: Smart Grouping Tool. No sign-up, runs entirely in the browser.


Available in other languages