Build a Mango Ranking Website

Build a mango ranking site with ELO ratings and shareable URL encoding.

Introduction

30 Second Summary

Everyone has opinions about their favorite mango, but when you have 16 varieties from around the world, you need more than gut instinct to rank them. You need a system that pits them against each other, one matchup at a time, and calculates a fair ranking from your choices.

In this project, you will build MangoRankings.com, a website that presents two mangoes side by side and uses the ELO rating algorithm to produce a personalized tier list you can share with anyone via a URL.

What You'll Build

A single-page web application where users compare mangoes in head-to-head matchups, with ELO-based ratings and a shareable URL system that works without any backend server.

By the end of this project, you'll have:

  • A complete mango ranking website featuring 16 real-world mango varieties from 10 countries, presented in interactive head-to-head matchup cards.
  • An ELO rating engine built in JavaScript that calculates fair rankings from pairwise comparisons.
  • A zero-backend sharing system that encodes your final rankings into the URL using Base64, so anyone with the link sees your exact tier list.
  • Secret Mission: a "Quick Rank" mode that uses a merge-sort comparison strategy to reduce matchups by approximately 60%.

Are there any prerequisites?

Basic familiarity with HTML, CSS, and JavaScript is helpful but not required. This project walks you through every line of code.

Set Up Your Project

Every ranking website needs a home. For MangoRankings.com, that home is three files working together: an HTML file for structure, a CSS file for styling, and a JavaScript file for logic.

In this step, you will create the project folder, set up the HTML page skeleton, and load in a curated dataset of 16 mango varieties from around the world.

In this step, get ready to:
  • Create the mango-rankings project folder and files.
  • Set up the HTML boilerplate with linked CSS and JavaScript.
  • Add the mango dataset with 16 real-world varieties.
Create the project folder and HTML boilerplate

