Build a Japanese Vocabulary Match Game

Build a browser game that imports Japanese Excel vocabulary and saves progress.

Introduction

30 Second Summary

Recognising a Japanese word on a page can feel easy until you have to recall its reading from memory. A paper list also makes completed words hard to separate from the ones that still need practice.

In this project, you will build a private browser game for matching kanji with hiragana in both directions. A four-column Microsoft Excel import expands the supplied five-word set into your own study list.

What You'll Build

Watch なまえ lock beneath 名前 on a charcoal study card with Name visible as the hint.

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

  • A dark matching board where five supplied Japanese words appear as question cards with their English hints directly underneath.
  • Two practice directions that reverse kanji and hiragana while keeping each English hint visible.
  • A four-column workbook import that replaces the starter set with complete rows from your first worksheet. The remaining-word counter updates to the size of your data. Saved progress keeps completed entries out of later rounds after a refresh.
  • Secret Mission: Add a per-round mistakes counter that increases after each wrong choice.

Are there any prerequisites?

You need a Windows computer with internet access while the page loads SheetJS Community Edition from its official CDN. Your four-column Microsoft Excel workbook is optional because the lesson supplies five starter entries.

Before We Start

This step is your moment to commit to a private Japanese study game that practises kanji and hiragana in both directions. The game starts with supplied vocabulary before growing from your four-column Microsoft Excel workbook.

Set Up Your Copy-Paste Workspace

Your study-game idea now needs a dependable place to run. Visual Studio Code keeps the three project files together while you build.

Live Preview serves your page from a stable browser origin. That stable address lets browser storage recognize the game across later sessions.

In this step, get ready to:
  • Prepare the required editor setup on Windows.
  • Create the kanji-match-game folder with its three project files.
  • Run the dark starter page from a Live Preview origin.
Install Visual Studio Code and Live Preview

This project uses Visual Studio Code 1.135 with Live Preview 0.4.20. The correct setup path depends on what is already installed on your computer.

  • Check Windows search for Visual Studio Code.
  • Compare the version in its About window with 1.135 if the editor opens.
  • Choose the tab that matches what you find:

✔️ I see version 1.135

Your installed editor meets the project requirement. Keep Visual Studio Code open for the extension setup.

ⓧ I see an older version

The current Windows User setup updates your editor to Visual Studio Code 1.135. The installer opens a short setup wizard with default choices suitable for this project.

The updated editor is ready when its About window shows 1.135.

ⓧ Visual Studio Code is not installed

The Windows User setup installs Visual Studio Code without requiring administrator permissions. Its setup wizard offers default choices that work for this project.

  • Visit the official Visual Studio Code download page.
  • Download the Windows User setup.
  • Run the downloaded installer.
  • Complete the setup wizard with its default choices.
  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search box.
  • Press Enter to open the editor.

The installation is complete when the About window shows 1.135.

Live Preview adds the local server that displays your page inside the editor. Its extension ID separates the official Microsoft result from similarly named extensions.

  • Return to Visual Studio Code from earlier.
  • Press Ctrl+Shift+X to open the Extensions view.
  • Enter ms-vscode.live-server in the extension search box.
  • Choose the tab that matches the result:

✔️ I see version 0.4.20

  • Select Live Preview in the search results.
  • Confirm that the publisher is Microsoft.
  • Confirm that the extension ID is ms-vscode.live-server.

Live Preview 0.4.20 is installed and ready to serve the starter page.

ⓧ I see an older version

  • Select Live Preview in the search results.
  • Confirm that the publisher is Microsoft.
  • Open the extension's settings menu.
  • Select Install Another Version.
  • Select version 0.4.20.

The extension details should now show version 0.4.20.

ⓧ Live Preview is not installed

  • Select Live Preview in the search results.
  • Confirm that the publisher is Microsoft.
  • Confirm that the extension ID is ms-vscode.live-server.
  • Select Install.

The extension details should show version 0.4.20 after installation.

Your editor setup is ready. Visual Studio Code can now serve the project through a stable local address.

Having trouble with the editor setup?

  • Confirm that Windows finished installing Visual Studio Code before searching for the extension.
  • Check that the Live Preview result shows Microsoft as its publisher.
  • Ask for help with the exact setup state you see: Help me set up Visual Studio Code 1.135 and Live Preview 0.4.20 on Windows.
  • Share a screenshot of the extension details in the NextWork community if your screen looks different.
Create the project files

HTML provides the page structure. CSS controls the dark appearance. JavaScript becomes the entry point for the game logic.

  • Select File from the top menu in Visual Studio Code.
  • Select Open Folder.
  • Choose your Desktop in the folder window.
  • Create a folder named kanji-match-game.
  • Select the kanji-match-game folder.
  • Select Select Folder.

Your Workspace Is Now Anchored

The Explorer sidebar should show kanji-match-game as the open folder. Every file you create next belongs inside this folder.

  • Create index.html with the new-file control in the Explorer sidebar.
  • Create style.css with the new-file control in the Explorer sidebar.

You should see index.html and style.css beneath kanji-match-game in the Explorer sidebar.

  • Create script.js with the new-file control in the Explorer sidebar.

The Explorer sidebar should now list all three files inside kanji-match-game.

Build and preview the starter page

The starter page proves that the files are linked before the game board grows more complex. First, the page loads a heading from index.html.

  • Select index.html in the Explorer sidebar.
  • Add the starter page by pasting this code:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Kanji Match</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main class="app-shell">
      <header class="game-header">
        <p class="eyebrow">Personal Japanese Study Game</p>
        <h1>Kanji Match</h1>
        <p class="subtitle">Pick a question, then choose its matching answer.</p>
      </header>
    </main>
    <script src="script.js"></script>
  </body>
</html>

What Does This Page Do?

  • The link element connects style.css to the page so the browser can apply the dark design.
  • The header gives the game a title plus a short instruction.
  • The script element connects script.js as the game's JavaScript entry point.
  • Save index.html.
  • Right-click inside the index.html editor.
  • Select Show Preview.

You should see Kanji Match with its instruction in the Live Preview pane. This confirms that the page entry point is loading.

Seeing a Blank Page or File Listing?

  • Confirm that index.html is saved inside kanji-match-game.
  • Run Show Preview from the open index.html editor.
  • Ask for help with the preview result: Why is Live Preview showing a blank page or file listing for my index.html file?

The page structure is visible. A dark foundation in style.css now turns it into the first visual slice of the game.

  • Switch to style.css in the Explorer sidebar.
  • Add the dark foundation by pasting this code:
:root {
  color-scheme: dark;
  font-family: "Segoe UI", "Yu Gothic UI", "Meiryo", system-ui, sans-serif;
  --background: #121213;
  --panel: #1a1a1b;
  --tile: #3a3a3c;
  --tile-hover: #4b4b4d;
  --border: #565758;
  --text: #f8f8f8;
  --muted: #b7b7b7;
  --green: #538d4e;
  --yellow: #b59f3b;
  --red: #b91c1c;
  --focus: #85c7ff;
}

* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; background: var(--background); color: var(--text); }
.app-shell { width: min(1050px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 48px; }
.game-header { text-align: center; }

What Does This Styling Do?

  • The values inside :root hold the colors that later game controls can reuse.
  • The body rule gives the full page its charcoal background with light text.
  • The .app-shell rule keeps the content centered with space around it.
  • Save style.css.
  • Switch back to the Live Preview pane from earlier.

You should see the page change to a charcoal background with centered light text. The stylesheet link is working.

Still Seeing a White Page?

  • Confirm that the stylesheet is named style.css.
  • Check that style.css sits beside index.html in the Explorer sidebar.
  • Ask for help with the stylesheet connection: Why is my style.css file not changing the Live Preview page?

The final starter styles give the title a clear visual hierarchy. They also establish the muted text color used throughout the finished interface.

  • Return to style.css.
  • Add these styles below .game-header { text-align: center; }:
.eyebrow {
  margin: 0;
  color: var(--muted);
  font-size: 0.8rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
h1 { margin: 8px 0; font-size: clamp(2.2rem, 7vw, 4rem); letter-spacing: 0.04em; }
.subtitle, .file-status, .status-message, .attribution { color: var(--muted); }

How Does the Header Change?

  • The .eyebrow rule turns the small introduction into an uppercase label.
  • The h1 rule gives Kanji Match a responsive title size.
  • The shared muted-color rule softens supporting text while keeping the title prominent.

Before you check the result, do you expect the same dark starter page to load in both the preview pane and a separate browser window?

  • Save style.css.
  • Switch back to the Live Preview pane from earlier.
  • Record the local address shown in Live Preview: your Live Preview URL.
  • Press the Windows key to open Windows search.
  • Type Microsoft Edge into the search box.
  • Press Enter to open the browser.
  • Paste your Live Preview URL into the address bar.
  • Press Enter to load the page.

You should see the dark Kanji Match starter page in Live Preview and Microsoft Edge. You should see a page instead of a blank screen or file listing.

Does the Local Address Fail in Edge?

  • Keep the Live Preview pane running while Microsoft Edge loads the same address.
  • Copy the complete address from Live Preview again if the browser shows a different page.
  • Ask for help with the local origin: Why can Live Preview show my page while Microsoft Edge cannot open the same local address?

✔️ Awesome, I've got everything!

Your dark starter page is running from Live Preview. The index.html, style.css, and script.js files are ready for the game board.

ⓧ I'd like to double check the full code

Your index.html should match:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Kanji Match</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main class="app-shell">
      <header class="game-header">
        <p class="eyebrow">Personal Japanese Study Game</p>
        <h1>Kanji Match</h1>
        <p class="subtitle">Pick a question, then choose its matching answer.</p>
      </header>
    </main>
    <script src="script.js"></script>
  </body>
</html>

Your style.css should match:

:root {
  color-scheme: dark;
  font-family: "Segoe UI", "Yu Gothic UI", "Meiryo", system-ui, sans-serif;
  --background: #121213;
  --panel: #1a1a1b;
  --tile: #3a3a3c;
  --tile-hover: #4b4b4d;
  --border: #565758;
  --text: #f8f8f8;
  --muted: #b7b7b7;
  --green: #538d4e;
  --yellow: #b59f3b;
  --red: #b91c1c;
  --focus: #85c7ff;
}

* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; background: var(--background); color: var(--text); }
.app-shell { width: min(1050px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 48px; }
.game-header { text-align: center; }
.eyebrow {
  margin: 0;
  color: var(--muted);
  font-size: 0.8rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
h1 { margin: 8px 0; font-size: clamp(2.2rem, 7vw, 4rem); letter-spacing: 0.04em; }
.subtitle, .file-status, .status-message, .attribution { color: var(--muted); }

Your script.js file should exist as an empty JavaScript entry point.

Your copy-paste workspace now serves a linked dark page from a stable browser origin. Next, you'll turn that starter page into the vocabulary board.

Build the Board with Starter Vocabulary

Your local page is running through Live Preview. Now the empty starter page needs a board that makes your Japanese vocabulary visible.

Starting with real entries gives you a working visual result before spreadsheet import adds any parsing complexity. You will combine HTML, CSS, and JavaScript to display five starter words with English hints.

In this step, get ready to:
  • Build the page structure for the game board and its controls.
  • Style the interface with charcoal tiles and responsive grids.
  • Render five supplied vocabulary entries with English hints.
Build the page structure

The page shell gives every control and board region a stable identifier. JavaScript uses those identifiers to place vocabulary on the page.

  • Switch back to index.html in Visual Studio Code.
  • Replace its current contents with this page shell:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Kanji Match</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main class="app-shell">
    </main>

    <script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
    <script src="script.js"></script>
  </body>
</html>

What does this structure do?

  • The viewport setting lets the board adapt to smaller screens.
  • The app-shell element contains the complete game interface.
  • The two script tags load the spreadsheet reader before script.js.
  • Save index.html.
  • Check the Live Preview pane for a page titled Kanji Match.

Still seeing the old page?

  • Confirm that you edited the index.html inside the open kanji-match-game folder.
  • Confirm that the file tab no longer shows an unsaved-change indicator.
  • Ask for help if the preview does not update. Help me check why Live Preview is showing an older version of my index.html file.

The header establishes what the page is for before the controls appear. Its short instruction also explains the matching flow.

  • Find the closing </main> tag in index.html.
  • Insert this header immediately above that tag:
      <header class="game-header">
        <p class="eyebrow">Personal Japanese Study Game</p>
        <h1>Kanji Match</h1>
        <p class="subtitle">Pick a question, then choose its matching answer.</p>
      </header>

What does this header do?

  • The eyebrow identifies the page as your personal study game.
  • The main heading gives the game a clear name.
  • The subtitle tells the player how a matching turn works.
  • Save index.html.
  • Check the Live Preview pane for the Kanji Match heading.

Missing the game heading?

  • Check that the header sits between the opening and closing main tags.
  • Check that every header element has a closing tag.
  • Ask for help with the page structure. Help me find why my Kanji Match header is missing from the preview.

The control panel groups file loading, practice direction, progress, and reset actions. Disabled buttons clearly show which controls need JavaScript before they can run.

  • Find the closing </main> tag again.
  • Insert this control panel immediately above that tag:
      <section class="control-panel" aria-label="Game controls">
        <label class="file-button" for="fileInput">Load Excel File</label>
        <input id="fileInput" type="file" accept=".xlsx,.xls" hidden />
        <p id="fileStatus" class="file-status">Starting with 5 built-in words</p>

        <div class="mode-switch" role="group" aria-label="Practice direction">
          <button id="kanjiMode" class="mode-button active" type="button" disabled>Kanji</button>
          <button id="hiraganaMode" class="mode-button" type="button" disabled>Hiragana</button>
        </div>

        <p id="wordCounter" class="word-counter" aria-live="polite">Words left: 0</p>
        <button id="resetButton" class="secondary-button" type="button" disabled>Reset Progress</button>
      </section>

How are the controls organized?

  • The hidden fileInput accepts .xlsx and .xls workbooks.
  • The mode buttons provide the two Japanese practice directions.
  • The wordCounter reports how many active entries remain.
  • The reset button starts disabled so it cannot clear progress before the game state exists.
  • Save index.html.
  • Check the preview for the file control and both mode buttons.

Controls appearing outside the page?

  • Confirm that the control panel appears after the header.
  • Confirm that the control panel remains above the closing </main> tag.
  • Ask for help with misplaced elements. Help me check why my game controls appear outside the main page shell.

The two grids separate Japanese questions from shuffled answers. Each question card later receives an English hint and a matching-answer slot.

  • Find the closing </main> tag one final time.
  • Insert these board regions immediately above that tag:
      <p id="statusMessage" class="status-message" aria-live="polite">Preparing the starter round.</p>

      <section aria-labelledby="questionsHeading">
        <h2 id="questionsHeading">Questions</h2>
        <div id="questionGrid" class="question-grid"></div>
      </section>

      <section aria-labelledby="answersHeading">
        <h2 id="answersHeading">Answer Bank</h2>
        <div id="answerBank" class="answer-bank"></div>
      </section>

      <button id="newRoundButton" class="primary-button" type="button" disabled>New Round</button>

      <p class="attribution">
        Spreadsheet import powered by
        <a href="https://sheetjs.com/" target="_blank" rel="noreferrer">SheetJS Community Edition</a>.
      </p>

What completes the board?

  • The statusMessage gives the player short instructions and feedback.
  • The questionGrid holds Japanese question cards with English hints.
  • The answerBank holds the possible Japanese matches.
  • The New Round button sits below both grids.
  • Save index.html.
  • Check the preview for the Questions and Answer Bank headings.

Missing a board section?

  • Compare the IDs in your file with questionGrid and answerBank.
  • Check that both section elements appear inside app-shell.
  • Ask for help with an incomplete board. Help me find a missing Questions or Answer Bank section in index.html.

✔️ Awesome, I've got everything!

Your page now contains the game header, controls, board regions, and script links. Keep index.html saved.

ⓧ I'd like to double check the full code

Compare your complete index.html file with this reference.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Kanji Match</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main class="app-shell">
      <header class="game-header">
        <p class="eyebrow">Personal Japanese Study Game</p>
        <h1>Kanji Match</h1>
        <p class="subtitle">Pick a question, then choose its matching answer.</p>
      </header>

      <section class="control-panel" aria-label="Game controls">
        <label class="file-button" for="fileInput">Load Excel File</label>
        <input id="fileInput" type="file" accept=".xlsx,.xls" hidden />
        <p id="fileStatus" class="file-status">Starting with 5 built-in words</p>

        <div class="mode-switch" role="group" aria-label="Practice direction">
          <button id="kanjiMode" class="mode-button active" type="button" disabled>Kanji</button>
          <button id="hiraganaMode" class="mode-button" type="button" disabled>Hiragana</button>
        </div>

        <p id="wordCounter" class="word-counter" aria-live="polite">Words left: 0</p>
        <button id="resetButton" class="secondary-button" type="button" disabled>Reset Progress</button>
      </section>

      <p id="statusMessage" class="status-message" aria-live="polite">Preparing the starter round.</p>

      <section aria-labelledby="questionsHeading">
        <h2 id="questionsHeading">Questions</h2>
        <div id="questionGrid" class="question-grid"></div>
      </section>

      <section aria-labelledby="answersHeading">
        <h2 id="answersHeading">Answer Bank</h2>
        <div id="answerBank" class="answer-bank"></div>
      </section>

      <button id="newRoundButton" class="primary-button" type="button" disabled>New Round</button>

      <p class="attribution">
        Spreadsheet import powered by
        <a href="https://sheetjs.com/" target="_blank" rel="noreferrer">SheetJS Community Edition</a>.
      </p>
    </main>

    <script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
    <script src="script.js"></script>
  </body>
</html>
Style the dark board

The stylesheet turns the plain document into a charcoal game interface. Responsive grids keep the cards readable on narrow and wide screens.

  • Switch back to style.css.
  • Replace its current contents with these color and page foundations:
:root {
  color-scheme: dark;
  font-family: "Segoe UI", "Yu Gothic UI", "Meiryo", system-ui, sans-serif;
  --background: #121213;
  --panel: #1a1a1b;
  --tile: #3a3a3c;
  --tile-hover: #4b4b4d;
  --border: #565758;
  --text: #f8f8f8;
  --muted: #b7b7b7;
  --green: #538d4e;
  --yellow: #b59f3b;
  --red: #b91c1c;
  --focus: #85c7ff;
}

* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; background: var(--background); color: var(--text); }
button, .file-button { font: inherit; }
button:focus-visible, .file-button:focus-visible, a:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 3px;
}
.app-shell { width: min(1050px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 48px; }
.game-header { text-align: center; }
.eyebrow {
  margin: 0;
  color: var(--muted);
  font-size: 0.8rem;
  font-weight: 800;

How does the dark theme work?

  • The custom properties keep the charcoal, text, feedback, and focus colors in one place.
  • The page uses the full browser height with a dark background.
  • The focus outline keeps keyboard-selected controls visible.
  • The app-shell limits the board width while preserving space at both sides.
  • Save style.css.
  • Check the preview for a charcoal background and centered page content.

Still seeing a white page?

  • Confirm that index.html links to style.css.
  • Check the spelling of --background in the custom property and body rule.
  • Ask for help with the stylesheet link. Help me check why style.css is not applying its dark background in Live Preview.
  • Continue at the bottom of style.css with these heading and control-panel styles:
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
h1 { margin: 8px 0; font-size: clamp(2.2rem, 7vw, 4rem); letter-spacing: 0.04em; }
h2 { margin: 30px 0 12px; font-size: 1rem; letter-spacing: 0.08em; text-transform: uppercase; }
.subtitle, .file-status, .status-message, .attribution { color: var(--muted); }
.control-panel {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  margin: 28px auto 18px;
  padding: 22px;
  border: 1px solid #2d2d2f;
  border-radius: 16px;
  background: var(--panel);
}
.file-button, .primary-button, .secondary-button, .mode-button, .answer-choice, .question-card {
  border: 0;
  border-radius: 8px;
  color: var(--text);
  cursor: pointer;
  font-weight: 800;
}
.file-button, .primary-button { padding: 12px 18px; background: var(--green); }
.file-button:hover, .primary-button:hover { filter: brightness(1.08); }
.file-status, .word-counter, .status-message { margin: 0; text-align: center; }

What changes in the control area?

  • The control panel stacks its contents in one centered column.
  • The green file and primary buttons identify the main actions.
  • Shared button rules provide consistent rounded corners and type weight.
  • Muted text separates supporting information from the main controls.
  • Save style.css.
  • Check the preview for a rounded control panel with a green file button.

Control panel still looks plain?

  • Check that the selector begins with .control-panel.
  • Check that the letter-spacing lines continue the open .eyebrow rule from the previous chunk.
  • Ask for help with a broken CSS rule. Help me find why my control-panel CSS is not styling the game controls.
  • Continue at the bottom of style.css with the mode and grid styles:
.mode-switch {
  display: grid;
  grid-template-columns: repeat(2, minmax(120px, 1fr));
  gap: 8px;
  width: min(100%, 320px);
}
.mode-button, .secondary-button { padding: 11px 16px; background: var(--tile); }
.mode-button.active { background: var(--yellow); color: #111111; }
.word-counter { font-size: 1.2rem; font-weight: 900; }
.secondary-button { border: 1px solid var(--border); }
button:disabled { cursor: not-allowed; opacity: 0.45; }
.status-message { min-height: 24px; padding: 4px; }
.question-grid, .answer-bank {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 10px;
}
.question-card {
  min-height: 142px;
  padding: 16px;
  border: 2px solid var(--border);
  background: var(--panel);
  text-align: center;
}
.question-card:hover:not(:disabled) { background: #242426; }
.question-card.selected { border-color: var(--yellow); box-shadow: 0 0 0 2px var(--yellow) inset; }
.question-card.solved { border-color: var(--green); background: #223522; opacity: 1; }

How do the grids respond?

  • The mode switch always uses two equal columns.
  • The question and answer grids begin with two columns for narrow screens.
  • Selected cards use yellow feedback.
  • Solved cards use green feedback.
  • Save style.css.
  • Check the preview for side-by-side mode buttons and two empty board columns.

Grids showing one wide column?

  • Check that display: grid appears inside the question and answer selector.
  • Check that both grid elements use the matching classes from index.html.
  • Ask for help with the grid layout. Help me check why question-grid and answer-bank are not using two columns.

Each question card needs three distinct reading levels. The Japanese prompt leads, the English meaning supports it, and the answer slot records the final match.

  • Continue at the bottom of style.css with these card-content styles:
.question-word, .english-hint, .answer-slot { display: block; }
.question-word { font-size: clamp(1.35rem, 4vw, 2rem); }
.english-hint {
  min-height: 24px;
  margin-top: 8px;
  color: #d7d7d7;
  font-size: 0.9rem;
  font-weight: 600;
}
.answer-slot {
  min-height: 26px;
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--border);
  color: var(--muted);
  font-size: 0.95rem;
}
.question-card.selected .answer-slot { color: #f7d96b; }
.question-card.solved .answer-slot { color: #bce8b8; font-weight: 800; }
.answer-choice { min-height: 58px; padding: 12px; background: var(--tile); font-size: 1.05rem; }
.answer-choice:hover { background: var(--tile-hover); }
.answer-choice.wrong { background: var(--red); animation: shake 0.35s linear; }

How does each card communicate?

  • The Japanese question receives the largest type size.
  • The English hint remains visible in smaller text below it.
  • The answer slot uses a border to separate the match from the hint.
  • Wrong answers receive red feedback with the shake animation added next.
  • Save style.css.
  • Check the preview for compact uppercase section headings and evenly spaced board regions.

Card text styles missing?

  • Check the class names question-word, english-hint, and answer-slot.
  • Check that the previous question-card rule ends with a closing brace.
  • Ask for help with the card selectors. Help me debug missing question-word, english-hint, and answer-slot styles.
  • Finish style.css with the animation and wide-screen layout:
.primary-button { display: block; width: min(100%, 280px); margin: 28px auto 0; }
.empty-state {
  grid-column: 1 / -1;
  padding: 24px;
  border: 1px dashed var(--border);
  border-radius: 10px;
  color: var(--muted);
  text-align: center;
}
.attribution { margin-top: 28px; font-size: 0.8rem; text-align: center; }
.attribution a { color: #a7d8ff; }
@keyframes shake {
  0%, 100% { transform: translateX(0); }
  25% { transform: translateX(-7px); }
  75% { transform: translateX(7px); }
}
@media (min-width: 760px) {
  .question-grid, .answer-bank { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}

What finishes the layout?

  • The primary button stays centered below the board.
  • The shake animation moves a wrong answer sideways before returning it to place.
  • The media query expands both grids to five columns at wider screen sizes.
  • The empty-state style spans the complete grid when no cards remain.
  • Save style.css.
  • Widen the preview pane to confirm that each empty grid can use five columns.

Layout not changing when widened?

  • Confirm that the media query uses 760px.
  • Check that the media query closes with its final brace.
  • Ask for help with the responsive rule. Help me debug why my board does not switch to five columns on a wide preview.

✔️ Awesome, I've got everything!

Your stylesheet now covers the charcoal layout, responsive grids, card states, controls, and feedback animation.

ⓧ I'd like to double check the full code

Compare your complete style.css file with this reference.

:root {
  color-scheme: dark;
  font-family: "Segoe UI", "Yu Gothic UI", "Meiryo", system-ui, sans-serif;
  --background: #121213;
  --panel: #1a1a1b;
  --tile: #3a3a3c;
  --tile-hover: #4b4b4d;
  --border: #565758;
  --text: #f8f8f8;
  --muted: #b7b7b7;
  --green: #538d4e;
  --yellow: #b59f3b;
  --red: #b91c1c;
  --focus: #85c7ff;
}

* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; background: var(--background); color: var(--text); }
button, .file-button { font: inherit; }
button:focus-visible, .file-button:focus-visible, a:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 3px;
}
.app-shell { width: min(1050px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 48px; }
.game-header { text-align: center; }
.eyebrow {
  margin: 0;
  color: var(--muted);
  font-size: 0.8rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
h1 { margin: 8px 0; font-size: clamp(2.2rem, 7vw, 4rem); letter-spacing: 0.04em; }
h2 { margin: 30px 0 12px; font-size: 1rem; letter-spacing: 0.08em; text-transform: uppercase; }
.subtitle, .file-status, .status-message, .attribution { color: var(--muted); }
.control-panel {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  margin: 28px auto 18px;
  padding: 22px;
  border: 1px solid #2d2d2f;
  border-radius: 16px;
  background: var(--panel);
}
.file-button, .primary-button, .secondary-button, .mode-button, .answer-choice, .question-card {
  border: 0;
  border-radius: 8px;
  color: var(--text);
  cursor: pointer;
  font-weight: 800;
}
.file-button, .primary-button { padding: 12px 18px; background: var(--green); }
.file-button:hover, .primary-button:hover { filter: brightness(1.08); }
.file-status, .word-counter, .status-message { margin: 0; text-align: center; }
.mode-switch {
  display: grid;
  grid-template-columns: repeat(2, minmax(120px, 1fr));
  gap: 8px;
  width: min(100%, 320px);
}
.mode-button, .secondary-button { padding: 11px 16px; background: var(--tile); }
.mode-button.active { background: var(--yellow); color: #111111; }
.word-counter { font-size: 1.2rem; font-weight: 900; }
.secondary-button { border: 1px solid var(--border); }
button:disabled { cursor: not-allowed; opacity: 0.45; }
.status-message { min-height: 24px; padding: 4px; }
.question-grid, .answer-bank {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 10px;
}
.question-card {
  min-height: 142px;
  padding: 16px;
  border: 2px solid var(--border);
  background: var(--panel);
  text-align: center;
}
.question-card:hover:not(:disabled) { background: #242426; }
.question-card.selected { border-color: var(--yellow); box-shadow: 0 0 0 2px var(--yellow) inset; }
.question-card.solved { border-color: var(--green); background: #223522; opacity: 1; }
.question-word, .english-hint, .answer-slot { display: block; }
.question-word { font-size: clamp(1.35rem, 4vw, 2rem); }
.english-hint {
  min-height: 24px;
  margin-top: 8px;
  color: #d7d7d7;
  font-size: 0.9rem;
  font-weight: 600;
}
.answer-slot {
  min-height: 26px;
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--border);
  color: var(--muted);
  font-size: 0.95rem;
}
.question-card.selected .answer-slot { color: #f7d96b; }
.question-card.solved .answer-slot { color: #bce8b8; font-weight: 800; }
.answer-choice { min-height: 58px; padding: 12px; background: var(--tile); font-size: 1.05rem; }
.answer-choice:hover { background: var(--tile-hover); }
.answer-choice.wrong { background: var(--red); animation: shake 0.35s linear; }
.primary-button { display: block; width: min(100%, 280px); margin: 28px auto 0; }
.empty-state {
  grid-column: 1 / -1;
  padding: 24px;
  border: 1px dashed var(--border);
  border-radius: 10px;
  color: var(--muted);
  text-align: center;
}
.attribution { margin-top: 28px; font-size: 0.8rem; text-align: center; }
.attribution a { color: #a7d8ff; }
@keyframes shake {
  0%, 100% { transform: translateX(0); }
  25% { transform: translateX(-7px); }
  75% { transform: translateX(7px); }
}
@media (min-width: 760px) {
  .question-grid, .answer-bank { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}
Add the starter vocabulary

Each vocabulary object keeps its number tag, kanji, hiragana, and English meaning together. The generated ID also keeps entries distinct when number tags repeat.

  • Switch back to script.js.
  • Replace its current contents with the round settings and five starter entries:
const ROUND_SIZE = 10;
const STORAGE_WORDS = "kanjiMatch.words.v2";
const STORAGE_COMPLETED = "kanjiMatch.completed.v2";

function makeWordId(rowPosition, numberTag, kanji, hiragana, english) {
  return `${rowPosition}::${numberTag}::${kanji}::${hiragana}::${english}`;
}

const STARTER_WORDS = [
  { numberTag: "1", kanji: "名前", hiragana: "なまえ", english: "Name" },
  { numberTag: "1", kanji: "有名(な)", hiragana: "ゆうめい(な)", english: "Famous" },
  { numberTag: "1", kanji: "氏名", hiragana: "しめい", english: "Fullname" },
  { numberTag: "2", kanji: "前", hiragana: "まえ", english: "Before" },
  { numberTag: "2", kanji: "午前", hiragana: "ごぜん", english: "A.M." },
].map((word, index) => ({
  ...word,
  id: makeWordId(index + 1, word.numberTag, word.kanji, word.hiragana, word.english),
}));

How is each word identified?

  • The two storage names reserve separate places for vocabulary and completed IDs.
  • The makeWordId function combines row position with every vocabulary field.
  • Repeated tags remain unchanged because the complete ID provides uniqueness.
  • The five objects preserve the supplied Japanese text and English meanings.
  • Save script.js.
  • Press F12 in Microsoft Edge to access the developer tools.
  • Select the Console tab.
  • Display the starter array by entering:
STARTER_WORDS

What should the console show?

The console displays an array containing five objects. Expanding an object shows its number tag, Japanese forms, English meaning, and generated ID.

Console cannot find STARTER_WORDS?

  • Confirm that script.js is saved.
  • Confirm that its script tag remains at the bottom of index.html.
  • Ask for help with the script connection. Help me find why STARTER_WORDS is unavailable in the browser console.

The board needs a working copy of the starter array and a randomized round order. Keeping the original array separate protects the supplied entries from later state changes.

  • Continue at the bottom of script.js with the board state and shuffle logic:
let allWords = [];
let currentMode = "kanji";
let currentRound = [];
let answerOrder = [];

const wordCounter = document.getElementById("wordCounter");
const questionGrid = document.getElementById("questionGrid");
const answerBank = document.getElementById("answerBank");

function shuffle(items) {
  const copy = [...items];
  for (let index = copy.length - 1; index > 0; index -= 1) {
    const randomIndex = Math.floor(Math.random() * (index + 1));
    [copy[index], copy[randomIndex]] = [copy[randomIndex], copy[index]];
  }
  return copy;
}

function getAvailableWords() {
  return allWords.filter((word) => !completedIds.has(word.id));
}

let completedIds = new Set();
allWords = STARTER_WORDS.map((word) => ({ ...word }));
const availableWords = getAvailableWords();
currentRound = shuffle(availableWords).slice(0, ROUND_SIZE);
answerOrder = shuffle(currentRound);

How is the first round prepared?

  • The three document references connect the script to the counter and both grids.
  • The shuffle function randomizes a copied array.
  • The active word list receives copies of all five starter objects.
  • The question and answer arrays receive separate shuffled orders.
  • Save script.js.
  • Return to the browser console.
  • Display the available entries by entering:
getAvailableWords()

What should this check show?

The function returns all five starter objects because the completed-ID set begins empty. This confirms that the board has five available entries.

Seeing a JavaScript error?

  • Check that completedIds is created before getAvailableWords() runs.
  • Check the closing braces in shuffle.
  • Ask for help with the round state. Help me debug the starter round setup in script.js.

Question cards combine the Japanese prompt with an English hint and an empty answer slot. Rendering them from the array keeps the visible board tied to the starter data.

  • Continue at the bottom of script.js with the question-card rendering logic:
function getQuestion(word) {
  return currentMode === "kanji" ? word.kanji : word.hiragana;
}

function updateCounter() {
  wordCounter.textContent = `Words left: ${getAvailableWords().length}`;
}

currentRound.forEach((word) => {
  const card = document.createElement("button");
  card.type = "button";
  card.className = "question-card";
  card.dataset.wordId = word.id;

  const question = document.createElement("span");
  question.className = "question-word";
  question.textContent = getQuestion(word);

  const englishHint = document.createElement("span");
  englishHint.className = "english-hint";
  englishHint.textContent = word.english;

  const answer = document.createElement("span");
  answer.className = "answer-slot";

  card.append(question, englishHint, answer);
  questionGrid.appendChild(card);
});

updateCounter();

How are the question cards built?

  • The current mode selects kanji as the first question direction.
  • Each word creates one button inside questionGrid.
  • The English meaning appears in its own span directly below the Japanese question.
  • The counter reads the size of the available-word array.
  • Save script.js.
  • Refresh the Live Preview pane.

You should see five charcoal question cards. Each card shows a kanji word with its English meaning directly underneath.

Question cards not appearing?

  • Check that questionGrid matches the ID in index.html.
  • Check the browser console for a syntax error near the card-building loop.
  • Ask for help with the rendered cards. Help me find why my five starter question cards are not rendering.

The answer bank uses the same round in a different shuffled order. Kanji mode displays each matching hiragana form as an answer tile.

  • Finish script.js with the answer-bank rendering logic:
function getAnswer(word) {
  return currentMode === "kanji" ? word.hiragana : word.kanji;
}

answerOrder.forEach((word) => {
  const choice = document.createElement("button");
  choice.type = "button";
  choice.className = "answer-choice";
  choice.textContent = getAnswer(word);
  answerBank.appendChild(choice);
});

How is the answer bank created?

  • The current mode selects hiragana as the answer form.
  • Each shuffled word creates one answer button.
  • The answer buttons use the answer-choice class from the stylesheet.
  • The separate shuffled order prevents questions and answers from following the same sequence.

Before you refresh, which three visible details will prove that the starter board is using all five entries?

  • Save script.js.
  • Refresh the Live Preview pane.

You should see Words left: 5, five Japanese question cards, and five shuffled hiragana answers. Every question card should keep its English meaning directly beneath the Japanese word.

Answer bank still empty?

  • Check that answerBank matches the ID in index.html.
  • Check that answerOrder receives the shuffled current round before the loop runs.
  • Ask for help with the answer tiles. Help me find why the starter questions render but the answer bank is empty.

✔️ Awesome, I've got everything!

That is the first visible game board complete. Your five supplied words now appear with English hints and shuffled answers.

ⓧ I'd like to double check the full code

Compare your complete script.js file with this reference.

const ROUND_SIZE = 10;
const STORAGE_WORDS = "kanjiMatch.words.v2";
const STORAGE_COMPLETED = "kanjiMatch.completed.v2";

function makeWordId(rowPosition, numberTag, kanji, hiragana, english) {
  return `${rowPosition}::${numberTag}::${kanji}::${hiragana}::${english}`;
}

const STARTER_WORDS = [
  { numberTag: "1", kanji: "名前", hiragana: "なまえ", english: "Name" },
  { numberTag: "1", kanji: "有名(な)", hiragana: "ゆうめい(な)", english: "Famous" },
  { numberTag: "1", kanji: "氏名", hiragana: "しめい", english: "Fullname" },
  { numberTag: "2", kanji: "前", hiragana: "まえ", english: "Before" },
  { numberTag: "2", kanji: "午前", hiragana: "ごぜん", english: "A.M." },
].map((word, index) => ({
  ...word,
  id: makeWordId(index + 1, word.numberTag, word.kanji, word.hiragana, word.english),
}));

let allWords = [];
let currentMode = "kanji";
let currentRound = [];
let answerOrder = [];

const wordCounter = document.getElementById("wordCounter");
const questionGrid = document.getElementById("questionGrid");
const answerBank = document.getElementById("answerBank");

function shuffle(items) {
  const copy = [...items];
  for (let index = copy.length - 1; index > 0; index -= 1) {
    const randomIndex = Math.floor(Math.random() * (index + 1));
    [copy[index], copy[randomIndex]] = [copy[randomIndex], copy[index]];
  }
  return copy;
}

function getAvailableWords() {
  return allWords.filter((word) => !completedIds.has(word.id));
}

let completedIds = new Set();
allWords = STARTER_WORDS.map((word) => ({ ...word }));
const availableWords = getAvailableWords();
currentRound = shuffle(availableWords).slice(0, ROUND_SIZE);
answerOrder = shuffle(currentRound);

function getQuestion(word) {
  return currentMode === "kanji" ? word.kanji : word.hiragana;
}

function updateCounter() {
  wordCounter.textContent = `Words left: ${getAvailableWords().length}`;
}

currentRound.forEach((word) => {
  const card = document.createElement("button");
  card.type = "button";
  card.className = "question-card";
  card.dataset.wordId = word.id;

  const question = document.createElement("span");
  question.className = "question-word";
  question.textContent = getQuestion(word);

  const englishHint = document.createElement("span");
  englishHint.className = "english-hint";
  englishHint.textContent = word.english;

  const answer = document.createElement("span");
  answer.className = "answer-slot";

  card.append(question, englishHint, answer);
  questionGrid.appendChild(card);
});

updateCounter();

function getAnswer(word) {
  return currentMode === "kanji" ? word.hiragana : word.kanji;
}

answerOrder.forEach((word) => {
  const choice = document.createElement("button");
  choice.type = "button";
  choice.className = "answer-choice";
  choice.textContent = getAnswer(word);
  answerBank.appendChild(choice);
});

Your dark board now turns the five supplied entries into a visible study round. Next, you will connect the controls and make both matching directions playable.

Make Both Matching Modes Playable

Your dark board already presents the five starter words with their English hints. Live Preview gives you a clear place to test every interaction.

The cards now need round state and answer checking. JavaScript will shuffle the words. It will also reverse the practice direction when you change modes.

In this step, get ready to:
  • Build random rounds in both Japanese directions.
  • Check answers with correct-match and wrong-answer feedback.
  • Connect the mode controls and the New Round button.
Add round state and direction helpers

Each round needs to remember its words and solved cards. It also needs one selected question before an answer can be checked.

  • Switch back to the existing script.js tab in Visual Studio Code.
  • Replace the existing board-state declarations and direction helpers beneath STARTER_WORDS with this block:
let allWords = [];
let completedIds = new Set();
let currentMode = "kanji";
let currentRound = [];
let answerOrder = [];
let roundSolved = new Set();
let selectedId = null;

function shuffle(items) {
  const copy = [...items];
  for (let index = copy.length - 1; index > 0; index -= 1) {
    const randomIndex = Math.floor(Math.random() * (index + 1));
    [copy[index], copy[randomIndex]] = [copy[randomIndex], copy[index]];
  }
  return copy;
}

function getAvailableWords() {
  return allWords.filter((word) => !completedIds.has(word.id));
}

function getQuestion(word) {
  return currentMode === "kanji" ? word.kanji : word.hiragana;
}

function getAnswer(word) {
  return currentMode === "kanji" ? word.hiragana : word.kanji;
}

How does round state work?

  • The arrays hold the active vocabulary and the current round order.
  • The sets track completed words and solved cards without storing duplicate IDs.
  • The direction helpers return opposite Japanese fields for the question and answer.
  • The shuffle() function creates a copied array before randomising its order.
  • Replace the existing element-reference and control-helper section with this block:
const fileInput = document.getElementById("fileInput");
const fileStatus = document.getElementById("fileStatus");
const kanjiModeButton = document.getElementById("kanjiMode");
const hiraganaModeButton = document.getElementById("hiraganaMode");
const wordCounter = document.getElementById("wordCounter");
const resetButton = document.getElementById("resetButton");
const statusMessage = document.getElementById("statusMessage");
const questionGrid = document.getElementById("questionGrid");
const answerBank = document.getElementById("answerBank");
const newRoundButton = document.getElementById("newRoundButton");

function setControlsEnabled(enabled) {
  kanjiModeButton.disabled = !enabled;
  hiraganaModeButton.disabled = !enabled;
  resetButton.disabled = !enabled;
  newRoundButton.disabled = !enabled;
}

function updateCounter() {
  wordCounter.textContent = `Words left: ${getAvailableWords().length}`;
}

What do these references control?

Each constant connects the script to an existing element in index.html. The helpers can then enable the controls or recalculate the remaining-word counter.

  • Save script.js.
  • Refresh the Live Preview pane.

You should still see the dark starter board with five words listed in the counter.

Does the starter board disappear?

  • Check that every element ID matches the existing ID in index.html.
  • Confirm that each state declaration appears only once in script.js.
  • Ask for help with the first error shown in your browser console.
Render selectable questions and answers

The board needs one renderer for question cards and another state refresh for card feedback. Rebuilding the answer bank after each correct match removes only the solved choice.

  • Add this empty-state helper below updateCounter() in script.js:
function renderEmptyState(message) {
  questionGrid.innerHTML = "";
  answerBank.innerHTML = "";
  const questionMessage = document.createElement("p");
  questionMessage.className = "empty-state";
  questionMessage.textContent = message;
  questionGrid.appendChild(questionMessage);
  const answerMessage = document.createElement("p");
  answerMessage.className = "empty-state";
  answerMessage.textContent = "Answers will appear here.";
  answerBank.appendChild(answerMessage);
}

Why have an empty state?

The empty state replaces both banks after every active word has been completed. It gives the learner a clear stopping point instead of leaving two blank sections.

  • Add the first part of renderRound() below renderEmptyState():
function renderRound() {
  questionGrid.innerHTML = "";
  answerBank.innerHTML = "";

  currentRound.forEach((word) => {
    const card = document.createElement("button");
    card.type = "button";
    card.className = "question-card";
    card.dataset.wordId = word.id;

    const question = document.createElement("span");
    question.className = "question-word";
    question.textContent = getQuestion(word);

    const englishHint = document.createElement("span");
    englishHint.className = "english-hint";
    englishHint.textContent = word.english;

    const answer = document.createElement("span");
    answer.className = "answer-slot";

    card.append(question, englishHint, answer);
    card.addEventListener("click", () => selectQuestion(word.id));
    questionGrid.appendChild(card);
  });
}

How are question cards built?

  • Each card stores its word ID in dataset.wordId so later checks can find the matching object.
  • The Japanese question comes from the active mode helper.
  • The English hint always uses the word's english value.
  • The empty answer slot reserves a place for the correct match.
  • Inside renderRound(), add this answer-bank loop after the currentRound loop:
  answerOrder.forEach((word) => {
    if (roundSolved.has(word.id)) return;
    const choice = document.createElement("button");
    choice.type = "button";
    choice.className = "answer-choice";
    choice.textContent = getAnswer(word);
    choice.addEventListener("click", () => chooseAnswer(word.id, choice));
    answerBank.appendChild(choice);
  });

How does the answer bank stay current?

The loop follows the independently shuffled answerOrder array. Solved IDs are skipped when the bank is rebuilt.

  • Add these card-state and selection functions below renderRound():
function refreshQuestionCards() {
  document.querySelectorAll(".question-card").forEach((card) => {
    const word = currentRound.find((candidate) => candidate.id === card.dataset.wordId);
    const answerSlot = card.querySelector(".answer-slot");
    const solved = roundSolved.has(word.id);
    const selected = selectedId === word.id;
    card.classList.toggle("solved", solved);
    card.classList.toggle("selected", selected && !solved);
    card.disabled = solved;
    answerSlot.textContent = solved
      ? getAnswer(word)
      : selected
        ? "Choose an answer below"
        : "Select this word";
  });
}

function selectQuestion(wordId) {
  if (roundSolved.has(wordId)) return;
  selectedId = wordId;
  statusMessage.textContent = "Now choose the matching answer.";
  refreshQuestionCards();
}

What changes when you select a card?

The selected card receives the existing selected style. Solved cards receive the solved style and become disabled.

The answer slot displays a prompt before a match. It displays the Japanese answer after the match succeeds.

  • Add this call as the final line inside renderRound():
  refreshQuestionCards();

Why refresh after rendering?

New card elements begin without selection or completion classes. This call immediately applies the current round state to them.

  • Save script.js.
  • Refresh the Live Preview pane.
  • Select any question card.

You should see a yellow selected border. Its answer slot should change to Choose an answer below.

Does selecting a card do nothing?

  • Confirm that refreshQuestionCards(); sits inside renderRound() after both loops.
  • Check that the card listener passes word.id into selectQuestion().
  • Ask for help tracing the selected card state.
Check answers and start new rounds

A wrong answer should provide feedback without removing the choice. A correct answer should lock beneath the English hint and reduce the available-word total.

  • Add this first version of chooseAnswer() below selectQuestion():
function chooseAnswer(choiceId, choiceButton) {
  if (!selectedId) {
    statusMessage.textContent = "Select a question card first.";
    return;
  }

  choiceButton.classList.add("wrong");
  statusMessage.textContent = "Not a match. Try another answer.";
  window.setTimeout(() => choiceButton.classList.remove("wrong"), 450);
}

How does wrong-answer feedback work?

An answer click requires a selected question first. A choice then receives the existing wrong class for a short red shake.

The timeout removes only the feedback class. The answer stays available for another attempt.

  • Save script.js.
  • Refresh the Live Preview pane.
  • Select the 名前 question card.

Before you test, predict whether a wrong answer will disappear from the answer bank.

  • Select any answer except なまえ.

The wrong choice should shake with a red background. It should remain in the answer bank after the feedback clears.

  • Inside chooseAnswer(), add this correct-match branch before the line that adds the wrong class:
  if (selectedId === choiceId) {
    roundSolved.add(selectedId);
    completedIds.add(selectedId);
    selectedId = null;
    updateCounter();
    renderRound();
    const wordsLeft = getAvailableWords().length;
    if (wordsLeft === 0) {
      statusMessage.textContent = "All words completed. Use Reset Progress to begin again.";
      newRoundButton.disabled = true;
    } else if (roundSolved.size === currentRound.length) {
      statusMessage.textContent = "Round complete. Start a new round.";
    } else {
      statusMessage.textContent = "Correct. Choose another question.";
    }
    return;
  }

What happens after a correct match?

  • The word ID enters both completion sets.
  • The selected question clears before the board is rebuilt.
  • The counter recalculates from words whose IDs are still available.
  • The early return prevents correct choices from receiving wrong-answer feedback.
  • Add startRound() below chooseAnswer():
function startRound(mode = currentMode) {
  currentMode = mode;
  selectedId = null;
  roundSolved = new Set();
  kanjiModeButton.classList.toggle("active", currentMode === "kanji");
  hiraganaModeButton.classList.toggle("active", currentMode === "hiragana");
  const availableWords = getAvailableWords();
  updateCounter();

  if (availableWords.length === 0) {
    currentRound = [];
    answerOrder = [];
    renderEmptyState("Every active word has been completed.");
    statusMessage.textContent = "All words completed. Use Reset Progress to begin again.";
    newRoundButton.disabled = true;
    return;
  }

  currentRound = shuffle(availableWords).slice(0, ROUND_SIZE);
  answerOrder = shuffle(currentRound);
  renderRound();
  newRoundButton.disabled = false;
  const questionType = currentMode === "kanji" ? "kanji" : "hiragana";
  statusMessage.textContent = `Select a ${questionType} question, then choose its answer.`;
}

How does a new round begin?

The function clears temporary round state before selecting up to ROUND_SIZE available entries. Questions and answers receive separate shuffled orders.

The active mode controls which Japanese field appears in each location. The English hint remains tied to the same word object.

  • Replace the earlier starter-board launch code at the bottom of script.js with these listeners and startup lines:
kanjiModeButton.addEventListener("click", () => startRound("kanji"));
hiraganaModeButton.addEventListener("click", () => startRound("hiragana"));
newRoundButton.addEventListener("click", () => startRound(currentMode));

allWords = STARTER_WORDS.map((word) => ({ ...word }));
completedIds = new Set();
setControlsEnabled(true);
startRound(currentMode);

What connects the controls?

Each button starts a round with the requested direction. The startup lines copy the starter objects into active state before launching Kanji mode.

  • Save script.js.
  • Refresh the Live Preview pane.
  • Select the 名前 question.
  • Select an answer other than なまえ.
  • Select the Hiragana mode button.
  • Confirm that なまえ becomes a question with Name directly beneath it.
  • Select the Kanji mode button.
  • Match 名前 with なまえ.

The correct answer should lock beneath the English hint. The counter should change from 5 to 4.

  • Select New Round to reshuffle the remaining entries.
  • Complete the four remaining starter matches.

You should finish with Words left: 0 and a message confirming that every active word is complete. That is the full matching loop working in both directions.

Are matches behaving incorrectly?

  • Confirm that both sides compare the word ID instead of the repeated number tag.
  • Check that the correct branch ends with return before the wrong-answer lines run.
  • Confirm that each button listener appears after the function declarations.
  • Ask for help tracing a match from selection to completion.

✔️ Awesome, I've got everything!

Great work. Your starter vocabulary now runs as a two-direction matching game with visible feedback and completed-word tracking.

ⓧ I'd like to double check the full code

Compare your complete script.js with this version. Your existing index.html and style.css stay unchanged in this step.

const ROUND_SIZE = 10;
const STORAGE_WORDS = "kanjiMatch.words.v2";
const STORAGE_COMPLETED = "kanjiMatch.completed.v2";

function makeWordId(rowPosition, numberTag, kanji, hiragana, english) {
  return `${rowPosition}::${numberTag}::${kanji}::${hiragana}::${english}`;
}

const STARTER_WORDS = [
  { numberTag: "1", kanji: "名前", hiragana: "なまえ", english: "Name" },
  { numberTag: "1", kanji: "有名(な)", hiragana: "ゆうめい(な)", english: "Famous" },
  { numberTag: "1", kanji: "氏名", hiragana: "しめい", english: "Fullname" },
  { numberTag: "2", kanji: "前", hiragana: "まえ", english: "Before" },
  { numberTag: "2", kanji: "午前", hiragana: "ごぜん", english: "A.M." },
].map((word, index) => ({
  ...word,
  id: makeWordId(index + 1, word.numberTag, word.kanji, word.hiragana, word.english),
}));

let allWords = [];
let completedIds = new Set();
let currentMode = "kanji";
let currentRound = [];
let answerOrder = [];
let roundSolved = new Set();
let selectedId = null;

const fileInput = document.getElementById("fileInput");
const fileStatus = document.getElementById("fileStatus");
const kanjiModeButton = document.getElementById("kanjiMode");
const hiraganaModeButton = document.getElementById("hiraganaMode");
const wordCounter = document.getElementById("wordCounter");
const resetButton = document.getElementById("resetButton");
const statusMessage = document.getElementById("statusMessage");
const questionGrid = document.getElementById("questionGrid");
const answerBank = document.getElementById("answerBank");
const newRoundButton = document.getElementById("newRoundButton");

function shuffle(items) {
  const copy = [...items];
  for (let index = copy.length - 1; index > 0; index -= 1) {
    const randomIndex = Math.floor(Math.random() * (index + 1));
    [copy[index], copy[randomIndex]] = [copy[randomIndex], copy[index]];
  }
  return copy;
}

function getAvailableWords() {
  return allWords.filter((word) => !completedIds.has(word.id));
}

function getQuestion(word) {
  return currentMode === "kanji" ? word.kanji : word.hiragana;
}

function getAnswer(word) {
  return currentMode === "kanji" ? word.hiragana : word.kanji;
}

function setControlsEnabled(enabled) {
  kanjiModeButton.disabled = !enabled;
  hiraganaModeButton.disabled = !enabled;
  resetButton.disabled = !enabled;
  newRoundButton.disabled = !enabled;
}

function updateCounter() {
  wordCounter.textContent = `Words left: ${getAvailableWords().length}`;
}

function renderEmptyState(message) {
  questionGrid.innerHTML = "";
  answerBank.innerHTML = "";
  const questionMessage = document.createElement("p");
  questionMessage.className = "empty-state";
  questionMessage.textContent = message;
  questionGrid.appendChild(questionMessage);
  const answerMessage = document.createElement("p");
  answerMessage.className = "empty-state";
  answerMessage.textContent = "Answers will appear here.";
  answerBank.appendChild(answerMessage);
}

function renderRound() {
  questionGrid.innerHTML = "";
  answerBank.innerHTML = "";

  currentRound.forEach((word) => {
    const card = document.createElement("button");
    card.type = "button";
    card.className = "question-card";
    card.dataset.wordId = word.id;

    const question = document.createElement("span");
    question.className = "question-word";
    question.textContent = getQuestion(word);

    const englishHint = document.createElement("span");
    englishHint.className = "english-hint";
    englishHint.textContent = word.english;

    const answer = document.createElement("span");
    answer.className = "answer-slot";

    card.append(question, englishHint, answer);
    card.addEventListener("click", () => selectQuestion(word.id));
    questionGrid.appendChild(card);
  });

  answerOrder.forEach((word) => {
    if (roundSolved.has(word.id)) return;
    const choice = document.createElement("button");
    choice.type = "button";
    choice.className = "answer-choice";
    choice.textContent = getAnswer(word);
    choice.addEventListener("click", () => chooseAnswer(word.id, choice));
    answerBank.appendChild(choice);
  });

  refreshQuestionCards();
}

function refreshQuestionCards() {
  document.querySelectorAll(".question-card").forEach((card) => {
    const word = currentRound.find((candidate) => candidate.id === card.dataset.wordId);
    const answerSlot = card.querySelector(".answer-slot");
    const solved = roundSolved.has(word.id);
    const selected = selectedId === word.id;
    card.classList.toggle("solved", solved);
    card.classList.toggle("selected", selected && !solved);
    card.disabled = solved;
    answerSlot.textContent = solved
      ? getAnswer(word)
      : selected
        ? "Choose an answer below"
        : "Select this word";
  });
}

function selectQuestion(wordId) {
  if (roundSolved.has(wordId)) return;
  selectedId = wordId;
  statusMessage.textContent = "Now choose the matching answer.";
  refreshQuestionCards();
}

function chooseAnswer(choiceId, choiceButton) {
  if (!selectedId) {
    statusMessage.textContent = "Select a question card first.";
    return;
  }

  if (selectedId === choiceId) {
    roundSolved.add(selectedId);
    completedIds.add(selectedId);
    selectedId = null;
    updateCounter();
    renderRound();
    const wordsLeft = getAvailableWords().length;
    if (wordsLeft === 0) {
      statusMessage.textContent = "All words completed. Use Reset Progress to begin again.";
      newRoundButton.disabled = true;
    } else if (roundSolved.size === currentRound.length) {
      statusMessage.textContent = "Round complete. Start a new round.";
    } else {
      statusMessage.textContent = "Correct. Choose another question.";
    }
    return;
  }

  choiceButton.classList.add("wrong");
  statusMessage.textContent = "Not a match. Try another answer.";
  window.setTimeout(() => choiceButton.classList.remove("wrong"), 450);
}

function startRound(mode = currentMode) {
  currentMode = mode;
  selectedId = null;
  roundSolved = new Set();
  kanjiModeButton.classList.toggle("active", currentMode === "kanji");
  hiraganaModeButton.classList.toggle("active", currentMode === "hiragana");
  const availableWords = getAvailableWords();
  updateCounter();

  if (availableWords.length === 0) {
    currentRound = [];
    answerOrder = [];
    renderEmptyState("Every active word has been completed.");
    statusMessage.textContent = "All words completed. Use Reset Progress to begin again.";
    newRoundButton.disabled = true;
    return;
  }

  currentRound = shuffle(availableWords).slice(0, ROUND_SIZE);
  answerOrder = shuffle(currentRound);
  renderRound();
  newRoundButton.disabled = false;
  const questionType = currentMode === "kanji" ? "kanji" : "hiragana";
  statusMessage.textContent = `Select a ${questionType} question, then choose its answer.`;
}

kanjiModeButton.addEventListener("click", () => startRound("kanji"));
hiraganaModeButton.addEventListener("click", () => startRound("hiragana"));
newRoundButton.addEventListener("click", () => startRound(currentMode));

allWords = STARTER_WORDS.map((word) => ({ ...word }));
completedIds = new Set();
setControlsEnabled(true);
startRound(currentMode);

Your five starter entries now form a complete matching game with two practice directions. Next up, you will replace the starter set with vocabulary from your Excel workbook.

Import Vocabulary from Excel

Your starter board now supports two matching directions. Correct choices lock into place while wrong choices stay available for another attempt.

Five words prove that the game works. Importing your Microsoft Excel workbook lets the same interface handle your full vocabulary source.

In this step, get ready to:
  • Read the first worksheet from the selected Excel workbook.
  • Convert complete rows from columns A through D into game words.
  • Replace the starter dataset with imported vocabulary.
Read the selected workbook

The page already loads SheetJS Community Edition version 0.20.3 before script.js. This gives your JavaScript access to the browser global XLSX.

The hidden fileInput already accepts .xlsx files. It also accepts .xls files.

  • Switch back to script.js in Visual Studio Code.
  • Find kanjiModeButton.addEventListener("click", () => startRound("kanji")); near the bottom of the file.
  • Paste the workbook reader above this line:
fileInput.addEventListener("change", async (event) => {
  const [file] = event.target.files;
  if (!file) return;

  try {
    if (typeof XLSX === "undefined") throw new Error("The spreadsheet reader did not load.");
    const data = await file.arrayBuffer();
    const workbook = XLSX.read(data);
    const firstSheetName = workbook.SheetNames[0];
    if (!firstSheetName) throw new Error("The workbook has no worksheets.");
    const worksheet = workbook.Sheets[firstSheetName];
    const rows = XLSX.utils.sheet_to_json(worksheet, {
      header: 1,
      defval: "",
      blankrows: false,
    });
  } catch (error) {
    statusMessage.textContent = `Could not load the spreadsheet: ${error.message}`;
  } finally {
    fileInput.value = "";
  }
});

What does this code do?

  • The change handler runs after you select a workbook.
  • file.arrayBuffer() reads the selected file as browser-accessible binary data.
  • XLSX.read(data) turns those bytes into a workbook object.
  • workbook.SheetNames[0] identifies the first worksheet.
  • header: 1 returns each spreadsheet row as an array.
  • defval: "" gives empty cells a predictable value.
  • blankrows: false removes completely blank rows.
Map complete spreadsheet rows

Each row needs four values before it can become a playable word. The number tag comes from column A. The kanji comes from column B. The hiragana comes from column C. The English meaning comes from column D.

  • In the new handler, find the closing }); directly below blankrows: false.
  • Insert this mapping code below that closing line:
    const importedWords = rows
      .slice(1)
      .map((row, index) => {
        const numberTag = String(row[0] ?? "").trim();
        const kanji = String(row[1] ?? "").trim();
        const hiragana = String(row[2] ?? "").trim();
        const english = String(row[3] ?? "").trim();
        const rowNumber = index + 2;
        if (!numberTag || !kanji || !hiragana || !english) return null;
        return {
          id: makeWordId(rowNumber, numberTag, kanji, hiragana, english),
          numberTag,
          kanji,
          hiragana,
          english,
        };
      })
      .filter(Boolean);

    if (importedWords.length === 0) {
      throw new Error("No complete entries were found after the header row in columns A through D.");
    }

    const importedIds = new Set(importedWords.map((word) => word.id));
    completedIds = new Set([...completedIds].filter((id) => importedIds.has(id)));
    allWords = importedWords;
    fileStatus.textContent = `${file.name}: ${allWords.length} valid entries`;
    setControlsEnabled(true);
    startRound(currentMode);

How does the import become game data?

  • rows.slice(1) skips the worksheet header row.
  • row[0] through row[3] read columns A through D.
  • String(...).trim() converts each cell into clean text.
  • makeWordId() combines the row number with all four values to keep repeated number tags distinct.
  • filter(Boolean) removes rows that failed the completeness check.
  • allWords = importedWords replaces the starter dataset with the valid imported entries.
  • startRound(currentMode) immediately renders a random round from the imported vocabulary.
  • Save script.js.

Before you choose the workbook, predict whether the five-word counter will grow to the number of complete spreadsheet rows.

  • Return to the Live Preview pane from earlier.
  • Select Load Excel File.
  • Choose your four-column vocabulary workbook.

You should see the workbook name followed by its valid entry count. Words left should show the same count when none of those imported IDs were previously completed.

Workbook not loading?

  • Confirm that your internet connection is active so the existing SheetJS script can load from its official CDN.
  • Check that the SheetJS script appears before script.js in index.html.
  • Check that the first worksheet has a header row followed by values in columns A through D.
  • Remove any row that is missing its number tag, kanji, hiragana, or English meaning.
  • Help me diagnose my Excel vocabulary import.
Verify the imported round

Imported rounds still use the existing random selection logic. The English meaning remains directly below each Japanese question.

Before you inspect the example entry, predict which values from the spreadsheet will appear as the question, hint, and matching answer.

  • Select Kanji in the mode switch.
  • Select New Round to sample another imported group.
  • Repeat the new-round action until the card showing 向いている appears.
  • Select the 向いている question card.
  • Choose むいている from the answer bank.

You should see 向いている as the question with To face toward directly underneath. This confirms that columns B and D reached the card.

After the correct match, むいている locks into the answer slot. This confirms that column C reached the matching logic while the row tagged 644 received a stable ID.

Your workbook now drives the same playable board as the starter words. That is the core import loop working with your own vocabulary.

✔️ Awesome, I've got everything!

Your workbook name appears beside its valid entry count. Imported Japanese questions now render with their English hints and matching answers.

ⓧ I'd like to double check the full code

Compare the complete import section in script.js with the code below.

  • Confirm that the existing hidden file input uses accept=".xlsx,.xls" in index.html.
  • Confirm that https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js appears before script.js.
  • Confirm that this handler appears above the mode-button event listeners:
fileInput.addEventListener("change", async (event) => {
  const [file] = event.target.files;
  if (!file) return;

  try {
    if (typeof XLSX === "undefined") throw new Error("The spreadsheet reader did not load.");
    const data = await file.arrayBuffer();
    const workbook = XLSX.read(data);
    const firstSheetName = workbook.SheetNames[0];
    if (!firstSheetName) throw new Error("The workbook has no worksheets.");
    const worksheet = workbook.Sheets[firstSheetName];
    const rows = XLSX.utils.sheet_to_json(worksheet, {
      header: 1,
      defval: "",
      blankrows: false,
    });

    const importedWords = rows
      .slice(1)
      .map((row, index) => {
        const numberTag = String(row[0] ?? "").trim();
        const kanji = String(row[1] ?? "").trim();
        const hiragana = String(row[2] ?? "").trim();
        const english = String(row[3] ?? "").trim();
        const rowNumber = index + 2;
        if (!numberTag || !kanji || !hiragana || !english) return null;
        return {
          id: makeWordId(rowNumber, numberTag, kanji, hiragana, english),
          numberTag,
          kanji,
          hiragana,
          english,
        };
      })
      .filter(Boolean);

    if (importedWords.length === 0) {
      throw new Error("No complete entries were found after the header row in columns A through D.");
    }

    const importedIds = new Set(importedWords.map((word) => word.id));
    completedIds = new Set([...completedIds].filter((id) => importedIds.has(id)));
    allWords = importedWords;
    fileStatus.textContent = `${file.name}: ${allWords.length} valid entries`;
    setControlsEnabled(true);
    startRound(currentMode);
  } catch (error) {
    statusMessage.textContent = `Could not load the spreadsheet: ${error.message}`;
  } finally {
    fileInput.value = "";
  }
});

Your game can now replace its starter words with vocabulary from your workbook. Next, you will keep that active data available across browser sessions and prevent completed words from returning.

Save Progress and Prevent Repeats

Your Excel import now turns a workbook into a playable vocabulary set. However, refreshing the Live Preview page still risks losing that study state.

A large study cycle can span several sessions. In this step, localStorage keeps the active vocabulary and completed IDs tied to the same browser origin.

In this step, get ready to:
  • Save active vocabulary and completed IDs as JSON strings.
  • Restore validated data or fall back to copied starter entries.
  • Exclude solved words from later rounds and protect progress resets.
Save active vocabulary and completed IDs

Browser storage organizes values under string keys. Separate keys let the game update imported words without mixing them with the list of completed IDs.

  • In script.js, find const ROUND_SIZE = 10;.
  • Add the two storage keys directly below that line using this configuration:
const ROUND_SIZE = 10;
const STORAGE_WORDS = "kanjiMatch.words.v2";
const STORAGE_COMPLETED = "kanjiMatch.completed.v2";

Why use separate storage keys?

The STORAGE_WORDS key holds every active word object. The STORAGE_COMPLETED key holds only the IDs that the learner has solved.

The .v2 suffix keeps these four-column objects separate from incompatible data created by an earlier game version.

  • Save script.js.
  • Refresh the Live Preview page.

You should still see the current matching board with its existing controls and vocabulary cards.

Storage accepts strings. The next helper converts the allWords array to JSON before saving it.

  • In script.js, find the closing brace of updateCounter().
  • Add this helper directly below updateCounter():
function saveWords() {
  try {
    localStorage.setItem(STORAGE_WORDS, JSON.stringify(allWords));
  } catch (error) {
    statusMessage.textContent = "The game is running, but the browser could not save the words.";
  }
}

What does this helper do?

  • JSON.stringify() converts the active word array into a string that browser storage can keep.
  • localStorage.setItem() saves that string under STORAGE_WORDS for the current Live Preview origin.
  • The catch branch keeps the game running if the browser blocks storage.
  • Save script.js.
  • Press Ctrl+Shift+J in the Microsoft Edge window to open its developer console.
  • Save the current allWords array by running this function:
saveWords();

What does this check do?

This calls the helper using the vocabulary already loaded into the game. The browser now has a JSON copy under STORAGE_WORDS.

  • Read the saved JSON string by running this expression:
localStorage.getItem(STORAGE_WORDS);

What should I see?

You should see a JSON string containing the current vocabulary objects. Each object includes an ID plus its number tag, kanji, hiragana, and English meaning.

Completed IDs need their own helper because a correct match changes more often than the active vocabulary set.

  • Add this helper directly below saveWords() in script.js:
function saveCompleted() {
  try {
    localStorage.setItem(STORAGE_COMPLETED, JSON.stringify([...completedIds]));
  } catch (error) {
    statusMessage.textContent = "The match worked, but the browser could not save this progress.";
  }
}

How are completed IDs saved?

The spread syntax converts completedIds from a Set into an array. JSON serialization can then store that array as a string.

Keeping only IDs makes the saved progress compact. The full word objects remain in the other storage key.

  • Find allWords = importedWords; near the end of the Excel import handler.
  • Add the two save calls below that assignment so the section looks like this:
    allWords = importedWords;
    saveWords();
    saveCompleted();
    fileStatus.textContent = `${file.name}: ${allWords.length} valid entries`;
    setControlsEnabled(true);
    startRound(currentMode);

Why save after an import?

A successful import replaces allWords. These calls immediately preserve the replacement dataset and any completed IDs that still belong to it.

  • Save script.js.
  • Return to the game page.
  • Select Load Excel File.
  • Choose your four-column workbook.
  • Return to the developer console.
  • Inspect the saved active dataset by running this expression:
localStorage.getItem(STORAGE_WORDS);

What proves the import was saved?

You should see a JSON string containing the imported rows. The game now writes its active workbook data to browser storage whenever an import succeeds.

Seeing a null storage value?

  • Confirm that the imported workbook produced a valid entry count before checking storage.
  • Check that both save calls sit after allWords = importedWords; inside the successful import branch.
  • Make sure the developer console belongs to the same Live Preview page that runs the game.
  • My imported vocabulary is not appearing in localStorage.
Restore validated data on startup

Saved JSON can become incomplete or malformed. A validator protects the game from restoring objects that cannot produce a complete question card.

  • Add this validator directly below saveCompleted() in script.js:
function isValidWord(word) {
  return (
    typeof word?.id === "string" &&
    typeof word?.numberTag === "string" &&
    typeof word?.kanji === "string" &&
    typeof word?.hiragana === "string" &&
    typeof word?.english === "string"
  );
}

What makes a saved word valid?

Every restored object needs string values for its ID, number tag, kanji, hiragana, and English meaning. The game filters out any object that fails one of these checks.

  • Add the restoration function directly below isValidWord() in script.js:
function restoreState() {
  try {
    const savedWords = JSON.parse(localStorage.getItem(STORAGE_WORDS) || "[]");
    const savedCompleted = JSON.parse(localStorage.getItem(STORAGE_COMPLETED) || "[]");
    allWords = Array.isArray(savedWords) ? savedWords.filter(isValidWord) : [];
    const validIds = new Set(allWords.map((word) => word.id));
    completedIds = new Set(
      Array.isArray(savedCompleted) ? savedCompleted.filter((id) => validIds.has(id)) : [],
    );
  } catch (error) {
    allWords = [];
    completedIds = new Set();
    localStorage.removeItem(STORAGE_WORDS);
    localStorage.removeItem(STORAGE_COMPLETED);
  }

  if (allWords.length === 0) {
    allWords = STARTER_WORDS.map((word) => ({ ...word }));
    completedIds = new Set();
    saveWords();
    saveCompleted();
    fileStatus.textContent = `Starter data: ${allWords.length} entries`;
  } else {
    fileStatus.textContent = `Saved data: ${allWords.length} entries`;
  }

  setControlsEnabled(true);
  startRound(currentMode);
}

How does restoration stay safe?

  • JSON.parse() turns each saved string back into JavaScript data.
  • isValidWord() removes incomplete word objects before they reach the board.
  • validIds removes completed IDs that do not belong to the active dataset.
  • STARTER_WORDS.map() creates fresh starter objects when no valid saved vocabulary remains.
  • startRound(currentMode) makes the restored game playable as soon as startup finishes.
  • Save script.js.
  • Return to the developer console.
  • Test the restoration function against the saved workbook by running this call:
restoreState();

What should restoration change?

The game should redraw a round from the saved workbook. The file status should report the saved entry count.

  • At the bottom of script.js, replace the existing direct-startup section with this single call:
restoreState();

Why call this at the bottom?

The browser defines every variable and function before reaching this line. The final call then restores the saved state or creates the starter fallback.

  • Save script.js.

Before you refresh, do you expect the imported entry count to return or the five starter entries to replace it?

  • Refresh the Live Preview page.

You should see the imported entry count return. You should also see a playable round built from the saved workbook.

Testing the empty-storage fallback removes only the game data saved under this Live Preview origin. Your workbook remains unchanged on your computer.

  • Return to the developer console.
  • Remove both game storage keys by running this code:
localStorage.removeItem(STORAGE_WORDS);
localStorage.removeItem(STORAGE_COMPLETED);

What does this test remove?

These calls remove the browser-local copies of the active vocabulary and completed IDs. They create the same storage state as a first visit to the game.

Before you refresh, which dataset should restoreState() choose when both saved values are missing?

  • Refresh the Live Preview page.

You should see Starter data: 5 entries in the file status. You should also see Words left: 5 above a playable starter round.

Starter words missing after refresh?

  • Confirm that restoreState(); is the final startup call in script.js.
  • Check that the fallback copies STARTER_WORDS into allWords before calling startRound(currentMode).
  • Help me debug why restoreState does not load the five starter words after localStorage is cleared.
Keep solved words out of later rounds

Persistence becomes useful when the game treats completed IDs as a filter. Every counter update and new round can then work from the unsolved vocabulary only.

  • Find the existing getAvailableWords() function in script.js.
  • Replace that function with this filtered version:
function getAvailableWords() {
  return allWords.filter((word) => !completedIds.has(word.id));
}

How does this prevent repeats?

The filter keeps words whose IDs do not appear in completedIds. Every later round now starts from the remaining unsolved words.

  • Find completedIds.add(selectedId); inside the correct-answer branch of chooseAnswer().
  • Add saveCompleted(); after selectedId = null; so the section looks like this:
    roundSolved.add(selectedId);
    completedIds.add(selectedId);
    selectedId = null;
    saveCompleted();
    updateCounter();
    renderRound();

When does progress get saved?

A correct answer first enters the word ID into both completion sets. The save call then preserves the updated long-term set before the counter and board redraw.

  • Save script.js.
  • Return to the starter game.
  • Select one question card.
  • Select its correct answer from the answer bank.

You should see the answer lock beneath its English hint. The counter should change from Words left: 5 to Words left: 4.

Before you refresh, do you expect the solved word to return to the round?

  • Refresh the Live Preview page.

You should still see Words left: 4. The solved word should be absent from the new round.

Resetting progress is destructive because it makes every completed word available again. A confirmation dialog gives you a safe exit before the saved completion set changes.

  • Find the existing resetButton click handler near the bottom of script.js.
  • Replace the full handler with this protected version:
resetButton.addEventListener("click", () => {
  const confirmed = window.confirm(
    "Reset all completed words and return the counter to the full active total?",
  );
  if (!confirmed) {
    statusMessage.textContent = "Reset cancelled. Your progress is unchanged.";
    return;
  }
  completedIds.clear();
  saveCompleted();
  startRound(currentMode);
  statusMessage.textContent = "Progress reset. Every active word is available.";
});

How is the reset protected?

  • window.confirm() returns false when you select Cancel.
  • The early return leaves the completed set unchanged after cancellation.
  • completedIds.clear() runs only after confirmation.
  • saveCompleted() stores the cleared set before a full round begins.
  • Save script.js.

Before you test both choices, what should happen to the remaining-word counter after Cancel and after OK?

  • Select Reset Progress.
  • Select Cancel in the confirmation dialog.

You should still see Words left: 4. The status should confirm that your progress is unchanged.

  • Select Reset Progress again.
  • Select OK in the confirmation dialog.

You should see Words left: 5 with every starter word available again. Refreshing the page should preserve that reset state.

Does progress return after a reset?

  • Confirm that saveCompleted(); appears immediately after completedIds.clear();.
  • Check that the correct-answer branch also calls saveCompleted(); before updating the board.
  • Help me debug why completed words return after refreshing or why Reset Progress does not persist.

✔️ Awesome, I've got everything!

Your game now restores active vocabulary, preserves completed IDs, excludes solved words, and protects progress resets. Confirm that script.js is saved.

ⓧ I'd like to double check the full code

Compare your complete script.js with this pre-secret-mission version. Your existing index.html and style.css remain unchanged.

const ROUND_SIZE = 10;
const STORAGE_WORDS = "kanjiMatch.words.v2";
const STORAGE_COMPLETED = "kanjiMatch.completed.v2";

function makeWordId(rowPosition, numberTag, kanji, hiragana, english) {
  return `${rowPosition}::${numberTag}::${kanji}::${hiragana}::${english}`;
}

const STARTER_WORDS = [
  { numberTag: "1", kanji: "名前", hiragana: "なまえ", english: "Name" },
  { numberTag: "1", kanji: "有名(な)", hiragana: "ゆうめい(な)", english: "Famous" },
  { numberTag: "1", kanji: "氏名", hiragana: "しめい", english: "Fullname" },
  { numberTag: "2", kanji: "前", hiragana: "まえ", english: "Before" },
  { numberTag: "2", kanji: "午前", hiragana: "ごぜん", english: "A.M." },
].map((word, index) => ({
  ...word,
  id: makeWordId(index + 1, word.numberTag, word.kanji, word.hiragana, word.english),
}));

let allWords = [];
let completedIds = new Set();
let currentMode = "kanji";
let currentRound = [];
let answerOrder = [];
let roundSolved = new Set();
let selectedId = null;

const fileInput = document.getElementById("fileInput");
const fileStatus = document.getElementById("fileStatus");
const kanjiModeButton = document.getElementById("kanjiMode");
const hiraganaModeButton = document.getElementById("hiraganaMode");
const wordCounter = document.getElementById("wordCounter");
const resetButton = document.getElementById("resetButton");
const statusMessage = document.getElementById("statusMessage");
const questionGrid = document.getElementById("questionGrid");
const answerBank = document.getElementById("answerBank");
const newRoundButton = document.getElementById("newRoundButton");

function shuffle(items) {
  const copy = [...items];
  for (let index = copy.length - 1; index > 0; index -= 1) {
    const randomIndex = Math.floor(Math.random() * (index + 1));
    [copy[index], copy[randomIndex]] = [copy[randomIndex], copy[index]];
  }
  return copy;
}

function getAvailableWords() {
  return allWords.filter((word) => !completedIds.has(word.id));
}

function getQuestion(word) {
  return currentMode === "kanji" ? word.kanji : word.hiragana;
}

function getAnswer(word) {
  return currentMode === "kanji" ? word.hiragana : word.kanji;
}

function setControlsEnabled(enabled) {
  kanjiModeButton.disabled = !enabled;
  hiraganaModeButton.disabled = !enabled;
  resetButton.disabled = !enabled;
  newRoundButton.disabled = !enabled;
}

function updateCounter() {
  wordCounter.textContent = `Words left: ${getAvailableWords().length}`;
}

function saveWords() {
  try {
    localStorage.setItem(STORAGE_WORDS, JSON.stringify(allWords));
  } catch (error) {
    statusMessage.textContent = "The game is running, but the browser could not save the words.";
  }
}

function saveCompleted() {
  try {
    localStorage.setItem(STORAGE_COMPLETED, JSON.stringify([...completedIds]));
  } catch (error) {
    statusMessage.textContent = "The match worked, but the browser could not save this progress.";
  }
}

function isValidWord(word) {
  return (
    typeof word?.id === "string" &&
    typeof word?.numberTag === "string" &&
    typeof word?.kanji === "string" &&
    typeof word?.hiragana === "string" &&
    typeof word?.english === "string"
  );
}

function restoreState() {
  try {
    const savedWords = JSON.parse(localStorage.getItem(STORAGE_WORDS) || "[]");
    const savedCompleted = JSON.parse(localStorage.getItem(STORAGE_COMPLETED) || "[]");
    allWords = Array.isArray(savedWords) ? savedWords.filter(isValidWord) : [];
    const validIds = new Set(allWords.map((word) => word.id));
    completedIds = new Set(
      Array.isArray(savedCompleted) ? savedCompleted.filter((id) => validIds.has(id)) : [],
    );
  } catch (error) {
    allWords = [];
    completedIds = new Set();
    localStorage.removeItem(STORAGE_WORDS);
    localStorage.removeItem(STORAGE_COMPLETED);
  }

  if (allWords.length === 0) {
    allWords = STARTER_WORDS.map((word) => ({ ...word }));
    completedIds = new Set();
    saveWords();
    saveCompleted();
    fileStatus.textContent = `Starter data: ${allWords.length} entries`;
  } else {
    fileStatus.textContent = `Saved data: ${allWords.length} entries`;
  }

  setControlsEnabled(true);
  startRound(currentMode);
}

function renderEmptyState(message) {
  questionGrid.innerHTML = "";
  answerBank.innerHTML = "";
  const questionMessage = document.createElement("p");
  questionMessage.className = "empty-state";
  questionMessage.textContent = message;
  questionGrid.appendChild(questionMessage);
  const answerMessage = document.createElement("p");
  answerMessage.className = "empty-state";
  answerMessage.textContent = "Answers will appear here.";
  answerBank.appendChild(answerMessage);
}

function renderRound() {
  questionGrid.innerHTML = "";
  answerBank.innerHTML = "";

  currentRound.forEach((word) => {
    const card = document.createElement("button");
    card.type = "button";
    card.className = "question-card";
    card.dataset.wordId = word.id;

    const question = document.createElement("span");
    question.className = "question-word";
    question.textContent = getQuestion(word);

    const englishHint = document.createElement("span");
    englishHint.className = "english-hint";
    englishHint.textContent = word.english;

    const answer = document.createElement("span");
    answer.className = "answer-slot";

    card.append(question, englishHint, answer);
    card.addEventListener("click", () => selectQuestion(word.id));
    questionGrid.appendChild(card);
  });

  answerOrder.forEach((word) => {
    if (roundSolved.has(word.id)) return;
    const choice = document.createElement("button");
    choice.type = "button";
    choice.className = "answer-choice";
    choice.textContent = getAnswer(word);
    choice.addEventListener("click", () => chooseAnswer(word.id, choice));
    answerBank.appendChild(choice);
  });

  refreshQuestionCards();
}

function refreshQuestionCards() {
  document.querySelectorAll(".question-card").forEach((card) => {
    const word = currentRound.find((candidate) => candidate.id === card.dataset.wordId);
    const answerSlot = card.querySelector(".answer-slot");
    const solved = roundSolved.has(word.id);
    const selected = selectedId === word.id;
    card.classList.toggle("solved", solved);
    card.classList.toggle("selected", selected && !solved);
    card.disabled = solved;
    answerSlot.textContent = solved
      ? getAnswer(word)
      : selected
        ? "Choose an answer below"
        : "Select this word";
  });
}

function selectQuestion(wordId) {
  if (roundSolved.has(wordId)) return;
  selectedId = wordId;
  statusMessage.textContent = "Now choose the matching answer.";
  refreshQuestionCards();
}

function chooseAnswer(choiceId, choiceButton) {
  if (!selectedId) {
    statusMessage.textContent = "Select a question card first.";
    return;
  }

  if (selectedId === choiceId) {
    roundSolved.add(selectedId);
    completedIds.add(selectedId);
    selectedId = null;
    saveCompleted();
    updateCounter();
    renderRound();
    const wordsLeft = getAvailableWords().length;
    if (wordsLeft === 0) {
      statusMessage.textContent = "All words completed. Use Reset Progress to begin again.";
      newRoundButton.disabled = true;
    } else if (roundSolved.size === currentRound.length) {
      statusMessage.textContent = "Round complete. Start a new round.";
    } else {
      statusMessage.textContent = "Correct. Choose another question.";
    }
    return;
  }

  choiceButton.classList.add("wrong");
  statusMessage.textContent = "Not a match. Try another answer.";
  window.setTimeout(() => choiceButton.classList.remove("wrong"), 450);
}

function startRound(mode = currentMode) {
  currentMode = mode;
  selectedId = null;
  roundSolved = new Set();
  kanjiModeButton.classList.toggle("active", currentMode === "kanji");
  hiraganaModeButton.classList.toggle("active", currentMode === "hiragana");
  const availableWords = getAvailableWords();
  updateCounter();

  if (availableWords.length === 0) {
    currentRound = [];
    answerOrder = [];
    renderEmptyState("Every active word has been completed.");
    statusMessage.textContent = "All words completed. Use Reset Progress to begin again.";
    newRoundButton.disabled = true;
    return;
  }

  currentRound = shuffle(availableWords).slice(0, ROUND_SIZE);
  answerOrder = shuffle(currentRound);
  renderRound();
  newRoundButton.disabled = false;
  const questionType = currentMode === "kanji" ? "kanji" : "hiragana";
  statusMessage.textContent = `Select a ${questionType} question, then choose its answer.`;
}

fileInput.addEventListener("change", async (event) => {
  const [file] = event.target.files;
  if (!file) return;

  try {
    if (typeof XLSX === "undefined") throw new Error("The spreadsheet reader did not load.");
    const data = await file.arrayBuffer();
    const workbook = XLSX.read(data);
    const firstSheetName = workbook.SheetNames[0];
    if (!firstSheetName) throw new Error("The workbook has no worksheets.");
    const worksheet = workbook.Sheets[firstSheetName];
    const rows = XLSX.utils.sheet_to_json(worksheet, {
      header: 1,
      defval: "",
      blankrows: false,
    });

    const importedWords = rows
      .slice(1)
      .map((row, index) => {
        const numberTag = String(row[0] ?? "").trim();
        const kanji = String(row[1] ?? "").trim();
        const hiragana = String(row[2] ?? "").trim();
        const english = String(row[3] ?? "").trim();
        const rowNumber = index + 2;
        if (!numberTag || !kanji || !hiragana || !english) return null;
        return {
          id: makeWordId(rowNumber, numberTag, kanji, hiragana, english),
          numberTag,
          kanji,
          hiragana,
          english,
        };
      })
      .filter(Boolean);

    if (importedWords.length === 0) {
      throw new Error("No complete entries were found after the header row in columns A through D.");
    }

    const importedIds = new Set(importedWords.map((word) => word.id));
    completedIds = new Set([...completedIds].filter((id) => importedIds.has(id)));
    allWords = importedWords;
    saveWords();
    saveCompleted();
    fileStatus.textContent = `${file.name}: ${allWords.length} valid entries`;
    setControlsEnabled(true);
    startRound(currentMode);
  } catch (error) {
    statusMessage.textContent = `Could not load the spreadsheet: ${error.message}`;
  } finally {
    fileInput.value = "";
  }
});

kanjiModeButton.addEventListener("click", () => startRound("kanji"));
hiraganaModeButton.addEventListener("click", () => startRound("hiragana"));
newRoundButton.addEventListener("click", () => startRound(currentMode));
resetButton.addEventListener("click", () => {
  const confirmed = window.confirm(
    "Reset all completed words and return the counter to the full active total?",
  );
  if (!confirmed) {
    statusMessage.textContent = "Reset cancelled. Your progress is unchanged.";
    return;
  }
  completedIds.clear();
  saveCompleted();
  startRound(currentMode);
  statusMessage.textContent = "Progress reset. Every active word is available.";
});

restoreState();

That completes the main game. Your vocabulary and no-repeat progress now survive refreshes on the same Live Preview origin.

Secret mission

Add a Mistakes Counter

Track accuracy inside each matching round by counting every wrong answer. The total resets whenever a new round begins, so each set gets a fresh score.

Clean Up Your Resources

Clean Up Your Resources

Everything in this project runs locally, so there are no ongoing cloud costs or paid services. Decide whether to keep the game ready, pause its local preview, or remove the project files and browser data.

Resources you used:

  • The local kanji-match-game folder containing index.html, style.css, and script.js.
  • The active vocabulary saved under kanjiMatch.words.v2 for the Live Preview origin.
  • The completed-word IDs saved under kanjiMatch.completed.v2 for the same origin.

Keep everything running

No action needed. Choose this if you want to continue studying with the same vocabulary and completed-word progress.

  • Reopen kanji-match-game in Visual Studio Code when you want to continue.
  • Open index.html in the editor.
  • Select Show Preview to return to the game through Live Preview.

Your active vocabulary remains available on the same browser origin. Your no-repeat progress remains available there too.

Pause - I'll come back to this later

Stop the local preview while keeping the game files and browser data for another study session.

  • Close the Live Preview page.
  • Close Visual Studio Code.
  • Leave kanji-match-game in its current location.

Your saved vocabulary stays on the Live Preview origin. Your completed-word IDs stay there until you clear that browser data.

Delete - I don't want to use this again

Removing these items is permanent, so this option clears the game data before deleting the project folder. An Excel workbook stored elsewhere remains untouched.

The saved vocabulary and completed IDs live separately from your project files. Clear both records from the running game page before closing its local preview.

  • Return to the running game page in Microsoft Edge.
  • Open the browser developer tools for that Live Preview page.
  • Select the console panel.
  • Remove both storage records by running this cleanup code:
// Remove the vocabulary and progress stored for this browser origin
localStorage.removeItem("kanjiMatch.words.v2");
localStorage.removeItem("kanjiMatch.completed.v2");

// Read both keys back to confirm that no stored strings remain
({
  words: localStorage.getItem("kanjiMatch.words.v2"),
  completed: localStorage.getItem("kanjiMatch.completed.v2"),
});

What does this cleanup do?

  • The removeItem calls delete the active vocabulary record and the completed-word record from this browser origin.
  • The getItem calls read both keys after deletion. A null value confirms that no stored string remains.
  • Read the final object returned in the console.

You should see null for both words and completed.

Still See Saved Values?

Make sure the console belongs to the Live Preview game page. A console attached to another tab uses a different browser origin.

Help me clear the two Kanji Match localStorage keys from the correct Live Preview origin.

The browser copies are gone. Delete the local folder to remove the authored game files.

  • Close the Live Preview page.
  • In Visual Studio Code, right-click kanji-match-game at the top of the Explorer sidebar.
  • Select Reveal in File Explorer.
  • Close Visual Studio Code.
  • Select kanji-match-game in File Explorer.
  • Press Shift+Delete to remove the folder permanently.
  • Select Yes in the confirmation window.
  • Search for kanji-match-game in File Explorer.

You should see no matching project folder. Your game files and browser-local study data are now removed.

Nice Work!

Nice Work!

You did it! Your private Japanese vocabulary matching game now runs through Live Preview with starter words ready to play.

You've learned how to:

  • Switch a dark bidirectional matching board between Kanji and Hiragana modes. Show an English hint beneath every Japanese question.
  • Replace the starter data with entries from a four-column Microsoft Excel workbook through SheetJS Community Edition.
  • Preserve no-repeat progress across browser sessions with localStorage. Protect completed work with a confirmation step before resets.
  • Secret Mission: Added a per-round mistakes counter that updates after every wrong choice. Reset the counter whenever a new round starts.

Ready to quiz yourself?