Movie Match
Build a two-player app that finds a movie you both want to watch.
Introduction
30 Second Summary
Picking a movie with someone else can feel impossible. You both scroll through endless options, vetoing each other's picks, until you settle on something neither of you really wanted.
In this project, you will build a Movie Match web app with HTML, CSS, and JavaScript. Two players each pick their favorite movie genres, and the app finds a movie they will both love.
What You'll Build
Two friends tap their favorite genres on opposite panels, hit Find Your Match, and the app reveals a movie they both love with a real poster, star rating, and plot summary pulled from a live movie database.
By the end of this project, you'll have:
- Two side-by-side genre pickers with clickable buttons pulled live from the TMDB REST API, where both players see their selections light up instantly.
- A matching engine that finds genres both players agree on and reveals a movie card with poster, title, year, rating, and plot summary.
- A persistent Watch History powered by localStorage that saves matched movies across browser sessions, with controls to remove individual entries or clear the list.
- Secret Mission: Add a minimum rating filter and a shuffle button to cycle through different match results.
Are there any prerequisites?
You should have basic HTML knowledge to follow along. No prior CSS or JavaScript experience is required.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters for your web development journey.
Set Up Your Project
Every web app starts with three files: one for structure (HTML), one for styling (CSS), and one for interactivity (JavaScript). Before you write any code, you need these files in place and an API key to fetch real movie data.
In this step, you will create your project folder, register for a free TMDB API key, and confirm everything is connected by fetching live data in the browser console.
In this step, get ready to:
- Create the project folder and files in VS Code.
- Register for a free TMDB API key.
- Test the API key in the browser console.
Create the project folder and files
Your app needs a home folder and three empty files. You will also install a VS Code extension called Live Server that serves your files on localhost so the browser can run them properly.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir movie-match
cd movie-match
- Open this folder in VS Code by clicking File in the menu bar.
- Click Open Folder.
- Select the movie-match folder on your Desktop, then click Select Folder.
Trust the authors?
VS Code may ask if you trust the authors of the files in this folder. Click Yes, I trust the authors to continue.
- In the Explorer sidebar on the left, hover over MOVIE-MATCH and click the New File icon (it looks like a page with a plus sign).
- Type index.html and press Enter.
- Click the New File icon again, type style.css, and press Enter.
- Click New File one more time, type script.js, and press Enter.
- Confirm the files exist by running this in the terminal:
ls
You should see index.html, script.js, and style.css listed in the output.
Don't see all three files?
- Make sure your terminal is inside the movie-match folder. Run pwd to check your current location.
- If a file is missing, click the New File icon again and create it with the exact name (including the extension).
- Help me find my missing files.
Now install the Live Server extension so you can view your pages in the browser.
- Click the Extensions icon in the left sidebar (it looks like four squares).
- Type Live Server in the search bar.
- Find Live Server by Ritwick Dey in the results.
- Click Install.
What is Live Server?
Live Server runs a small web server on your computer and opens your project in the browser at a localhost address. This avoids issues that happen when you open HTML files directly with the file:// protocol. It also auto-refreshes the browser every time you save a file.
Register for a TMDB API key
TMDB (The Movie Database) is a free movie database with an API you can call from JavaScript. You need an account and an access token to authenticate your requests.
- Open your browser and go to https://www.themoviedb.org/.
- Click Sign Up and create a free account with your email address.
- Check your email for a verification message from TMDB and click the link to activate your account.
- Log in to TMDB, then navigate to https://www.themoviedb.org/settings/api.
- Request a Developer API key by clicking the link on that page.
- When prompted, choose Developer and accept the terms of use.
Which API key type do I pick?
TMDB offers Developer and Commercial options. Developer is completely free for non-commercial projects like this one. You may be asked to fill in application details. For the application URL, enter http://localhost since this is a local project.
- Once your key is created, scroll down on the API settings page to find the value labeled API Read Access Token.
- Copy the entire token. This is a long string that starts with ey. You will paste it into your code in the next substep.
What is a Bearer token?
The API Read Access Token is a Bearer token. When you make an API request, you send it in the Authorization header so TMDB knows who is calling. This is the same authentication pattern used in production APIs everywhere.
Test your API key
Before building the full app, you will fire a quick test request to confirm your token works. This writes a temporary script that fetches the list of movie genres from TMDB.
- Switch back to VS Code and click index.html in the Explorer sidebar to open it.
- Add a minimal HTML shell so the browser can load your script. Paste this code into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Movie Match</title>
</head>
<body>
<script src="script.js"></script>
</body>
</html>
What does this HTML do?
This is a bare-bones HTML page. The only important line is <script src="script.js"></script> which tells the browser to load and run your JavaScript file. You will replace this entire file with the real page structure in the next step.
- Save index.html by pressing Ctrl+S.
- Click script.js in the Explorer sidebar to open it.
- Add the test fetch by pasting this code into script.js:
// Quick test: fetch genre list from TMDB to verify your API token works
fetch("https://api.themoviedb.org/3/genre/movie/list?language=en", {
headers: {
Authorization: "Bearer your-api-read-access-token-here",
},
})
.then((response) => response.json())
.then((data) => console.log(data.genres));
- Replace your-api-read-access-token-here with the API Read Access Token you copied from TMDB. Keep the surrounding quotes.
- Save script.js by pressing Ctrl+S.
What does this code do?
- fetch("https://api.themoviedb.org/3/genre/movie/list?language=en", ...) sends a GET request to the TMDB genre list endpoint.
- Authorization: "Bearer ..." attaches your token so TMDB knows you are authorized.
- .then((data) => console.log(data.genres)) prints the genres array to the browser console so you can see it worked.
Now open the page with Live Server so the script runs in the browser.
- In the Explorer sidebar, right-click index.html.
- Click Open with Live Server.
What just happened?
Live Server started a local web server and opened your page in the browser. The page looks blank because index.html has no visible content yet. The script ran silently in the background and printed its output to the console.
Before you check the console, what do you think you will see? A list of genres, or an error?
- Open the browser console by pressing F12, then click the Console tab.
You should see an array of genre objects with names like Action, Adventure, Animation, and Comedy. Each object has an id and a name.
Seeing a 401 error?
- A 401 means your token was not accepted. Double-check that you copied the API Read Access Token (not the shorter API Key). The token is a long string starting with ey.
- Make sure there are no extra spaces or line breaks inside the quotes around your token in script.js.
- If you pasted the shorter API Key by mistake, go back to https://www.themoviedb.org/settings/api and copy the longer API Read Access Token value instead.
- Help me fix my TMDB API authentication error.
✔️ Awesome, I've got everything!
Great. Double check you have saved both index.html and script.js.
ⓧ I'd like to double check the full code
Here is what each file should look like right now.
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Movie Match</title>
</head>
<body>
<script src="script.js"></script>
</body>
</html>
script.js (with your real token in place of the placeholder):
// Quick test: fetch genre list from TMDB to verify your API token works
fetch("https://api.themoviedb.org/3/genre/movie/list?language=en", {
headers: {
Authorization: "Bearer your-api-read-access-token-here",
},
})
.then((response) => response.json())
.then((data) => console.log(data.genres));
style.css should be empty.
Your workspace is ready and your API connection is confirmed. Next up, you will build the full page layout with HTML and style it with CSS so your app looks like a real product.
Build the Movie Match UI
Your TMDB API key is working and printing genres to the console. That proves the plumbing is solid. Now it is time to build the page your users will actually see and interact with.
Every web app starts with two layers: structure (HTML) and style (CSS). In this step you will build both from scratch, creating a dark-themed movie matching interface with two player panels, a result card, and a watch history section.
In this step, get ready to:
- Build the HTML structure with a header, two player panels, a match button, a result card, and a history section.
- Style the page with a dark theme using CSS Grid, Flexbox, and a responsive media query.
- Clear the test code from script.js so it is ready for real application logic.
Build the HTML structure
HTML defines every element on your page. You will create the document skeleton, the header, two player panels, a match button, a result area, and a watch history section.
- Start building the page structure by pasting this code into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Movie Match</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>Movie Match</h1>
<p>Pick your genres. Find your perfect movie.</p>
</header>
<main>
<section class="players">
<div class="player">
<h2>Player 1</h2>
<div class="genre-list" id="genres1"></div>
</div>
<div class="player">
<h2>Player 2</h2>
<div class="genre-list" id="genres2"></div>
</div>
</section>
<button id="match-btn" class="match-btn">Find Your Match</button>
What does this HTML do?
- The <head> links your style.css file so the browser loads your styles.
- The .players section holds two panels. Each has an empty .genre-list div that JavaScript will fill with buttons later.
- The #match-btn button is what players click to trigger the matching logic.
- Save index.html.
- Switch to your browser. You should see "Movie Match" as a heading, "Player 1" and "Player 2" labels, and a default-styled "Find Your Match" button. Everything is unstyled for now.
Page is blank?
Make sure you right-clicked index.html in the VS Code Explorer and selected Open with Live Server. If Live Server is already running, check that your browser tab shows 127.0.0.1:5500 in the address bar. Help me troubleshoot my blank page.
- Add the movie result section directly below the <button> line:
<section id="result" class="result hidden">
<h2>Your Movie Match</h2>
<div id="movie-card" class="movie-card hidden">
<img id="movie-poster" src="" alt="">
<div class="movie-info">
<h3 id="movie-title"></h3>
<p id="movie-year"></p>
<p id="movie-rating"></p>
<p id="movie-overview"></p>
<button id="save-btn" class="save-btn">Save to Watch History</button>
</div>
</div>
<div id="no-match" class="no-match hidden">
<p id="no-match-message"></p>
</div>
</section>
What is this section for?
The #movie-card div holds the poster image, title, year, rating, and overview for a matched movie. The #no-match div holds a message for when no movie is found.
Both areas have a hidden class. Once you add CSS later in this step, that class will hide them until JavaScript reveals one.
- Save index.html and switch to your browser.
- You should see "Your Movie Match" and a "Save to Watch History" button appear below the match button.
- Add the watch history section, footer, and closing tags directly below the </section> tag you just added:
<section id="history" class="history hidden">
<h2>Watch History</h2>
<div id="history-list"></div>
<button id="clear-history" class="clear-btn">Clear History</button>
</section>
</main>
<footer>
<p>Data provided by
<a href="https://www.themoviedb.org/" target="_blank" rel="noopener">TMDB</a>
</p>
</footer>
<script src="script.js"></script>
</body>
</html>
What do the footer and script tag do?
- The footer credits TMDB as the data source, which is required for using their free API.
- The <script src="script.js"> tag loads your JavaScript file. Placing it at the bottom ensures the HTML is fully parsed before any code runs.
- Save index.html and switch to your browser.
- You should now see "Watch History", "Clear History", and a "Data provided by TMDB" footer at the bottom of the page.
✔️ Awesome, I've got everything!
Great. Double check you have saved index.html and move on to CSS.
ⓧ I'd like to double check the full code
Scroll up and confirm your index.html contains all three sections, in this order:
- The document head and header, ending with the match <button>.
- The result section with #movie-card and #no-match divs.
- The history section, footer, <script> tag, and closing </body></html> tags.
Style the page with CSS
Right now everything on the page is plain black text on a white background. CSS transforms that bare structure into a dark-themed, professional-looking interface.
- Click on style.css in the VS Code Explorer sidebar to open it.
You will add the styles in chunks, refreshing after each one to see the page transform.
- Set the page foundation by pasting these styles into style.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background-color: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
}
header {
text-align: center;
padding: 2rem;
background-color: #16213e;
}
header h1 {
font-size: 2.5rem;
color: #e94560;
}
header p {
font-size: 1.1rem;
color: #a0a0b0;
margin-top: 0.5rem;
}
What is a CSS reset?
The * { margin: 0; padding: 0; } block is called a CSS reset. Browsers add their own default spacing to elements, which varies between browsers. The reset removes all of it so you start from a clean, predictable baseline.
The #1a1a2e background color is a deep navy blue. The #e94560 accent on the heading is a vibrant red. These two colors form the core of the dark theme.
- Save style.css and switch to your browser.
- The page background is now dark navy. The "Movie Match" heading is red and centered on a darker banner.
Background is still white?
Make sure you are editing style.css, not index.html. Check that your index.html has <link rel="stylesheet" href="style.css"> in the <head>. Help me figure out why my CSS isn't loading.
- Add the two-column layout styles below the header styles in style.css:
main {
max-width: 900px;
margin: 0 auto;
padding: 2rem 1rem;
}
.players {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.player {
background-color: #16213e;
border-radius: 12px;
padding: 1.5rem;
}
.player h2 {
text-align: center;
margin-bottom: 1rem;
color: #e94560;
}
What is CSS Grid?
CSS Grid is a layout system that arranges elements into rows and columns. The line grid-template-columns: 1fr 1fr creates two equal-width columns. The 1fr unit means "one fraction of the available space."
The gap: 2rem property adds spacing between the two columns without needing margin hacks.
- Save style.css and switch to your browser.
- The "Player 1" and "Player 2" panels now sit side by side as two dark rounded cards with red headings.
Panels stacked on top of each other?
Check that your .players selector has display: grid and grid-template-columns: 1fr 1fr. A missing semicolon on the line above can break the grid. Help me fix my CSS Grid layout.
- Add the genre button container and button styles below your player styles:
.genre-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
}
.genre-btn {
padding: 0.5rem 1rem;
border: 2px solid #e94560;
background-color: transparent;
color: #e0e0e0;
border-radius: 20px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.genre-btn:hover {
background-color: rgba(233, 69, 96, 0.2);
}
.genre-btn.selected {
background-color: #e94560;
color: #ffffff;
}
What is Flexbox?
Flexbox is a layout system designed for one-dimensional rows or columns. The flex-wrap: wrap property lets items flow onto new lines when they run out of space, which is perfect for a grid of genre buttons.
The .genre-btn uses border-radius: 20px to create a pill shape. The .selected class fills the pill with the accent color when clicked.
- Save style.css and switch to your browser.
- Right-click inside one of the player panels and select Inspect. In the Styles panel, you should see display: flex applied to the .genre-list div. The panels are still empty because JavaScript creates the buttons in Step 3.
- Add the match button styles:
.match-btn {
display: block;
width: 100%;
max-width: 300px;
margin: 2rem auto;
padding: 1rem 2rem;
font-size: 1.2rem;
font-weight: bold;
border: none;
border-radius: 30px;
background-color: #e94560;
color: #ffffff;
cursor: pointer;
transition: background-color 0.3s;
}
.match-btn:hover {
background-color: #c73652;
}
What makes the button look like a pill?
The combination of border-radius: 30px (rounding the corners) and border: none (removing the default border) turns a standard rectangle into a smooth pill shape. The transition property adds a smooth color fade on hover.
- Save style.css and switch to your browser.
- The "Find Your Match" button is now a bold, red pill-shaped button centered below the panels. Hover over it to see the color darken.
The layout and interactive elements are styled. Now let's style the movie result card that appears when players find their match.
- Add the result section and movie card layout styles:
.result {
background-color: #16213e;
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
}
.result h2 {
text-align: center;
color: #e94560;
margin-bottom: 1.5rem;
}
.movie-card {
display: grid;
grid-template-columns: 200px 1fr;
gap: 1.5rem;
align-items: start;
}
#movie-poster {
width: 100%;
border-radius: 8px;
}
.movie-info h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
Why use Grid for the movie card?
The grid-template-columns: 200px 1fr creates a fixed 200-pixel column for the poster and a flexible column for the movie info. This ensures the poster always stays the same width while the text fills the remaining space.
- Save style.css and switch to your browser.
- The "Your Movie Match" heading is now styled in red on a dark card background below the match button.
- Add the movie detail text and save button styles:
#movie-year {
color: #a0a0b0;
margin-bottom: 0.5rem;
}
#movie-rating {
color: #f5c518;
font-weight: bold;
margin-bottom: 1rem;
}
#movie-overview {
line-height: 1.6;
margin-bottom: 1rem;
}
.save-btn {
padding: 0.5rem 1.5rem;
border: 2px solid #e94560;
background-color: transparent;
color: #e94560;
border-radius: 20px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
What does this code do?
The #movie-rating gets a gold color (#f5c518) to make scores stand out. The .save-btn uses a transparent background with a red border, creating an outlined pill button.
- Save style.css and switch to your browser.
- The "Save to Watch History" button is now a red-outlined pill below the movie info area.
- Add the save button hover, no-match message, and history section styles:
.save-btn:hover {
background-color: #e94560;
color: #ffffff;
}
.no-match {
text-align: center;
padding: 2rem;
}
.no-match p {
font-size: 1.2rem;
color: #a0a0b0;
}
.history {
background-color: #16213e;
border-radius: 12px;
padding: 2rem;
}
.history h2 {
text-align: center;
color: #e94560;
margin-bottom: 1.5rem;
}
What does this code do?
The .save-btn:hover fills the button with red when the cursor hovers over it. The .history section uses the same dark card style as the player panels and result section for visual consistency.
- Save style.css and switch to your browser.
- The "Watch History" heading now appears inside its own dark card at the bottom of the page.
- Add the history item and button styles:
.history-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 1px solid #2a2a4a;
}
.history-item:last-child {
border-bottom: none;
}
.history-item span {
font-size: 1rem;
}
.remove-btn {
padding: 0.25rem 0.75rem;
border: 1px solid #e94560;
background-color: transparent;
color: #e94560;
border-radius: 12px;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s;
}
What does this code do?
These styles prepare the watch history list. Each .history-item is a flex row with the movie title on one side and a remove button on the other. You will see them in action once JavaScript populates the history in Step 5.
- Save style.css.
- Add the clear button, hidden utility, and footer styles:
.remove-btn:hover {
background-color: #e94560;
color: #ffffff;
}
.clear-btn {
display: block;
margin: 1rem auto 0;
padding: 0.5rem 1.5rem;
border: 2px solid #e94560;
background-color: transparent;
color: #e94560;
border-radius: 20px;
cursor: pointer;
transition: all 0.2s;
}
.clear-btn:hover {
background-color: #e94560;
color: #ffffff;
}
.hidden {
display: none;
}
What does the hidden class do?
The .hidden { display: none; } class is a utility that completely hides an element from the page. JavaScript can show and hide sections by adding or removing this class. The result and history sections use it to stay invisible until they have content to show.
- Save style.css and switch to your browser.
- The "Your Movie Match" and "Watch History" sections have vanished. The .hidden class is now active, so only the header, player panels, match button, and footer remain visible.
Result and history sections still showing?
Check that your .hidden rule says exactly display: none; with no typos. Also look for a missing closing brace } on the rule above it, which can break all rules that follow. Help me find the CSS error.
- Add the footer and responsive styles as the final block in style.css:
footer {
text-align: center;
padding: 2rem;
color: #a0a0b0;
}
footer a {
color: #e94560;
text-decoration: none;
}
@media (max-width: 600px) {
.players {
grid-template-columns: 1fr;
}
.movie-card {
grid-template-columns: 1fr;
text-align: center;
}
#movie-poster {
max-width: 200px;
margin: 0 auto;
}
}
What is a media query?
A media query applies styles only when the screen matches a condition. The @media (max-width: 600px) block overrides the two-column Grid to a single column when the browser window is narrower than 600 pixels. This makes the app work on phones.
- Save style.css and switch to your browser.
- The TMDB footer link is now red. Drag the edge of the browser window to make it narrow. The two player panels should stack vertically instead of sitting side by side.
Panels not stacking on narrow screens?
Make sure the @media block has its own opening and closing braces, and that the .players override inside it has grid-template-columns: 1fr (one column, not two). Help me fix my media query.
✔️ Awesome, I've got everything!
Great. Double check you have saved style.css and move on to the final cleanup.
ⓧ I'd like to double check the full code
Scroll up and confirm your style.css contains all the style blocks you added, in this order:
- CSS reset, body, and header styles.
- Layout styles (main, .players, .player, .player h2).
- Genre button styles (.genre-list, .genre-btn, hover, and selected).
- Match button styles (.match-btn and hover).
- Result section, movie card, and movie info styles.
- History, remove, and clear button styles.
- The .hidden class, footer, and @media query.
Clear the test code from script.js
The test fetch you added in Step 1 has done its job. Time to clear it out so you start the next step with a clean JavaScript file.
- Click on script.js in the VS Code Explorer sidebar.
- Select all the code with Ctrl+A and delete it.
- Save the now-empty script.js file.
Before you move on, predict what the page should look like right now. Which sections are visible and which are hidden?
Switch to your browser and confirm. You should see a dark-themed page with "Movie Match" in red at the top, two empty player panels side by side, and a big red "Find Your Match" button. The result and history sections are hidden. The TMDB credit is at the bottom.
- Resize the browser window narrow to confirm the two panels stack vertically.
Your app looks like a real product, but those genre panels are empty. The next step brings them to life by pulling genres from the TMDB API and making the buttons interactive.
Connect to the TMDB API
Your Movie Match page looks polished, with a dark theme, two player panels, and a styled match button. There is one problem: those genre panels are completely empty.
In this step, you will bring the page to life by connecting to the TMDB API. You will fetch real genre data and turn it into clickable buttons that each player can tap to pick their favorite genres.
In this step, get ready to:
- Fetch the list of movie genres from the TMDB API using a Bearer token for authentication.
- Dynamically create genre buttons in both player panels and make them respond to clicks.
Fetch genres from the TMDB API
Every request to the TMDB API needs three things: the base URL, your API Read Access Token for authentication, and the specific endpoint you want to call. You will set these up as constants so every function in the file can reuse them.
- Switch back to VS Code and open script.js in the movie-match folder.
- Add the API configuration constants, two player selection trackers, and a function to fetch genres by pasting the following code:
// Your TMDB API Read Access Token for authentication
const API_TOKEN = "your-api-read-access-token-here";
const BASE_URL = "https://api.themoviedb.org/3";
const IMG_URL = "https://image.tmdb.org/t/p/w500";
// Track each player's selected genre IDs
const player1Genres = new Set();
const player2Genres = new Set();
// Fetch the full list of movie genres from TMDB
async function fetchGenres() {
const response = await fetch(`${BASE_URL}/genre/movie/list?language=en`, {
headers: {
Authorization: `Bearer ${API_TOKEN}`,
},
});
const data = await response.json();
return data.genres;
}
- Replace your-api-read-access-token-here with the API Read Access Token you copied from your TMDB account settings in Step 1. Keep the surrounding quotes.
What does this code do?
- API_TOKEN stores your Bearer token. Every TMDB request sends this in the Authorization header to prove you are allowed to access the data.
- BASE_URL and IMG_URL are the root addresses for the TMDB data API and its image server. You will use IMG_URL later when displaying movie posters.
- player1Genres and player2Genres are JavaScript Set objects. A Set automatically prevents duplicates, so a player can never accidentally add the same genre twice.
- fetchGenres() calls the /genre/movie/list endpoint, parses the JSON response, and returns just the genres array.
What is a Bearer token?
A Bearer token is a long string you include in the Authorization header of an HTTP request. The server checks this token to confirm you have permission to access the data. Think of it like a keycard that unlocks the door to the API.
- Save script.js.
Nothing visible changes in the browser yet because no function is calling fetchGenres(). The next chunk wires everything up.
Render genre buttons and handle selection
The API can return genre data, but you need a function that turns that data into real buttons on the page. Each button needs to respond to clicks by toggling the genre in or out of a player's selected set.
- Add the button-building function, the startup function, and its call at the bottom of script.js, below fetchGenres():
function createGenreButtons(genres, containerId, selectedSet) {
const container = document.getElementById(containerId);
container.innerHTML = "";
genres.forEach((genre) => {
// Create a pill-shaped button for each genre
const button = document.createElement("button");
button.textContent = genre.name;
button.classList.add("genre-btn");
button.dataset.id = genre.id;
// Toggle genre in/out of the player's selected set
button.addEventListener("click", () => {
if (selectedSet.has(genre.id)) {
selectedSet.delete(genre.id);
button.classList.remove("selected");
} else {
selectedSet.add(genre.id);
button.classList.add("selected");
}
});
container.appendChild(button);
});
}
// Start the app by loading genres into both panels
async function init() {
const genres = await fetchGenres();
createGenreButtons(genres, "genres1", player1Genres);
createGenreButtons(genres, "genres2", player2Genres);
}
init();
What does this code do?
- createGenreButtons() loops through the genres array and creates a <button> element for each one. Each button gets the genre-btn CSS class (the pill styling from your stylesheet) and a data-id attribute storing the genre's numeric ID.
- The click listener checks whether the genre ID is already in the player's Set. If it is, it removes the ID and strips the selected class. If it is not, it adds the ID and applies the selected class, which fills the button with the accent color.
- init() ties everything together. It fetches the genre list once and then calls createGenreButtons() twice, once for each player's container (genres1 and genres2).
- Save script.js.
Before you check your browser, what do you think you will see in the two player panels? Will they still be empty, or will something new appear?
- Switch to your browser. Live Server auto-refreshes the page.
Both player panels now show pill-shaped genre buttons (Action, Adventure, Animation, Comedy, and more) loaded live from the TMDB API. Click a genre button and it fills with the accent color. Click it again and it deselects. Try clicking the Find Your Match button. Nothing happens yet. That is expected because the matching logic does not exist yet.
Genre buttons not showing up?
- Open the browser console (press F12, then click Console). If you see a 401 error, your API token is incorrect. Double-check that you pasted the full API Read Access Token (not the shorter API Key) from your TMDB account settings.
- Make sure you kept the quotes around the token value in API_TOKEN. The line should look like const API_TOKEN = "eyJh..." with your actual token inside the quotes.
- Confirm the container IDs in your init() function (genres1 and genres2) match the id attributes in your index.html exactly.
Why are my TMDB genre buttons not appearing in the browser?
✔️ Awesome, I've got everything!
Great. Double check you have saved script.js.
ⓧ I'd like to double check the full code
Scroll up and confirm each block is present in script.js, in this order:
- The three constants (API_TOKEN, BASE_URL, IMG_URL) and two Sets (player1Genres, player2Genres).
- The fetchGenres() function that calls the TMDB genre endpoint.
- The createGenreButtons() function that builds buttons and wires up click listeners.
- The init() function followed by init(); at the very bottom of the file.
Your app now talks to a live API and responds to user interaction. Next up, you will build the matching engine that finds a movie both players will love.
Build the Matching Engine
Your genre panels are loaded with real data from the TMDB API, and clicking a genre button highlights it in red. But the centerpiece of the app, the "Find Your Match" button, does absolutely nothing.
This step builds the brain of Movie Match. You will write functions to find which genres both players agree on, fetch a movie recommendation from TMDB, and display the result as a polished movie card.
In this step, get ready to:
- Write functions to find overlapping genres and fetch movies from TMDB.
- Build display functions for the movie card and error messages.
- Wire up the "Find Your Match" button with edge case handling.
Write the overlap and discover functions
The matching engine needs two pieces of logic. First, it needs to compare what Player 1 selected with what Player 2 selected and find the genres they share. Second, it needs to ask TMDB for popular movies in those shared genres.
- In script.js, find the closing brace } of the createGenreButtons function.
- Add these two new functions below it, above the init() function:
// Find genres selected by both players
function findOverlappingGenres() {
const overlap = [];
player1Genres.forEach((genreId) => {
if (player2Genres.has(genreId)) {
overlap.push(genreId);
}
});
return overlap;
}
// Fetch popular movies matching the given genre IDs from TMDB
async function discoverMovie(genreIds) {
const genreString = genreIds.join("|");
const response = await fetch(
`${BASE_URL}/discover/movie?with_genres=${genreString}&sort_by=popularity.desc&vote_count.gte=100&language=en-US&page=1`,
{
headers: {
Authorization: `Bearer ${API_TOKEN}`,
},
}
);
const data = await response.json();
return data.results;
}
What does this code do?
findOverlappingGenres() loops through every genre ID in player1Genres and checks if player2Genres also contains it. Only the shared IDs make it into the overlap array.
discoverMovie() joins those IDs with a pipe character (|) so TMDB treats them as an OR query. It calls the /discover/movie endpoint sorted by popularity, filtering out movies with fewer than 100 votes to avoid obscure titles.
- Save script.js.
- In the browser, click a few genre buttons on both sides. Make sure at least one genre (like Comedy) is selected for both players.
- Open the browser console by pressing F12, then clicking the Console tab.
- Test your overlap function by running this in the console:
console.log(findOverlappingGenres())
You should see an array of genre ID numbers printed in the console. These are the genres both players selected.
Seeing an empty array?
Make sure you clicked the same genre on both the Player 1 and Player 2 panels. The overlap only includes genres both players selected.
If you see an error instead, check that the two functions are placed between createGenreButtons and init in your file.
Why is my console showing an error for findOverlappingGenres?
Build the display functions
The overlap function finds shared genres and the discover function fetches movies. Now you need functions that take a movie object and render it on screen, or show an error message when something goes wrong.
- In script.js, find the closing brace of discoverMovie.
- Add these two display functions below it:
// Show the matched movie card with details
function displayMovie(movie) {
document.getElementById("result").classList.remove("hidden");
document.getElementById("movie-card").classList.remove("hidden");
document.getElementById("no-match").classList.add("hidden");
document.getElementById("movie-poster").src = movie.poster_path
? `${IMG_URL}${movie.poster_path}`
: "";
document.getElementById("movie-poster").alt = movie.title;
document.getElementById("movie-title").textContent = movie.title;
document.getElementById("movie-year").textContent = movie.release_date
? movie.release_date.substring(0, 4)
: "Unknown year";
document.getElementById("movie-rating").textContent =
`Rating: ${movie.vote_average.toFixed(1)} / 10`;
document.getElementById("movie-overview").textContent = movie.overview;
document.getElementById("save-btn").onclick = () => saveToHistory(movie);
}
// Show an error or info message instead of a movie card
function displayNoMatch(message) {
document.getElementById("result").classList.remove("hidden");
document.getElementById("movie-card").classList.add("hidden");
document.getElementById("no-match").classList.remove("hidden");
document.getElementById("no-match-message").textContent = message;
}
What does this code do?
displayMovie() unhides the result section and movie card, then fills in the poster image, title, release year, rating, and overview from the movie object the API returned. It also wires the "Save to Watch History" button to saveToHistory. That function does not exist yet, so the save button will not work until a later step.
displayNoMatch() does the opposite. It hides the movie card and shows the error message area instead, filling it with whatever message you pass in.
- Save script.js.
Wire up the match button and handle edge cases
All the helper functions are ready. The last piece is an event listener on the "Find Your Match" button that ties everything together: validate selections, find the overlap, fetch movies, and display the result.
- In script.js, find the closing brace of displayNoMatch.
- Add this click event listener below it, above the init() function:
// Handle the Find Your Match button click
document.getElementById("match-btn").addEventListener("click", async () => {
if (player1Genres.size === 0 || player2Genres.size === 0) {
displayNoMatch("Both players need to select at least one genre!");
return;
}
const overlap = findOverlappingGenres();
if (overlap.length === 0) {
displayNoMatch(
"No matching genres! You two have very different taste. Try again!"
);
return;
}
const movies = await discoverMovie(overlap);
if (movies.length === 0) {
displayNoMatch(
"No movies found for your shared genres. Try a different combo!"
);
return;
}
// Pick a random movie from the top 10 results
const randomIndex = Math.floor(Math.random() * Math.min(movies.length, 10));
displayMovie(movies[randomIndex]);
});
What does this code do?
The listener runs three checks before showing a movie. First, it confirms both players selected at least one genre. Second, it checks that their selections actually overlap. Third, it verifies the API returned at least one result.
If all three checks pass, it picks a random movie from the top 10 most popular results and calls displayMovie to render it. Limiting to the top 10 keeps recommendations relevant instead of pulling from deep in the popularity rankings.
- Save script.js.
Time to see the matching engine in action. Before you test, think about this: if both players select Comedy, what do you expect to see when you click "Find Your Match"?
- In the browser, select at least one overlapping genre on both sides. For example, click Comedy for Player 1 and Comedy for Player 2.
- Click the Find Your Match button.
A movie card appears below the button showing a poster, title, release year, rating, and overview. Your matching engine works.
Now test the edge cases. What do you think happens if you click "Find Your Match" without selecting any genres?
- Refresh the page to clear all selections.
- Click Find Your Match without selecting any genres.
You should see the message "Both players need to select at least one genre!" in the result area.
- Now select a genre for Player 1 that Player 2 does not have selected. For example, select Horror for Player 1 and Romance for Player 2.
- Click Find Your Match.
You should see "No matching genres! You two have very different taste. Try again!" confirming the no-overlap path works.
Not seeing a movie card?
Open the console (F12, then Console tab) and check for errors. A 401 status means your API token is incorrect. Double-check that your API_TOKEN value at the top of script.js is the full API Read Access Token from your TMDB settings.
If the page looks blank or the buttons disappeared, check that the new functions are placed between createGenreButtons and init. A misplaced closing brace can break the entire script.
Why is my movie card not showing up when I click Find Your Match?
✔️ Awesome, I've got everything!
Great. Double check you have saved script.js.
ⓧ I'd like to double check the full code
Your script.js is too long to show in one block. Scroll up through your file and confirm these sections appear in order:
- Constants at the top: API_TOKEN, BASE_URL, IMG_URL, and the two Set declarations.
- fetchGenres() function.
- createGenreButtons() function.
- findOverlappingGenres() function (new in this step).
- discoverMovie() function (new in this step).
- displayMovie() function (new in this step).
- displayNoMatch() function (new in this step).
- match-btn click event listener (new in this step).
- init() function and init() call at the bottom.
The index.html and style.css files are unchanged from the previous step.
Your matching engine is alive. Both players can pick genres, find their overlap, and see a real movie recommendation pulled straight from TMDB. Next up, you will give the app a memory so matched movies are not lost when you refresh the page.
Add Watch History
Your matching engine finds the perfect movie for both players. But try refreshing the page after a match and everything disappears.
There is no memory of past matches. In this step, you will use localStorage to give your app persistent storage. Matched movies go into a Watch History that survives page refreshes, teaching the same Create, Read, and Delete patterns used in real databases.
In this step, get ready to:
- Save matched movies to localStorage so they persist across page refreshes.
- Render a Watch History section that displays saved movies with title, year, and rating.
- Add remove and clear functionality so users can manage their history.
Save matched movies to localStorage
Your displayMovie function already wires the Save to Watch History button to call a saveToHistory function. That function does not exist yet, so clicking the button does nothing.
- In script.js, scroll down to the displayNoMatch function.
- Add the following saveToHistory function directly below it:
function saveToHistory(movie) {
// Read existing history from localStorage, or start with an empty array
const history = JSON.parse(localStorage.getItem("watchHistory")) || [];
// Check if this movie is already saved
const alreadySaved = history.some((item) => item.id === movie.id);
if (alreadySaved) {
alert("This movie is already in your watch history!");
return;
}
// Add the new movie entry
history.push({
id: movie.id,
title: movie.title,
year: movie.release_date ? movie.release_date.substring(0, 4) : "Unknown",
rating: movie.vote_average,
});
// Write the updated array back to localStorage
localStorage.setItem("watchHistory", JSON.stringify(history));
renderHistory();
}
What does this code do?
localStorage stores data as strings, so JSON.parse converts the stored string back into a JavaScript array. The || [] fallback handles the very first save, when nothing exists in storage yet.
The .some() array method checks whether any entry already shares the same movie ID, preventing duplicates. If the movie is new, it gets pushed into the array and written back with JSON.stringify.
- Save script.js. You will test this function in a moment, after adding the renderHistory function it calls at the end.
Render the Watch History on screen
Saving data to localStorage is invisible to the user. You need a function that reads the stored history and builds visible HTML elements on the page.
- Add the renderHistory function below saveToHistory in script.js:
function renderHistory() {
// Read history from localStorage
const history = JSON.parse(localStorage.getItem("watchHistory")) || [];
const historySection = document.getElementById("history");
const historyList = document.getElementById("history-list");
// Hide the section if there are no entries
if (history.length === 0) {
historySection.classList.add("hidden");
return;
}
// Show the section and clear old content
historySection.classList.remove("hidden");
historyList.innerHTML = "";
// Create a list item for each saved movie
history.forEach((movie) => {
const item = document.createElement("div");
item.classList.add("history-item");
item.innerHTML = `
<span>${movie.title} (${movie.year}) - ${movie.rating.toFixed(1)}/10</span>
<button class="remove-btn" data-id="${movie.id}">Remove</button>
`;
historyList.appendChild(item);
});
// Wire each Remove button to removeFromHistory
historyList.querySelectorAll(".remove-btn").forEach((btn) => {
btn.addEventListener("click", () => {
removeFromHistory(Number(btn.dataset.id));
});
});
}
What does this code do?
This function is the Read half of your Create-Read-Delete pattern. It reads the watchHistory array from localStorage, and if it is empty, hides the entire history section using the hidden CSS class.
When entries exist, it loops through each movie and creates a .history-item div showing the title, year, and rating. Each item gets a Remove button wired to a removeFromHistory function you will add next.
- Save script.js.
- In the browser, select at least one overlapping genre for both players and click Find Your Match.
- Click the Save to Watch History button on the movie card.
A Watch History section appears below the movie card, showing the saved movie's title, year, and rating.
History not appearing?
- Make sure you saved script.js after adding both saveToHistory and renderHistory. The save function calls renderHistory() at the end, so both must exist.
- Open the browser console (F12, then the Console tab) and check for any red error messages.
- Confirm that the renderHistory function is placed after saveToHistory and before the match-btn event listener.
Why isn't my Watch History showing up after I click Save?
Add remove and clear functionality
Users can save movies, but there is no way to remove individual entries or clear the entire list. The Remove buttons are already rendered by renderHistory, but they call a removeFromHistory function that does not exist yet.
- Add the removeFromHistory function between saveToHistory and renderHistory in script.js:
function removeFromHistory(movieId) {
// Read history and filter out the movie with the matching ID
let history = JSON.parse(localStorage.getItem("watchHistory")) || [];
history = history.filter((item) => item.id !== movieId);
localStorage.setItem("watchHistory", JSON.stringify(history));
renderHistory();
}
What does this code do?
The .filter() array method creates a new array that includes every movie except the one whose ID matches movieId. The filtered array is written back to localStorage, then renderHistory() re-draws the list so the removed entry vanishes from the page.
You also need to wire up the Clear History button that already exists in your HTML, and call renderHistory() when the page first loads so any previously saved movies appear immediately.
- Scroll down to the match-btn event listener. Add this new event listener directly below it:
document.getElementById("clear-history").addEventListener("click", () => {
// Remove the entire watchHistory key from localStorage
localStorage.removeItem("watchHistory");
renderHistory();
});
What does this code do?
Instead of filtering individual entries, localStorage.removeItem deletes the entire watchHistory key in one shot. Calling renderHistory() afterwards detects the empty state and hides the history section.
- Scroll down to the init function at the bottom of script.js.
- Find this line:
createGenreButtons(genres, "genres2", player2Genres);
}
- Add renderHistory(); on a new line between createGenreButtons and the closing }, so the function now looks like this:
createGenreButtons(genres, "genres2", player2Genres);
renderHistory();
}
Why call renderHistory in init?
Without this call, saved movies only appear when the user saves a new one. Adding renderHistory() inside init() means the history section appears as soon as the page loads, even after a refresh or coming back days later.
Before you test, do you think your saved movie will still be there after refreshing the page? What about after clicking Remove?
- Save script.js.
- Refresh the page in your browser.
The movie you saved earlier is still there in the Watch History section. That is localStorage in action.
- Click the Remove button next to a history entry.
The entry disappears from the list immediately.
- Match and save another movie so the history section reappears.
- Click Clear History.
The entire Watch History section disappears. Your app now fully supports Create, Read, and Delete for movie history.
History not persisting after refresh?
- Make sure you added renderHistory(); inside the init() function. Without it, saved data exists in storage but is never drawn on page load.
- Check that the localStorage key name is exactly watchHistory (capital H) in every function. A mismatched key creates a separate entry that renderHistory never finds.
- Open DevTools (F12), click the Application tab, and expand Local Storage. You should see a watchHistory key with your saved movie data.
Help me debug why my Watch History disappears after a page refresh.
✔️ Awesome, I've got everything!
Great. Double check you have saved script.js.
ⓧ I'd like to double check the full code
Scroll through your script.js file and confirm each of these sections is present, in this order:
- Constants (API_TOKEN, BASE_URL, IMG_URL) and the two player Sets.
- fetchGenres() function.
- createGenreButtons() function.
- findOverlappingGenres() function.
- discoverMovie() function.
- displayMovie() function.
- displayNoMatch() function.
- saveToHistory() function (new in this step).
- removeFromHistory() function (new in this step).
- renderHistory() function (new in this step).
- match-btn click event listener.
- clear-history click event listener (new in this step).
- init() function with renderHistory() as the last call inside it.
- init(); call at the very bottom.
The index.html and style.css files did not change in this step.
Your Movie Match app now has persistent memory. Users can save matches, revisit them after closing the browser, and clean up whenever they want. This is the same data lifecycle every full-stack app uses, just running entirely in the browser for now.
Secret mission
Rating Filter and Shuffle
Your Movie Match app works, but users are stuck with whatever random movie comes up first. There is no way to filter out low-rated films, and no way to see another option without starting over. In this mission, you will add a minimum rating slider that filters API results and a shuffle button that cycles through different matches without re-calling the API.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a free API, so there are no ongoing costs.
Resources you used:
- movie-match project folder on your Desktop.
- TMDB API Read Access Token (free for non-commercial use).
- watchHistory data stored in your browser's localStorage.
- Live Server VS Code extension.
Keep everything running
No action needed. Choose this if you are still actively building or want to experiment with the app further.
- Your TMDB API key stays active and free for non-commercial use.
- Your movie-match folder and all three files remain on your Desktop.
- Your Watch History persists in localStorage and loads automatically the next time you open the app.
- Open index.html with Live Server any time to use the app.
Pause - I'll come back to this later
Stop Live Server to free up the local port, but keep your project files and data intact for later.
- In VS Code, click the port number (e.g. Port: 5500) in the bottom status bar to stop Live Server.
- Your movie-match folder, TMDB API key, and localStorage data all remain untouched.
- When you are ready to come back, open index.html with Live Server again.
Delete - I don't want to use this again
Remove all project files, clear your saved data, and optionally revoke your API key.
- Clear your Watch History by opening the browser console (press F12, then click the Console tab) and running:
localStorage.removeItem("watchHistory")
- Delete the movie-match project folder from your Desktop.
macOS
cd ~/Desktop
rm -rf movie-match
Windows
cd $HOME\Desktop
Remove-Item -Recurse -Force movie-match
Linux
cd ~/Desktop
rm -rf movie-match
- Revoke your TMDB API key by going to https://www.themoviedb.org/settings/api and deleting your API key from the settings page.
- Uninstall the Live Server extension by clicking the Extensions icon in the VS Code sidebar, finding Live Server in your installed list, and clicking Uninstall.
Nice Work!
Nice Work!
Nice work! You just built a fully interactive Movie Match app from scratch using HTML, CSS, and JavaScript.
You've learned how to:
- Build a responsive two-panel layout using CSS Grid, Flexbox, and media queries that stacks cleanly on mobile screens.
- Connect to a real-world REST API using fetch, async/await, and Bearer token authentication to pull live movie data from TMDB.
- Implement persistent client-side storage with localStorage using the same Create, Read, Delete patterns used in real databases.
- Secret Mission: Added a minimum rating filter and a shuffle button, layering new functionality onto an existing codebase.
Ready to quiz yourself?