Before writing any code, you need a dedicated folder on your Desktop to hold the project files. This keeps everything organized and easy to find.

  • In Cursor, open the integrated terminal by pressing Ctrl+` (the backtick key, next to the 1 key).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir mango-rankings
cd mango-rankings
  • Confirm the folder exists by running:
ls

You should see an empty output since the folder is new and has no files yet.

  • In Cursor, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the mango-rankings folder, then click Open.

What does opening a folder do?

Opening a folder in Cursor makes it your active workspace. The Explorer sidebar on the left now shows the contents of mango-rankings, and any terminal you open starts inside this folder.

  • Create the three project files by running this command in the terminal:
touch index.html style.css script.js
  • Confirm the files were created by running:
ls

You should see index.html, style.css, and script.js listed in the output.

Don't see the files?

Make sure you opened the mango-rankings folder in Cursor before running touch. If your terminal shows a different path, run cd ~/Desktop/mango-rankings first.

On Windows, if touch is not recognized, use New-Item index.html, style.css, script.js in PowerShell instead.

Still stuck? Help me create files in my Cursor terminal

Now it is time to give your HTML file its starting structure. This boilerplate connects all three files together and sets up the page for responsive viewing.

  • Click on index.html in the Explorer sidebar to open it.
  • Paste the following HTML boilerplate into the file:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <!-- Responsive viewport for mobile devices -->
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>MangoRankings.com</title>
    <!-- Link the CSS stylesheet -->
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header>
        <h1>MangoRankings.com</h1>
        <p class="tagline">Rank the world's best mangoes, one matchup at a time</p>
    </header>
    <!-- Main content area for matchups and results -->
    <main></main>
    <!-- Load the JavaScript at the end of body -->
    <script src="script.js"></script>
</body>
</html>

What does this code do?

The <meta name="viewport"> tag tells mobile browsers to match the page width to the device screen. Without it, your site would appear zoomed out on phones.

The <link> tag loads your stylesheet, and the <script> tag at the bottom of the body loads your JavaScript after the HTML has rendered.

The <header> contains the site title and tagline. The empty <main> element is where you will add the matchup interface in a later step.

  • Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Seeing a blank page later?

Check that style.css and script.js are spelled exactly as shown in the <link> and <script> tags. A typo in the filename means the browser cannot find the file.

Need help? Why isn't my CSS or JS file loading?

✔️ Awesome, I've got everything!

Great. Make sure you have saved index.html before moving on.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>MangoRankings.com</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header>
        <h1>MangoRankings.com</h1>
        <p class="tagline">Rank the world's best mangoes, one matchup at a time</p>
    </header>
    <main></main>
    <script src="script.js"></script>
</body>
</html>
Add the mango dataset to your script

Your ranking engine needs data to rank. You will add a curated list of 16 mango varieties spanning 10 countries, each with a unique flavor profile.

  • Click on script.js in the Explorer sidebar to open it.
  • Paste the following mango dataset into the file:
// The complete mango dataset - 16 varieties from around the world
const MANGOES = [
    { id: "alphonso", name: "Alphonso", origin: "India", flavor: "Rich, buttery, saffron-like sweetness", emoji: "🥭" },
    { id: "ataulfo", name: "Ataulfo", origin: "Mexico", flavor: "Buttery, honey-sweet, fiberless", emoji: "🥭" },
    { id: "carabao", name: "Carabao", origin: "Philippines", flavor: "Buttery, floral, silky sweet", emoji: "🥭" },
    { id: "kent", name: "Kent", origin: "USA", flavor: "Sweet, rich, low fiber, peachy", emoji: "🥭" },
    { id: "namdocmai", name: "Nam Doc Mai", origin: "Thailand", flavor: "Silky, floral, nectar-like", emoji: "🥭" },
    { id: "haden", name: "Haden", origin: "USA", flavor: "Sweet-tart, classic tropical perfume", emoji: "🥭" },
    { id: "kesar", name: "Kesar", origin: "India", flavor: "Creamy, sweet-aromatic, gentle tang", emoji: "🥭" },
    { id: "chaunsa", name: "Chaunsa", origin: "Pakistan", flavor: "Very sweet, melting, deeply aromatic", emoji: "🥭" },
    { id: "sindhri", name: "Sindhri", origin: "Pakistan", flavor: "Silky, honeyed, gentle tang", emoji: "🥭" },
    { id: "dasheri", name: "Dasheri", origin: "India", flavor: "Fragrant, honey-like, perfumed", emoji: "🥭" },
    { id: "langra", name: "Langra", origin: "India", flavor: "Tangy-sweet, bold, distinctive aroma", emoji: "🥭" },
    { id: "tommy", name: "Tommy Atkins", origin: "USA", flavor: "Mildly sweet, firm, fibrous", emoji: "🥭" },
    { id: "keitt", name: "Keitt", origin: "USA", flavor: "Sweet, tangy, firm and juicy", emoji: "🥭" },
    { id: "julie", name: "Julie", origin: "Caribbean", flavor: "Creamy, spicy, low fiber", emoji: "🥭" },
    { id: "kensington", name: "Kensington Pride", origin: "Australia", flavor: "Sweet, aromatic, juicy", emoji: "🥭" },
    { id: "palmer", name: "Palmer", origin: "Brazil", flavor: "Mildly sweet, firm, low fiber", emoji: "🥭" }
];

// Verify the dataset loaded correctly
console.log(`MangoRankings loaded: ${MANGOES.length} varieties`);

What does this code do?

The MANGOES constant is an array of 16 objects. Each object represents one mango variety with five properties: a unique id for internal lookups, a display name, the country of origin, a flavor description, and an emoji icon.

The console.log at the bottom is a quick sanity check. It prints the number of mangoes loaded so you can confirm everything is wired up correctly.

  • Save script.js by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Seeing a syntax error in Cursor?

Make sure every object in the array has a closing brace } followed by a comma, except the last entry (Palmer) which has just a closing brace.

Check that the array ends with ]; on its own line. A missing bracket or semicolon will cause a syntax error when the browser loads the script.

Need help? I'm getting a syntax error in my MANGOES array

✔️ Awesome, I've got everything!

Great. Make sure you have saved script.js before moving on.

ⓧ I'd like to double check the full code

const MANGOES = [
    { id: "alphonso", name: "Alphonso", origin: "India", flavor: "Rich, buttery, saffron-like sweetness", emoji: "🥭" },
    { id: "ataulfo", name: "Ataulfo", origin: "Mexico", flavor: "Buttery, honey-sweet, fiberless", emoji: "🥭" },
    { id: "carabao", name: "Carabao", origin: "Philippines", flavor: "Buttery, floral, silky sweet", emoji: "🥭" },
    { id: "kent", name: "Kent", origin: "USA", flavor: "Sweet, rich, low fiber, peachy", emoji: "🥭" },
    { id: "namdocmai", name: "Nam Doc Mai", origin: "Thailand", flavor: "Silky, floral, nectar-like", emoji: "🥭" },
    { id: "haden", name: "Haden", origin: "USA", flavor: "Sweet-tart, classic tropical perfume", emoji: "🥭" },
    { id: "kesar", name: "Kesar", origin: "India", flavor: "Creamy, sweet-aromatic, gentle tang", emoji: "🥭" },
    { id: "chaunsa", name: "Chaunsa", origin: "Pakistan", flavor: "Very sweet, melting, deeply aromatic", emoji: "🥭" },
    { id: "sindhri", name: "Sindhri", origin: "Pakistan", flavor: "Silky, honeyed, gentle tang", emoji: "🥭" },
    { id: "dasheri", name: "Dasheri", origin: "India", flavor: "Fragrant, honey-like, perfumed", emoji: "🥭" },
    { id: "langra", name: "Langra", origin: "India", flavor: "Tangy-sweet, bold, distinctive aroma", emoji: "🥭" },
    { id: "tommy", name: "Tommy Atkins", origin: "USA", flavor: "Mildly sweet, firm, fibrous", emoji: "🥭" },
    { id: "keitt", name: "Keitt", origin: "USA", flavor: "Sweet, tangy, firm and juicy", emoji: "🥭" },
    { id: "julie", name: "Julie", origin: "Caribbean", flavor: "Creamy, spicy, low fiber", emoji: "🥭" },
    { id: "kensington", name: "Kensington Pride", origin: "Australia", flavor: "Sweet, aromatic, juicy", emoji: "🥭" },
    { id: "palmer", name: "Palmer", origin: "Brazil", flavor: "Mildly sweet, firm, low fiber", emoji: "🥭" }
];

console.log(`MangoRankings loaded: ${MANGOES.length} varieties`);
Verify the setup in your browser

The ultimate test is opening your page in a browser and checking that JavaScript loaded without errors.

  • Open Finder (macOS) or File Explorer (Windows) and navigate to your Desktop.
  • Open the mango-rankings folder.
  • Double-click index.html to open it in your default browser.

You should see the heading "MangoRankings.com" and the tagline on the page. The page is mostly empty for now.

  • Open the browser's developer console by pressing Cmd+Option+J (macOS Chrome) or Ctrl+Shift+J (Windows Chrome).
  • Look for the message MangoRankings loaded: 16 varieties in the console output.

What should I see?

The console should show exactly one line: MangoRankings loaded: 16 varieties. No red error messages should appear. If the number 16 shows up, your dataset is wired correctly.

Don't see the log message?

If the console is empty, check that the <script src="script.js"> tag in index.html matches the filename exactly (lowercase, no extra spaces).

If you see a red error like Unexpected token, there is likely a missing comma or bracket in your MANGOES array. Compare your code against the double-check tab above.

Still stuck? Help me debug my console output

Your project skeleton is in place and the mango data is loaded. Next up, you will build the ELO rating engine that powers the head-to-head ranking system.

Build the ELO Rating Engine

Your project files are set up and the 16-mango dataset is loaded. Now you need a way to determine which mangoes are "better" based on user choices.

In this step, you will build the ELO rating algorithm that powers the ranking system. Every time a user picks one mango over another, both mangoes' ratings adjust. Beating a highly-rated mango is worth more than beating a low-rated one, which produces more nuanced rankings from the same number of comparisons.

In this step, get ready to:
  • Set up the game state with constants and a ratings initializer.
  • Write the ELO calculation functions that adjust ratings after each matchup.
  • Create the matchup generator and game flow logic.
Set up the game state and initialize ratings

Before writing any math, you need a place to store each mango's rating and track which matchups have been played. Every mango starts with an ELO rating of 1500, the standard starting point.

  • In script.js, find the console.log line at the bottom of the file.
  • Add the following code above it:
const K_FACTOR = 32;
const INITIAL_RATING = 1500;

let gameState = {
    ratings: {},
    matchups: [],
    currentIndex: 0,
    results: []
};

// Set every mango to the starting rating of 1500
function initializeRatings() {
    gameState.ratings = {};
    MANGOES.forEach(mango => {
        gameState.ratings[mango.id] = INITIAL_RATING;
    });
}

What does this code do?

  • K_FACTOR controls how much a single matchup moves ratings. A value of 32 is the standard for fast convergence with a small item set.
  • gameState is the central object that tracks everything: current ratings, the full list of matchup pairs, which matchup you are on, and a history of results.
  • initializeRatings() loops through all 16 mangoes and assigns each one a starting rating of 1500.

You will not see a change yet. The next chunk builds the math that uses these constants.

Write the ELO calculation functions

The ELO system works in two steps: first calculate the expected probability that a mango wins, then adjust both ratings based on whether the actual result matched that expectation.

  • Add the following code below the initializeRatings function you just wrote:
// Calculate the probability that mango A beats mango B
function expectedScore(ratingA, ratingB) {
    return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400));
}

// Adjust both ratings after a matchup result
function updateRatings(winnerId, loserId) {
    const winnerRating = gameState.ratings[winnerId];
    const loserRating = gameState.ratings[loserId];
    const expectedWin = expectedScore(winnerRating, loserRating);
    const expectedLose = expectedScore(loserRating, winnerRating);
    gameState.ratings[winnerId] = Math.round(winnerRating + K_FACTOR * (1 - expectedWin));
    gameState.ratings[loserId] = Math.round(loserRating + K_FACTOR * (0 - expectedLose));
}

What does the ELO formula do?

The expectedScore function uses the formula 1 / (1 + 10^((ratingB - ratingA) / 400)). If two mangoes have the same rating, each has a 50% expected chance of winning. If one is rated much higher, its expected score approaches 1.

The updateRatings function then applies the update rule: newRating = oldRating + K * (actualScore - expectedScore). The winner gains points and the loser loses them. An upset (low-rated mango beating a high-rated one) causes a bigger swing than a predictable outcome.

You will not see a change yet. The next chunk adds the matchup generator that feeds pairs into these functions.

Create the matchup generator and game flow

With 16 mangoes, there are 120 unique pairs (the combination formula gives 16 * 15 / 2 = 120). You need a function that generates all those pairs, shuffles them into a random order, and a function that processes each result as the user clicks.

  • Add the following code below the updateRatings function:
// Fisher-Yates shuffle for unbiased random ordering
function shuffleArray(array) {
    const shuffled = [...array];
    for (let i = shuffled.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
    }
    return shuffled;
}

// Generate all 120 unique mango pairs and shuffle them
function generateMatchups() {
    const pairs = [];
    for (let i = 0; i < MANGOES.length; i++) {
        for (let j = i + 1; j < MANGOES.length; j++) {
            pairs.push([MANGOES[i].id, MANGOES[j].id]);
        }
    }
    gameState.matchups = shuffleArray(pairs);
    gameState.currentIndex = 0;
}

// Process a user's choice: update ratings and advance the game
function recordResult(winnerId, loserId) {
    updateRatings(winnerId, loserId);
    gameState.results.push({ winner: winnerId, loser: loserId });
    gameState.currentIndex++;
    if (gameState.currentIndex >= gameState.matchups.length) {
        showResults();
    } else {
        renderMatchup();
    }
}

What does this code do?

  • shuffleArray uses the Fisher-Yates algorithm to randomize the matchup order. This avoids any bias from alphabetical or array-position ordering.
  • generateMatchups builds every unique pair using a nested loop (i vs j where j > i), producing exactly 120 pairs. It then shuffles them so users see matchups in a different order each time.
  • recordResult ties everything together. It updates ratings via the ELO functions, logs the result, advances the index, and decides whether to show the next matchup or transition to results.

Why does recordResult call showResults and renderMatchup?

These two functions (showResults and renderMatchup) do not exist yet. You will build them in the next step when you create the comparison UI. For now, calling them will throw an error, but that is expected. The ELO logic itself works independently.

  • Save script.js.
  • Refresh your browser and open the browser console (press Cmd+Option+J on macOS or Ctrl+Shift+J on Windows).
  • Run the following commands one at a time in the console to test the ELO engine:
initializeRatings()
generateMatchups()
recordResult("alphonso", "tommy")
console.log(gameState.ratings["alphonso"])
console.log(gameState.ratings["tommy"])
  • You should see alphonso's rating above 1500 (it won the matchup) and tommy's rating below 1500 (it lost). The console will also show a ReferenceError for renderMatchup not being defined. That is expected because you have not built the UI yet.

Both ratings still showing 1500?

  • Make sure you called initializeRatings() before recordResult(). Without initialization, the ratings object is empty and the math operates on undefined.
  • Check that you saved script.js and refreshed the browser before running commands in the console.
  • Verify the mango IDs are spelled exactly as in the MANGOES array (all lowercase, e.g. "alphonso" not "Alphonso").

Why are my mango ratings not updating in the console?

✔️ Awesome, I've got everything!

Great. Double check you have saved your file.

ⓧ I'd like to double check the full code

Here is the full script.js file as it should look at the end of this step:

const MANGOES = [
    { id: "alphonso", name: "Alphonso", origin: "India", flavor: "Rich, buttery, saffron-like sweetness", emoji: "🥭" },
    { id: "ataulfo", name: "Ataulfo", origin: "Mexico", flavor: "Buttery, honey-sweet, fiberless", emoji: "🥭" },
    { id: "carabao", name: "Carabao", origin: "Philippines", flavor: "Buttery, floral, silky sweet", emoji: "🥭" },
    { id: "kent", name: "Kent", origin: "USA", flavor: "Sweet, rich, low fiber, peachy", emoji: "🥭" },
    { id: "namdocmai", name: "Nam Doc Mai", origin: "Thailand", flavor: "Silky, floral, nectar-like", emoji: "🥭" },
    { id: "haden", name: "Haden", origin: "USA", flavor: "Sweet-tart, classic tropical perfume", emoji: "🥭" },
    { id: "kesar", name: "Kesar", origin: "India", flavor: "Creamy, sweet-aromatic, gentle tang", emoji: "🥭" },
    { id: "chaunsa", name: "Chaunsa", origin: "Pakistan", flavor: "Very sweet, melting, deeply aromatic", emoji: "🥭" },
    { id: "sindhri", name: "Sindhri", origin: "Pakistan", flavor: "Silky, honeyed, gentle tang", emoji: "🥭" },
    { id: "dasheri", name: "Dasheri", origin: "India", flavor: "Fragrant, honey-like, perfumed", emoji: "🥭" },
    { id: "langra", name: "Langra", origin: "India", flavor: "Tangy-sweet, bold, distinctive aroma", emoji: "🥭" },
    { id: "tommy", name: "Tommy Atkins", origin: "USA", flavor: "Mildly sweet, firm, fibrous", emoji: "🥭" },
    { id: "keitt", name: "Keitt", origin: "USA", flavor: "Sweet, tangy, firm and juicy", emoji: "🥭" },
    { id: "julie", name: "Julie", origin: "Caribbean", flavor: "Creamy, spicy, low fiber", emoji: "🥭" },
    { id: "kensington", name: "Kensington Pride", origin: "Australia", flavor: "Sweet, aromatic, juicy", emoji: "🥭" },
    { id: "palmer", name: "Palmer", origin: "Brazil", flavor: "Mildly sweet, firm, low fiber", emoji: "🥭" }
];

const K_FACTOR = 32;
const INITIAL_RATING = 1500;

let gameState = {
    ratings: {},
    matchups: [],
    currentIndex: 0,
    results: []
};

function initializeRatings() {
    gameState.ratings = {};
    MANGOES.forEach(mango => {
        gameState.ratings[mango.id] = INITIAL_RATING;
    });
}

function expectedScore(ratingA, ratingB) {
    return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400));
}

function updateRatings(winnerId, loserId) {
    const winnerRating = gameState.ratings[winnerId];
    const loserRating = gameState.ratings[loserId];
    const expectedWin = expectedScore(winnerRating, loserRating);
    const expectedLose = expectedScore(loserRating, winnerRating);
    gameState.ratings[winnerId] = Math.round(winnerRating + K_FACTOR * (1 - expectedWin));
    gameState.ratings[loserId] = Math.round(loserRating + K_FACTOR * (0 - expectedLose));
}

function shuffleArray(array) {
    const shuffled = [...array];
    for (let i = shuffled.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
    }
    return shuffled;
}

function generateMatchups() {
    const pairs = [];
    for (let i = 0; i < MANGOES.length; i++) {
        for (let j = i + 1; j < MANGOES.length; j++) {
            pairs.push([MANGOES[i].id, MANGOES[j].id]);
        }
    }
    gameState.matchups = shuffleArray(pairs);
    gameState.currentIndex = 0;
}

function recordResult(winnerId, loserId) {
    updateRatings(winnerId, loserId);
    gameState.results.push({ winner: winnerId, loser: loserId });
    gameState.currentIndex++;
    if (gameState.currentIndex >= gameState.matchups.length) {
        showResults();
    } else {
        renderMatchup();
    }
}

console.log(`MangoRankings loaded: ${MANGOES.length} varieties`);

Your ELO engine is ready to rank mangoes. Next up, you will build the head-to-head comparison UI so users can click their favorite and watch the rankings come alive.

Create the Comparison UI

Your ELO engine is ready to crunch numbers, but right now there is no way for a user to actually pick a mango. You need a visual interface that presents two mangoes side by side and lets the user click their favorite.

In this step you will build the battle screen HTML, wire up the rendering and click logic in JavaScript, and style everything with a warm mango-themed design.

In this step, get ready to:
  • Build the HTML page structure with two mango cards, a progress bar, and a hidden results section.
  • Write the JavaScript that renders matchups, handles clicks, and transitions to results.
  • Style the battle screen with a warm gradient, card hover effects, and responsive layout.
Build the HTML battle screen structure

The comparison interface needs two clickable mango cards, a progress indicator, and a hidden results section that appears after all matchups are complete.

  • In index.html, find the empty <main></main> tag.
  • Replace it with the following structure:
    <main>
        <section id="battle-screen">
            <div class="progress-container">
                <div class="progress-bar" id="progress-bar"></div>
                <span class="progress-text" id="progress-text">Match 1 of 120</span>
            </div>
            <div class="battle-arena">
                <div class="mango-card" id="card-left" tabindex="0" role="button" aria-label="Pick left mango">
                    <div class="mango-emoji" id="emoji-left"></div>
                    <h2 class="mango-name" id="name-left"></h2>
                    <p class="mango-origin" id="origin-left"></p>
                    <p class="mango-flavor" id="flavor-left"></p>
                    <span class="pick-label">Pick this one</span>
                </div>
                <div class="vs-badge">VS</div>
                <div class="mango-card" id="card-right" tabindex="0" role="button" aria-label="Pick right mango">
                    <div class="mango-emoji" id="emoji-right"></div>
                    <h2 class="mango-name" id="name-right"></h2>
                    <p class="mango-origin" id="origin-right"></p>
                    <p class="mango-flavor" id="flavor-right"></p>
                    <span class="pick-label">Pick this one</span>
                </div>
            </div>
        </section>

        <section id="results-screen" class="hidden">
            <h2>Your Mango Rankings</h2>
            <ol id="rankings-list"></ol>
            <button id="share-btn" class="share-button">Share My Rankings</button>
            <button id="restart-btn" class="restart-button">Rank Again</button>
        </section>

        <div id="toast" class="toast hidden">Link copied!</div>
    </main>

What does this HTML do?

The #battle-screen section holds the head-to-head comparison UI. It contains a progress bar at the top and two .mango-card divs separated by a "VS" badge.

Each card has empty elements (#emoji-left, #name-left, etc.) that JavaScript will fill with mango data. The entire card is clickable, not just the "Pick this one" label.

The #results-screen starts hidden and only appears once all 120 matchups are complete. The #toast div is a notification that briefly appears when the user copies a share link.

  • Save index.html.
  • Refresh your browser. You should see a blank page with just the header. The cards will appear once the JavaScript renders them.

Seeing broken HTML or nothing at all?

  • Check that every opening tag has a matching closing tag. Missing a </div> or </section> will break the layout.
  • Make sure the <main> tag still sits between </header> and the <script> tag at the bottom.
  • Help me fix my HTML structure for the battle screen.
Write the matchup rendering and game flow logic

The HTML has empty placeholder elements. You need JavaScript to fill them with mango data for the current matchup and advance through all 120 pairs when the user clicks a card.

  • In script.js, find the console.log line at the very bottom of the file and delete it.
  • Add the following code below the recordResult function to look up mango data and render matchups:
function getMangoById(id) {
    return MANGOES.find(m => m.id === id);
}

function renderMatchup() {
    const pair = gameState.matchups[gameState.currentIndex];
    const left = getMangoById(pair[0]);
    const right = getMangoById(pair[1]);

    // Fill the left card with mango data
    document.getElementById("emoji-left").textContent = left.emoji;
    document.getElementById("name-left").textContent = left.name;
    document.getElementById("origin-left").textContent = left.origin;
    document.getElementById("flavor-left").textContent = left.flavor;

    // Fill the right card with mango data
    document.getElementById("emoji-right").textContent = right.emoji;
    document.getElementById("name-right").textContent = right.name;
    document.getElementById("origin-right").textContent = right.origin;
    document.getElementById("flavor-right").textContent = right.flavor;

    // Update the progress bar and counter
    const total = gameState.matchups.length;
    const current = gameState.currentIndex + 1;
    document.getElementById("progress-text").textContent = `Match ${current} of ${total}`;
    document.getElementById("progress-bar").style.width = `${(current / total) * 100}%`;
}

What does this code do?

  • getMangoById() searches the MANGOES array for an object matching the given ID. This converts IDs stored in the matchup pairs back into full mango objects.
  • renderMatchup() grabs the current pair from gameState.matchups, looks up both mangoes, and writes their emoji, name, origin, and flavor into the HTML card elements.
  • The progress bar width is set as a percentage of matchups completed, so it fills smoothly from left to right.
  • Below renderMatchup, add the results rendering functions that display the final ranked list:
function getSortedRankings() {
    // Combine mango data with their current rating and sort highest first
    return MANGOES
        .map(m => ({ ...m, rating: gameState.ratings[m.id] }))
        .sort((a, b) => b.rating - a.rating);
}

function getTierBadge(index, total) {
    // Assign S/A/B/C tier based on which quartile the mango falls in
    const quartile = index / total;
    if (quartile < 0.25) return { label: "S", className: "tier-s" };
    if (quartile < 0.5) return { label: "A", className: "tier-a" };
    if (quartile < 0.75) return { label: "B", className: "tier-b" };
    return { label: "C", className: "tier-c" };
}

function renderResults() {
    const rankings = getSortedRankings();
    const list = document.getElementById("rankings-list");
    list.innerHTML = "";
    rankings.forEach((mango, index) => {
        const tier = getTierBadge(index, rankings.length);
        const li = document.createElement("li");
        li.innerHTML = `
            <div class="ranking-info">
                <div class="ranking-name">${mango.emoji} ${mango.name}</div>
                <div class="ranking-origin">${mango.origin} &middot; ${mango.rating} pts</div>
            </div>
            <span class="ranking-badge ${tier.className}">${tier.label}</span>
        `;
        list.appendChild(li);
    });
}

function showResults() {
    // Hide the battle screen and reveal the results
    document.getElementById("battle-screen").classList.add("hidden");
    document.getElementById("results-screen").classList.remove("hidden");
    renderResults();
}

What does this code do?

  • getSortedRankings() merges each mango's data with its current ELO rating and sorts the array from highest to lowest rating.
  • getTierBadge() assigns a letter grade (S, A, B, or C) based on which quartile the mango lands in. The top 25% get S-tier.
  • renderResults() creates a numbered list item for each mango showing its name, origin, rating score, and tier badge.
  • showResults() toggles the CSS hidden class to swap the battle screen for the results screen.
  • Finally, add the game initialization and click handler code at the very bottom of script.js:
function startGame() {
    // Set up fresh ratings and generate randomized matchup pairs
    initializeRatings();
    generateMatchups();
    document.getElementById("battle-screen").classList.remove("hidden");
    document.getElementById("results-screen").classList.add("hidden");
    renderMatchup();
}

function init() {
    console.log(`MangoRankings loaded: ${MANGOES.length} varieties`);
    startGame();

    // Clicking the left card records left mango as winner
    document.getElementById("card-left").addEventListener("click", () => {
        const pair = gameState.matchups[gameState.currentIndex];
        recordResult(pair[0], pair[1]);
    });

    // Clicking the right card records right mango as winner
    document.getElementById("card-right").addEventListener("click", () => {
        const pair = gameState.matchups[gameState.currentIndex];
        recordResult(pair[1], pair[0]);
    });
}

init();

What does this code do?

  • startGame() resets all ratings to 1500, generates 120 shuffled matchup pairs, and renders the first matchup on screen.
  • init() is the entry point that runs on page load. It starts the game and attaches click handlers to both cards.
  • Each click handler reads the current matchup pair and calls recordResult with the clicked mango as the winner. The recordResult function (from Step 2) then updates ratings and either renders the next matchup or calls showResults().
  • Save script.js and refresh your browser.
  • You should see two mango names appear in the page (unstyled). Clicking either one should swap the text to a new pair and update the "Match X of 120" counter.

Cards not showing mango data?

  • Open the browser console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows). Look for red errors mentioning "Cannot read properties of null." This means an element ID in your HTML does not match what the JavaScript expects.
  • Make sure you deleted the standalone console.log line that was at the bottom before. The init() call should be the last line in the file.
  • Help me debug why my mango cards are not rendering data.
Style the battle screen with a warm gradient and card effects

The page works, but it looks like raw HTML. Time to add the mango-themed visual design with a warm gradient background, hoverable cards, and a responsive layout that stacks on mobile.

  • Open style.css (currently empty) and add the base styles, header, and battle screen layout:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 50%, #ffcc80 100%);
    min-height: 100vh;
    color: #333;
}

header {
    text-align: center;
    padding: 2rem 1rem 1rem;
}

header h1 {
    font-size: 2.5rem;
    color: #e65100;
    text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1);
}

.tagline {
    color: #bf360c;
    font-size: 1.1rem;
    margin-top: 0.5rem;
}

main {
    max-width: 900px;
    margin: 0 auto;
    padding: 1rem;
}

/* Progress bar at the top of the battle screen */
.progress-container {
    background: rgba(255, 255, 255, 0.6);
    border-radius: 20px;
    padding: 0.3rem;
    margin-bottom: 2rem;
    position: relative;
}

.progress-bar {
    height: 8px;
    background: linear-gradient(90deg, #ff6d00, #ff9100);
    border-radius: 20px;
    transition: width 0.3s ease;
    width: 0%;
}

.progress-text {
    display: block;
    text-align: center;
    font-size: 0.85rem;
    color: #666;
    margin-top: 0.3rem;
}

/* Side-by-side card layout */
.battle-arena {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 1rem;
    flex-wrap: wrap;
}

.mango-card {
    background: white;
    border-radius: 16px;
    padding: 2rem 1.5rem;
    width: 300px;
    text-align: center;
    cursor: pointer;
    transition: transform 0.2s ease, box-shadow 0.2s ease;
    box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
    user-select: none;
}

.mango-card:hover {
    transform: scale(1.05);
    box-shadow: 0 8px 25px rgba(230, 81, 0, 0.2);
}

.mango-card:active {
    transform: scale(0.98);
}

.mango-emoji {
    font-size: 4rem;
    margin-bottom: 1rem;
}

.mango-name {
    font-size: 1.4rem;
    color: #e65100;
    margin-bottom: 0.5rem;
}

.mango-origin {
    font-size: 0.95rem;
    color: #666;
    margin-bottom: 0.5rem;
}

.mango-flavor {
    font-size: 0.9rem;
    color: #888;
    font-style: italic;
    margin-bottom: 1rem;
}

.pick-label {
    display: inline-block;
    background: linear-gradient(135deg, #ff6d00, #ff9100);
    color: white;
    padding: 0.5rem 1.5rem;
    border-radius: 25px;
    font-size: 0.9rem;
    font-weight: 600;
}

.vs-badge {
    font-size: 1.5rem;
    font-weight: 800;
    color: #bf360c;
    padding: 0.5rem;
}

What do these styles do?

  • The body gradient blends light orange tones (#fff3e0 to #ffcc80) for a warm, mango-inspired feel.
  • The .mango-card:hover rule scales the card up by 5% and adds a deeper shadow, giving a satisfying "lift" effect when the user hovers.
  • The .battle-arena uses flexbox with flex-wrap: wrap so cards sit side-by-side on desktop but will stack when space runs out.
  • Below the battle screen styles, add the results screen, buttons, and responsive rules:
/* Results screen heading and ranked list */
#results-screen h2 {
    text-align: center;
    font-size: 2rem;
    color: #e65100;
    margin-bottom: 1.5rem;
}

#rankings-list {
    list-style: none;
    counter-reset: ranking;
}

#rankings-list li {
    counter-increment: ranking;
    background: white;
    border-radius: 12px;
    padding: 1rem 1.5rem;
    margin-bottom: 0.75rem;
    display: flex;
    align-items: center;
    gap: 1rem;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}

#rankings-list li::before {
    content: counter(ranking);
    background: linear-gradient(135deg, #ff6d00, #ff9100);
    color: white;
    width: 32px;
    height: 32px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: 700;
    font-size: 0.9rem;
    flex-shrink: 0;
}

.ranking-info {
    flex: 1;
}

.ranking-name {
    font-weight: 600;
    color: #333;
}

.ranking-origin {
    font-size: 0.85rem;
    color: #666;
}

.ranking-badge {
    padding: 0.25rem 0.75rem;
    border-radius: 12px;
    font-size: 0.8rem;
    font-weight: 700;
}

/* Tier badge color schemes */
.tier-s { background: #fff3e0; color: #e65100; }
.tier-a { background: #e8f5e9; color: #2e7d32; }
.tier-b { background: #e3f2fd; color: #1565c0; }
.tier-c { background: #f3e5f5; color: #6a1b9a; }

/* Action buttons */
.share-button {
    display: block;
    margin: 2rem auto 1rem;
    padding: 1rem 2.5rem;
    background: linear-gradient(135deg, #ff6d00, #ff9100);
    color: white;
    border: none;
    border-radius: 30px;
    font-size: 1.1rem;
    font-weight: 600;
    cursor: pointer;
    transition: transform 0.2s ease, box-shadow 0.2s ease;
}

.share-button:hover {
    transform: scale(1.05);
    box-shadow: 0 4px 15px rgba(255, 109, 0, 0.4);
}

.restart-button {
    display: block;
    margin: 0 auto;
    padding: 0.75rem 2rem;
    background: transparent;
    color: #e65100;
    border: 2px solid #e65100;
    border-radius: 30px;
    font-size: 1rem;
    cursor: pointer;
    transition: background 0.2s ease;
}

.restart-button:hover {
    background: rgba(230, 81, 0, 0.1);
}

/* Toast notification */
.toast {
    position: fixed;
    bottom: 2rem;
    left: 50%;
    transform: translateX(-50%);
    background: #333;
    color: white;
    padding: 0.75rem 1.5rem;
    border-radius: 25px;
    font-size: 0.95rem;
    transition: opacity 0.3s ease;
    z-index: 1000;
}

/* Utility class to hide elements */
.hidden {
    display: none;
}

/* Stack cards vertically on small screens */
@media (max-width: 680px) {
    .battle-arena {
        flex-direction: column;
    }

    .mango-card {
        width: 100%;
        max-width: 320px;
    }

    .vs-badge {
        margin: 0.5rem 0;
    }

    header h1 {
        font-size: 1.8rem;
    }
}

What do these styles do?

  • The counter-reset and counter-increment properties create automatic rank numbers (1, 2, 3...) using a CSS counter instead of relying on the HTML <ol> default styling.
  • The four .tier-s through .tier-c classes give each tier badge a distinct color so the ranking feels visual at a glance.
  • The @media (max-width: 680px) rule switches the battle arena to a vertical column layout on phones, keeping the cards readable.
  • Save style.css and refresh your browser.
  • You should see a warm orange gradient background with two white mango cards side by side. Each card shows a mango emoji, name, origin, flavor, and an orange "Pick this one" button. Hovering a card lifts it slightly.

Cards not side by side?

  • Check that your .battle-arena has display: flex. Without it, divs stack vertically by default.
  • If the background is still white, confirm the <link rel="stylesheet" href="style.css"> line is in your HTML <head>.
  • Help me fix my CSS layout for the mango battle cards.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for all three files at this point. Compare yours against these:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>MangoRankings.com</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header>
        <h1>MangoRankings.com</h1>
        <p class="tagline">Rank the world's best mangoes, one matchup at a time</p>
    </header>

    <main>
        <section id="battle-screen">
            <div class="progress-container">
                <div class="progress-bar" id="progress-bar"></div>
                <span class="progress-text" id="progress-text">Match 1 of 120</span>
            </div>
            <div class="battle-arena">
                <div class="mango-card" id="card-left" tabindex="0" role="button" aria-label="Pick left mango">
                    <div class="mango-emoji" id="emoji-left"></div>
                    <h2 class="mango-name" id="name-left"></h2>
                    <p class="mango-origin" id="origin-left"></p>
                    <p class="mango-flavor" id="flavor-left"></p>
                    <span class="pick-label">Pick this one</span>
                </div>
                <div class="vs-badge">VS</div>
                <div class="mango-card" id="card-right" tabindex="0" role="button" aria-label="Pick right mango">
                    <div class="mango-emoji" id="emoji-right"></div>
                    <h2 class="mango-name" id="name-right"></h2>
                    <p class="mango-origin" id="origin-right"></p>
                    <p class="mango-flavor" id="flavor-right"></p>
                    <span class="pick-label">Pick this one</span>
                </div>
            </div>
        </section>

        <section id="results-screen" class="hidden">
            <h2>Your Mango Rankings</h2>
            <ol id="rankings-list"></ol>
            <button id="share-btn" class="share-button">Share My Rankings</button>
            <button id="restart-btn" class="restart-button">Rank Again</button>
        </section>

        <div id="toast" class="toast hidden">Link copied!</div>
    </main>

    <script src="script.js"></script>
</body>
</html>
const MANGOES = [
    { id: "alphonso", name: "Alphonso", origin: "India", flavor: "Rich, buttery, saffron-like sweetness", emoji: "\ud83e\udd6d" },
    { id: "ataulfo", name: "Ataulfo", origin: "Mexico", flavor: "Buttery, honey-sweet, fiberless", emoji: "\ud83e\udd6d" },
    { id: "carabao", name: "Carabao", origin: "Philippines", flavor: "Buttery, floral, silky sweet", emoji: "\ud83e\udd6d" },
    { id: "kent", name: "Kent", origin: "USA", flavor: "Sweet, rich, low fiber, peachy", emoji: "\ud83e\udd6d" },
    { id: "namdocmai", name: "Nam Doc Mai", origin: "Thailand", flavor: "Silky, floral, nectar-like", emoji: "\ud83e\udd6d" },
    { id: "haden", name: "Haden", origin: "USA", flavor: "Sweet-tart, classic tropical perfume", emoji: "\ud83e\udd6d" },
    { id: "kesar", name: "Kesar", origin: "India", flavor: "Creamy, sweet-aromatic, gentle tang", emoji: "\ud83e\udd6d" },
    { id: "chaunsa", name: "Chaunsa", origin: "Pakistan", flavor: "Very sweet, melting, deeply aromatic", emoji: "\ud83e\udd6d" },
    { id: "sindhri", name: "Sindhri", origin: "Pakistan", flavor: "Silky, honeyed, gentle tang", emoji: "\ud83e\udd6d" },
    { id: "dasheri", name: "Dasheri", origin: "India", flavor: "Fragrant, honey-like, perfumed", emoji: "\ud83e\udd6d" },
    { id: "langra", name: "Langra", origin: "India", flavor: "Tangy-sweet, bold, distinctive aroma", emoji: "\ud83e\udd6d" },
    { id: "tommy", name: "Tommy Atkins", origin: "USA", flavor: "Mildly sweet, firm, fibrous", emoji: "\ud83e\udd6d" },
    { id: "keitt", name: "Keitt", origin: "USA", flavor: "Sweet, tangy, firm and juicy", emoji: "\ud83e\udd6d" },
    { id: "julie", name: "Julie", origin: "Caribbean", flavor: "Creamy, spicy, low fiber", emoji: "\ud83e\udd6d" },
    { id: "kensington", name: "Kensington Pride", origin: "Australia", flavor: "Sweet, aromatic, juicy", emoji: "\ud83e\udd6d" },
    { id: "palmer", name: "Palmer", origin: "Brazil", flavor: "Mildly sweet, firm, low fiber", emoji: "\ud83e\udd6d" }
];

const K_FACTOR = 32;
const INITIAL_RATING = 1500;

let gameState = {
    ratings: {},
    matchups: [],
    currentIndex: 0,
    results: []
};

function initializeRatings() {
    gameState.ratings = {};
    MANGOES.forEach(mango => {
        gameState.ratings[mango.id] = INITIAL_RATING;
    });
}

function expectedScore(ratingA, ratingB) {
    return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400));
}

function updateRatings(winnerId, loserId) {
    const winnerRating = gameState.ratings[winnerId];
    const loserRating = gameState.ratings[loserId];
    const expectedWin = expectedScore(winnerRating, loserRating);
    const expectedLose = expectedScore(loserRating, winnerRating);
    gameState.ratings[winnerId] = Math.round(winnerRating + K_FACTOR * (1 - expectedWin));
    gameState.ratings[loserId] = Math.round(loserRating + K_FACTOR * (0 - expectedLose));
}

function shuffleArray(array) {
    const shuffled = [...array];
    for (let i = shuffled.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
    }
    return shuffled;
}

function generateMatchups() {
    const pairs = [];
    for (let i = 0; i < MANGOES.length; i++) {
        for (let j = i + 1; j < MANGOES.length; j++) {
            pairs.push([MANGOES[i].id, MANGOES[j].id]);
        }
    }
    gameState.matchups = shuffleArray(pairs);
    gameState.currentIndex = 0;
}

function recordResult(winnerId, loserId) {
    updateRatings(winnerId, loserId);
    gameState.results.push({ winner: winnerId, loser: loserId });
    gameState.currentIndex++;
    if (gameState.currentIndex >= gameState.matchups.length) {
        showResults();
    } else {
        renderMatchup();
    }
}

function getMangoById(id) {
    return MANGOES.find(m => m.id === id);
}

function renderMatchup() {
    const pair = gameState.matchups[gameState.currentIndex];
    const left = getMangoById(pair[0]);
    const right = getMangoById(pair[1]);

    document.getElementById("emoji-left").textContent = left.emoji;
    document.getElementById("name-left").textContent = left.name;
    document.getElementById("origin-left").textContent = left.origin;
    document.getElementById("flavor-left").textContent = left.flavor;

    document.getElementById("emoji-right").textContent = right.emoji;
    document.getElementById("name-right").textContent = right.name;
    document.getElementById("origin-right").textContent = right.origin;
    document.getElementById("flavor-right").textContent = right.flavor;

    const total = gameState.matchups.length;
    const current = gameState.currentIndex + 1;
    document.getElementById("progress-text").textContent = `Match ${current} of ${total}`;
    document.getElementById("progress-bar").style.width = `${(current / total) * 100}%`;
}

function getSortedRankings() {
    return MANGOES
        .map(m => ({ ...m, rating: gameState.ratings[m.id] }))
        .sort((a, b) => b.rating - a.rating);
}

function getTierBadge(index, total) {
    const quartile = index / total;
    if (quartile < 0.25) return { label: "S", className: "tier-s" };
    if (quartile < 0.5) return { label: "A", className: "tier-a" };
    if (quartile < 0.75) return { label: "B", className: "tier-b" };
    return { label: "C", className: "tier-c" };
}

function renderResults() {
    const rankings = getSortedRankings();
    const list = document.getElementById("rankings-list");
    list.innerHTML = "";
    rankings.forEach((mango, index) => {
        const tier = getTierBadge(index, rankings.length);
        const li = document.createElement("li");
        li.innerHTML = `
            <div class="ranking-info">
                <div class="ranking-name">${mango.emoji} ${mango.name}</div>
                <div class="ranking-origin">${mango.origin} &middot; ${mango.rating} pts</div>
            </div>
            <span class="ranking-badge ${tier.className}">${tier.label}</span>
        `;
        list.appendChild(li);
    });
}

function showResults() {
    document.getElementById("battle-screen").classList.add("hidden");
    document.getElementById("results-screen").classList.remove("hidden");
    renderResults();
}

function startGame() {
    initializeRatings();
    generateMatchups();
    document.getElementById("battle-screen").classList.remove("hidden");
    document.getElementById("results-screen").classList.add("hidden");
    renderMatchup();
}

function init() {
    console.log(`MangoRankings loaded: ${MANGOES.length} varieties`);
    startGame();

    document.getElementById("card-left").addEventListener("click", () => {
        const pair = gameState.matchups[gameState.currentIndex];
        recordResult(pair[0], pair[1]);
    });

    document.getElementById("card-right").addEventListener("click", () => {
        const pair = gameState.matchups[gameState.currentIndex];
        recordResult(pair[1], pair[0]);
    });
}

init();
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 50%, #ffcc80 100%);
    min-height: 100vh;
    color: #333;
}

header {
    text-align: center;
    padding: 2rem 1rem 1rem;
}

header h1 {
    font-size: 2.5rem;
    color: #e65100;
    text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1);
}

.tagline {
    color: #bf360c;
    font-size: 1.1rem;
    margin-top: 0.5rem;
}

main {
    max-width: 900px;
    margin: 0 auto;
    padding: 1rem;
}

.progress-container {
    background: rgba(255, 255, 255, 0.6);
    border-radius: 20px;
    padding: 0.3rem;
    margin-bottom: 2rem;
    position: relative;
}

.progress-bar {
    height: 8px;
    background: linear-gradient(90deg, #ff6d00, #ff9100);
    border-radius: 20px;
    transition: width 0.3s ease;
    width: 0%;
}

.progress-text {
    display: block;
    text-align: center;
    font-size: 0.85rem;
    color: #666;
    margin-top: 0.3rem;
}

.battle-arena {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 1rem;
    flex-wrap: wrap;
}

.mango-card {
    background: white;
    border-radius: 16px;
    padding: 2rem 1.5rem;
    width: 300px;
    text-align: center;
    cursor: pointer;
    transition: transform 0.2s ease, box-shadow 0.2s ease;
    box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
    user-select: none;
}

.mango-card:hover {
    transform: scale(1.05);
    box-shadow: 0 8px 25px rgba(230, 81, 0, 0.2);
}

.mango-card:active {
    transform: scale(0.98);
}

.mango-emoji {
    font-size: 4rem;
    margin-bottom: 1rem;
}

.mango-name {
    font-size: 1.4rem;
    color: #e65100;
    margin-bottom: 0.5rem;
}

.mango-origin {
    font-size: 0.95rem;
    color: #666;
    margin-bottom: 0.5rem;
}

.mango-flavor {
    font-size: 0.9rem;
    color: #888;
    font-style: italic;
    margin-bottom: 1rem;
}

.pick-label {
    display: inline-block;
    background: linear-gradient(135deg, #ff6d00, #ff9100);
    color: white;
    padding: 0.5rem 1.5rem;
    border-radius: 25px;
    font-size: 0.9rem;
    font-weight: 600;
}

.vs-badge {
    font-size: 1.5rem;
    font-weight: 800;
    color: #bf360c;
    padding: 0.5rem;
}

#results-screen h2 {
    text-align: center;
    font-size: 2rem;
    color: #e65100;
    margin-bottom: 1.5rem;
}

#rankings-list {
    list-style: none;
    counter-reset: ranking;
}

#rankings-list li {
    counter-increment: ranking;
    background: white;
    border-radius: 12px;
    padding: 1rem 1.5rem;
    margin-bottom: 0.75rem;
    display: flex;
    align-items: center;
    gap: 1rem;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}

#rankings-list li::before {
    content: counter(ranking);
    background: linear-gradient(135deg, #ff6d00, #ff9100);
    color: white;
    width: 32px;
    height: 32px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: 700;
    font-size: 0.9rem;
    flex-shrink: 0;
}

.ranking-info {
    flex: 1;
}

.ranking-name {
    font-weight: 600;
    color: #333;
}

.ranking-origin {
    font-size: 0.85rem;
    color: #666;
}

.ranking-badge {
    padding: 0.25rem 0.75rem;
    border-radius: 12px;
    font-size: 0.8rem;
    font-weight: 700;
}

.tier-s { background: #fff3e0; color: #e65100; }
.tier-a { background: #e8f5e9; color: #2e7d32; }
.tier-b { background: #e3f2fd; color: #1565c0; }
.tier-c { background: #f3e5f5; color: #6a1b9a; }

.share-button {
    display: block;
    margin: 2rem auto 1rem;
    padding: 1rem 2.5rem;
    background: linear-gradient(135deg, #ff6d00, #ff9100);
    color: white;
    border: none;
    border-radius: 30px;
    font-size: 1.1rem;
    font-weight: 600;
    cursor: pointer;
    transition: transform 0.2s ease, box-shadow 0.2s ease;
}

.share-button:hover {
    transform: scale(1.05);
    box-shadow: 0 4px 15px rgba(255, 109, 0, 0.4);
}

.restart-button {
    display: block;
    margin: 0 auto;
    padding: 0.75rem 2rem;
    background: transparent;
    color: #e65100;
    border: 2px solid #e65100;
    border-radius: 30px;
    font-size: 1rem;
    cursor: pointer;
    transition: background 0.2s ease;
}

.restart-button:hover {
    background: rgba(230, 81, 0, 0.1);
}

.toast {
    position: fixed;
    bottom: 2rem;
    left: 50%;
    transform: translateX(-50%);
    background: #333;
    color: white;
    padding: 0.75rem 1.5rem;
    border-radius: 25px;
    font-size: 0.95rem;
    transition: opacity 0.3s ease;
    z-index: 1000;
}

.hidden {
    display: none;
}

@media (max-width: 680px) {
    .battle-arena {
        flex-direction: column;
    }

    .mango-card {
        width: 100%;
        max-width: 320px;
    }

    .vs-badge {
        margin: 0.5rem 0;
    }

    header h1 {
        font-size: 1.8rem;
    }
}

Your comparison UI is alive. Users can click through all 120 matchups and the ELO engine silently ranks mangoes in the background. Next up, you will build the results display and the shareable URL system so users can show off their rankings.

Build the Results and Sharing System

Your battle screen works perfectly. Users can click through all 120 matchups and the ELO algorithm adjusts ratings behind the scenes.

One problem remains: once the results screen appears, there is no way for a user to share their personalized ranking with anyone else. In this step, you will build a URL-based sharing system that encodes the final ratings into the page address itself. Anyone who opens that link sees the same ranked list without ever clicking through matchups.

In this step, get ready to:
  • Write encode and decode functions that convert rankings to a URL-safe string and back.
  • Wire up the Share button to copy a shareable link to the clipboard.
  • Update initialization so that opening a shared URL skips matchups and shows results directly.
Encode and decode rankings as a URL hash

To share rankings without a backend server, you need a way to pack 16 mango ratings into the URL itself. Base64 encoding converts a JSON string into URL-safe characters that fit neatly into the hash portion of a URL.

  • In script.js, scroll down to below the showResults() function.
  • Add the following two functions that handle encoding and decoding:
function encodeRankings() {
    // Build a compact array of IDs and their final ratings
    const data = MANGOES.map(m => ({
        id: m.id,
        r: gameState.ratings[m.id]
    }));
    return btoa(JSON.stringify(data));
}

function decodeRankings(hash) {
    try {
        // Strip the leading # if present
        const clean = hash.startsWith("#") ? hash.slice(1) : hash;
        if (!clean) return null;
        // Reverse the encoding: Base64 decode, then parse JSON
        const data = JSON.parse(atob(clean));
        if (!Array.isArray(data)) return null;
        return data;
    } catch (e) {
        return null;
    }
}

What does this code do?

encodeRankings() takes all 16 mango IDs and their current ELO ratings, serializes them to JSON, then converts that JSON string to Base64 using btoa(). The result is a long string of letters and numbers safe for a URL.

decodeRankings() reverses the process. It strips the # prefix, runs atob() to get JSON text back, then parses it into a JavaScript array. The try-catch block ensures that invalid or corrupted hashes return null instead of crashing the page.

  • Save script.js.

You will not see a change yet. The next chunk wires these functions into the share button.

Add the sharing and toast notification functions

The encode/decode pair handles data conversion. Now you need a function that actually constructs the full URL, copies it to the clipboard, and gives the user feedback that it worked.

  • Below the decodeRankings() function you just added, paste the following two functions:
function shareRankings() {
    const encoded = encodeRankings();
    // Build the full shareable URL with the encoded hash
    const url = `${window.location.origin}${window.location.pathname}#${encoded}`;
    navigator.clipboard.writeText(url).then(() => {
        showToast();
    }).catch(() => {
        // Fallback for browsers that block clipboard access
        prompt("Copy this link:", url);
    });
}

