BACK TO BLOG
2026-08-30Meghmalhar Bhowmick

Writing an Unbribable MUN Allocator

TypeScriptAlgorithmsMUNNext.jsOptimizationBackend

PASMUN Bokaro is a Model United Nations conference. Delegates register online, list their top three committee preferences, and then the conference staff manually allocates everyone into committees while trying to satisfy a bunch of competing rules: don't overcrowd any room, don't cluster all the younger kids in one place, spread the experienced delegates evenly, don't let the same school section dominate a committee.

When I built the online registration platform at pasmun-alpha, the organizers were doing this allocation in a Google Sheet by hand. With 150+ delegates across multiple committees, it took hours and the result was always contested by someone. I automated it with a greedy cost-heuristic allocator that runs in the serverless API endpoint at src/app/api/admin/allocate/route.ts.

The constraints

Five simultaneous rules:

  1. Capacity: No committee exceeds 40 delegates (stats.total < stats.cap).
  2. Grade 7 cap: Younger students get distributed proportionally: grade7Cap = Math.ceil(totalGrade7 / 3). If a committee already has its share of Grade 7 delegates, it's blocked for the next one.
  3. Star delegate distribution: Experienced delegates (marked isStar) must be spread across committees, not concentrated in a single room.
  4. Class de-clumping: Minimize delegates from the same class section (8A, 9B, 11C) in the same committee, to push cross-school interaction.
  5. Preference satisfaction: Honor 1st, 2nd, and 3rd committee preferences where possible.

No single constraint can override the others. You need to balance all five simultaneously. This is exactly the kind of problem where people reach for linear programming solvers — but for 150 delegates and 8 committees, a well-designed greedy heuristic runs in under 5ms and produces results that feel fair to everyone involved.

The two-pass shuffled pipeline

To prevent starvation, delegates are split into two groups and each is independently shuffled with Fisher-Yates:

const starCandidates = shuffle(candidates.filter((c) => c.isStar));
const nonStarCandidates = shuffle(candidates.filter((c) => !c.isStar));
const finalCandidatesOrder = [...starCandidates, ...nonStarCandidates];

Star delegates get evaluated first. By the time general candidates are processed, experienced delegates are already distributed across all committees, so the star-balance penalty naturally pushes subsequent stars toward under-represented rooms.

The Fisher-Yates shuffle within each group eliminates registration-order bias. The first delegate to register shouldn't always get their first preference just because they registered early.

The cost function

For each candidate, every committee that passes the hard capacity and demographic checks gets a cost score:

// Preference penalty: first choice = 0, second = 50, third = 100, fallback = 500
const prefCost = prefIndex === 0 ? 0 : prefIndex === 1 ? 50 : prefIndex === 2 ? 100 : 500;

// Star balance penalty: discourages stacking stars
const starCost = isStar ? 20 * stats.stars : 0;

// Class balance penalty: +10 per existing delegate from the same class section
const classCost = 10 * (stats.classCounts[classKey] || 0);

// Size balance penalty: +1 per delegate already in this committee
const sizeCost = stats.total;

const totalCost = prefCost + starCost + classCost + sizeCost;

The candidate gets assigned to the committee with the lowest total cost. Statistics are updated in memory immediately before the next candidate is evaluated — so each assignment feeds back into subsequent decisions, producing a self-balancing cascade.

Tuning the weights

The penalty weights matter a lot and required iteration. If prefCost for a fallback is too low, delegates get ignored for their preferences in favor of marginal balance improvements. If classCost is too high, the algorithm over-optimizes for class de-clumping at the expense of preference satisfaction.

The final numbers (prefCost = 0/50/100/500, starCost = 20×count, classCost = 10×count, sizeCost = 1×total) came from running the allocator against realistic delegate lists and checking whether the outputs felt fair to a human reviewer. The 500-point fallback penalty is intentionally severe — if someone gets their fallback committee, it means every preferred committee was hard-blocked by capacity or demographics, not just slightly unfavorable.

Why not a proper solver

I considered using a constraint satisfaction library or an ILP solver. The honest answer is: I didn't need to. For the problem size (150 delegates, 8 committees, 5 constraints), the greedy heuristic converges to allocations that are indistinguishable from optimal in practice. A proper solver would add dependencies, cold-start latency in a serverless endpoint, and complexity for marginal gains that nobody in the room would notice.

Greedy cost heuristics are underrated for real-world logistics. They're fast, they're debuggable, and when you carefully design the penalty weights, they produce results that feel genuinely fair even to the people who got their second choice.

Takeaway

You don't need operations research packages to solve real-world allocation problems. A two-pass shuffle with a multi-variable penalty function runs in milliseconds, produces balanced allocations, and is simple enough to explain to a non-technical conference organizer. The key is designing your penalty weights to reflect what "fair" actually means to the humans involved — not just what minimizes a mathematical objective function.

[ GALLERY ]