Matchmaking Trade-off Lab
Build a browser simulator to compare matchmaking quality, roles, and queue time.
Introduction
30 Second Summary
A multiplayer match can look balanced on average. One player can still wait too long for a role they dislike.
In this project, you will build a League of Legends-inspired matchmaking laboratory in Safari. You will compare three policies against the same reproducible player queue to expose the trade-off between match quality and waiting time.
What You'll Build
Your finished lab shows three matchmaking policies producing different outcomes from the same seeded population.
By the end of this project, you'll have:
- A reproducible population whose role counts plus MMR fingerprint stay identical when you rerun the same seed.
- A side-by-side policy comparison showing whether each policy forms a match. You can compare average wait, team MMR gap, autofill count, role-parity mismatches, plus simulated fairness.
- A transparent case study where every simulated score traces back to displayed inputs. Its system architecture gives you an interview-ready explanation of the trade-offs.
- Secret Mission: Stress-test the high-MMR scenario across seeds 7, 42, plus 99. Defend your policy choice using scheduler language.
Are there any prerequisites?
Familiarity with JavaScript fundamentals helps, while no prior system-design experience is required. The local project needs Visual Studio Code plus Safari on a Mac, with no account or paid service required.
Before We Start
Before hands-on work begins, this checkpoint commits you to a transparent synthetic teaching model that compares deterministic naive MMR, strict primary-role, and progressive-relaxation policies. The model must never claim to represent real League of Legends matchmaking, hidden MMR, Riot scoring, or actual player satisfaction.
Set Up the Local Simulator
A transparent comparison needs one browser shell before it can measure matchmaking trade-offs. Keeping the project dependency-free removes API work from the experiment.
You will use Visual Studio Code to create the local workspace. Safari will open the finished page directly from your Mac.
In this step, get ready to:
- Create the local workspace with three project files.
- Build the complete static dashboard shell.
- Style the responsive interface before opening it in Safari.
Create the local workspace
A workspace keeps every simulator file inside one named folder. Placing it on your Desktop also makes it easy to find from Finder.
- Press Cmd+Space to open macOS search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
- Click File in the menu bar.
- Select Open Folder....
- Choose Desktop as the location.
- Use the folder creation control to create a folder named matchmaking-tradeoff-lab.
- Select the new matchmaking-tradeoff-lab folder.
- Click Open to use the folder as your workspace.
You should see matchmaking-tradeoff-lab at the top of the Explorer sidebar. The empty workspace now has a predictable location.
Why Use Local Files?
This simulator makes no network requests. A local page keeps packages outside the experiment.
The browser can focus on the matchmaking model. Every value remains synthetic and inspectable.
The Explorer sidebar represents the files inside the workspace. You need one document for structure, one stylesheet for presentation, plus one browser script for later simulator logic.
- Click New File... in the Explorer sidebar.
- Enter index.html as the file name.
You should see index.html in the Explorer sidebar.
- Click New File... in the Explorer sidebar.
- Enter styles.css as the file name.
You should now see styles.css below the HTML document.
- Click New File... in the Explorer sidebar.
- Enter app.js as the file name.
You should see index.html, styles.css, plus app.js in the Explorer sidebar. Keep app.js empty during this step.
Build the dashboard shell
The HTML shell gives each later policy a place to display inputs and measurements. The deferred classic script waits until the document is parsed before the browser runs it.
- Select index.html in the Explorer sidebar.
- Create the document foundation by pasting this code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Matchmaking Trade-off Lab</title>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<header class="hero">
<p class="eyebrow">System Design Portfolio Project</p>
<h1>Matchmaking Trade-off Lab</h1>
<p>
Compare three League-inspired policies on the same synthetic queue.
This is a teaching model, not Riot's production algorithm.
</p>
</header>
<main>
</main>
<footer>
Synthetic values only. No Riot API, hidden MMR, or production matchmaking data is used.
</footer>
</body>
</html>
What Does This Structure Do?
- The document head gives the browser the page title and display settings.
- The stylesheet link connects styles.css to the document.
- The deferred script connects the empty app.js file without blocking document parsing.
- The hero and footer disclose that the simulator uses synthetic values.
- Save index.html.
- Press Cmd+Space to open macOS search.
- Type Finder into the search field.
- Press Enter to open Finder.
- Select Desktop in the Finder sidebar.
- Open the matchmaking-tradeoff-lab folder.
- Control-click index.html.
- Choose Open With.
- Choose Safari.
You should see the Matchmaking Trade-off Lab title and the synthetic-data disclaimer. Safari is now reading the local HTML document.
Page Does Not Open?
Confirm that Finder shows index.html inside the matchmaking-tradeoff-lab folder. Check that the file name has no extra extension.
Help me check why my local HTML file does not open in Safari.
The experiment controls define the inputs for each comparison. Later JavaScript reads the seed and population scenario from these elements.
- Switch back to index.html in Visual Studio Code.
- Place your cursor between the opening and closing main tags.
- Add the experiment controls by pasting this code:
<section class="panel controls" aria-labelledby="controls-title">
<div>
<p class="eyebrow">Experiment controls</p>
<h2 id="controls-title">Generate one population, test every policy</h2>
</div>
<label>
Seed
<input id="seed" type="number" value="42" min="0" step="1">
</label>
<label>
Population scenario
<select id="scenario">
<option value="standard">Standard population</option>
<option value="high">High-MMR, low population</option>
</select>
</label>
<button id="run" type="button">Run comparison</button>
<p id="population-summary" class="summary" aria-live="polite"></p>
</section>
How Do the Controls Support the Experiment?
- The seed input starts at 42 so the first generated population can be repeated.
- The scenario selector switches between the standard population and the scarce high-MMR population.
- The run button becomes the entry point for each comparison.
- The population summary provides a visible fingerprint for the generated tickets.
- Save index.html.
- Return to Safari.
- Refresh the page.
You should see a seed field set to 42, a population selector, plus a Run comparison button.
Controls Missing From the Page?
Check that the controls section sits between the main tags. Confirm that index.html was saved before you refreshed Safari.
Help me find why the controls in my local HTML page are missing.
The comparison table gives every policy the same measurement columns. Its body stays empty until later simulator logic produces results.
- Place your cursor below the controls section in index.html.
- Add the comparison table by pasting this code:
<section class="panel" aria-labelledby="results-title">
<p class="eyebrow">Measured trade-offs</p>
<h2 id="results-title">Policy comparison</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Policy</th>
<th>Matched</th>
<th>Avg wait</th>
<th>Team MMR gap</th>
<th>Autofills</th>
<th>Parity mismatches</th>
<th>Simulated fairness</th>
</tr>
</thead>
<tbody id="results-body"></tbody>
</table>
</div>
<div id="policy-notes" class="policy-grid"></div>
</section>
What Will the Table Measure?
Each row reports whether a policy formed a match. The remaining columns expose its queue-time and assignment costs.
The empty results-body and policy-notes elements give the browser script safe rendering targets.
- Save index.html.
- Return to Safari.
- Refresh the page.
You should see the Policy comparison heading above seven table columns. The table should contain no result rows.
Table Columns Look Incomplete?
Confirm that every table heading is inside the same tr element. Check that the closing table tags match the snippet.
Help me repair the policy comparison table in my HTML.
A transparent heuristic needs its assumptions displayed beside the measurements. This panel keeps the synthetic score from being mistaken for production data.
- Place your cursor below the results section in index.html.
- Add the heuristic disclosure by pasting this code:
<section class="panel warning" aria-labelledby="heuristic-title">
<p class="eyebrow">Transparent by design</p>
<h2 id="heuristic-title">The fairness score is a simulated heuristic</h2>
<p>
It is not Riot's score and it does not measure actual player satisfaction.
It exists only to make this model's assumptions inspectable.
</p>
<code>
100 - min(35, MMR gap / 8) - min(25, average wait / 12)
- (4 x autofills) - (8 x parity mismatches)
</code>
</section>
Why Disclose the Formula?
The formula turns every penalty into a visible project parameter. A reviewer can challenge the weights or replace them.
The disclosure keeps the teaching model honest. It makes no claim about actual player satisfaction.
- Save index.html.
- Return to Safari.
- Refresh the page.
You should see a simulated fairness heading and the complete penalty formula below the empty results table.
Heuristic Panel Looks Malformed?
Confirm that the formula is inside the opening and closing code tags. Check that the closing section tag follows the formula.
Help me fix the heuristic formula panel in my HTML.
The architecture flow traces synthetic players from generation to visible measurements. This makes the simulator explainable as a system.
- Place your cursor below the heuristic section in index.html.
- Add the architecture flow by pasting this code:
<section class="panel" aria-labelledby="architecture-title">
<p class="eyebrow">Interview walkthrough</p>
<h2 id="architecture-title">System architecture</h2>
<div class="architecture" role="img" aria-label="Player Generator to Ticket Queue to Policy Engine to Team Assignment to Metrics Dashboard">
<div class="node">Player Generator</div>
<span>-></span>
<div class="node">Ticket Queue</div>
<span>-></span>
<div class="node">Policy Engine</div>
<span>-></span>
<div class="node">Team Assignment</div>
<span>-></span>
<div class="node">Metrics Dashboard</div>
</div>
</section>
How Does the Architecture Flow?
- The player generator creates the synthetic workload.
- The ticket queue holds candidates for evaluation.
- The policy engine selects a team assignment.
- The metrics dashboard exposes the resulting trade-offs.
- Save index.html.
- Return to Safari.
- Refresh the page.
You should see five architecture stages in one sequence below the heuristic panel.
Architecture Stages Out of Order?
Check that the five node elements appear in the same order as the snippet. Confirm that every separator sits between two nodes.
Help me repair the architecture flow in my HTML.
The final cards connect matchmaking to other allocation problems. These links turn the experiment into a transferable system-design case study.
- Place your cursor below the architecture section in index.html.
- Add the transfer cards by pasting this code:
<section class="panel" aria-labelledby="transfer-title">
<p class="eyebrow">Transferable mental models</p>
<h2 id="transfer-title">Where else this design appears</h2>
<div class="transfer-grid">
<article>
<h3>Scheduling</h3>
<p>Players become jobs, roles become required capabilities, and wait time becomes job age.</p>
</article>
<article>
<h3>Resource allocation</h3>
<p>Hard feasibility rules filter impossible assignments before soft scores rank valid ones.</p>
</article>
<article>
<h3>Distributed queues</h3>
<p>Region or mode can partition work, improving scale while shrinking each candidate pool.</p>
</article>
<article>
<h3>Latency versus quality</h3>
<p>Waiting longer can widen the search, trading an ideal allocation for a timely result.</p>
</article>
</div>
</section>
What Do the Transfer Cards Show?
The same allocation structure appears in schedulers and distributed queues. Each domain gives the tickets and constraints different names.
The core decision stays visible. A system trades allocation quality against waiting time.
- Save index.html.
- Return to Safari.
- Refresh the page.
You should see four transfer explanations followed by the synthetic-data footer. The complete dashboard shell is now visible.
Transfer Cards Missing?
Confirm that the transfer section remains inside the main element. Check that every article has a closing tag.
Help me find a missing tag in the transfer-card HTML.
Style and verify the dashboard
The static shell now contains every dashboard section. CSS turns that document into a readable experiment interface across wide and narrow windows.
- Select styles.css in the Explorer sidebar.
- Add the page foundation by pasting this code:
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #07111f;
color: #e9f0f7;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(22, 163, 174, 0.2), transparent 35%),
#07111f;
}
.hero,
main,
footer {
width: min(1120px, calc(100% - 32px));
margin-inline: auto;
}
What Does the Foundation Control?
- The root rules establish the dark palette and shared font stack.
- Border-box sizing keeps padding inside each element's width.
- The body gradient adds depth without requiring an image.
- The shared width keeps the hero and dashboard aligned.
- Save styles.css.
- Return to Safari.
- Refresh the page.
You should see a dark navy background with a teal glow. The content should sit inside a centered column.
Page Still Has a White Background?
Confirm that index.html links to styles.css in the document head. Check that both files are in the same folder.
Help me diagnose why styles.css is not loading in Safari.
- Add the hero spacing and dashboard rhythm at the bottom of styles.css by pasting this code:
.hero {
padding: 64px 0 28px;
}
.hero h1 {
max-width: 760px;
margin: 4px 0 12px;
font-size: clamp(2.4rem, 7vw, 5.4rem);
line-height: 0.98;
}
.hero > p:last-child {
max-width: 720px;
color: #aebed0;
font-size: 1.05rem;
}
main {
display: grid;
gap: 20px;
padding-bottom: 40px;
}
How Does the Page Gain Hierarchy?
The responsive title grows with the viewport while staying inside a readable range. The main grid gives every dashboard section consistent separation.
- Save styles.css.
- Return to Safari.
- Refresh the page.
You should see a large Matchmaking Trade-off Lab title. The dashboard sections should have consistent vertical spacing.
Title Size Unchanged?
Check that the heading remains inside an element with the hero class. Confirm that the selector includes h1.
Help me fix the hero title styling in styles.css.
- Add the panel and eyebrow styles at the bottom of styles.css by pasting this code:
.panel {
padding: 24px;
border: 1px solid #20344b;
border-radius: 18px;
background: rgba(11, 25, 43, 0.92);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.22);
}
.eyebrow {
margin: 0;
color: #59d4c8;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h2 {
margin: 6px 0 18px;
}
Why Use Panels and Eyebrows?
Panels separate experiment stages without changing pages. Eyebrows give each section a short category label.
- Save styles.css.
- Return to Safari.
- Refresh the page.
You should see rounded dark panels with teal uppercase labels. Each panel should cast a soft shadow.
Panels Still Look Like Plain Text?
Confirm that each dashboard section has the panel class. Check the leading dots in the CSS selectors.
Help me find why my panel and eyebrow styles are not applying.
- Add the control layout and field styles at the bottom of styles.css by pasting this code:
.controls {
display: grid;
grid-template-columns: 1.5fr repeat(3, minmax(150px, auto));
gap: 16px;
align-items: end;
}
label {
display: grid;
gap: 7px;
color: #b8c6d6;
font-size: 0.88rem;
font-weight: 700;
}
input,
select,
button {
min-height: 44px;
border: 1px solid #34516f;
border-radius: 10px;
padding: 0 12px;
background: #0d1c2f;
color: #f4f8fb;
font: inherit;
}
How Are the Controls Arranged?
The grid places the heading and three controls on one row when space allows. Shared field styles keep the interactive elements consistent.
- Save styles.css.
- Return to Safari.
- Refresh the page.
You should see the control heading and three form controls arranged across the first panel.
Controls Remain Stacked on a Wide Window?
Confirm that the controls section contains both panel and controls in its class attribute. Check the grid-template-columns declaration.
Help me troubleshoot the controls grid in styles.css.
- Add the button and overflow styles at the bottom of styles.css by pasting this code:
button {
border-color: #59d4c8;
background: #59d4c8;
color: #06201d;
font-weight: 900;
cursor: pointer;
}
button:hover {
background: #7be3da;
}
.summary {
grid-column: 1 / -1;
margin: 0;
color: #9fb1c4;
}
.table-wrap {
overflow-x: auto;
}
What Changes in This Layer?
The teal button becomes the primary action. Horizontal table overflow protects the layout when metric columns need more width.
- Save styles.css.
- Return to Safari.
- Refresh the page.
You should see a teal Run comparison button. Its background should brighten when you move the pointer over it.
Button Does Not Turn Teal?
Confirm that the control in index.html is a button element. Check that no closing brace is missing from the earlier CSS.
Help me find why the Run comparison button styles are missing.
- Add the table styles at the bottom of styles.css by pasting this code:
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 13px 10px;
border-bottom: 1px solid #20344b;
text-align: left;
white-space: nowrap;
}
th {
color: #91a8bf;
font-size: 0.76rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
How Does the Table Stay Readable?
Collapsed borders create one divider between rows. Non-wrapping cells keep every metric label intact.
- Save styles.css.
- Return to Safari.
- Refresh the page.
You should see seven compact uppercase column headings above a thin divider.
Table Headings Wrap Onto Multiple Lines?
Check that white-space is set to nowrap in the shared cell rules. Confirm that the table remains inside table-wrap.
Help me fix wrapping in the dashboard table headings.
- Add the card styles at the bottom of styles.css by pasting this code:
.policy-grid,
.transfer-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin-top: 20px;
}
.policy-card,
.transfer-grid article {
padding: 16px;
border: 1px solid #20344b;
border-radius: 12px;
background: #0a1728;
}
.policy-card h3,
.transfer-grid h3 {
margin: 0 0 8px;
}
.policy-card p,
.transfer-grid p {
margin: 0;
color: #aebed0;
line-height: 1.55;
}
Why Share These Card Styles?
Policy explanations and transfer examples use the same visual unit. Shared rules keep both groups consistent as the page grows.
- Save styles.css.
- Return to Safari.
- Refresh the page.
You should see the four transfer examples presented as bordered cards. The policy-card area remains empty.
Transfer Examples Lack Card Borders?
Confirm that the examples are article elements inside an element with the transfer-grid class. Check the grouped selector punctuation.
Help me repair the transfer-card CSS selectors.
- Add the heuristic and architecture container styles at the bottom of styles.css by pasting this code:
.warning {
border-color: #826927;
background: rgba(61, 48, 15, 0.65);
}
code {
display: block;
overflow-x: auto;
padding: 14px;
border-radius: 10px;
background: #07111f;
color: #f0d778;
white-space: pre-wrap;
}
.architecture {
display: flex;
gap: 10px;
align-items: center;
overflow-x: auto;
padding-bottom: 8px;
}
How Are Assumptions Highlighted?
The warning panel uses an amber treatment to distinguish the heuristic disclosure. The architecture container keeps its stages in a horizontal sequence.
- Save styles.css.
- Return to Safari.
- Refresh the page.
You should see an amber heuristic panel with the formula inside a dark code box. The architecture stages should remain on one horizontal line.
Heuristic Panel Matches Every Other Panel?
Confirm that the heuristic section has both panel and warning in its class attribute. Check that the formula uses a code element.
Help me fix the warning and formula styles in my dashboard.
- Add the architecture-node and footer styles at the bottom of styles.css by pasting this code:
.node {
min-width: 150px;
padding: 18px 12px;
border: 1px solid #3e7f85;
border-radius: 12px;
background: #0c2434;
text-align: center;
font-weight: 800;
}
.architecture span {
color: #59d4c8;
font-weight: 900;
}
.transfer-grid {
grid-template-columns: repeat(4, 1fr);
}
footer {
padding: 0 0 36px;
color: #7f93a8;
text-align: center;
}
What Completes the Wide Layout?
Each architecture stage becomes a distinct node. Four equal transfer columns use the available width.
- Save styles.css.
- Return to Safari.
- Refresh the page.
You should see bordered architecture nodes with teal separators. The transfer section should use four columns in a wide window.
Architecture Still Looks Like Plain Text?
Confirm that every stage has the node class. Check that the architecture selector starts with a leading dot.
Help me find why my architecture nodes are not styled.
The final rule adapts the dashboard to narrow windows. Controls and card grids switch to one column below the defined width.
- Add the responsive rule at the bottom of styles.css by pasting this code:
@media (max-width: 860px) {
.controls,
.policy-grid,
.transfer-grid {
grid-template-columns: 1fr;
}
}
What Does the Responsive Rule Change?
The rule replaces multi-column grids with one column when the viewport reaches 860px or less. The content stays readable without changing the HTML.
Before the final check, which dashboard areas do you expect to remain empty?
- Save styles.css.
- Return to Safari.
- Refresh the page.
- Narrow the Safari window below 860px.
You should see the controls and transfer cards stack into one column. The population summary, result rows, plus policy notes should remain empty.
That is the local shell working. Safari is loading the HTML structure and linked stylesheet directly from your Mac.
Layout Does Not Stack?
Confirm that the media rule sits after the earlier grid declarations. Check the braces around the rule and its three selectors.
Help me troubleshoot the responsive CSS in my dashboard.
✔️ Awesome, I've got everything!
Your three files are saved. Safari should show the complete static dashboard with an empty policy table.
ⓧ I'd like to double check the full code
Compare index.html and styles.css with the complete versions below. Confirm that app.js exists and contains no code.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Matchmaking Trade-off Lab</title>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<header class="hero">
<p class="eyebrow">System Design Portfolio Project</p>
<h1>Matchmaking Trade-off Lab</h1>
<p>
Compare three League-inspired policies on the same synthetic queue.
This is a teaching model, not Riot's production algorithm.
</p>
</header>
<main>
<section class="panel controls" aria-labelledby="controls-title">
<div>
<p class="eyebrow">Experiment controls</p>
<h2 id="controls-title">Generate one population, test every policy</h2>
</div>
<label>
Seed
<input id="seed" type="number" value="42" min="0" step="1">
</label>
<label>
Population scenario
<select id="scenario">
<option value="standard">Standard population</option>
<option value="high">High-MMR, low population</option>
</select>
</label>
<button id="run" type="button">Run comparison</button>
<p id="population-summary" class="summary" aria-live="polite"></p>
</section>
<section class="panel" aria-labelledby="results-title">
<p class="eyebrow">Measured trade-offs</p>
<h2 id="results-title">Policy comparison</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Policy</th>
<th>Matched</th>
<th>Avg wait</th>
<th>Team MMR gap</th>
<th>Autofills</th>
<th>Parity mismatches</th>
<th>Simulated fairness</th>
</tr>
</thead>
<tbody id="results-body"></tbody>
</table>
</div>
<div id="policy-notes" class="policy-grid"></div>
</section>
<section class="panel warning" aria-labelledby="heuristic-title">
<p class="eyebrow">Transparent by design</p>
<h2 id="heuristic-title">The fairness score is a simulated heuristic</h2>
<p>
It is not Riot's score and it does not measure actual player satisfaction.
It exists only to make this model's assumptions inspectable.
</p>
<code>
100 - min(35, MMR gap / 8) - min(25, average wait / 12)
- (4 x autofills) - (8 x parity mismatches)
</code>
</section>
<section class="panel" aria-labelledby="architecture-title">
<p class="eyebrow">Interview walkthrough</p>
<h2 id="architecture-title">System architecture</h2>
<div class="architecture" role="img" aria-label="Player Generator to Ticket Queue to Policy Engine to Team Assignment to Metrics Dashboard">
<div class="node">Player Generator</div>
<span>-></span>
<div class="node">Ticket Queue</div>
<span>-></span>
<div class="node">Policy Engine</div>
<span>-></span>
<div class="node">Team Assignment</div>
<span>-></span>
<div class="node">Metrics Dashboard</div>
</div>
</section>
<section class="panel" aria-labelledby="transfer-title">
<p class="eyebrow">Transferable mental models</p>
<h2 id="transfer-title">Where else this design appears</h2>
<div class="transfer-grid">
<article>
<h3>Scheduling</h3>
<p>Players become jobs, roles become required capabilities, and wait time becomes job age.</p>
</article>
<article>
<h3>Resource allocation</h3>
<p>Hard feasibility rules filter impossible assignments before soft scores rank valid ones.</p>
</article>
<article>
<h3>Distributed queues</h3>
<p>Region or mode can partition work, improving scale while shrinking each candidate pool.</p>
</article>
<article>
<h3>Latency versus quality</h3>
<p>Waiting longer can widen the search, trading an ideal allocation for a timely result.</p>
</article>
</div>
</section>
</main>
<footer>
Synthetic values only. No Riot API, hidden MMR, or production matchmaking data is used.
</footer>
</body>
</html>
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #07111f;
color: #e9f0f7;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(22, 163, 174, 0.2), transparent 35%),
#07111f;
}
.hero,
main,
footer {
width: min(1120px, calc(100% - 32px));
margin-inline: auto;
}
.hero {
padding: 64px 0 28px;
}
.hero h1 {
max-width: 760px;
margin: 4px 0 12px;
font-size: clamp(2.4rem, 7vw, 5.4rem);
line-height: 0.98;
}
.hero > p:last-child {
max-width: 720px;
color: #aebed0;
font-size: 1.05rem;
}
main {
display: grid;
gap: 20px;
padding-bottom: 40px;
}
.panel {
padding: 24px;
border: 1px solid #20344b;
border-radius: 18px;
background: rgba(11, 25, 43, 0.92);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.22);
}
.eyebrow {
margin: 0;
color: #59d4c8;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h2 {
margin: 6px 0 18px;
}
.controls {
display: grid;
grid-template-columns: 1.5fr repeat(3, minmax(150px, auto));
gap: 16px;
align-items: end;
}
label {
display: grid;
gap: 7px;
color: #b8c6d6;
font-size: 0.88rem;
font-weight: 700;
}
input,
select,
button {
min-height: 44px;
border: 1px solid #34516f;
border-radius: 10px;
padding: 0 12px;
background: #0d1c2f;
color: #f4f8fb;
font: inherit;
}
button {
border-color: #59d4c8;
background: #59d4c8;
color: #06201d;
font-weight: 900;
cursor: pointer;
}
button:hover {
background: #7be3da;
}
.summary {
grid-column: 1 / -1;
margin: 0;
color: #9fb1c4;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 13px 10px;
border-bottom: 1px solid #20344b;
text-align: left;
white-space: nowrap;
}
th {
color: #91a8bf;
font-size: 0.76rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.policy-grid,
.transfer-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin-top: 20px;
}
.policy-card,
.transfer-grid article {
padding: 16px;
border: 1px solid #20344b;
border-radius: 12px;
background: #0a1728;
}
.policy-card h3,
.transfer-grid h3 {
margin: 0 0 8px;
}
.policy-card p,
.transfer-grid p {
margin: 0;
color: #aebed0;
line-height: 1.55;
}
.warning {
border-color: #826927;
background: rgba(61, 48, 15, 0.65);
}
code {
display: block;
overflow-x: auto;
padding: 14px;
border-radius: 10px;
background: #07111f;
color: #f0d778;
white-space: pre-wrap;
}
.architecture {
display: flex;
gap: 10px;
align-items: center;
overflow-x: auto;
padding-bottom: 8px;
}
.node {
min-width: 150px;
padding: 18px 12px;
border: 1px solid #3e7f85;
border-radius: 12px;
background: #0c2434;
text-align: center;
font-weight: 800;
}
.architecture span {
color: #59d4c8;
font-weight: 900;
}
.transfer-grid {
grid-template-columns: repeat(4, 1fr);
}
footer {
padding: 0 0 36px;
color: #7f93a8;
text-align: center;
}
@media (max-width: 860px) {
.controls,
.policy-grid,
.transfer-grid {
grid-template-columns: 1fr;
}
}
Your local simulator shell is ready. Next, you will generate a reproducible player population and give the empty comparison table its first measurable result.
Build a Reproducible Naive Baseline
Your local dashboard is ready in Safari. It now needs a repeatable population before any matchmaking policy can be compared fairly.
You will use JavaScript to generate the same synthetic queue from the same seed. The first policy optimizes for MMR before assigning roles. Its preference misses reveal why one fairness signal cannot represent the whole player experience.
In this step, get ready to:
- Generate a deterministic population from the selected seed and scenario.
- Build a naive policy that balances ten players by MMR before assigning roles.
- Render the population fingerprint and policy metrics in the dashboard.
Generate a reproducible player population
A deterministic simulation produces the same output whenever it receives the same seed. This gives every policy an identical queue to evaluate.
- Select app.js in the Explorer sidebar in Visual Studio Code.
- Replace the placeholder content with the role configuration and seeded random generator below:
const ROLES = ["Top", "Jungle", "Mid", "ADC", "Support"];
const SCENARIOS = {
standard: {
label: "Standard population",
meanMmr: 1400,
spread: 500,
maxWait: 120,
roleCounts: { Top: 8, Jungle: 6, Mid: 10, ADC: 8, Support: 8 },
},
high: {
label: "High-MMR, low population",
meanMmr: 2600,
spread: 180,
maxWait: 300,
roleCounts: { Top: 3, Jungle: 1, Mid: 6, ADC: 3, Support: 1 },
},
};
function makeRandom(seed) {
let state = Number(seed) >>> 0;
return function random() {
state = (Math.imul(1664525, state) + 1013904223) >>> 0;
return state / 4294967296;
};
}
How does the seed control the experiment?
- ROLES defines the five positions that every completed team needs.
- SCENARIOS stores the population size and distribution for each workload.
- makeRandom() turns the seed into a repeatable sequence of decimal values.
- The same seed starts from the same internal state, so later random choices occur in the same order.
- Add the array-shuffling helper below makeRandom() by pasting this code:
function shuffle(items, random) {
const copy = [...items];
for (let index = copy.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(random() * (index + 1));
[copy[index], copy[swapIndex]] = [copy[swapIndex], copy[index]];
}
return copy;
}
What does this helper do?
shuffle() rearranges a copy of the supplied array. Using the seeded random function keeps that new order reproducible.
- Add the player generator below shuffle() by pasting this code:
function generatePlayers(seed, scenario) {
const random = makeRandom(seed);
const primaryRoles = [];
for (const role of ROLES) {
for (let count = 0; count < scenario.roleCounts[role]; count += 1) {
primaryRoles.push(role);
}
}
return shuffle(primaryRoles, random).map((primary, index) => {
const secondaryChoices = ROLES.filter((role) => role !== primary);
const secondary = secondaryChoices[Math.floor(random() * secondaryChoices.length)];
const centeredRoll = random() + random() + random() - 1.5;
return {
id: `P${String(index + 1).padStart(2, "0")}`,
mmr: Math.round(scenario.meanMmr + centeredRoll * scenario.spread),
wait: Math.round(15 + random() * (scenario.maxWait - 15)),
primary,
secondary,
};
});
}
What does each ticket contain?
- id gives each synthetic player a stable label.
- mmr stores the generated skill value used by the baseline policy.
- wait records how many simulated seconds the ticket has spent in the queue.
- primary and secondary store the player's role preferences.
The first visible checkpoint only needs the generated tickets. The summary turns their IDs and MMR values into a compact fingerprint that you can compare between runs.
- Add the population renderer below generatePlayers() by pasting this code:
function renderPopulation(players, seed, scenario) {
const counts = ROLES.map((role) => {
const count = players.filter((player) => player.primary === role).length;
return `${role} ${count}`;
}).join(" | ");
const firstFive = players
.slice(0, 5)
.map((player) => `${player.id}:${player.mmr}`)
.join(", ");
document.getElementById("population-summary").textContent =
`Seed ${seed} | ${scenario.label} | ${players.length} tickets | ${counts} | Fingerprint ${firstFive}`;
}
What does the fingerprint prove?
renderPopulation() displays the seed and primary-role counts. It also displays the first five player IDs with their MMR values.
Matching fingerprints prove that the generator supplied the same workload. This prevents population changes from being mistaken for policy effects.
- Add the initial simulation runner below renderPopulation() by pasting this code:
function runSimulation() {
const seed = Number(document.getElementById("seed").value) || 0;
const scenarioKey = document.getElementById("scenario").value;
const scenario = SCENARIOS[scenarioKey];
const players = generatePlayers(seed, scenario);
renderPopulation(players, seed, scenario);
}
document.getElementById("run").addEventListener("click", runSimulation);
runSimulation();
How does the page start the simulation?
- runSimulation() reads the current seed and population scenario from the controls.
- generatePlayers() creates one in-memory population from those inputs.
- addEventListener() runs the simulation whenever you select the comparison button.
- The final call fills the population summary as soon as the local page loads.
- Save app.js.
- Return to the local index.html page in Safari.
- Refresh the page.
Your population summary now shows seed 42, the Standard population label, role counts, and a five-player fingerprint. That visible fingerprint confirms your seeded queue is running.
Population summary still empty?
Confirm that runSimulation(); remains at the bottom of app.js. Check that the page still loads the same file with the deferred script tag.
Check the braces around SCENARIOS and generatePlayers() if the summary remains blank.
Help me find why my seeded population summary is empty.
Build the naive MMR policy
The baseline selects the tightest ten-player MMR window. It balances those players between two teams before assigning the required roles.
Role preference is evaluated after team formation. This ordering makes collisions measurable through autofills and parity mismatches.
- Place your cursor above renderPopulation() in app.js.
- Insert the metric and assignment helpers by pasting this code:
function average(items, value) {
return items.reduce((total, item) => total + value(item), 0) / items.length;
}
function preferenceLevel(player, assignedRole) {
if (player.primary === assignedRole) return 0;
if (player.secondary === assignedRole) return 1;
return 2;
}
function assignPlayer(player, assignedRole) {
const preference = preferenceLevel(player, assignedRole);
return {
...player,
assignedRole,
preference,
autofilled: preference === 2,
};
}
How are preference misses represented?
- average() calculates a metric from a selected group of players.
- preferenceLevel() assigns cost 0 to a primary role and cost 1 to a secondary role. Every other assignment receives cost 2.
- assignPlayer() preserves the ticket data while recording the assigned role and preference cost.
- autofilled becomes true when the assigned role matches neither stated preference.
- Add the role-slot assignment helper directly below assignPlayer() by pasting this code:
function assignSlots(team) {
const available = [...team];
const assignments = [];
for (const role of ROLES) {
let index = available.findIndex((player) => player.primary === role);
if (index === -1) {
index = available.findIndex((player) => player.secondary === role);
}
if (index === -1) index = 0;
const [player] = available.splice(index, 1);
assignments.push(assignPlayer(player, role));
}
return assignments;
}
How are the five roles filled?
assignSlots() tries a primary-role match first. It tries a secondary-role match next.
The helper falls back to the first remaining player when neither preference is available. That fallback makes role collisions visible in the final metrics.
- Save app.js.
- Refresh the page in Safari.
The population fingerprint still fills with the same seeded values. This confirms the new assignment helpers load without interrupting generation.
- Add the tightest-window search below assignSlots() by pasting this code:
function closestMmrWindow(players, size) {
const sorted = [...players].sort((a, b) => a.mmr - b.mmr);
let best = sorted.slice(0, size);
let bestSpan = best[best.length - 1].mmr - best[0].mmr;
for (let start = 1; start <= sorted.length - size; start += 1) {
const window = sorted.slice(start, start + size);
const span = window[window.length - 1].mmr - window[0].mmr;
if (span < bestSpan) {
best = window;
bestSpan = span;
}
}
return best;
}
How does the baseline choose candidates?
closestMmrWindow() sorts every ticket by MMR. It compares each possible group of ten players.
The group with the smallest distance between its lowest and highest MMR becomes the candidate match. Queue age and role feasibility do not affect this selection.
- Add the team-balancing helper below closestMmrWindow() by pasting this code:
function balanceByMmr(players) {
const blue = [];
const red = [];
let blueTotal = 0;
let redTotal = 0;
for (const player of [...players].sort((a, b) => b.mmr - a.mmr)) {
const sendBlue = blue.length < 5 && (red.length === 5 || blueTotal <= redTotal);
if (sendBlue) {
blue.push(player);
blueTotal += player.mmr;
} else {
red.push(player);
redTotal += player.mmr;
}
}
return { blue: assignSlots(blue), red: assignSlots(red) };
}
How are the teams balanced?
balanceByMmr() processes the selected players from highest MMR to lowest. Each player goes to the eligible team with the lower running total.
Only after the teams contain five players does assignSlots() fill their roles. That sequence creates the baseline's deliberate blind spot.
- Save app.js.
- Refresh the page in Safari.
The population fingerprint continues to render. Your candidate selection and team-balancing functions now load alongside the generator.
The result builder spans the next two adjacent snippets. Paste both snippets before saving the file.
- Add the no-match branch below balanceByMmr() by pasting the first part of makeResult() below:
function makeResult(policy, matched, teams, explanation, search = "") {
if (!matched) {
return {
policy,
matched: false,
explanation,
search,
avgWait: null,
mmrGap: null,
autofills: null,
parityMismatches: null,
score: null,
};
}
Why support a no-match result?
makeResult() uses explicit null metrics when a policy cannot form a match. This keeps an infeasible allocation distinct from a successful result with a poor score.
- Complete makeResult() by pasting this second part immediately after the previous snippet:
const allPlayers = [...teams.blue, ...teams.red];
const blueMmr = average(teams.blue, (player) => player.mmr);
const redMmr = average(teams.red, (player) => player.mmr);
const mmrGap = Math.abs(blueMmr - redMmr);
const avgWait = average(allPlayers, (player) => player.wait);
const autofills = allPlayers.filter((player) => player.autofilled).length;
const parityMismatches = ROLES.filter((role) => {
const bluePlayer = teams.blue.find((player) => player.assignedRole === role);
const redPlayer = teams.red.find((player) => player.assignedRole === role);
return bluePlayer.preference !== redPlayer.preference;
}).length;
const mmrPenalty = Math.min(35, mmrGap / 8);
const waitPenalty = Math.min(25, avgWait / 12);
const score = Math.max(
0,
Math.round(100 - mmrPenalty - waitPenalty - autofills * 4 - parityMismatches * 8),
);
return {
policy,
matched: true,
explanation,
search,
avgWait,
mmrGap,
autofills,
parityMismatches,
score,
};
}
Which trade-offs become measurable?
- avgWait measures the selected tickets' average simulated queue age.
- mmrGap measures the difference between the teams' average MMR values.
- autofills counts assignments outside both stated role preferences.
- parityMismatches counts roles where the two opposing players have different preference costs.
- Add the baseline policy below makeResult() by pasting this code:
function naivePolicy(players) {
const selected = closestMmrWindow(players, 10);
const teams = balanceByMmr(selected);
return makeResult(
"Naive MMR",
true,
teams,
"Builds numerically close teams first, then discovers role collisions during assignment.",
"Tightest ten-player MMR window",
);
}
What makes this policy naive?
naivePolicy() optimizes candidate selection around one signal. It chooses a compact MMR window before it considers role preferences.
The policy always returns a match when ten tickets exist. The resulting autofills reveal the quality cost hidden by that success.
- Save app.js.
- Refresh the page in Safari.
The seeded population still renders after the policy code loads. The next substep connects that result to the comparison table.
Fingerprint disappeared after adding the policy?
Check that the second makeResult() snippet sits directly after the first snippet. Its final brace closes the complete function.
Confirm that naivePolicy() appears after makeResult() and before renderPopulation().
Help me debug the naive policy without changing its identifiers.
Render and verify the baseline
The dashboard already contains a table body and a policy-notes area. DOM helpers now translate the policy result into cells and a short explanation card.
- Insert the table-cell helper immediately above renderPopulation() by pasting this code:
function addCell(row, text) {
const cell = document.createElement("td");
cell.textContent = text;
row.appendChild(cell);
}
How is each table cell created?
addCell() creates a table cell through the browser's DOM API. Using textContent places each metric into that cell.
- Insert the metric formatter immediately below renderPopulation() by pasting this code:
function formatMetric(result, key, suffix = "") {
if (!result.matched) return "N/A";
const value = result[key];
return `${typeof value === "number" ? value.toFixed(key === "avgWait" || key === "mmrGap" ? 1 : 0) : value}${suffix}`;
}
Why format metrics in one place?
formatMetric() gives wait and MMR-gap values one decimal place. It turns unavailable metrics into a consistent N/A label.
- Add the result renderer below formatMetric() by pasting this code:
function renderResults(results) {
const body = document.getElementById("results-body");
const notes = document.getElementById("policy-notes");
body.textContent = "";
notes.textContent = "";
for (const result of results) {
const row = document.createElement("tr");
addCell(row, result.policy);
addCell(row, result.matched ? "Yes" : "No");
addCell(row, formatMetric(result, "avgWait", " s"));
addCell(row, formatMetric(result, "mmrGap"));
addCell(row, formatMetric(result, "autofills"));
addCell(row, formatMetric(result, "parityMismatches"));
addCell(row, result.matched ? `${result.score}/100` : "N/A");
body.appendChild(row);
const card = document.createElement("article");
card.className = "policy-card";
const title = document.createElement("h3");
title.textContent = result.policy;
const detail = document.createElement("p");
detail.textContent = `${result.explanation} Search behavior: ${result.search}.`;
card.append(title, detail);
notes.appendChild(card);
}
}
What reaches the dashboard?
renderResults() clears the previous output before adding one row per policy. The row exposes matching status and every measured trade-off.
The policy card discloses the selection behavior in plain language. This keeps the metric row connected to the decision that produced it.
The current runner generates the population and stops after rendering its fingerprint. One final edit passes that unchanged population into the naive policy.
- Find the current runSimulation() function near the bottom of app.js.
- Use this reference to confirm the current function:
function runSimulation() {
const seed = Number(document.getElementById("seed").value) || 0;
const scenarioKey = document.getElementById("scenario").value;
const scenario = SCENARIOS[scenarioKey];
const players = generatePlayers(seed, scenario);
renderPopulation(players, seed, scenario);
}
What does the current runner produce?
The current function creates one population and renders its fingerprint. The policy result is not passed to the table in this version.
- Replace the current runSimulation() function with this completed version:
function runSimulation() {
const seed = Number(document.getElementById("seed").value) || 0;
const scenarioKey = document.getElementById("scenario").value;
const scenario = SCENARIOS[scenarioKey];
const players = generatePlayers(seed, scenario);
renderPopulation(players, seed, scenario);
renderResults([
naivePolicy(players),
]);
}
Why reuse the same population array?
runSimulation() generates the tickets once. It passes that same in-memory array into naivePolicy().
Future policies can receive the same array through this result list. That paired comparison isolates policy behavior from workload variation.
- Save app.js.
- Return to the local page in Safari.
- Refresh the page.
- Enter 42 in the Seed field.
- Select Standard population from the Population scenario menu.
Before you run the comparison, do you expect the population fingerprint and naive metrics to change when the seed stays fixed?
- Select Run comparison once.
You will see a populated fingerprint and one Naive MMR row. The row shows a successful match with average wait, team MMR gap, autofills, parity mismatches, and a simulated fairness score.
- Select Run comparison again.
The fingerprint and every naive-policy metric remain identical. Any nonzero autofill or parity value shows that balanced team averages can still contain individual role-preference misses.
Table still empty?
Confirm that renderResults() appears before runSimulation(). Check that the result list contains naivePolicy(players).
Check that the HTML targets still use results-body and policy-notes.
Help me debug an empty naive-policy table.
✔️ Awesome, I've got everything!
Your seeded population and Naive MMR metrics now repeat exactly for seed 42. Make sure app.js is saved before continuing.
ⓧ I'd like to double check the full code
Compare your app.js file with this complete baseline implementation:
const ROLES = ["Top", "Jungle", "Mid", "ADC", "Support"];
const SCENARIOS = {
standard: {
label: "Standard population",
meanMmr: 1400,
spread: 500,
maxWait: 120,
roleCounts: { Top: 8, Jungle: 6, Mid: 10, ADC: 8, Support: 8 },
},
high: {
label: "High-MMR, low population",
meanMmr: 2600,
spread: 180,
maxWait: 300,
roleCounts: { Top: 3, Jungle: 1, Mid: 6, ADC: 3, Support: 1 },
},
};
function makeRandom(seed) {
let state = Number(seed) >>> 0;
return function random() {
state = (Math.imul(1664525, state) + 1013904223) >>> 0;
return state / 4294967296;
};
}
function shuffle(items, random) {
const copy = [...items];
for (let index = copy.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(random() * (index + 1));
[copy[index], copy[swapIndex]] = [copy[swapIndex], copy[index]];
}
return copy;
}
function generatePlayers(seed, scenario) {
const random = makeRandom(seed);
const primaryRoles = [];
for (const role of ROLES) {
for (let count = 0; count < scenario.roleCounts[role]; count += 1) {
primaryRoles.push(role);
}
}
return shuffle(primaryRoles, random).map((primary, index) => {
const secondaryChoices = ROLES.filter((role) => role !== primary);
const secondary = secondaryChoices[Math.floor(random() * secondaryChoices.length)];
const centeredRoll = random() + random() + random() - 1.5;
return {
id: `P${String(index + 1).padStart(2, "0")}`,
mmr: Math.round(scenario.meanMmr + centeredRoll * scenario.spread),
wait: Math.round(15 + random() * (scenario.maxWait - 15)),
primary,
secondary,
};
});
}
function average(items, value) {
return items.reduce((total, item) => total + value(item), 0) / items.length;
}
function preferenceLevel(player, assignedRole) {
if (player.primary === assignedRole) return 0;
if (player.secondary === assignedRole) return 1;
return 2;
}
function assignPlayer(player, assignedRole) {
const preference = preferenceLevel(player, assignedRole);
return {
...player,
assignedRole,
preference,
autofilled: preference === 2,
};
}
function assignSlots(team) {
const available = [...team];
const assignments = [];
for (const role of ROLES) {
let index = available.findIndex((player) => player.primary === role);
if (index === -1) {
index = available.findIndex((player) => player.secondary === role);
}
if (index === -1) index = 0;
const [player] = available.splice(index, 1);
assignments.push(assignPlayer(player, role));
}
return assignments;
}
function closestMmrWindow(players, size) {
const sorted = [...players].sort((a, b) => a.mmr - b.mmr);
let best = sorted.slice(0, size);
let bestSpan = best[best.length - 1].mmr - best[0].mmr;
for (let start = 1; start <= sorted.length - size; start += 1) {
const window = sorted.slice(start, start + size);
const span = window[window.length - 1].mmr - window[0].mmr;
if (span < bestSpan) {
best = window;
bestSpan = span;
}
}
return best;
}
function balanceByMmr(players) {
const blue = [];
const red = [];
let blueTotal = 0;
let redTotal = 0;
for (const player of [...players].sort((a, b) => b.mmr - a.mmr)) {
const sendBlue = blue.length < 5 && (red.length === 5 || blueTotal <= redTotal);
if (sendBlue) {
blue.push(player);
blueTotal += player.mmr;
} else {
red.push(player);
redTotal += player.mmr;
}
}
return { blue: assignSlots(blue), red: assignSlots(red) };
}
function makeResult(policy, matched, teams, explanation, search = "") {
if (!matched) {
return {
policy,
matched: false,
explanation,
search,
avgWait: null,
mmrGap: null,
autofills: null,
parityMismatches: null,
score: null,
};
}
const allPlayers = [...teams.blue, ...teams.red];
const blueMmr = average(teams.blue, (player) => player.mmr);
const redMmr = average(teams.red, (player) => player.mmr);
const mmrGap = Math.abs(blueMmr - redMmr);
const avgWait = average(allPlayers, (player) => player.wait);
const autofills = allPlayers.filter((player) => player.autofilled).length;
const parityMismatches = ROLES.filter((role) => {
const bluePlayer = teams.blue.find((player) => player.assignedRole === role);
const redPlayer = teams.red.find((player) => player.assignedRole === role);
return bluePlayer.preference !== redPlayer.preference;
}).length;
const mmrPenalty = Math.min(35, mmrGap / 8);
const waitPenalty = Math.min(25, avgWait / 12);
const score = Math.max(
0,
Math.round(100 - mmrPenalty - waitPenalty - autofills * 4 - parityMismatches * 8),
);
return {
policy,
matched: true,
explanation,
search,
avgWait,
mmrGap,
autofills,
parityMismatches,
score,
};
}
function naivePolicy(players) {
const selected = closestMmrWindow(players, 10);
const teams = balanceByMmr(selected);
return makeResult(
"Naive MMR",
true,
teams,
"Builds numerically close teams first, then discovers role collisions during assignment.",
"Tightest ten-player MMR window",
);
}
function addCell(row, text) {
const cell = document.createElement("td");
cell.textContent = text;
row.appendChild(cell);
}
function renderPopulation(players, seed, scenario) {
const counts = ROLES.map((role) => {
const count = players.filter((player) => player.primary === role).length;
return `${role} ${count}`;
}).join(" | ");
const firstFive = players
.slice(0, 5)
.map((player) => `${player.id}:${player.mmr}`)
.join(", ");
document.getElementById("population-summary").textContent =
`Seed ${seed} | ${scenario.label} | ${players.length} tickets | ${counts} | Fingerprint ${firstFive}`;
}
function formatMetric(result, key, suffix = "") {
if (!result.matched) return "N/A";
const value = result[key];
return `${typeof value === "number" ? value.toFixed(key === "avgWait" || key === "mmrGap" ? 1 : 0) : value}${suffix}`;
}
function renderResults(results) {
const body = document.getElementById("results-body");
const notes = document.getElementById("policy-notes");
body.textContent = "";
notes.textContent = "";
for (const result of results) {
const row = document.createElement("tr");
addCell(row, result.policy);
addCell(row, result.matched ? "Yes" : "No");
addCell(row, formatMetric(result, "avgWait", " s"));
addCell(row, formatMetric(result, "mmrGap"));
addCell(row, formatMetric(result, "autofills"));
addCell(row, formatMetric(result, "parityMismatches"));
addCell(row, result.matched ? `${result.score}/100` : "N/A");
body.appendChild(row);
const card = document.createElement("article");
card.className = "policy-card";
const title = document.createElement("h3");
title.textContent = result.policy;
const detail = document.createElement("p");
detail.textContent = `${result.explanation} Search behavior: ${result.search}.`;
card.append(title, detail);
notes.appendChild(card);
}
}
function runSimulation() {
const seed = Number(document.getElementById("seed").value) || 0;
const scenarioKey = document.getElementById("scenario").value;
const scenario = SCENARIOS[scenarioKey];
const players = generatePlayers(seed, scenario);
renderPopulation(players, seed, scenario);
renderResults([
naivePolicy(players),
]);
}
document.getElementById("run").addEventListener("click", runSimulation);
runSimulation();
What should you compare?
Check the function order and every identifier against your file. The final runner should render exactly one policy result at this stage.
You now have a reproducible workload and a measurable MMR-only baseline. Next, you will enforce exact primary-role composition and watch that stronger constraint stall a scarce queue.
Add Strict Role Constraints
Your deterministic simulation now gives every policy the same queue. The naive policy balances MMR before assigning roles.
A hard constraint can require two primary players for every role. Scarce populations can make that allocation infeasible.
In this step, get ready to:
- Pair same-role players across opposing teams.
- Require two primary players for every role.
- Expose the strict policy's infeasible result in a scarce queue.
Pair primary-role players across teams
Each role contributes a two-player pair to the match. A greedy allocation chooses the pair orientation that keeps the running team totals closest.
- Switch back to app.js in Visual Studio Code.
- Find the closing brace of naivePolicy(players).
- Add the role-pair orientation helper directly below it by pasting this function:
function assignRolePairs(pairs) {
const blue = [];
const red = [];
let blueTotal = 0;
let redTotal = 0;
for (const pair of pairs) {
const [first, second] = pair.players;
const directGap = Math.abs(blueTotal + first.mmr - (redTotal + second.mmr));
const swappedGap = Math.abs(blueTotal + second.mmr - (redTotal + first.mmr));
const bluePlayer = directGap <= swappedGap ? first : second;
const redPlayer = directGap <= swappedGap ? second : first;
blue.push(assignPlayer(bluePlayer, pair.role));
red.push(assignPlayer(redPlayer, pair.role));
blueTotal += bluePlayer.mmr;
redTotal += redPlayer.mmr;
}
return { blue, red };
}
What does this code do?
- The blueTotal and redTotal values track each team's running MMR total.
- The directGap value measures the gap when the first player joins blue.
- The swappedGap value measures the gap after reversing that orientation.
- The smaller gap decides which player receives each team assignment.
- The existing assignPlayer() helper records the required role plus its preference cost.
- Save app.js.
- Switch back to the local page in Safari.
- Reload the page.
You'll still see the seeded population summary plus the Naive MMR row. This confirms the new helper parses without breaking the existing baseline.
Did the baseline disappear?
Check that assignRolePairs(pairs) sits after the complete naivePolicy(players) function. A missing brace can prevent the script from loading.
Compare the opening and closing braces inside the new function. Help me find a syntax issue in assignRolePairs().
Enforce exact primary-role composition
The strict policy treats role feasibility as a gate. It only builds teams after finding two primary players for every role in ROLES.
- Stay in app.js.
- Find the closing brace of assignRolePairs(pairs).
- Add the strict policy directly below it by pasting this function:
function strictRolePolicy(players) {
const center = average(players, (player) => player.mmr);
const pairs = [];
for (const role of ROLES) {
const primaryPlayers = players
.filter((player) => player.primary === role)
.sort((a, b) => Math.abs(a.mmr - center) - Math.abs(b.mmr - center));
if (primaryPlayers.length < 2) {
return makeResult(
"Strict primary role",
false,
null,
`No match: the queue does not contain two primary ${role} players.`,
"Two primary players required for every role",
);
}
pairs.push({ role, players: primaryPlayers.slice(0, 2) });
}
return makeResult(
"Strict primary role",
true,
assignRolePairs(pairs),
"Guarantees two primary players per role, but can leave older or less convenient tickets waiting.",
"Exact role composition before team assignment",
);
}
How does the strict policy work?
- The center value represents the average MMR across the generated population.
- The role filter keeps players whose primary value matches the current role.
- The sort places players nearest the queue's MMR center first.
- A role with fewer than two primary players returns an explicit unmatched result.
- A feasible population sends five role pairs into assignRolePairs() for team balancing.
Both policies must receive the identical players array. This keeps population differences from contaminating the comparison.
- Scroll to runSimulation() near the bottom of app.js.
- Find the existing renderResults() call.
- Replace that call with this two-policy comparison:
renderResults([
naivePolicy(players),
strictRolePolicy(players),
]);
Why reuse the same player array?
The generator runs once before either policy is evaluated. Each row now measures a policy decision against the same player IDs plus the same MMR values.
This creates a paired comparison. Any change between rows comes from policy behavior.
- Save app.js.
- Switch back to the local page in Safari.
- Enter 42 in the Seed field.
- Select Standard population in the Population scenario menu.
- Click Run comparison.
You'll see Naive MMR plus Strict primary role in the results table. The strict row shows Yes under Matched for this population.
Is the strict row missing?
Confirm that strictRolePolicy(players) appears inside the array passed to renderResults(). Confirm that the function name uses the same capitalization in both places.
Reload the local page after saving app.js. Help me debug a missing Strict primary role row.
Reveal the strict queue stall
The standard population contains enough primary-role candidates for the strict policy. The scarce population tests whether the same rule remains feasible under pressure.
- Select High-MMR, low population in the Population scenario menu.
Before you run the comparison, do you think an exact two-primary-player rule can allocate every required role from these counts?
- Click Run comparison.
You'll see Strict primary role show No under Matched. Its note identifies the first infeasible role with No match: the queue does not contain two primary Jungle players.
Why is this failure useful?
The synthetic high-MMR population contains one primary Jungle player plus one primary Support player. The strict policy needs two primary players for each role.
The explicit unmatched result proves the policy is enforcing its constraint. It also exposes the cost of demanding perfect composition from a scarce population.
✔️ Awesome, I've got everything!
Your strict policy now forms exact-role matches when the queue is feasible. It also returns a clear unmatched result when any required role lacks two primary players.
ⓧ I'd like to double check the full code
Compare your cumulative app.js file with this version. The final renderResults() call should evaluate both policies against the same generated population.
const ROLES = ["Top", "Jungle", "Mid", "ADC", "Support"];
const SCENARIOS = {
standard: {
label: "Standard population",
meanMmr: 1400,
spread: 500,
maxWait: 120,
roleCounts: { Top: 8, Jungle: 6, Mid: 10, ADC: 8, Support: 8 },
},
high: {
label: "High-MMR, low population",
meanMmr: 2600,
spread: 180,
maxWait: 300,
roleCounts: { Top: 3, Jungle: 1, Mid: 6, ADC: 3, Support: 1 },
},
};
function makeRandom(seed) {
let state = Number(seed) >>> 0;
return function random() {
state = (Math.imul(1664525, state) + 1013904223) >>> 0;
return state / 4294967296;
};
}
function shuffle(items, random) {
const copy = [...items];
for (let index = copy.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(random() * (index + 1));
[copy[index], copy[swapIndex]] = [copy[swapIndex], copy[index]];
}
return copy;
}
function generatePlayers(seed, scenario) {
const random = makeRandom(seed);
const primaryRoles = [];
for (const role of ROLES) {
for (let count = 0; count < scenario.roleCounts[role]; count += 1) {
primaryRoles.push(role);
}
}
return shuffle(primaryRoles, random).map((primary, index) => {
const secondaryChoices = ROLES.filter((role) => role !== primary);
const secondary = secondaryChoices[Math.floor(random() * secondaryChoices.length)];
const centeredRoll = random() + random() + random() - 1.5;
return {
id: `P${String(index + 1).padStart(2, "0")}`,
mmr: Math.round(scenario.meanMmr + centeredRoll * scenario.spread),
wait: Math.round(15 + random() * (scenario.maxWait - 15)),
primary,
secondary,
};
});
}
function average(items, value) {
return items.reduce((total, item) => total + value(item), 0) / items.length;
}
function preferenceLevel(player, assignedRole) {
if (player.primary === assignedRole) return 0;
if (player.secondary === assignedRole) return 1;
return 2;
}
function assignPlayer(player, assignedRole) {
const preference = preferenceLevel(player, assignedRole);
return {
...player,
assignedRole,
preference,
autofilled: preference === 2,
};
}
function assignSlots(team) {
const available = [...team];
const assignments = [];
for (const role of ROLES) {
let index = available.findIndex((player) => player.primary === role);
if (index === -1) {
index = available.findIndex((player) => player.secondary === role);
}
if (index === -1) index = 0;
const [player] = available.splice(index, 1);
assignments.push(assignPlayer(player, role));
}
return assignments;
}
function closestMmrWindow(players, size) {
const sorted = [...players].sort((a, b) => a.mmr - b.mmr);
let best = sorted.slice(0, size);
let bestSpan = best[best.length - 1].mmr - best[0].mmr;
for (let start = 1; start <= sorted.length - size; start += 1) {
const window = sorted.slice(start, start + size);
const span = window[window.length - 1].mmr - window[0].mmr;
if (span < bestSpan) {
best = window;
bestSpan = span;
}
}
return best;
}
function balanceByMmr(players) {
const blue = [];
const red = [];
let blueTotal = 0;
let redTotal = 0;
for (const player of [...players].sort((a, b) => b.mmr - a.mmr)) {
const sendBlue = blue.length < 5 && (red.length === 5 || blueTotal <= redTotal);
if (sendBlue) {
blue.push(player);
blueTotal += player.mmr;
} else {
red.push(player);
redTotal += player.mmr;
}
}
return { blue: assignSlots(blue), red: assignSlots(red) };
}
function makeResult(policy, matched, teams, explanation, search = "") {
if (!matched) {
return {
policy,
matched: false,
explanation,
search,
avgWait: null,
mmrGap: null,
autofills: null,
parityMismatches: null,
score: null,
};
}
const allPlayers = [...teams.blue, ...teams.red];
const blueMmr = average(teams.blue, (player) => player.mmr);
const redMmr = average(teams.red, (player) => player.mmr);
const mmrGap = Math.abs(blueMmr - redMmr);
const avgWait = average(allPlayers, (player) => player.wait);
const autofills = allPlayers.filter((player) => player.autofilled).length;
const parityMismatches = ROLES.filter((role) => {
const bluePlayer = teams.blue.find((player) => player.assignedRole === role);
const redPlayer = teams.red.find((player) => player.assignedRole === role);
return bluePlayer.preference !== redPlayer.preference;
}).length;
const mmrPenalty = Math.min(35, mmrGap / 8);
const waitPenalty = Math.min(25, avgWait / 12);
const score = Math.max(
0,
Math.round(100 - mmrPenalty - waitPenalty - autofills * 4 - parityMismatches * 8),
);
return {
policy,
matched: true,
explanation,
search,
avgWait,
mmrGap,
autofills,
parityMismatches,
score,
};
}
function naivePolicy(players) {
const selected = closestMmrWindow(players, 10);
const teams = balanceByMmr(selected);
return makeResult(
"Naive MMR",
true,
teams,
"Builds numerically close teams first, then discovers role collisions during assignment.",
"Tightest ten-player MMR window",
);
}
function assignRolePairs(pairs) {
const blue = [];
const red = [];
let blueTotal = 0;
let redTotal = 0;
for (const pair of pairs) {
const [first, second] = pair.players;
const directGap = Math.abs(blueTotal + first.mmr - (redTotal + second.mmr));
const swappedGap = Math.abs(blueTotal + second.mmr - (redTotal + first.mmr));
const bluePlayer = directGap <= swappedGap ? first : second;
const redPlayer = directGap <= swappedGap ? second : first;
blue.push(assignPlayer(bluePlayer, pair.role));
red.push(assignPlayer(redPlayer, pair.role));
blueTotal += bluePlayer.mmr;
redTotal += redPlayer.mmr;
}
return { blue, red };
}
function strictRolePolicy(players) {
const center = average(players, (player) => player.mmr);
const pairs = [];
for (const role of ROLES) {
const primaryPlayers = players
.filter((player) => player.primary === role)
.sort((a, b) => Math.abs(a.mmr - center) - Math.abs(b.mmr - center));
if (primaryPlayers.length < 2) {
return makeResult(
"Strict primary role",
false,
null,
`No match: the queue does not contain two primary ${role} players.`,
"Two primary players required for every role",
);
}
pairs.push({ role, players: primaryPlayers.slice(0, 2) });
}
return makeResult(
"Strict primary role",
true,
assignRolePairs(pairs),
"Guarantees two primary players per role, but can leave older or less convenient tickets waiting.",
"Exact role composition before team assignment",
);
}
function addCell(row, text) {
const cell = document.createElement("td");
cell.textContent = text;
row.appendChild(cell);
}
function renderPopulation(players, seed, scenario) {
const counts = ROLES.map((role) => {
const count = players.filter((player) => player.primary === role).length;
return `${role} ${count}`;
}).join(" | ");
const firstFive = players
.slice(0, 5)
.map((player) => `${player.id}:${player.mmr}`)
.join(", ");
document.getElementById("population-summary").textContent =
`Seed ${seed} | ${scenario.label} | ${players.length} tickets | ${counts} | Fingerprint ${firstFive}`;
}
function formatMetric(result, key, suffix = "") {
if (!result.matched) return "N/A";
const value = result[key];
return `${typeof value === "number" ? value.toFixed(key === "avgWait" || key === "mmrGap" ? 1 : 0) : value}${suffix}`;
}
function renderResults(results) {
const body = document.getElementById("results-body");
const notes = document.getElementById("policy-notes");
body.textContent = "";
notes.textContent = "";
for (const result of results) {
const row = document.createElement("tr");
addCell(row, result.policy);
addCell(row, result.matched ? "Yes" : "No");
addCell(row, formatMetric(result, "avgWait", " s"));
addCell(row, formatMetric(result, "mmrGap"));
addCell(row, formatMetric(result, "autofills"));
addCell(row, formatMetric(result, "parityMismatches"));
addCell(row, result.matched ? `${result.score}/100` : "N/A");
body.appendChild(row);
const card = document.createElement("article");
card.className = "policy-card";
const title = document.createElement("h3");
title.textContent = result.policy;
const detail = document.createElement("p");
detail.textContent = `${result.explanation} Search behavior: ${result.search}.`;
card.append(title, detail);
notes.appendChild(card);
}
}
function runSimulation() {
const seed = Number(document.getElementById("seed").value) || 0;
const scenarioKey = document.getElementById("scenario").value;
const scenario = SCENARIOS[scenarioKey];
const players = generatePlayers(seed, scenario);
renderPopulation(players, seed, scenario);
renderResults([
naivePolicy(players),
strictRolePolicy(players),
]);
}
document.getElementById("run").addEventListener("click", runSimulation);
runSimulation();
Your simulator now makes the cost of exact role constraints visible. Next, you'll recover the stalled queue by widening the search plus measuring the preference costs of fallback assignments.
Recover Matches with Progressive Relaxation
Your strict policy protects every primary role when the queue has enough supply. The scarce population exposed the cost of that hard constraint: a valid allocation never forms.
Progressive relaxation recovers the queue by widening the MMR search around its oldest ticket. It then minimizes paired preference misses instead of waiting forever for perfect primary-role coverage.
In this step, get ready to:
- Score candidate pairs using preference parity and assignment cost.
- Build scarce-role pairs from an expanding candidate pool.
- Compare progressive relaxation with the naive and strict policies.
Score preference-aware pairs
A flexible matchmaker still needs a consistent way to choose between imperfect assignments. This score prioritizes equal preference status across opposing players before total preference cost or a small MMR difference.
- In app.js, find the closing brace of strictRolePolicy().
- Add the pair-scoring function below that brace by pasting this code:
function choosePairForRole(available, role) {
let best = null;
for (let first = 0; first < available.length - 1; first += 1) {
for (let second = first + 1; second < available.length; second += 1) {
const playerA = available[first];
const playerB = available[second];
const costA = preferenceLevel(playerA, role);
const costB = preferenceLevel(playerB, role);
const score =
Math.abs(costA - costB) * 10 +
(costA + costB) * 4 +
Math.abs(playerA.mmr - playerB.mmr) / 1000;
if (!best || score < best.score) {
best = { players: [playerA, playerB], score };
}
}
}
return best.players;
}
How does the pair score work?
- The nested loops evaluate every possible pair for the requested role.
- The parity term penalizes pairs whose players receive different preference levels.
- The preference term favors primary or secondary assignments over autofills.
- The MMR term breaks close ties using the distance between the two players.
- Save app.js.
- Switch back to Safari.
- Reload the local page.
You should still see the population summary with the Naive MMR and Strict primary role rows. This confirms the new function parses without disrupting the existing policies.
Did the existing results disappear?
Check the braces around the nested loops in choosePairForRole(). A missing brace stops the rest of app.js from running.
Compare each score term with the code above. Help me find a syntax problem in choosePairForRole().
The next helper applies that score one role at a time. It starts with scarce roles so flexible players remain available for roles with more options.
- Add the flexible pairing function directly below choosePairForRole() by pasting this code:
function buildFlexiblePairs(candidates) {
const available = [...candidates];
const pairs = [];
const roleOrder = [...ROLES].sort((roleA, roleB) => {
const countA = available.filter((player) => player.primary === roleA).length;
const countB = available.filter((player) => player.primary === roleB).length;
return countA - countB;
});
for (const role of roleOrder) {
const players = choosePairForRole(available, role);
pairs.push({ role, players });
for (const player of players) {
const index = available.findIndex((candidate) => candidate.id === player.id);
available.splice(index, 1);
}
}
return pairs;
}
What does this function do?
- The copied available array protects the original candidate population from mutation.
- The sorted roleOrder places roles with fewer primary players first.
- Each selected pair is removed from available so one player cannot occupy two role slots.
- The returned five pairs give both teams one assigned player for every required role.
- Save app.js.
- Switch back to Safari.
- Reload the local page.
You should see the same two policy rows with no missing controls. The simulator can now build five non-overlapping role pairs from ten candidates.
Does the page stop rendering now?
Confirm that available.splice(index, 1) remains inside the inner loop. Moving it outside the loop leaves a selected player available for another role.
Check that buildFlexiblePairs() returns pairs. Help me debug the flexible role-pair builder.
Build the progressive policy
The oldest ticket anchors the search because its wait has become the most urgent. Every additional wait interval expands its MMR window until the policy can select ten candidates or use the full-queue fallback.
Why use progressive relaxation?
Strict matching treats every rule as permanent. Progressive relaxation preserves quality early in the wait before gradually allowing wider candidates.
This approach makes latency part of the allocation decision. Older tickets receive a broader search instead of remaining blocked by scarce capabilities.
- Add the progressive policy directly below buildFlexiblePairs() by pasting this code:
function progressivePolicy(players) {
const oldest = [...players].sort((a, b) => b.wait - a.wait)[0];
const window = 90 + Math.floor(oldest.wait / 30) * 60;
const eligible = players.filter((player) => Math.abs(player.mmr - oldest.mmr) <= window);
const candidates = (eligible.length >= 10 ? eligible : players)
.sort((a, b) => {
if (eligible.length >= 10) return b.wait - a.wait;
return Math.abs(a.mmr - oldest.mmr) - Math.abs(b.mmr - oldest.mmr);
})
.slice(0, 10);
const pairs = buildFlexiblePairs(candidates);
const search = eligible.length >= 10
? `Widened to +/-${window} MMR around the oldest ticket`
: `Fallback used after +/-${window} MMR found only ${eligible.length} tickets`;
return makeResult(
"Progressive relaxation",
true,
assignRolePairs(pairs),
"Prioritizes an aging ticket, widens the search, and pairs equivalent preference costs where possible.",
search,
);
}
How does the search expand?
- The highest wait value identifies the oldest ticket.
- The window grows in fixed increments as that ticket waits longer.
- The policy favors eligible tickets by age when at least ten fit inside the window.
- The fallback chooses the nearest available MMR candidates when the window contains fewer than ten tickets.
- The existing makeResult() helper measures wait time, MMR gap, autofills, parity mismatches, and the simulated score.
- Save app.js.
- Switch back to Safari.
- Reload the local page.
You should still see both existing policy rows and the population fingerprint. This confirms the progressive policy loads alongside the earlier simulator code.
Is the population summary missing?
Check the parentheses around the eligible filter and candidate sort. Also confirm that the template strings use matching backticks.
Keep progressivePolicy() above runSimulation(). Help me debug the progressive policy.
The dashboard evaluates only the policies listed inside runSimulation(). Passing the same players array to every policy keeps the comparison paired and reproducible.
- In app.js, find the existing runSimulation() function.
- Replace the whole function with this updated version:
function runSimulation() {
const seed = Number(document.getElementById("seed").value) || 0;
const scenarioKey = document.getElementById("scenario").value;
const scenario = SCENARIOS[scenarioKey];
const players = generatePlayers(seed, scenario);
renderPopulation(players, seed, scenario);
renderResults([
naivePolicy(players),
strictRolePolicy(players),
progressivePolicy(players),
]);
}
What changed in the comparison?
The function still generates one population for the selected seed and scenario. It now passes that unchanged population to all three policies.
The shared input isolates policy behavior from population variation. Any difference in the table now comes from the allocation strategy.
- Save app.js.
- Switch back to Safari.
- Reload the local page.
You should now see three result rows: Naive MMR, Strict primary role, and Progressive relaxation. That third row confirms the new policy runs against the same generated population.
Is the progressive row missing?
Confirm that progressivePolicy(players) appears inside the array passed to renderResults(). Check the comma after the strict-policy entry.
Make sure the progressivePolicy() function name uses the same capitalization everywhere. Help me restore the third policy row.
✔️ Awesome, I've got everything!
Your file is saved with all three matchmaking policies. The dashboard should now render Naive MMR, Strict primary role, and Progressive relaxation from the same player population.
ⓧ I'd like to double check the full code
const ROLES = ["Top", "Jungle", "Mid", "ADC", "Support"];
const SCENARIOS = {
standard: {
label: "Standard population",
meanMmr: 1400,
spread: 500,
maxWait: 120,
roleCounts: { Top: 8, Jungle: 6, Mid: 10, ADC: 8, Support: 8 },
},
high: {
label: "High-MMR, low population",
meanMmr: 2600,
spread: 180,
maxWait: 300,
roleCounts: { Top: 3, Jungle: 1, Mid: 6, ADC: 3, Support: 1 },
},
};
function makeRandom(seed) {
let state = Number(seed) >>> 0;
return function random() {
state = (Math.imul(1664525, state) + 1013904223) >>> 0;
return state / 4294967296;
};
}
function shuffle(items, random) {
const copy = [...items];
for (let index = copy.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(random() * (index + 1));
[copy[index], copy[swapIndex]] = [copy[swapIndex], copy[index]];
}
return copy;
}
function generatePlayers(seed, scenario) {
const random = makeRandom(seed);
const primaryRoles = [];
for (const role of ROLES) {
for (let count = 0; count < scenario.roleCounts[role]; count += 1) {
primaryRoles.push(role);
}
}
return shuffle(primaryRoles, random).map((primary, index) => {
const secondaryChoices = ROLES.filter((role) => role !== primary);
const secondary = secondaryChoices[Math.floor(random() * secondaryChoices.length)];
const centeredRoll = random() + random() + random() - 1.5;
return {
id: `P${String(index + 1).padStart(2, "0")}`,
mmr: Math.round(scenario.meanMmr + centeredRoll * scenario.spread),
wait: Math.round(15 + random() * (scenario.maxWait - 15)),
primary,
secondary,
};
});
}
function average(items, value) {
return items.reduce((total, item) => total + value(item), 0) / items.length;
}
function preferenceLevel(player, assignedRole) {
if (player.primary === assignedRole) return 0;
if (player.secondary === assignedRole) return 1;
return 2;
}
function assignPlayer(player, assignedRole) {
const preference = preferenceLevel(player, assignedRole);
return {
...player,
assignedRole,
preference,
autofilled: preference === 2,
};
}
function assignSlots(team) {
const available = [...team];
const assignments = [];
for (const role of ROLES) {
let index = available.findIndex((player) => player.primary === role);
if (index === -1) {
index = available.findIndex((player) => player.secondary === role);
}
if (index === -1) index = 0;
const [player] = available.splice(index, 1);
assignments.push(assignPlayer(player, role));
}
return assignments;
}
function closestMmrWindow(players, size) {
const sorted = [...players].sort((a, b) => a.mmr - b.mmr);
let best = sorted.slice(0, size);
let bestSpan = best[best.length - 1].mmr - best[0].mmr;
for (let start = 1; start <= sorted.length - size; start += 1) {
const window = sorted.slice(start, start + size);
const span = window[window.length - 1].mmr - window[0].mmr;
if (span < bestSpan) {
best = window;
bestSpan = span;
}
}
return best;
}
function balanceByMmr(players) {
const blue = [];
const red = [];
let blueTotal = 0;
let redTotal = 0;
for (const player of [...players].sort((a, b) => b.mmr - a.mmr)) {
const sendBlue = blue.length < 5 && (red.length === 5 || blueTotal <= redTotal);
if (sendBlue) {
blue.push(player);
blueTotal += player.mmr;
} else {
red.push(player);
redTotal += player.mmr;
}
}
return { blue: assignSlots(blue), red: assignSlots(red) };
}
function makeResult(policy, matched, teams, explanation, search = "") {
if (!matched) {
return {
policy,
matched: false,
explanation,
search,
avgWait: null,
mmrGap: null,
autofills: null,
parityMismatches: null,
score: null,
};
}
const allPlayers = [...teams.blue, ...teams.red];
const blueMmr = average(teams.blue, (player) => player.mmr);
const redMmr = average(teams.red, (player) => player.mmr);
const mmrGap = Math.abs(blueMmr - redMmr);
const avgWait = average(allPlayers, (player) => player.wait);
const autofills = allPlayers.filter((player) => player.autofilled).length;
const parityMismatches = ROLES.filter((role) => {
const bluePlayer = teams.blue.find((player) => player.assignedRole === role);
const redPlayer = teams.red.find((player) => player.assignedRole === role);
return bluePlayer.preference !== redPlayer.preference;
}).length;
const mmrPenalty = Math.min(35, mmrGap / 8);
const waitPenalty = Math.min(25, avgWait / 12);
const score = Math.max(
0,
Math.round(100 - mmrPenalty - waitPenalty - autofills * 4 - parityMismatches * 8),
);
return {
policy,
matched: true,
explanation,
search,
avgWait,
mmrGap,
autofills,
parityMismatches,
score,
};
}
function naivePolicy(players) {
const selected = closestMmrWindow(players, 10);
const teams = balanceByMmr(selected);
return makeResult(
"Naive MMR",
true,
teams,
"Builds numerically close teams first, then discovers role collisions during assignment.",
"Tightest ten-player MMR window",
);
}
function assignRolePairs(pairs) {
const blue = [];
const red = [];
let blueTotal = 0;
let redTotal = 0;
for (const pair of pairs) {
const [first, second] = pair.players;
const directGap = Math.abs(blueTotal + first.mmr - (redTotal + second.mmr));
const swappedGap = Math.abs(blueTotal + second.mmr - (redTotal + first.mmr));
const bluePlayer = directGap <= swappedGap ? first : second;
const redPlayer = directGap <= swappedGap ? second : first;
blue.push(assignPlayer(bluePlayer, pair.role));
red.push(assignPlayer(redPlayer, pair.role));
blueTotal += bluePlayer.mmr;
redTotal += redPlayer.mmr;
}
return { blue, red };
}
function strictRolePolicy(players) {
const center = average(players, (player) => player.mmr);
const pairs = [];
for (const role of ROLES) {
const primaryPlayers = players
.filter((player) => player.primary === role)
.sort((a, b) => Math.abs(a.mmr - center) - Math.abs(b.mmr - center));
if (primaryPlayers.length < 2) {
return makeResult(
"Strict primary role",
false,
null,
`No match: the queue does not contain two primary ${role} players.`,
"Two primary players required for every role",
);
}
pairs.push({ role, players: primaryPlayers.slice(0, 2) });
}
return makeResult(
"Strict primary role",
true,
assignRolePairs(pairs),
"Guarantees two primary players per role, but can leave older or less convenient tickets waiting.",
"Exact role composition before team assignment",
);
}
function choosePairForRole(available, role) {
let best = null;
for (let first = 0; first < available.length - 1; first += 1) {
for (let second = first + 1; second < available.length; second += 1) {
const playerA = available[first];
const playerB = available[second];
const costA = preferenceLevel(playerA, role);
const costB = preferenceLevel(playerB, role);
const score =
Math.abs(costA - costB) * 10 +
(costA + costB) * 4 +
Math.abs(playerA.mmr - playerB.mmr) / 1000;
if (!best || score < best.score) {
best = { players: [playerA, playerB], score };
}
}
}
return best.players;
}
function buildFlexiblePairs(candidates) {
const available = [...candidates];
const pairs = [];
const roleOrder = [...ROLES].sort((roleA, roleB) => {
const countA = available.filter((player) => player.primary === roleA).length;
const countB = available.filter((player) => player.primary === roleB).length;
return countA - countB;
});
for (const role of roleOrder) {
const players = choosePairForRole(available, role);
pairs.push({ role, players });
for (const player of players) {
const index = available.findIndex((candidate) => candidate.id === player.id);
available.splice(index, 1);
}
}
return pairs;
}
function progressivePolicy(players) {
const oldest = [...players].sort((a, b) => b.wait - a.wait)[0];
const window = 90 + Math.floor(oldest.wait / 30) * 60;
const eligible = players.filter((player) => Math.abs(player.mmr - oldest.mmr) <= window);
const candidates = (eligible.length >= 10 ? eligible : players)
.sort((a, b) => {
if (eligible.length >= 10) return b.wait - a.wait;
return Math.abs(a.mmr - oldest.mmr) - Math.abs(b.mmr - oldest.mmr);
})
.slice(0, 10);
const pairs = buildFlexiblePairs(candidates);
const search = eligible.length >= 10
? `Widened to +/-${window} MMR around the oldest ticket`
: `Fallback used after +/-${window} MMR found only ${eligible.length} tickets`;
return makeResult(
"Progressive relaxation",
true,
assignRolePairs(pairs),
"Prioritizes an aging ticket, widens the search, and pairs equivalent preference costs where possible.",
search,
);
}
function addCell(row, text) {
const cell = document.createElement("td");
cell.textContent = text;
row.appendChild(cell);
}
function renderPopulation(players, seed, scenario) {
const counts = ROLES.map((role) => {
const count = players.filter((player) => player.primary === role).length;
return `${role} ${count}`;
}).join(" | ");
const firstFive = players
.slice(0, 5)
.map((player) => `${player.id}:${player.mmr}`)
.join(", ");
document.getElementById("population-summary").textContent =
`Seed ${seed} | ${scenario.label} | ${players.length} tickets | ${counts} | Fingerprint ${firstFive}`;
}
function formatMetric(result, key, suffix = "") {
if (!result.matched) return "N/A";
const value = result[key];
return `${typeof value === "number" ? value.toFixed(key === "avgWait" || key === "mmrGap" ? 1 : 0) : value}${suffix}`;
}
function renderResults(results) {
const body = document.getElementById("results-body");
const notes = document.getElementById("policy-notes");
body.textContent = "";
notes.textContent = "";
for (const result of results) {
const row = document.createElement("tr");
addCell(row, result.policy);
addCell(row, result.matched ? "Yes" : "No");
addCell(row, formatMetric(result, "avgWait", " s"));
addCell(row, formatMetric(result, "mmrGap"));
addCell(row, formatMetric(result, "autofills"));
addCell(row, formatMetric(result, "parityMismatches"));
addCell(row, result.matched ? `${result.score}/100` : "N/A");
body.appendChild(row);
const card = document.createElement("article");
card.className = "policy-card";
const title = document.createElement("h3");
title.textContent = result.policy;
const detail = document.createElement("p");
detail.textContent = `${result.explanation} Search behavior: ${result.search}.`;
card.append(title, detail);
notes.appendChild(card);
}
}
function runSimulation() {
const seed = Number(document.getElementById("seed").value) || 0;
const scenarioKey = document.getElementById("scenario").value;
const scenario = SCENARIOS[scenarioKey];
const players = generatePlayers(seed, scenario);
renderPopulation(players, seed, scenario);
renderResults([
naivePolicy(players),
strictRolePolicy(players),
progressivePolicy(players),
]);
}
document.getElementById("run").addEventListener("click", runSimulation);
runSimulation();
How to use this reference
Compare this reference with your saved app.js file. The progressive helpers should sit between the strict policy and the DOM rendering functions.
Verify queue recovery
Before you test, ask yourself whether progressive relaxation will remain blocked or recover a match from the scarce population.
- Select High-MMR, low population from the Population scenario menu.
- Click Run comparison.
You should see No in the Strict primary role row. You should see Yes in the Progressive relaxation row.
The progressive row should also show numeric average wait, team MMR gap, autofills, parity mismatches, and simulated fairness values. You have recovered the stalled queue while keeping every compromise visible.
Your simulator now demonstrates the central latency-versus-quality trade-off with measurable evidence. Next, you will turn those results into a transparent portfolio case study.
Present the Portfolio Case Study
Your deterministic matchmaker now compares three policies against one seeded queue. Progressive relaxation can recover the scarce scenario that stalls strict matching.
Raw metrics do not communicate engineering judgment on their own.
This step makes every score traceable to displayed inputs. It also connects the matching design to other allocation systems.
In this step, get ready to:
- Trace every dashboard score to the disclosed fairness formula.
- Present the architecture as a flow from player generation to metrics.
- Connect matchmaking trade-offs to four non-game allocation systems.
Trace every score to its inputs
The simulated fairness heuristic turns several trade-offs into one score. Disclosing each penalty lets a reviewer challenge the model's assumptions.
- Enter 42 in the Seed field.
- Choose Standard population in the Population scenario menu.
Before you select Run comparison, which values should repeat when the seed stays the same?
- Select Run comparison.
You will see a population summary with the seed, ticket count, role counts, and player MMR fingerprint. The table shows one measured result for each policy.
- Select Run comparison again.
The population summary stays identical. Every policy metric also stays identical.
- Compare Avg wait, Team MMR gap, Autofills, and Parity mismatches across the three rows.
- Read the formula in the Transparent by design panel.
- Confirm the disclosure says the heuristic is neither Riot's score nor actual player satisfaction.
How Is the Score Calculated?
The displayed formula is 100 - min(35, MMR gap / 8) - min(25, average wait / 12) - (4 x autofills) - (8 x parity mismatches).
- The MMR penalty grows with the team gap and stops at 35 points.
- The wait penalty grows with average wait and stops at 25 points.
- Each autofill deducts 4 points.
- Each role-parity mismatch deducts 8 points.
Walk through the architecture
The system architecture turns the simulator into a component story. Each stage has one responsibility that you can explain during a demonstration.
- Trace the displayed flow from Player Generator through Metrics Dashboard.
- Rehearse one sentence that explains the responsibility of each stage.
What Does Each Stage Contribute?
- The Player Generator creates a deterministic population from the selected seed and scenario.
- The Ticket Queue represents players waiting with MMR, role preferences, and ticket age.
- The Policy Engine applies naive, strict, or progressively relaxed selection rules.
- The Team Assignment stage fills the required role slots on both teams.
- The Metrics Dashboard exposes the quality and latency costs of the resulting allocation.
The complete flow reads Player Generator -> Ticket Queue -> Policy Engine -> Team Assignment -> Metrics Dashboard.
Connect the design to other systems
The same allocation decisions appear outside multiplayer games. The transfer cards make those connections concrete.
- Use Scheduling to map players to jobs.
- Use Resource allocation to explain how hard constraints filter impossible assignments.
- Use Distributed queues to explain how partitioning shrinks a candidate pool.
- Use Latency versus quality to explain why widening accepts a less ideal allocation.
Before you run the final comparison, which policy do you expect to stop matching when role supply becomes scarce?
- Choose Standard population in the Population scenario menu.
- Select Run comparison.
You will see all three policy rows report Yes in the Matched column.
- Choose High-MMR, low population in the Population scenario menu.
- Select Run comparison.
The Strict primary role row now reports No. Progressive relaxation reports Yes with explicit autofill and parity costs.
- Use the results table to trace every score to MMR gap, average wait, autofills, and parity mismatches.
- Compare the policy explanation cards to identify each policy's search behavior.
- Confirm the warning panel displays the disclosed formula and its limitation.
- Trace the architecture flow from player generation to the metrics dashboard.
- Read the four transfer cards to identify where the allocation pattern appears outside games.
Every score can now be traced to displayed inputs. The page explicitly identifies the fairness score as a teaching heuristic.
Strong finish: your simulator now supports an evidence-based explanation of fairness, latency, scarcity, and fallback behavior.
Secret mission
Defend the Design Like a Scheduler
One favorable seed can make a policy look stronger than it is. Stress-test all three policies across three reproducible workloads. Then defend the trade-off using scheduling and resource-allocation language.
Clean Up Your Resources
Clean Up Your Resources
Your simulator runs from local files in Safari. Its source stays in the folder you edited with Visual Studio Code.
There are no ongoing costs because the project uses only files on your Mac. Decide whether to keep the folder ready, close the app views for now, or remove the folder entirely.
Resources you used:
- The local matchmaking-tradeoff-lab folder containing the completed simulator.
Keep everything running
No action is needed. Choose this if you are still testing seeds or demonstrating the simulator.
- Leave the matchmaking-tradeoff-lab folder on your Mac.
- Continue using the Safari tab whenever you want to run another comparison.
Pause - I'll come back to this later
Closing the active views frees screen space while preserving every project file. Your simulator remains ready for your next experiment.
- Close the Safari tab that displays index.html.
- Close the Visual Studio Code window for the matchmaking-tradeoff-lab workspace.
- Leave the matchmaking-tradeoff-lab folder unchanged on your Mac.
Delete - I don't want to use this again
Deleting the lab becomes permanent after you empty the Trash. It affects only the local project folder, so your installed apps stay on your Mac.
- Close the Safari tab that displays index.html.
- Close the Visual Studio Code window for the matchmaking-tradeoff-lab workspace.
- Click Finder in the Dock.
- Enter matchmaking-tradeoff-lab in the search field at the top of the Finder window.
- Select the folder named matchmaking-tradeoff-lab.
- Drag the selected folder to Trash in the Dock.
- Open the Trash by clicking Trash in the Dock.
You should see the matchmaking-tradeoff-lab folder in the Trash. Your Mac may ask for confirmation when you permanently remove it.
- Empty the Trash to permanently remove the folder.
- Approve the permanent deletion if your Mac requests confirmation.
- Return to Finder.
- Search for matchmaking-tradeoff-lab again.
That is the cleanup complete: the empty search confirms that the only project resource is gone.
Nice Work!
Nice Work!
You did it! Your deterministic simulation now shows how matchmaking policies trade queue speed for match quality.
You've learned how to:
- Generate a reproducible synthetic population from a seed. Use its stable fingerprint to prove that every policy receives identical inputs.
- Compare Naive MMR, Strict primary role, and Progressive relaxation on the same player population. Show how hard constraints can stall an allocation. Recover the queue while exposing autofill costs and role-parity mismatches.
- Present a transparent simulated fairness heuristic with visible inputs and disclosed weights. Trace the architecture flow from player generation to the metrics dashboard. Connect the allocation pattern to scheduling. Apply the same reasoning to resource allocation. Extend it to distributed queues and latency-versus-quality systems.
- Secret Mission: Stress-test your conclusion with seeds 7, 42, and 99. Defend the policy using scheduler language. Explain why a favorable mean can conceal a bad individual experience.
Ready to quiz yourself?