function showToast() {
    const toast = document.getElementById("toast");
    toast.classList.remove("hidden");
    // Hide the toast after 2 seconds
    setTimeout(() => {
        toast.classList.add("hidden");
    }, 2000);
}

What does this code do?

shareRankings() calls encodeRankings() to get the Base64 payload, then assembles a complete URL by combining the current page address with the encoded hash. It uses navigator.clipboard.writeText() to copy the link directly to the clipboard.

If clipboard access is blocked (this API requires HTTPS or localhost), the .catch() fallback opens a browser prompt() dialog showing the URL so the user can copy it manually.

showToast() briefly reveals the #toast element (which says "Link copied!") for 2 seconds, then hides it again.

  • Save script.js.

You will not see a change yet. The final piece is updating init() to connect everything.

Update init to detect shared URLs and attach event listeners

Right now, init() always starts a fresh game. You need it to check the URL hash first. If the hash contains valid rankings data, the page should skip matchups entirely and jump straight to results.

  • In script.js, find the existing init() function at the bottom of the file.
  • Replace the entire init() function with this updated version:
function init() {
    console.log(`MangoRankings loaded: ${MANGOES.length} varieties`);

    // Check if the URL contains shared rankings
    const hash = window.location.hash;
    const decoded = decodeRankings(hash);

    if (decoded) {
        // Restore shared rankings without playing matchups
        initializeRatings();
        decoded.forEach(item => {
            if (gameState.ratings.hasOwnProperty(item.id)) {
                gameState.ratings[item.id] = item.r;
            }
        });
        showResults();
    } else {
        startGame();
    }

    document.getElementById("card-left").addEventListener("click", () => {
        const pair = gameState.matchups[gameState.currentIndex];
        recordResult(pair[0], pair[1]);
    });

    document.getElementById("card-right").addEventListener("click", () => {
        const pair = gameState.matchups[gameState.currentIndex];
        recordResult(pair[1], pair[0]);
    });

    // Share and restart button handlers
    document.getElementById("share-btn").addEventListener("click", shareRankings);
    document.getElementById("restart-btn").addEventListener("click", () => {
        window.location.hash = "";
        startGame();
    });
}

init();

What changed in init?

Instead of calling startGame() immediately, the function now reads window.location.hash and passes it to decodeRankings(). If the hash contains valid data, it initializes ratings, overwrites each mango's score with the decoded values, and calls showResults() directly.

Two new event listeners connect the #share-btn to shareRankings() and the #restart-btn to a handler that clears the hash and starts fresh.

  • Save script.js and refresh your browser.
  • Click through all 120 matchups. When the results screen appears, you should see your mangoes ranked from highest to lowest ELO, each with an S, A, B, or C tier badge.
  • Click Share My Rankings. You should see a "Link copied!" toast at the bottom of the screen.
  • Paste the copied URL into a new browser tab. The results page should appear immediately with the same ranked list, skipping all matchups.

Rankings not loading from the shared URL?

Make sure you pasted the entire URL including the long # hash at the end. If the hash is truncated, decodeRankings() returns null and the page starts a fresh game instead.

If the toast does not appear when you click Share, check that you are opening index.html via a local server (like Live Server) or from localhost. The Clipboard API requires a secure context.

help me debug my sharing system

✔️ Awesome, I've got everything!

Great. Double check you have saved script.js before moving on.

ⓧ I'd like to double check the full code

const MANGOES = [
    { id: "alphonso", name: "Alphonso", origin: "India", flavor: "Rich, buttery, saffron-like sweetness", emoji: "\ud83e\udd6d" },
    { id: "ataulfo", name: "Ataulfo", origin: "Mexico", flavor: "Buttery, honey-sweet, fiberless", emoji: "\ud83e\udd6d" },
    { id: "carabao", name: "Carabao", origin: "Philippines", flavor: "Buttery, floral, silky sweet", emoji: "\ud83e\udd6d" },
    { id: "kent", name: "Kent", origin: "USA", flavor: "Sweet, rich, low fiber, peachy", emoji: "\ud83e\udd6d" },
    { id: "namdocmai", name: "Nam Doc Mai", origin: "Thailand", flavor: "Silky, floral, nectar-like", emoji: "\ud83e\udd6d" },
    { id: "haden", name: "Haden", origin: "USA", flavor: "Sweet-tart, classic tropical perfume", emoji: "\ud83e\udd6d" },
    { id: "kesar", name: "Kesar", origin: "India", flavor: "Creamy, sweet-aromatic, gentle tang", emoji: "\ud83e\udd6d" },
    { id: "chaunsa", name: "Chaunsa", origin: "Pakistan", flavor: "Very sweet, melting, deeply aromatic", emoji: "\ud83e\udd6d" },
    { id: "sindhri", name: "Sindhri", origin: "Pakistan", flavor: "Silky, honeyed, gentle tang", emoji: "\ud83e\udd6d" },
    { id: "dasheri", name: "Dasheri", origin: "India", flavor: "Fragrant, honey-like, perfumed", emoji: "\ud83e\udd6d" },
    { id: "langra", name: "Langra", origin: "India", flavor: "Tangy-sweet, bold, distinctive aroma", emoji: "\ud83e\udd6d" },
    { id: "tommy", name: "Tommy Atkins", origin: "USA", flavor: "Mildly sweet, firm, fibrous", emoji: "\ud83e\udd6d" },
    { id: "keitt", name: "Keitt", origin: "USA", flavor: "Sweet, tangy, firm and juicy", emoji: "\ud83e\udd6d" },
    { id: "julie", name: "Julie", origin: "Caribbean", flavor: "Creamy, spicy, low fiber", emoji: "\ud83e\udd6d" },
    { id: "kensington", name: "Kensington Pride", origin: "Australia", flavor: "Sweet, aromatic, juicy", emoji: "\ud83e\udd6d" },
    { id: "palmer", name: "Palmer", origin: "Brazil", flavor: "Mildly sweet, firm, low fiber", emoji: "\ud83e\udd6d" }
];

const K_FACTOR = 32;
const INITIAL_RATING = 1500;

let gameState = {
    ratings: {},
    matchups: [],
    currentIndex: 0,
    results: []
};

function initializeRatings() {
    gameState.ratings = {};
    MANGOES.forEach(mango => {
        gameState.ratings[mango.id] = INITIAL_RATING;
    });
}

function expectedScore(ratingA, ratingB) {
    return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400));
}

function updateRatings(winnerId, loserId) {
    const winnerRating = gameState.ratings[winnerId];
    const loserRating = gameState.ratings[loserId];
    const expectedWin = expectedScore(winnerRating, loserRating);
    const expectedLose = expectedScore(loserRating, winnerRating);
    gameState.ratings[winnerId] = Math.round(winnerRating + K_FACTOR * (1 - expectedWin));
    gameState.ratings[loserId] = Math.round(loserRating + K_FACTOR * (0 - expectedLose));
}

function shuffleArray(array) {
    const shuffled = [...array];
    for (let i = shuffled.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
    }
    return shuffled;
}

function generateMatchups() {
    const pairs = [];
    for (let i = 0; i < MANGOES.length; i++) {
        for (let j = i + 1; j < MANGOES.length; j++) {
            pairs.push([MANGOES[i].id, MANGOES[j].id]);
        }
    }
    gameState.matchups = shuffleArray(pairs);
    gameState.currentIndex = 0;
}

function recordResult(winnerId, loserId) {
    updateRatings(winnerId, loserId);
    gameState.results.push({ winner: winnerId, loser: loserId });
    gameState.currentIndex++;
    if (gameState.currentIndex >= gameState.matchups.length) {
        showResults();
    } else {
        renderMatchup();
    }
}

function getMangoById(id) {
    return MANGOES.find(m => m.id === id);
}

function renderMatchup() {
    const pair = gameState.matchups[gameState.currentIndex];
    const left = getMangoById(pair[0]);
    const right = getMangoById(pair[1]);

    document.getElementById("emoji-left").textContent = left.emoji;
    document.getElementById("name-left").textContent = left.name;
    document.getElementById("origin-left").textContent = left.origin;
    document.getElementById("flavor-left").textContent = left.flavor;

    document.getElementById("emoji-right").textContent = right.emoji;
    document.getElementById("name-right").textContent = right.name;
    document.getElementById("origin-right").textContent = right.origin;
    document.getElementById("flavor-right").textContent = right.flavor;

    const total = gameState.matchups.length;
    const current = gameState.currentIndex + 1;
    document.getElementById("progress-text").textContent = `Match ${current} of ${total}`;
    document.getElementById("progress-bar").style.width = `${(current / total) * 100}%`;
}

function getSortedRankings() {
    return MANGOES
        .map(m => ({ ...m, rating: gameState.ratings[m.id] }))
        .sort((a, b) => b.rating - a.rating);
}

function getTierBadge(index, total) {
    const quartile = index / total;
    if (quartile < 0.25) return { label: "S", className: "tier-s" };
    if (quartile < 0.5) return { label: "A", className: "tier-a" };
    if (quartile < 0.75) return { label: "B", className: "tier-b" };
    return { label: "C", className: "tier-c" };
}

function renderResults() {
    const rankings = getSortedRankings();
    const list = document.getElementById("rankings-list");
    list.innerHTML = "";
    rankings.forEach((mango, index) => {
        const tier = getTierBadge(index, rankings.length);
        const li = document.createElement("li");
        li.innerHTML = `
            <div class="ranking-info">
                <div class="ranking-name">${mango.emoji} ${mango.name}</div>
                <div class="ranking-origin">${mango.origin} &middot; ${mango.rating} pts</div>
            </div>
            <span class="ranking-badge ${tier.className}">${tier.label}</span>
        `;
        list.appendChild(li);
    });
}

function showResults() {
    document.getElementById("battle-screen").classList.add("hidden");
    document.getElementById("results-screen").classList.remove("hidden");
    renderResults();
}

function encodeRankings() {
    const data = MANGOES.map(m => ({
        id: m.id,
        r: gameState.ratings[m.id]
    }));
    return btoa(JSON.stringify(data));
}

function decodeRankings(hash) {
    try {
        const clean = hash.startsWith("#") ? hash.slice(1) : hash;
        if (!clean) return null;
        const data = JSON.parse(atob(clean));
        if (!Array.isArray(data)) return null;
        return data;
    } catch (e) {
        return null;
    }
}

function shareRankings() {
    const encoded = encodeRankings();
    const url = `${window.location.origin}${window.location.pathname}#${encoded}`;
    navigator.clipboard.writeText(url).then(() => {
        showToast();
    }).catch(() => {
        prompt("Copy this link:", url);
    });
}

function showToast() {
    const toast = document.getElementById("toast");
    toast.classList.remove("hidden");
    setTimeout(() => {
        toast.classList.add("hidden");
    }, 2000);
}

function startGame() {
    initializeRatings();
    generateMatchups();
    document.getElementById("battle-screen").classList.remove("hidden");
    document.getElementById("results-screen").classList.add("hidden");
    renderMatchup();
}

function init() {
    console.log(`MangoRankings loaded: ${MANGOES.length} varieties`);

    const hash = window.location.hash;
    const decoded = decodeRankings(hash);

    if (decoded) {
        initializeRatings();
        decoded.forEach(item => {
            if (gameState.ratings.hasOwnProperty(item.id)) {
                gameState.ratings[item.id] = item.r;
            }
        });
        showResults();
    } else {
        startGame();
    }

    document.getElementById("card-left").addEventListener("click", () => {
        const pair = gameState.matchups[gameState.currentIndex];
        recordResult(pair[0], pair[1]);
    });

    document.getElementById("card-right").addEventListener("click", () => {
        const pair = gameState.matchups[gameState.currentIndex];
        recordResult(pair[1], pair[0]);
    });

    document.getElementById("share-btn").addEventListener("click", shareRankings);
    document.getElementById("restart-btn").addEventListener("click", () => {
        window.location.hash = "";
        startGame();
    });
}

init();

Why use the URL hash instead of a query string?

The hash portion of a URL (everything after #) stays client-side and is never sent to any server. This makes it perfect for encoding user state in a static site with no backend.

For 16 mangoes with ratings, the Base64 payload is roughly 400-500 characters. This is well under the practical 2000-character limit for shareable URLs across browsers.

Your mango ranking site is fully functional. Users can rank, view results, and share their personalized list with a single click. Next up, the Secret Mission challenges you to cut the number of matchups dramatically using a merge-sort comparison strategy.

Secret mission

Add Quick Rank Mode

120 matchups is thorough, but it takes a while. What if you could get accurate rankings in about 50 comparisons instead? In this secret mission, you'll implement a merge-sort-driven Quick Rank mode that uses the same battle UI but dramatically fewer matchups.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your files for future mango ranking sessions or delete the folder entirely.

Resources you used:

  • The mango-rankings folder on your Desktop (contains index.html, style.css, and script.js).

Keep everything running

No action needed. Choose this if you want to keep ranking mangoes or share your results with friends.

  • Open index.html in your browser anytime to start a fresh ranking session.
  • Deploy to any free static hosting service (GitHub Pages, Netlify, or Vercel) to get a live URL you can share with anyone.

Pause - I'll come back to this later

Since this is a local project with no running processes, there is nothing to pause. Your files sit quietly on your Desktop until you open them again.

  • Your mango-rankings folder stays on your Desktop, ready whenever you want to revisit the project.

Delete - I don't want to use this again

Remove all project files from your machine.

  • Delete the project folder by running this command in your terminal (macOS/Linux):
  • On Windows, use:
rm -rf ~/Desktop/mango-rankings
rmdir /s /q %USERPROFILE%\Desktop\mango-rankings
  • Confirm the folder is gone by checking your Desktop. You should no longer see mango-rankings listed.

Nice Work!

Nice Work!

Nice work! You just built MangoRankings.com, a fully interactive mango ranking website powered by algorithmic scoring and shareable client-side URLs.

You've learned how to:

  • Implemented the ELO rating algorithm for pairwise comparisons in JavaScript, calculating expected scores and updating ratings with a K-factor of 32.
  • Created a responsive, mango-themed UI with card-based head-to-head matchups, CSS gradient backgrounds, hover effects, and a mobile-first flexbox layout.
  • Built a zero-backend sharing system using Base64 URL hash encoding so anyone can view a personalized ranking list without a server.
  • Secret Mission: Added a Quick Rank mode using merge-sort-driven comparisons to reduce matchups by approximately 60%.

Ready to quiz yourself?