Build a RAG Mystery Game
Build a RAG mystery game with Cloudflare Workers, AI, and Vectorize.
Introduction
30 Second Summary
A good mystery falls apart when useful clues stay hidden behind wording you did not guess. Searching for a private credential should still uncover evidence about a personal badge.
In this project, you will build Case File 404, a browser-based detective game running on Cloudflare Workers. The game contrasts brittle keyword search with retrieval-augmented generation that grounds an AI detective in nine evidence records.
What You'll Build
Your finished public game turns each question into a grounded finding with the supporting clue cards visible beside it.
By the end of this project, you'll have:
- A playable detective board where you can inspect four suspects before filing an evidence-backed accusation.
- A side-by-side retrieval test where the planned paraphrase fails in Keyword mode before RAG mode retrieves the relevant clues.
- A public mystery game at a workers.dev URL that displays grounded answers with the exact supporting clue IDs.
- Secret Mission: Add visual similarity bars that display each cited clue's numeric Vectorize score.
Are there any prerequisites?
You need basic comfort reading JavaScript and using a terminal. The walkthrough includes Cloudflare account setup within the project's free allowances.
Before We Start
Before the investigation begins, lock in the case you are about to solve. You will investigate the theft of the Astral Compass to discover why semantic RAG retrieval can uncover clues that exact-word Keyword mode misses.
Set Up the Case Room
A mystery game needs a dependable local case room before cloud resources or AI enter the story. This starter page proves that Node.js can load the project on Windows 11.
You will assemble the files in Visual Studio Code. Project-local Wrangler will serve them through the Cloudflare Workers development environment.
In this step, get ready to:
- Create a free Cloudflare account.
- Build the static case-room starter inside case-file-404.
- Install Wrangler 4.147.0 with npm before running the local page.
Create your Cloudflare account
The account gives later steps somewhere to create the evidence index before deploying the game. This setup creates no Worker or Vectorize index yet.
- Visit the Cloudflare sign-up page in your browser.
- Enter your email address in the Email field.
- Enter a unique password in the Password field.
- Select Create Account.
- Use the message Cloudflare sends to verify your email address.
Your Cloudflare dashboard confirms that the account is ready. The case now has a cloud destination for its later deployment.
Your local files need one named workspace so the editor and terminal always point to the same project. You will keep that workspace on your Desktop.
- Press the Windows key to open Windows Search.
- Type Visual Studio Code into the search box.
- Press Enter to open Visual Studio Code.
You will see the Visual Studio Code welcome window.
- Select File in the top menu.
- Select Open Folder....
- Select Desktop in the folder dialog.
- Select New Folder.
- Type case-file-404 as the folder name.
- Press Enter to create the folder.
- Select the new case-file-404 folder.
- Select Select Folder.
You will see case-file-404 at the top of the Explorer sidebar. That folder is now the home for every case file.
- Confirm that you trust the folder if a Workspace Trust prompt appears.
Add the starter case files
The starter needs one project manifest, one Wrangler configuration file, and one browser page. Each file has a single job that you can verify from the Explorer sidebar.
- Select New File... at the top of the Explorer sidebar.
- Type package.json as the file name.
- Press Enter to create the file.
A blank package.json opens in the editor.
- Define the project scripts and pinned Wrangler dependency by pasting this code into package.json:
{"name":"case-file-404","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"wrangler dev","deploy":"wrangler deploy","index:create":"wrangler vectorize create case-file-404-evidence --dimensions=384 --metric=cosine","worker:delete":"wrangler delete","index:delete":"wrangler vectorize delete case-file-404-evidence"},"devDependencies":{"wrangler":"4.147.0"}}
What does this file do?
- The project name keeps npm output tied to case-file-404.
- The dev script gives you one command for starting the local Wrangler server.
- The devDependencies entry pins Wrangler to 4.147.0 inside this project.
- Save package.json.
- Check the Explorer sidebar to confirm that package.json is listed inside case-file-404.
Seeing JSON errors in package.json?
Check that every property name uses straight double quotes. Confirm that the opening and closing braces are both present.
Ask for help with the file structure: Help me compare my package.json with the Case File 404 starter and find the JSON syntax problem.
- Select New File... at the top of the Explorer sidebar.
- Type wrangler.jsonc as the file name.
- Press Enter to create the file.
The new wrangler.jsonc file appears beside package.json.
- Configure static asset delivery by pasting this code into wrangler.jsonc:
{"$schema":"./node_modules/wrangler/config-schema.json","name":"case-file-404","compatibility_date":"2026-10-05","assets":{"directory":"./public/"}}
What does this configuration do?
- The schema path lets Visual Studio Code check Wrangler configuration keys after the dependency is installed.
- The name value identifies this project as case-file-404.
- The compatibility_date value sets Cloudflare Workers behavior to 2026-10-05.
- The assets setting tells Wrangler to serve files from public/.
- Save wrangler.jsonc.
- Check the Explorer sidebar to confirm that wrangler.jsonc is listed inside case-file-404.
Does Wrangler flag the configuration?
Confirm that the file is named wrangler.jsonc. Check that the asset directory is written as ./public/.
Ask for a focused comparison: Help me find the configuration mismatch in my wrangler.jsonc file.
- Select New Folder... at the top of the Explorer sidebar.
- Type public as the folder name.
- Press Enter to create the folder.
You will see the new public folder in the Explorer sidebar.
- Select the public folder in the Explorer sidebar.
- Select New File... at the top of the Explorer sidebar.
- Type index.html as the file name.
- Press Enter to create the file.
The file path in Explorer now reads public/index.html.
- Create the visible case-room message by pasting this markup into public/index.html:
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Case File 404</title></head>
<body><main><h1>Case File 404: Case room online</h1></main></body>
</html>
What does this page do?
- The metadata prepares the page for standard character encoding and responsive browser sizing.
- The document title labels the browser tab as Case File 404.
- The heading gives you a visible signal that Wrangler found the correct static asset.
- Save public/index.html.
- Expand the public folder in Explorer to confirm that index.html is listed.
Is index.html outside public?
Drag index.html into the public folder if both appear at the same level. Wrangler only serves this starter page from the configured asset directory.
Ask for help with the file tree: Help me check whether public/index.html is in the correct Case File 404 folder.
✔️ Awesome, I've got everything!
Your project manifest, Wrangler configuration, and starter page are all in place.
ⓧ I'd like to double check the full code
Your package.json should match this reference.
{"name":"case-file-404","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"wrangler dev","deploy":"wrangler deploy","index:create":"wrangler vectorize create case-file-404-evidence --dimensions=384 --metric=cosine","worker:delete":"wrangler delete","index:delete":"wrangler vectorize delete case-file-404-evidence"},"devDependencies":{"wrangler":"4.147.0"}}
Your wrangler.jsonc should match this reference.
{"$schema":"./node_modules/wrangler/config-schema.json","name":"case-file-404","compatibility_date":"2026-10-05","assets":{"directory":"./public/"}}
Your public/index.html should match this reference.
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Case File 404</title></head>
<body><main><h1>Case File 404: Case room online</h1></main></body>
</html>
Install Wrangler and launch the room
The manifest names Wrangler as a local development dependency. Installing it inside case-file-404 keeps this project on the tested version.
- Select View in the Visual Studio Code menu.
- Select Terminal to open the integrated terminal inside case-file-404.
The installation downloads the project dependency. It also records the exact dependency tree in package-lock.json.
- Install the dependencies listed in package.json by running:
npm install
What does this command do?
npm reads package.json before installing its listed development dependency. It creates package-lock.json so later installations resolve the same dependency tree.
- Wait for the terminal to return to its command prompt.
- Check the Explorer sidebar to confirm that package-lock.json now appears inside case-file-404.
Did the dependency installation fail?
Confirm that the integrated terminal is open inside case-file-404. Check that package.json is saved before repeating the installation command.
Ask for help with the terminal output: Help me diagnose why npm install failed in my Case File 404 project.
Wrangler is version-pinned because its configuration behavior can change between releases. The next check proves that npm installed the expected project-local release.
- Check the project-local Wrangler version by running:
npx wrangler --version
What does this command check?
npx runs the Wrangler executable installed inside this project. The printed version confirms which release your local commands use.
✔️ I see version 4.147.0
Your local toolchain is locked in. Wrangler 4.147.0 is ready to serve the case room.
ⓧ I see an older version
- Return to package.json.
- Confirm that the Wrangler dependency is set to 4.147.0.
- Save package.json.
- Repeat the dependency installation command shown above.
- Repeat the version check shown above.
ⓧ Command not found
- Confirm that the integrated terminal belongs to the open case-file-404 workspace.
- Repeat the dependency installation command shown above.
- Wait for npm to return to the command prompt.
- Repeat the version check shown above.
Still unable to run Wrangler?
Confirm that package-lock.json exists after installation. A missing lock file usually means npm did not finish inside this project.
Ask for help with the version check: Help me get project-local Wrangler 4.147.0 working through npx.
The development script keeps the terminal occupied while Wrangler serves the page. Press Ctrl+C in that terminal whenever you need to stop the server.
Before you start the server, do you expect Wrangler to find the page inside public/?
- Start the static-assets-only development server by running:
npm run dev
What happens when the server starts?
npm uses the dev script from package.json to start Wrangler. Wrangler reads wrangler.jsonc before serving the files inside public/.
- Record the local address printed by Wrangler as your local Wrangler URL.
- Switch back to the browser from your Cloudflare account setup.
- Paste your local Wrangler URL into the address bar.
- Press Enter.
You will see Case File 404: Case room online in the browser. Your local case room is now being served by Wrangler.
Does the case room stay offline?
- Keep the integrated terminal running while the browser page is open.
- Confirm that index.html sits inside the public folder.
- Confirm that wrangler.jsonc points to ./public/.
Ask for help with the local server: Help me diagnose why Wrangler is not serving my Case File 404 page.
Your local case room is online. Next, you will turn this single heading into a playable detective board with suspects, evidence controls, and an accusation panel.
Build the Detective Board
Your starter case room is live through Cloudflare Workers. That result proves your local static asset path works.
Now the player needs a visible case to investigate. You will turn the starter message into a responsive detective board where someone can inspect suspects before retrieval logic enters the story.
In this step, get ready to:
- Build the case header, suspect grid, investigation console, citation area, and accusation form.
- Style the detective board with a responsive dark archive design.
- Add browser interactions for retrieval modes, status messages, citations, and accusation feedback.
Build the case interface
The HTML gives the mystery its visible structure. You will build it in complete sections so each refresh reveals another part of the case.
- In the VS Code Explorer sidebar, select public/index.html.
- Replace the starter page with the case header 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>Case File 404</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="shell">
<header class="hero panel">
<p class="eyebrow">Midnight Museum Bureau</p>
<div class="hero-grid">
<div><h1>Case File 404</h1><p class="lead">The Astral Compass vanished from Gallery C during an eight-minute camera blackout.</p></div>
<div class="case-stamp"><span>Status</span><strong>Unsolved</strong><small>Incident 11:49 PM</small></div>
</div>
</header>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
What does this structure do?
- The .shell element holds the entire detective board inside one page container.
- The hero introduces the stolen Astral Compass and gives the case a visible unsolved state.
- The stylesheet and module links connect the interface to the files you build next.
- Save public/index.html.
- Refresh the local browser page.
You should see the Case File 404 heading, the museum incident summary, and the unsolved case status.
Still seeing the starter message?
- Confirm that you replaced the contents of public/index.html inside the case-file-404 folder.
- Confirm that the local Wrangler terminal from the previous step is still running.
Ask for help with the page update if the starter message remains visible.
- Insert the suspect section immediately before the closing </main> tag by pasting this code:
<section class="panel">
<div class="section-heading">
<div><p class="eyebrow">Persons of interest</p><h2>Four suspects, one contradiction</h2></div>
<button id="seed-button" class="button secondary" type="button">Index evidence</button>
</div>
<p id="seed-status" class="status" aria-live="polite">Evidence index not initialized.</p>
<div class="suspect-grid">
<article class="suspect"><span>01</span><h3>Mara Vale</h3><p>Curator. Says she left at 11:30 PM.</p></article>
<article class="suspect"><span>02</span><h3>Theo Grant</h3><p>Night guard. Assigned to the east gate.</p></article>
<article class="suspect"><span>03</span><h3>Lina Chen</h3><p>Conservator. Worked late in the north wing.</p></article>
<article class="suspect"><span>04</span><h3>Felix Rowe</h3><p>Donor. Departed after the private preview.</p></article>
</div>
</section>
How does the suspect grid help?
- Each suspect card gives the player one claim or location to test against later evidence.
- The Index evidence control reserves the action that loads clues into the future semantic index.
- The live status area gives that action a visible response without moving the player away from the board.
- Save public/index.html.
- Refresh the local browser page.
You should see four suspect cards for Mara Vale, Theo Grant, Lina Chen, and Felix Rowe beneath the case header.
Missing one or more suspects?
- Check that the suspect section sits inside .shell before the closing </main> tag.
- Compare the opening and closing tags around .suspect-grid.
Ask for help finding an HTML nesting problem.
- Insert the investigation workspace immediately before the closing </main> tag by pasting this code:
<section class="investigation-grid">
<article class="panel console">
<p class="eyebrow">Investigation console</p><h2>Question the case file</h2>
<form id="ask-form">
<label for="mode">Retrieval mode</label>
<select id="mode" name="mode"><option value="keyword">Keyword</option><option value="rag">RAG</option></select>
<p id="mode-hint" class="hint">Keyword mode only matches words that appear in a clue.</p>
<label for="question">Question</label>
<textarea id="question" name="question" rows="4">Which insider's private credential disproves the story they told?</textarea>
<button class="button primary" type="submit">Investigate</button>
</form>
<p id="ask-status" class="status" aria-live="polite">Ready for a question.</p>
</article>
<article class="panel answer-panel">
<p class="eyebrow">Analyst response</p><h2>Finding</h2>
<p id="answer">Run the keyword test first, then compare it with RAG mode.</p>
<div class="divider"></div><h3>Retrieved evidence</h3>
<div id="sources" class="sources"><p class="empty">No evidence retrieved yet.</p></div>
</article>
</section>
What happens in the investigation workspace?
- The retrieval selector lets the player compare Keyword mode with RAG mode from the same interface.
- The default question deliberately paraphrases the wording used by the evidence records.
- The analyst panel keeps the answer beside its supporting citation cards.
- Save public/index.html.
- Refresh the local browser page.
You should see the retrieval selector, the default paraphrased question, an investigation button, an analyst response, and an empty citation area.
Investigation controls missing?
- Confirm that #ask-form and #sources both appear before the investigation section closes.
- Check that the textarea closing tag appears after the complete default question.
Ask for help checking the investigation workspace markup.
- Insert the accusation panel immediately before the closing </main> tag by pasting this code:
<section class="panel accusation">
<div><p class="eyebrow">Final action</p><h2>File an accusation</h2><p>Choose only after the access log, key registry, and statement form one theory.</p></div>
<form id="accuse-form">
<label for="suspect">Suspect</label>
<select id="suspect" name="suspect">
<option value="">Choose a suspect</option><option>Mara Vale</option><option>Theo Grant</option><option>Lina Chen</option><option>Felix Rowe</option>
</select>
<button class="button danger" type="submit">Submit accusation</button>
</form>
<p id="verdict" class="verdict" aria-live="polite">The case remains open.</p>
</section>
Why include the accusation now?
- The accusation form gives the mystery a clear finish line before the retrieval system exists.
- The verdict area keeps feedback on the same board as the evidence.
- The player can form an early theory from the suspect cards before later clues challenge it.
- Save public/index.html.
- Refresh the local browser page.
You should see a suspect selector, a submit button, and the open-case verdict beneath the investigation workspace.
Accusation panel outside the board?
- Move the complete .accusation section above the closing </main> tag.
- Confirm that #verdict remains inside the accusation section.
Ask for help repairing the accusation panel structure.
✔️ Awesome, I've got everything!
Great. Your detective board now has every interface section the player needs.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Case File 404</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="shell">
<header class="hero panel">
<p class="eyebrow">Midnight Museum Bureau</p>
<div class="hero-grid">
<div><h1>Case File 404</h1><p class="lead">The Astral Compass vanished from Gallery C during an eight-minute camera blackout.</p></div>
<div class="case-stamp"><span>Status</span><strong>Unsolved</strong><small>Incident 11:49 PM</small></div>
</div>
</header>
<section class="panel">
<div class="section-heading">
<div><p class="eyebrow">Persons of interest</p><h2>Four suspects, one contradiction</h2></div>
<button id="seed-button" class="button secondary" type="button">Index evidence</button>
</div>
<p id="seed-status" class="status" aria-live="polite">Evidence index not initialized.</p>
<div class="suspect-grid">
<article class="suspect"><span>01</span><h3>Mara Vale</h3><p>Curator. Says she left at 11:30 PM.</p></article>
<article class="suspect"><span>02</span><h3>Theo Grant</h3><p>Night guard. Assigned to the east gate.</p></article>
<article class="suspect"><span>03</span><h3>Lina Chen</h3><p>Conservator. Worked late in the north wing.</p></article>
<article class="suspect"><span>04</span><h3>Felix Rowe</h3><p>Donor. Departed after the private preview.</p></article>
</div>
</section>
<section class="investigation-grid">
<article class="panel console">
<p class="eyebrow">Investigation console</p><h2>Question the case file</h2>
<form id="ask-form">
<label for="mode">Retrieval mode</label>
<select id="mode" name="mode"><option value="keyword">Keyword</option><option value="rag">RAG</option></select>
<p id="mode-hint" class="hint">Keyword mode only matches words that appear in a clue.</p>
<label for="question">Question</label>
<textarea id="question" name="question" rows="4">Which insider's private credential disproves the story they told?</textarea>
<button class="button primary" type="submit">Investigate</button>
</form>
<p id="ask-status" class="status" aria-live="polite">Ready for a question.</p>
</article>
<article class="panel answer-panel">
<p class="eyebrow">Analyst response</p><h2>Finding</h2>
<p id="answer">Run the keyword test first, then compare it with RAG mode.</p>
<div class="divider"></div><h3>Retrieved evidence</h3>
<div id="sources" class="sources"><p class="empty">No evidence retrieved yet.</p></div>
</article>
</section>
<section class="panel accusation">
<div><p class="eyebrow">Final action</p><h2>File an accusation</h2><p>Choose only after the access log, key registry, and statement form one theory.</p></div>
<form id="accuse-form">
<label for="suspect">Suspect</label>
<select id="suspect" name="suspect">
<option value="">Choose a suspect</option><option>Mara Vale</option><option>Theo Grant</option><option>Lina Chen</option><option>Felix Rowe</option>
</select>
<button class="button danger" type="submit">Submit accusation</button>
</form>
<p id="verdict" class="verdict" aria-live="polite">The case remains open.</p>
</section>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
Style the detective board
The board has all its controls, but its visual hierarchy is still missing. CSS turns those controls into a dark archive interface that remains readable on narrow screens.
- In the VS Code Explorer sidebar, create public/styles.css.
- Add the visual foundation by pasting this code:
:root {
color-scheme: dark;
--ink: #f5efe0;
--muted: #aaa493;
--panel: #20231d;
--line: #3c4035;
--acid: #d5ff62;
--danger: #ff6f61;
--shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
background: radial-gradient(circle at 15% 0%, rgba(213, 255, 98, 0.1), transparent 28rem), linear-gradient(135deg, #0c0e0b, #151913 55%, #0b0d0a);
}
button, select, textarea { font: inherit; }
.shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 64px; }
.panel { border: 1px solid var(--line); border-radius: 18px; background: #20231d; box-shadow: var(--shadow); }
What does the visual foundation control?
- The variables keep the archive colours consistent across every panel and control.
- The body gradients separate the page from the evidence panels without adding images.
- The shell limits the board width while preserving space at smaller window sizes.
- Save public/styles.css.
- Refresh the local browser page.
You should see a dark green archive background with rounded panels and light text.
Page still has default browser styling?
- Confirm that styles.css sits inside the public folder.
- Confirm that the stylesheet link in public/index.html uses /styles.css.
Ask for help tracing the missing stylesheet.
- Add the hero typography beneath the existing styles by pasting this code:
.hero { padding: 32px; margin-bottom: 20px; }
.hero-grid, .section-heading, .accusation { display: flex; justify-content: space-between; gap: 24px; align-items: center; }
.eyebrow { margin: 0 0 8px; color: var(--acid); letter-spacing: 0.18em; text-transform: uppercase; font-size: 0.72rem; font-weight: 800; }
h1, h2, h3, p { margin-top: 0; }
h1 { margin-bottom: 10px; font-size: clamp(2.7rem, 8vw, 6rem); line-height: 0.9; letter-spacing: -0.06em; }
h2 { margin-bottom: 12px; font-size: clamp(1.5rem, 3vw, 2.2rem); }
.lead { max-width: 650px; color: var(--muted); font-size: 1.05rem; }
.case-stamp { min-width: 180px; padding: 18px; border: 1px dashed var(--acid); transform: rotate(2deg); text-transform: uppercase; }
.case-stamp span, .case-stamp small { display: block; color: var(--muted); }
.case-stamp strong { display: block; margin: 4px 0; color: var(--acid); font-size: 1.5rem; }
section.panel { padding: 26px; margin-bottom: 20px; }
How does the hero establish hierarchy?
- The large case title makes the incident the first thing the player reads.
- The muted summary keeps the stolen object visible without competing with the title.
- The rotated status stamp makes the unsolved state feel like part of a physical case file.
- Save public/styles.css.
- Refresh the local browser page.
You should see a large Case File 404 title beside a tilted unsolved status stamp.
Hero layout looks unchanged?
- Check that the new rules appear after the closing brace for .panel.
- Confirm that the header still uses both hero and panel classes.
Ask for help checking the hero selectors.
- Add the suspect grid styles beneath the hero rules by pasting this code:
.status, .hint, .empty { color: var(--muted); }
.status { min-height: 1.4em; margin: 10px 0 18px; }
.suspect-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.suspect { min-height: 170px; padding: 18px; border: 1px solid var(--line); border-radius: 14px; background: #171a15; }
.suspect span { color: var(--acid); font-family: ui-monospace, monospace; }
.suspect h3 { margin: 42px 0 8px; }
.suspect p { color: var(--muted); font-size: 0.92rem; }
What makes the suspects scannable?
- The four-column grid lets the player compare every suspect at once on a wide screen.
- The numbered cards create a consistent rhythm across names and alibis.
- Muted descriptions keep attention on each suspect name.
- Save public/styles.css.
- Refresh the local browser page.
You should see four evenly spaced suspect cards with bright reference numbers.
Suspects still appear as plain text?
- Confirm that the card container uses class="suspect-grid".
- Confirm that each card uses class="suspect".
Ask for help matching the suspect classes.
- Add the investigation and form styles beneath the suspect rules by pasting this code:
.investigation-grid { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); gap: 20px; }
.investigation-grid .panel { margin: 0; padding: 26px; }
label { display: block; margin: 18px 0 7px; font-weight: 750; }
select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 10px; padding: 12px; color: var(--ink); background: #11130f; }
textarea { resize: vertical; }
select:focus, textarea:focus, button:focus-visible { outline: 2px solid var(--acid); outline-offset: 2px; }
.button { border: 0; border-radius: 999px; padding: 11px 18px; cursor: pointer; font-weight: 850; }
.button:disabled { cursor: wait; opacity: 0.55; }
.primary { width: 100%; margin-top: 16px; background: var(--acid); color: #12140f; }
.secondary { border: 1px solid var(--acid); background: transparent; color: var(--acid); }
.danger { background: var(--danger); color: #1b0c09; }
How do the controls communicate state?
- The two-column grid keeps questions beside the analyst response.
- The shared form styles make each input readable against the dark panels.
- The focus outline shows keyboard users which control is active.
- Save public/styles.css.
- Refresh the local browser page.
You should see the investigation form and analyst panel side by side with distinct green, outlined, and red buttons.
Form controls hard to read?
- Check the closing brace after the select, textarea rule.
- Confirm that the investigation area uses class="investigation-grid".
Ask for help with the investigation layout.
- Add the answer and evidence styles beneath the button rules by pasting this code:
.answer-panel { min-height: 440px; }
#answer { white-space: pre-wrap; font-size: 1.06rem; line-height: 1.65; }
.divider { height: 1px; margin: 24px 0; background: var(--line); }
.sources { display: grid; gap: 10px; }
.source-card { padding: 14px; border-left: 3px solid var(--acid); background: #151712; }
.source-card strong { display: block; margin-bottom: 5px; }
.source-card p { margin: 0; color: var(--muted); line-height: 1.5; }
.accusation { margin-top: 20px; padding: 26px; flex-wrap: wrap; }
.accusation > div { flex: 1; }
.accusation form { width: min(360px, 100%); }
.verdict { flex-basis: 100%; margin: 0; color: var(--acid); font-weight: 800; }
Why reserve space for evidence?
- The answer panel keeps a stable height while responses and citation cards change.
- Each source card uses a bright edge to separate retrieved evidence from the generated finding.
- The verdict spans the accusation panel so the final result remains easy to spot.
- Save public/styles.css.
- Refresh the local browser page.
You should see a tall analyst panel and a full-width verdict area beneath the accusation controls.
Answer panel height looks wrong?
- Confirm that the analyst article uses class="panel answer-panel".
- Check that the evidence container still uses id="sources" and class="sources".
Ask for help matching the answer selectors.
- Add the responsive layouts at the end of public/styles.css by pasting this code:
@media (max-width: 860px) {
.suspect-grid { grid-template-columns: repeat(2, 1fr); }
.investigation-grid { grid-template-columns: 1fr; }
.hero-grid, .section-heading, .accusation { align-items: stretch; flex-direction: column; }
.case-stamp { width: 180px; }
}
@media (max-width: 520px) {
.shell { width: min(100% - 20px, 1180px); padding-top: 10px; }
.hero, section.panel, .investigation-grid .panel, .accusation { padding: 19px; }
.suspect-grid { grid-template-columns: 1fr; }
}
How does the board adapt?
- At narrower widths, the suspect grid moves from four columns to two.
- The investigation workspace becomes one column so the form and answer remain readable.
- At phone width, the suspect cards stack into a single column with tighter panel padding.
- Save public/styles.css.
- Narrow the local browser window to test the responsive layout.
You should see two suspect columns at medium width and one suspect column at phone width.
Cards do not rearrange?
- Confirm that both media blocks appear after the standard suspect grid rule.
- Check that each media block has its own closing brace.
Ask for help checking the responsive rules.
✔️ Awesome, I've got everything!
Your case room now looks like a responsive museum investigation board.
ⓧ I'd like to double check the full code
:root {
color-scheme: dark;
--ink: #f5efe0;
--muted: #aaa493;
--panel: #20231d;
--line: #3c4035;
--acid: #d5ff62;
--danger: #ff6f61;
--shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
background: radial-gradient(circle at 15% 0%, rgba(213, 255, 98, 0.1), transparent 28rem), linear-gradient(135deg, #0c0e0b, #151913 55%, #0b0d0a);
}
button, select, textarea { font: inherit; }
.shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 64px; }
.panel { border: 1px solid var(--line); border-radius: 18px; background: #20231d; box-shadow: var(--shadow); }
.hero { padding: 32px; margin-bottom: 20px; }
.hero-grid, .section-heading, .accusation { display: flex; justify-content: space-between; gap: 24px; align-items: center; }
.eyebrow { margin: 0 0 8px; color: var(--acid); letter-spacing: 0.18em; text-transform: uppercase; font-size: 0.72rem; font-weight: 800; }
h1, h2, h3, p { margin-top: 0; }
h1 { margin-bottom: 10px; font-size: clamp(2.7rem, 8vw, 6rem); line-height: 0.9; letter-spacing: -0.06em; }
h2 { margin-bottom: 12px; font-size: clamp(1.5rem, 3vw, 2.2rem); }
.lead { max-width: 650px; color: var(--muted); font-size: 1.05rem; }
.case-stamp { min-width: 180px; padding: 18px; border: 1px dashed var(--acid); transform: rotate(2deg); text-transform: uppercase; }
.case-stamp span, .case-stamp small { display: block; color: var(--muted); }
.case-stamp strong { display: block; margin: 4px 0; color: var(--acid); font-size: 1.5rem; }
section.panel { padding: 26px; margin-bottom: 20px; }
.status, .hint, .empty { color: var(--muted); }
.status { min-height: 1.4em; margin: 10px 0 18px; }
.suspect-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.suspect { min-height: 170px; padding: 18px; border: 1px solid var(--line); border-radius: 14px; background: #171a15; }
.suspect span { color: var(--acid); font-family: ui-monospace, monospace; }
.suspect h3 { margin: 42px 0 8px; }
.suspect p { color: var(--muted); font-size: 0.92rem; }
.investigation-grid { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); gap: 20px; }
.investigation-grid .panel { margin: 0; padding: 26px; }
label { display: block; margin: 18px 0 7px; font-weight: 750; }
select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 10px; padding: 12px; color: var(--ink); background: #11130f; }
textarea { resize: vertical; }
select:focus, textarea:focus, button:focus-visible { outline: 2px solid var(--acid); outline-offset: 2px; }
.button { border: 0; border-radius: 999px; padding: 11px 18px; cursor: pointer; font-weight: 850; }
.button:disabled { cursor: wait; opacity: 0.55; }
.primary { width: 100%; margin-top: 16px; background: var(--acid); color: #12140f; }
.secondary { border: 1px solid var(--acid); background: transparent; color: var(--acid); }
.danger { background: var(--danger); color: #1b0c09; }
.answer-panel { min-height: 440px; }
#answer { white-space: pre-wrap; font-size: 1.06rem; line-height: 1.65; }
.divider { height: 1px; margin: 24px 0; background: var(--line); }
.sources { display: grid; gap: 10px; }
.source-card { padding: 14px; border-left: 3px solid var(--acid); background: #151712; }
.source-card strong { display: block; margin-bottom: 5px; }
.source-card p { margin: 0; color: var(--muted); line-height: 1.5; }
.accusation { margin-top: 20px; padding: 26px; flex-wrap: wrap; }
.accusation > div { flex: 1; }
.accusation form { width: min(360px, 100%); }
.verdict { flex-basis: 100%; margin: 0; color: var(--acid); font-weight: 800; }
@media (max-width: 860px) {
.suspect-grid { grid-template-columns: repeat(2, 1fr); }
.investigation-grid { grid-template-columns: 1fr; }
.hero-grid, .section-heading, .accusation { align-items: stretch; flex-direction: column; }
.case-stamp { width: 180px; }
}
@media (max-width: 520px) {
.shell { width: min(100% - 20px, 1180px); padding-top: 10px; }
.hero, section.panel, .investigation-grid .panel, .accusation { padding: 19px; }
.suspect-grid { grid-template-columns: 1fr; }
}
Add the board interactions
The interface now communicates the case visually. JavaScript connects its controls to status messages, citation cards, retrieval mode hints, and the accusation verdict.
- In the VS Code Explorer sidebar, create public/app.js.
- Add the board references and retrieval mode behavior by pasting this code:
const seedButton = document.querySelector("#seed-button");
const seedStatus = document.querySelector("#seed-status");
const askForm = document.querySelector("#ask-form");
const askStatus = document.querySelector("#ask-status");
const modeSelect = document.querySelector("#mode");
const modeHint = document.querySelector("#mode-hint");
const answer = document.querySelector("#answer");
const sources = document.querySelector("#sources");
const accuseForm = document.querySelector("#accuse-form");
const verdict = document.querySelector("#verdict");
modeSelect.addEventListener("change", () => {
modeHint.textContent = modeSelect.value === "keyword"
? "Keyword mode only matches words that appear in a clue."
: "RAG mode retrieves clues by meaning and asks AI to explain only those clues.";
});
What does this interaction control?
- The constants keep direct references to every interactive board element.
- The change listener updates the mode explanation as soon as the player selects another retrieval method.
- The interface can now explain the difference between exact-word matching and meaning-based retrieval.
- Save public/app.js.
- Refresh the local browser page.
- Select RAG from the retrieval mode control.
You should see the mode hint change to explain that RAG retrieves clues by meaning.
Mode hint does not change?
- Confirm that public/index.html loads /app.js as a module.
- Confirm that the retrieval selector uses id="mode".
Ask for help checking the mode listener.
- Insert the citation renderer immediately before the mode change listener by pasting this code:
function renderSources(items = []) {
sources.replaceChildren();
if (!items.length) {
const empty = document.createElement("p");
empty.className = "empty";
empty.textContent = "No evidence retrieved.";
sources.append(empty);
return;
}
items.forEach((source) => {
const card = document.createElement("article");
card.className = "source-card";
const title = document.createElement("strong");
title.textContent = `${source.id}: ${source.title}`;
const text = document.createElement("p");
text.textContent = source.text;
card.append(title, text);
sources.append(card);
});
}
How are citations rendered?
- The function clears old citation cards before displaying a new result.
- An empty result produces a clear no-evidence message.
- Each returned clue becomes a source card with its identifier, title, and text.
- Save public/app.js.
- Refresh the local browser page.
You should still see the empty retrieved-evidence message with no browser page errors.
Board disappears after this edit?
- Check the braces around renderSources().
- Confirm that the function appears after the element constants.
Ask for help finding a JavaScript syntax problem.
- Add the response reader immediately after renderSources() by pasting this code:
async function readJson(response) {
const data = await response.json();
if (!response.ok) throw new Error(data.error || "The request failed.");
return data;
}
Why centralize response handling?
- The helper converts a response body into data for the interface.
- An unsuccessful response becomes an error that the relevant form can display.
- Both investigation actions can share the same response rules.
- Save public/app.js.
- Select Keyword in the retrieval mode control.
You should see the exact-word mode hint return, which confirms the script still runs after the helper was added.
Mode control stopped responding?
- Check the closing brace after readJson().
- Confirm that the mode listener still appears below the helper.
Ask for help checking the helper placement.
- Add the evidence-index control immediately after the mode change listener by pasting this code:
seedButton.addEventListener("click", async () => {
seedButton.disabled = true;
seedStatus.textContent = "Embedding and indexing nine clues...";
try {
const response = await fetch("/api/seed", { method: "POST" });
const data = await readJson(response);
seedStatus.textContent = data.message;
} catch (error) {
seedStatus.textContent = error.message;
} finally {
seedButton.disabled = false;
}
});
How does indexing feedback work?
- The button becomes unavailable while the request is active.
- The status line tells the player that nine clues are being processed.
- The final block restores the button after either outcome.
- Save public/app.js.
- Click Index evidence.
You should see the evidence status update while the local interface handles the request. The button becomes available again after the request finishes.
Index button stays disabled?
- Confirm that the finally block appears inside the click listener.
- Check that seedButton.disabled = false; appears before the listener closes.
Ask for help repairing the button state.
- Add the investigation form listener immediately after the evidence-index listener by pasting this code:
askForm.addEventListener("submit", async (event) => {
event.preventDefault();
const question = String(new FormData(askForm).get("question") || "").trim();
const endpoint = modeSelect.value === "keyword" ? "/api/keyword" : "/api/investigate";
askStatus.textContent = "Searching the case file...";
answer.textContent = "Working...";
renderSources([]);
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ question })
});
const data = await readJson(response);
answer.textContent = data.answer;
renderSources(data.sources);
askStatus.textContent = `${data.sources.length} clue(s) retrieved.`;
} catch (error) {
answer.textContent = "The investigation request failed.";
askStatus.textContent = error.message;
}
});
How does the investigation form respond?
- The listener keeps the form submission inside the detective board.
- The selected retrieval mode determines which project route receives the question.
- The answer and source areas update together so every finding can remain connected to its clues.
- Save public/app.js.
- Click Investigate with the default question.
You should see the form status and answer area update without the browser leaving the page.
Form reloads the whole page?
- Confirm that event.preventDefault(); is the first statement inside the submit listener.
- Confirm that the form uses id="ask-form".
Ask for help checking the form listener.
- Add the accusation listener at the end of public/app.js by pasting this code:
accuseForm.addEventListener("submit", (event) => {
event.preventDefault();
const suspect = String(new FormData(accuseForm).get("suspect") || "");
if (!suspect) verdict.textContent = "Choose a suspect before filing the accusation.";
else if (suspect === "Mara Vale") verdict.textContent = "Case solved. Mara's statement conflicts with her badge access, and her signed-out key opened the case [E-01, E-02, E-04].";
else verdict.textContent = `${suspect} is not supported by the strongest combined evidence. Recheck the statement, access log, and key registry.`;
});
How is the accusation evaluated?
- An empty selection prompts the player to choose a suspect.
- Mara Vale produces an evidence-backed solved-case verdict with three clue IDs.
- Every other selection directs the player back to the statement, access log, and key registry.
- Save public/app.js.
- Select Mara Vale from the suspect control.
- Click Submit accusation.
You should see a solved-case verdict that cites E-01, E-02, and E-04.
Verdict does not update?
- Confirm that the form uses id="accuse-form".
- Confirm that the result paragraph uses id="verdict".
- Check that the option text matches Mara Vale exactly.
Ask for help checking the accusation path.
✔️ Awesome, I've got everything!
Your board now responds to retrieval modes, request states, citation results, and accusations.
ⓧ I'd like to double check the full code
const seedButton = document.querySelector("#seed-button");
const seedStatus = document.querySelector("#seed-status");
const askForm = document.querySelector("#ask-form");
const askStatus = document.querySelector("#ask-status");
const modeSelect = document.querySelector("#mode");
const modeHint = document.querySelector("#mode-hint");
const answer = document.querySelector("#answer");
const sources = document.querySelector("#sources");
const accuseForm = document.querySelector("#accuse-form");
const verdict = document.querySelector("#verdict");
function renderSources(items = []) {
sources.replaceChildren();
if (!items.length) {
const empty = document.createElement("p");
empty.className = "empty";
empty.textContent = "No evidence retrieved.";
sources.append(empty);
return;
}
items.forEach((source) => {
const card = document.createElement("article");
card.className = "source-card";
const title = document.createElement("strong");
title.textContent = `${source.id}: ${source.title}`;
const text = document.createElement("p");
text.textContent = source.text;
card.append(title, text);
sources.append(card);
});
}
async function readJson(response) {
const data = await response.json();
if (!response.ok) throw new Error(data.error || "The request failed.");
return data;
}
modeSelect.addEventListener("change", () => {
modeHint.textContent = modeSelect.value === "keyword"
? "Keyword mode only matches words that appear in a clue."
: "RAG mode retrieves clues by meaning and asks AI to explain only those clues.";
});
seedButton.addEventListener("click", async () => {
seedButton.disabled = true;
seedStatus.textContent = "Embedding and indexing nine clues...";
try {
const response = await fetch("/api/seed", { method: "POST" });
const data = await readJson(response);
seedStatus.textContent = data.message;
} catch (error) {
seedStatus.textContent = error.message;
} finally {
seedButton.disabled = false;
}
});
askForm.addEventListener("submit", async (event) => {
event.preventDefault();
const question = String(new FormData(askForm).get("question") || "").trim();
const endpoint = modeSelect.value === "keyword" ? "/api/keyword" : "/api/investigate";
askStatus.textContent = "Searching the case file...";
answer.textContent = "Working...";
renderSources([]);
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ question })
});
const data = await readJson(response);
answer.textContent = data.answer;
renderSources(data.sources);
askStatus.textContent = `${data.sources.length} clue(s) retrieved.`;
} catch (error) {
answer.textContent = "The investigation request failed.";
askStatus.textContent = error.message;
}
});
accuseForm.addEventListener("submit", (event) => {
event.preventDefault();
const suspect = String(new FormData(accuseForm).get("suspect") || "");
if (!suspect) verdict.textContent = "Choose a suspect before filing the accusation.";
else if (suspect === "Mara Vale") verdict.textContent = "Case solved. Mara's statement conflicts with her badge access, and her signed-out key opened the case [E-01, E-02, E-04].";
else verdict.textContent = `${suspect} is not supported by the strongest combined evidence. Recheck the statement, access log, and key registry.`;
});
Before you refresh, which parts of the board do you expect to rearrange when the browser becomes narrow?
- Save every open project file in VS Code.
- Refresh the local browser page.
- Narrow the browser window to phone width.
You should see the Case File 404 header, four suspect cards, the investigation form, the evidence-index control, the accusation panel, and a single-column phone layout. That is the board ready for its first retrieval experiment.
Your playable case interface is ready. Next, you will make exact-word retrieval fail against the mystery's planned paraphrased question.
Let Keyword Search Fail
Your detective board can collect a question. Its clues still need a retrieval path that can decide which evidence belongs in the answer panel.
This step turns the case file into a Cloudflare Worker API. The first retrieval method uses keyword search, which depends on the question sharing words with the clues.
In this step, get ready to:
- Route API requests through a Worker while keeping the detective board available.
- Score the nine evidence records with exact-word matching.
- Test the planned paraphrased question against the keyword search.
Route API requests through a Worker
The browser currently receives every file as a static asset. Worker-first routing lets requests under /api/* reach JavaScript logic while the existing board continues loading from public.
- Right-click the case-file-404 folder in the VS Code file tree.
- Create a folder named src inside case-file-404.
- Right-click the new src folder.
- Create a file named index.js inside src.
- Add a health route plus the static-asset fallback by pasting this starter into src/index.js:
function json(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json; charset=utf-8" }
});
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
try {
if (url.pathname === "/api/health") return json({ ok: true, case: "Case File 404" });
if (url.pathname.startsWith("/api/")) return json({ error: "API route not found." }, 404);
return env.ASSETS.fetch(request);
} catch (error) {
return json({ error: String(error?.message || error) }, 500);
}
}
};
What does this Worker do?
- The json() helper turns a JavaScript value into a JSON response with the correct content type.
- The /api/health route gives you a small response that proves Worker code is running.
- The env.ASSETS.fetch(request) fallback keeps serving the detective board for every non-API request.
- The catch block converts runtime failures into readable API responses.
- Replace the contents of wrangler.jsonc with this Worker and static-assets configuration:
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "case-file-404",
"main": "./src/index.js",
"compatibility_date": "2026-10-05",
"assets": {
"directory": "./public/",
"binding": "ASSETS",
"run_worker_first": ["/api/*"]
}
}
How does the routing work?
- The main setting identifies src/index.js as the Worker entry point.
- The ASSETS binding gives the Worker access to files inside public.
- The run_worker_first pattern sends API paths to the Worker before Wrangler checks for a static file.
- Save src/index.js.
- Save wrangler.jsonc.
- Switch back to the terminal from the previous step.
- Press Ctrl+C if the earlier development server is still running.
- Restart the local development server by running:
npm run dev
Worker does not start?
Check that index.js is inside src. Confirm that wrangler.jsonc points to the same path.
Use the terminal output to spot a missing brace or quote. Help me debug why my local Worker does not start.
Before you check the new route, what response do you think proves that the request reached Worker code?
- Return to the browser tab showing the local detective board.
- Enter the local URL printed by Wrangler with /api/health appended in the address bar.
- Press Enter.
You should see {"ok":true,"case":"Case File 404"}. Your static site now has a working Worker API behind it.
Add the evidence and exact-word matcher
Each clue needs an ID plus the text that keyword search examines. The matcher turns the question into tokens before counting which tokens appear inside each clue.
- Return to src/index.js in VS Code.
- Paste the evidence records above function json:
const EVIDENCE = [
{ id: "E-01", title: "Curator statement", text: "Mara Vale said she left the museum through the west entrance at 11:30 PM and did not return that night." },
{ id: "E-02", title: "Gallery access log", text: "At 11:42 PM, Gallery C's staff door opened with badge M-01, the personal badge assigned to curator Mara Vale." },
{ id: "E-03", title: "Display case report", text: "The Astral Compass case was opened at 11:49 PM with its brass override key. No glass was broken and the alarm seal was not cut." },
{ id: "E-04", title: "Override key registry", text: "Two brass override keys exist. One remained sealed in the security office. The second was issued to Mara Vale and was still signed out when the theft was reported." },
{ id: "E-05", title: "Camera outage report", text: "Gallery cameras went dark from 11:47 PM to 11:55 PM after the archive-corridor breaker was switched off by hand." },
{ id: "E-06", title: "Staff corridor map", text: "The staff archive corridor links the west entrance, breaker cabinet, and Gallery C without crossing the public lobby cameras." },
{ id: "E-07", title: "Guard radio log", text: "Guard Theo Grant's radio checked in from the east gate at 11:40 PM, 11:45 PM, 11:50 PM, and 11:55 PM." },
{ id: "E-08", title: "Conservation lab log", text: "Lina Chen's badge opened the sealed conservation lab at 11:36 PM and logged out at 12:04 AM. The lab is in the north wing." },
{ id: "E-09", title: "Donor travel receipt", text: "Felix Rowe's rideshare receipt records a museum pickup at 11:18 PM and a drop-off across town at 11:34 PM." }
];
const STOP_WORDS = new Set(["about", "after", "could", "their", "there", "these", "those", "which", "whose", "would"]);
How is the case file structured?
The EVIDENCE array holds all nine fictional clues. Each record carries the clue ID that later appears on its citation card.
The STOP_WORDS set removes common words that would create weak matches. The remaining tokens carry more of the question's meaning.
- Confirm that the final record in src/index.js is labeled E-09.
- Paste the request reader plus keyword matcher below json():
async function readQuestion(request) {
const body = await request.json();
return String(body.question || "").trim();
}
function keywordSearch(question) {
const words = question.toLowerCase().match(/[a-z0-9]+/g) || [];
const tokens = words.filter((word) => word.length > 3 && !STOP_WORDS.has(word));
return EVIDENCE.map((clue) => {
const haystack = `${clue.title} ${clue.text}`.toLowerCase();
const score = tokens.filter((token) => haystack.includes(token)).length;
return { ...clue, score };
})
.filter((clue) => clue.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 5);
}
How are keyword matches ranked?
- The readQuestion() helper reads the submitted JSON body. It returns a trimmed question string.
- The token filter removes short words plus anything listed in STOP_WORDS.
- The matcher counts exact token appearances inside each clue title plus its text.
- The final chain removes zero-score clues. It sorts the remaining clues by score before keeping five results.
- Scroll to the export default block at the bottom of src/index.js.
- Find this current handler:
export default {
async fetch(request, env) {
const url = new URL(request.url);
try {
if (url.pathname === "/api/health") return json({ ok: true, case: "Case File 404" });
if (url.pathname.startsWith("/api/")) return json({ error: "API route not found." }, 404);
return env.ASSETS.fetch(request);
} catch (error) {
return json({ error: String(error?.message || error) }, 500);
}
}
};
What are you replacing?
This handler currently recognizes only the health route. The next version adds a dedicated POST route for keyword questions.
- Replace the entire current handler with this updated version:
export default {
async fetch(request, env) {
const url = new URL(request.url);
try {
if (url.pathname === "/api/health") return json({ ok: true, case: "Case File 404" });
if (url.pathname === "/api/keyword" && request.method === "POST") {
const question = await readQuestion(request);
if (!question) return json({ error: "Enter a question first." }, 400);
const sources = keywordSearch(question);
return json({
answer: sources.length ? `Exact-word search found ${sources.length} possible clue(s).` : "No clue uses those exact words. Keyword search cannot connect this paraphrase to the case file.",
sources,
mode: "keyword"
});
}
if (url.pathname.startsWith("/api/")) return json({ error: "API route not found." }, 404);
return env.ASSETS.fetch(request);
} catch (error) {
return json({ error: String(error?.message || error) }, 500);
}
}
};
What changed in the handler?
The /api/keyword route now accepts a submitted question. It passes that question to keywordSearch() before returning the answer plus matching sources.
An empty question receives a clear error response. A question with no matches receives the planned explanation of the exact-word limitation.
- Save src/index.js.
- Return to the browser tab showing /api/health.
- Refresh the page.
You should still see {"ok":true,"case":"Case File 404"}. The new retrieval code has not broken the existing API route.
Health route stopped working?
Check that readQuestion() closes before keywordSearch() begins. Confirm that the closing braces around the /api/keyword route match the full handler above.
Review the terminal for the line number that failed. Help me debug my keyword Worker code.
Connect the board and run the failed search
The API can now score questions. The browser needs to send its form data to that route before it can display the returned answer and citations.
- Switch back to public/app.js in VS Code.
- Find the closing brace of renderSources().
- Add this response helper immediately below renderSources():
async function readJson(response) {
const data = await response.json();
if (!response.ok) throw new Error(data.error || "The request failed.");
return data;
}
Why check the response status?
The browser can receive JSON from both successful and failed requests. The readJson() helper turns failed response statuses into errors that the interface can display.
- Find the existing local-only click handler for seedButton.
- Delete the complete seedButton.addEventListener block.
- Find the existing askForm.addEventListener block.
- Replace that complete block with this API-connected version:
askForm.addEventListener("submit", async (event) => {
event.preventDefault();
const question = String(new FormData(askForm).get("question") || "").trim();
const endpoint = modeSelect.value === "keyword" ? "/api/keyword" : "/api/investigate";
askStatus.textContent = "Searching the case file...";
answer.textContent = "Working...";
renderSources([]);
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ question })
});
const data = await readJson(response);
answer.textContent = data.answer;
renderSources(data.sources);
askStatus.textContent = `${data.sources.length} clue(s) retrieved.`;
} catch (error) {
answer.textContent = "The investigation request failed.";
askStatus.textContent = error.message;
}
});
How does the form reach the API?
- The handler reads the question from the existing form.
- Keyword mode chooses /api/keyword as its endpoint.
- The request body sends the question as JSON.
- A successful response updates the answer plus citation cards. A failed response updates the visible status text.
- Save public/app.js.
- Return to the local detective board in your browser.
- Refresh the page.
Before you investigate, do you think exact-word matching can connect the question's wording to the clue about Mara's badge?
- Select Keyword in the retrieval mode control.
- Leave Which insider's private credential disproves the story they told? in the question field.
- Click Investigate.
You will see No clue uses those exact words. Keyword search cannot connect this paraphrase to the case file. The retrieved-evidence area shows no citation cards.
That failure is the result you needed. The question points toward Mara's personal badge through meaning, while the clue uses different words.
Do not see the no-match answer?
- Confirm that the retrieval mode shows Keyword.
- Check that the question matches the planned paraphrase exactly.
- Look at the terminal for a Worker error after clicking Investigate.
Use the visible browser status plus terminal output when asking for help. Help me debug why my keyword search does not show the planned no-match result.
✔️ Awesome, I've got everything!
Your Worker route and browser form are connected. Confirm that you saved every changed file.
ⓧ I'd like to double check the full code
Compare your completed files with these versions. Keep package.json, public/index.html, and public/styles.css unchanged from the previous step.
const EVIDENCE = [
{ id: "E-01", title: "Curator statement", text: "Mara Vale said she left the museum through the west entrance at 11:30 PM and did not return that night." },
{ id: "E-02", title: "Gallery access log", text: "At 11:42 PM, Gallery C's staff door opened with badge M-01, the personal badge assigned to curator Mara Vale." },
{ id: "E-03", title: "Display case report", text: "The Astral Compass case was opened at 11:49 PM with its brass override key. No glass was broken and the alarm seal was not cut." },
{ id: "E-04", title: "Override key registry", text: "Two brass override keys exist. One remained sealed in the security office. The second was issued to Mara Vale and was still signed out when the theft was reported." },
{ id: "E-05", title: "Camera outage report", text: "Gallery cameras went dark from 11:47 PM to 11:55 PM after the archive-corridor breaker was switched off by hand." },
{ id: "E-06", title: "Staff corridor map", text: "The staff archive corridor links the west entrance, breaker cabinet, and Gallery C without crossing the public lobby cameras." },
{ id: "E-07", title: "Guard radio log", text: "Guard Theo Grant's radio checked in from the east gate at 11:40 PM, 11:45 PM, 11:50 PM, and 11:55 PM." },
{ id: "E-08", title: "Conservation lab log", text: "Lina Chen's badge opened the sealed conservation lab at 11:36 PM and logged out at 12:04 AM. The lab is in the north wing." },
{ id: "E-09", title: "Donor travel receipt", text: "Felix Rowe's rideshare receipt records a museum pickup at 11:18 PM and a drop-off across town at 11:34 PM." }
];
const STOP_WORDS = new Set(["about", "after", "could", "their", "there", "these", "those", "which", "whose", "would"]);
function json(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json; charset=utf-8" }
});
}
async function readQuestion(request) {
const body = await request.json();
return String(body.question || "").trim();
}
function keywordSearch(question) {
const words = question.toLowerCase().match(/[a-z0-9]+/g) || [];
const tokens = words.filter((word) => word.length > 3 && !STOP_WORDS.has(word));
return EVIDENCE.map((clue) => {
const haystack = `${clue.title} ${clue.text}`.toLowerCase();
const score = tokens.filter((token) => haystack.includes(token)).length;
return { ...clue, score };
})
.filter((clue) => clue.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 5);
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
try {
if (url.pathname === "/api/health") return json({ ok: true, case: "Case File 404" });
if (url.pathname === "/api/keyword" && request.method === "POST") {
const question = await readQuestion(request);
if (!question) return json({ error: "Enter a question first." }, 400);
const sources = keywordSearch(question);
return json({
answer: sources.length ? `Exact-word search found ${sources.length} possible clue(s).` : "No clue uses those exact words. Keyword search cannot connect this paraphrase to the case file.",
sources,
mode: "keyword"
});
}
if (url.pathname.startsWith("/api/")) return json({ error: "API route not found." }, 404);
return env.ASSETS.fetch(request);
} catch (error) {
return json({ error: String(error?.message || error) }, 500);
}
}
};
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "case-file-404",
"main": "./src/index.js",
"compatibility_date": "2026-10-05",
"assets": {
"directory": "./public/",
"binding": "ASSETS",
"run_worker_first": ["/api/*"]
}
}
const seedButton = document.querySelector("#seed-button");
const seedStatus = document.querySelector("#seed-status");
const askForm = document.querySelector("#ask-form");
const askStatus = document.querySelector("#ask-status");
const modeSelect = document.querySelector("#mode");
const modeHint = document.querySelector("#mode-hint");
const answer = document.querySelector("#answer");
const sources = document.querySelector("#sources");
const accuseForm = document.querySelector("#accuse-form");
const verdict = document.querySelector("#verdict");
function renderSources(items = []) {
sources.replaceChildren();
if (!items.length) {
const empty = document.createElement("p");
empty.className = "empty";
empty.textContent = "No evidence retrieved.";
sources.append(empty);
return;
}
items.forEach((source) => {
const card = document.createElement("article");
card.className = "source-card";
const title = document.createElement("strong");
title.textContent = `${source.id}: ${source.title}`;
const text = document.createElement("p");
text.textContent = source.text;
card.append(title, text);
sources.append(card);
});
}
async function readJson(response) {
const data = await response.json();
if (!response.ok) throw new Error(data.error || "The request failed.");
return data;
}
modeSelect.addEventListener("change", () => {
modeHint.textContent = modeSelect.value === "keyword"
? "Keyword mode only matches words that appear in a clue."
: "RAG mode retrieves clues by meaning and asks AI to explain only those clues.";
});
askForm.addEventListener("submit", async (event) => {
event.preventDefault();
const question = String(new FormData(askForm).get("question") || "").trim();
const endpoint = modeSelect.value === "keyword" ? "/api/keyword" : "/api/investigate";
askStatus.textContent = "Searching the case file...";
answer.textContent = "Working...";
renderSources([]);
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ question })
});
const data = await readJson(response);
answer.textContent = data.answer;
renderSources(data.sources);
askStatus.textContent = `${data.sources.length} clue(s) retrieved.`;
} catch (error) {
answer.textContent = "The investigation request failed.";
askStatus.textContent = error.message;
}
});
accuseForm.addEventListener("submit", (event) => {
event.preventDefault();
const suspect = String(new FormData(accuseForm).get("suspect") || "");
if (!suspect) verdict.textContent = "Choose a suspect before filing the accusation.";
else if (suspect === "Mara Vale") verdict.textContent = "Case solved. Mara's statement conflicts with her badge access, and her signed-out key opened the case [E-01, E-02, E-04].";
else verdict.textContent = `${suspect} is not supported by the strongest combined evidence. Recheck the statement, access log, and key registry.`;
});
You have proved that shared meaning can disappear when retrieval depends on shared words. Next, you will embed the clues in Cloudflare Vectorize so the same question can retrieve evidence by meaning.
Turn Clues into Semantic Evidence
Your detective board has exposed the weakness in exact-word retrieval. The planned question describes Mara's badge without repeating the clue's wording.
This step turns each clue into an embedding with Cloudflare Workers AI.
Cloudflare Vectorize powers the semantic search. The resulting RAG path can connect that paraphrase to Mara's statement and badge log.
In this step, get ready to:
- Authenticate Wrangler and create the remote evidence index.
- Connect the Worker to Workers AI and Vectorize.
- Embed the evidence and retrieve clues by meaning.
Create the semantic index
A vector database stores numerical representations of meaning. This index needs 384 dimensions because the selected embedding model returns a vector with that size.
Why Vectorize instead of text search?
Text search compares words. Vectorize compares the direction of numerical vectors using the cosine metric.
That comparison lets differently worded sentences rank as related. It directly addresses the retrieval failure you saw in Keyword mode.
The authorization step can feel sensitive. Wrangler opens Cloudflare's browser flow so you can review the request before approving it.
- Switch back to the terminal from earlier.
- Press Ctrl+C if the local server is still running.
- Authenticate your project-local Wrangler by running this command:
npx wrangler login
What does this command do?
Wrangler opens the Cloudflare browser authorization flow. The terminal records the authenticated session after you approve it.
- Complete the Cloudflare authorization flow in your browser.
- Return to the terminal after Wrangler confirms authentication.
- Create the remote Vectorize index by running:
npm run index:create
How is the index configured?
- The script creates case-file-404-evidence as the remote index for the nine clues.
- The --dimensions=384 option matches the embedding model's output size.
- The --metric=cosine option configures similarity ranking by vector direction.
The terminal confirms that the index was created. Your remote evidence store is ready to receive vectors.
Did the index creation fail?
Confirm that the login command completed in the same terminal session. Check that the terminal is inside the case-file-404 folder.
If Cloudflare reports that the index already exists, keep using that existing index.
Help me troubleshoot creating the Vectorize index.
Connect the AI bindings and seed clues
Remote bindings give your local Worker access to Cloudflare services through env.AI and env.VECTOR_INDEX. Calls from local development affect the real remote index.
- Switch back to wrangler.jsonc in VS Code.
- Replace its contents with this configuration:
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "case-file-404",
"main": "./src/index.js",
"compatibility_date": "2026-10-05",
"assets": {
"directory": "./public/",
"binding": "ASSETS",
"run_worker_first": ["/api/*"]
},
"ai": {
"binding": "AI",
"remote": true
},
"vectorize": [
{
"binding": "VECTOR_INDEX",
"index_name": "case-file-404-evidence",
"remote": true
}
]
}
What do these bindings provide?
- The AI binding exposes Workers AI through env.AI.
- The VECTOR_INDEX binding exposes case-file-404-evidence through env.VECTOR_INDEX.
- The remote settings send both binding operations to Cloudflare while your Worker and static files run locally.
- Save wrangler.jsonc.
- Restart the local Worker by running:
npm run dev
What does this command prove?
Wrangler loads the updated configuration and starts the local Worker. A successful startup proves that it recognizes the remote bindings.
Open the local URL printed by Wrangler. You should still see the Case File 404 detective board.
Did the Worker reject the bindings?
Check the braces and commas in wrangler.jsonc. Confirm that VECTOR_INDEX matches the binding name shown above.
Help me debug my Wrangler bindings.
✔️ Awesome, I've got everything!
Great. Save wrangler.jsonc before continuing.
ⓧ I'd like to double check the full code
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "case-file-404",
"main": "./src/index.js",
"compatibility_date": "2026-10-05",
"assets": {
"directory": "./public/",
"binding": "ASSETS",
"run_worker_first": ["/api/*"]
},
"ai": {
"binding": "AI",
"remote": true
},
"vectorize": [
{
"binding": "VECTOR_INDEX",
"index_name": "case-file-404-evidence",
"remote": true
}
]
}
This reference includes the static asset configuration plus both remote service bindings.
- Switch back to src/index.js.
- Add the embedding model constant at the top of the file by copying this line:
const EMBEDDING_MODEL = "@cf/baai/bge-small-en-v1.5";
Why store the model identifier?
The constant keeps clue embeddings and question embeddings tied to the same model. Both sides must use compatible numerical representations.
- Save src/index.js.
Wrangler reloads the Worker without a syntax error. The embedding model is now available to the next function.
Did the Worker stop reloading?
Confirm that the model identifier is wrapped in matching double quotes. Keep the constant above the existing EVIDENCE array.
Help me fix the embedding model constant.
- Find the closing brace of keywordSearch() in src/index.js.
- Add seedEvidence() below that function by copying this code:
async function seedEvidence(env) {
const documents = EVIDENCE.map((clue) => `${clue.title}. ${clue.text}`);
const embeddings = await env.AI.run(EMBEDDING_MODEL, { text: documents, pooling: "cls" });
const vectors = EVIDENCE.map((clue, index) => ({
id: clue.id,
values: embeddings.data[index],
metadata: { title: clue.title, text: clue.text }
}));
const mutation = await env.VECTOR_INDEX.upsert(vectors);
return json({
count: vectors.length,
mutationId: mutation.mutationId,
message: "Evidence queued. Wait a few seconds before investigating."
});
}
How does evidence become searchable?
- The documents array combines each clue title with its text.
- The Workers AI call embeds all nine documents with pooling: "cls".
- Each vector keeps its clue ID as the vector ID. Its title and text remain attached as metadata.
- The upsert() call queues the vectors in the remote index.
- Save src/index.js.
Wrangler reloads successfully. The Worker can now transform all nine clues into vectors.
Seeing an error in seedEvidence()?
Check that seedEvidence() sits outside keywordSearch(). Confirm that every closing parenthesis and brace matches the snippet.
Help me debug the evidence seeding function.
- Find the /api/health condition inside the existing fetch() handler.
- Add this seed route directly below it:
if (url.pathname === "/api/seed" && request.method === "POST") return await seedEvidence(env);
How does the seed route work?
A POST request to /api/seed now calls seedEvidence(). The route returns the queued mutation details as JSON.
- Save src/index.js.
Wrangler reloads without an error. The local Worker now accepts evidence-indexing requests.
- Switch back to public/app.js.
- Add this click handler below the existing mode-change handler:
seedButton.addEventListener("click", async () => {
seedButton.disabled = true;
seedStatus.textContent = "Embedding and indexing nine clues...";
try {
const response = await fetch("/api/seed", { method: "POST" });
const data = await readJson(response);
seedStatus.textContent = data.message;
} catch (error) {
seedStatus.textContent = error.message;
} finally {
seedButton.disabled = false;
}
});
What does the button handler manage?
- The button becomes disabled while the request runs. This prevents duplicate clicks.
- The browser sends a POST request to /api/seed.
- The status area displays the message returned by the Worker.
- The finally block restores the button after success or failure.
- Save public/app.js.
- Refresh the detective board in your browser.
Vectorize writes become searchable asynchronously. A short pause of a few seconds is normal after the button responds.
- Click Index evidence.
You should see Evidence queued. Wait a few seconds before investigating. The remote index now contains the nine clue vectors.
Did evidence indexing fail?
Check that the local Worker is still running. Confirm that the AI and VECTOR_INDEX binding names match the names used in src/index.js.
Help me troubleshoot the evidence indexing request.
Retrieve clues by meaning
The stored clues and the incoming question must use the same embedding model. They must also use the same cls pooling method.
The question vector becomes the search key. Vectorize returns the five closest clues with their metadata and similarity scores.
- Switch back to src/index.js.
- Add investigate() below seedEvidence() by copying this code:
async function investigate(question, env) {
const embeddedQuestion = await env.AI.run(EMBEDDING_MODEL, { text: [question], pooling: "cls" });
const matches = await env.VECTOR_INDEX.query(embeddedQuestion.data[0], { topK: 5, returnMetadata: "all" });
const sources = (matches.matches || []).map((match) => ({
id: match.id,
title: String(match.metadata?.title || "Untitled clue"),
text: String(match.metadata?.text || ""),
score: match.score
}));
return { answer: "The case file does not prove that.", sources };
}
How does semantic retrieval work?
- Workers AI embeds the question with the same model and pooling method used for the clues.
- The query() call searches the stored vectors for the five closest matches.
- The returnMetadata: "all" option returns each clue's stored title and text.
- The mapped source objects preserve each clue ID and its retrieval score.
- Save src/index.js.
Wrangler reloads successfully. The semantic retrieval function is ready to receive a question.
Did investigate() break the Worker?
Confirm that investigate() sits outside seedEvidence(). Check the optional chaining characters in the metadata mapping.
Help me debug semantic retrieval.
- Find the existing /api/keyword condition inside the fetch() handler.
- Add this investigation route below its closing brace:
if (url.pathname === "/api/investigate" && request.method === "POST") {
const question = await readQuestion(request);
if (!question) return json({ error: "Enter a question first." }, 400);
return json(await investigate(question, env));
}
What does the investigation route do?
The route reads the same JSON question used by Keyword mode. It rejects empty input before calling the semantic retrieval function.
- Save src/index.js.
Wrangler reloads without an error. The Worker now serves semantic retrieval through /api/investigate.
✔️ Awesome, I've got everything!
Your Worker now includes evidence seeding and semantic retrieval. Save src/index.js before continuing.
ⓧ I'd like to double check the full code
const EMBEDDING_MODEL = "@cf/baai/bge-small-en-v1.5";
const EVIDENCE = [
{ id: "E-01", title: "Curator statement", text: "Mara Vale said she left the museum through the west entrance at 11:30 PM and did not return that night." },
{ id: "E-02", title: "Gallery access log", text: "At 11:42 PM, Gallery C's staff door opened with badge M-01, the personal badge assigned to curator Mara Vale." },
{ id: "E-03", title: "Display case report", text: "The Astral Compass case was opened at 11:49 PM with its brass override key. No glass was broken and the alarm seal was not cut." },
{ id: "E-04", title: "Override key registry", text: "Two brass override keys exist. One remained sealed in the security office. The second was issued to Mara Vale and was still signed out when the theft was reported." },
{ id: "E-05", title: "Camera outage report", text: "Gallery cameras went dark from 11:47 PM to 11:55 PM after the archive-corridor breaker was switched off by hand." },
{ id: "E-06", title: "Staff corridor map", text: "The staff archive corridor links the west entrance, breaker cabinet, and Gallery C without crossing the public lobby cameras." },
{ id: "E-07", title: "Guard radio log", text: "Guard Theo Grant's radio checked in from the east gate at 11:40 PM, 11:45 PM, 11:50 PM, and 11:55 PM." },
{ id: "E-08", title: "Conservation lab log", text: "Lina Chen's badge opened the sealed conservation lab at 11:36 PM and logged out at 12:04 AM. The lab is in the north wing." },
{ id: "E-09", title: "Donor travel receipt", text: "Felix Rowe's rideshare receipt records a museum pickup at 11:18 PM and a drop-off across town at 11:34 PM." }
];
const STOP_WORDS = new Set(["about", "after", "could", "their", "there", "these", "those", "which", "whose", "would"]);
function json(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json; charset=utf-8" }
});
}
async function readQuestion(request) {
const body = await request.json();
return String(body.question || "").trim();
}
function keywordSearch(question) {
const words = question.toLowerCase().match(/[a-z0-9]+/g) || [];
const tokens = words.filter((word) => word.length > 3 && !STOP_WORDS.has(word));
return EVIDENCE.map((clue) => {
const haystack = `${clue.title} ${clue.text}`.toLowerCase();
const score = tokens.filter((token) => haystack.includes(token)).length;
return { ...clue, score };
})
.filter((clue) => clue.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 5);
}
async function seedEvidence(env) {
const documents = EVIDENCE.map((clue) => `${clue.title}. ${clue.text}`);
const embeddings = await env.AI.run(EMBEDDING_MODEL, { text: documents, pooling: "cls" });
const vectors = EVIDENCE.map((clue, index) => ({
id: clue.id,
values: embeddings.data[index],
metadata: { title: clue.title, text: clue.text }
}));
const mutation = await env.VECTOR_INDEX.upsert(vectors);
return json({
count: vectors.length,
mutationId: mutation.mutationId,
message: "Evidence queued. Wait a few seconds before investigating."
});
}
async function investigate(question, env) {
const embeddedQuestion = await env.AI.run(EMBEDDING_MODEL, { text: [question], pooling: "cls" });
const matches = await env.VECTOR_INDEX.query(embeddedQuestion.data[0], { topK: 5, returnMetadata: "all" });
const sources = (matches.matches || []).map((match) => ({
id: match.id,
title: String(match.metadata?.title || "Untitled clue"),
text: String(match.metadata?.text || ""),
score: match.score
}));
return { answer: "The case file does not prove that.", sources };
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
try {
if (url.pathname === "/api/health") return json({ ok: true, case: "Case File 404" });
if (url.pathname === "/api/seed" && request.method === "POST") return await seedEvidence(env);
if (url.pathname === "/api/keyword" && request.method === "POST") {
const question = await readQuestion(request);
if (!question) return json({ error: "Enter a question first." }, 400);
const sources = keywordSearch(question);
return json({
answer: sources.length ? `Exact-word search found ${sources.length} possible clue(s).` : "No clue uses those exact words. Keyword search cannot connect this paraphrase to the case file.",
sources,
mode: "keyword"
});
}
if (url.pathname === "/api/investigate" && request.method === "POST") {
const question = await readQuestion(request);
if (!question) return json({ error: "Enter a question first." }, 400);
return json(await investigate(question, env));
}
if (url.pathname.startsWith("/api/")) return json({ error: "API route not found." }, 404);
return env.ASSETS.fetch(request);
} catch (error) {
return json({ error: String(error?.message || error) }, 500);
}
}
};
This reference preserves the keyword route while adding evidence seeding and semantic retrieval.
- Switch back to public/app.js.
- Find the line that selects the request endpoint.
- Set the endpoint selection to this line:
const endpoint = modeSelect.value === "keyword" ? "/api/keyword" : "/api/investigate";
How does the interface choose retrieval?
Keyword mode sends the question to /api/keyword. RAG mode sends the same question to /api/investigate.
Keeping the question unchanged makes the comparison fair. Only the retrieval method changes.
- Save public/app.js.
- Confirm that the existing mode-change handler describes RAG retrieval by meaning.
✔️ Awesome, I've got everything!
Your interface can now seed the index and choose between both retrieval routes. Save public/app.js.
ⓧ I'd like to double check the full code
const seedButton = document.querySelector("#seed-button");
const seedStatus = document.querySelector("#seed-status");
const askForm = document.querySelector("#ask-form");
const askStatus = document.querySelector("#ask-status");
const modeSelect = document.querySelector("#mode");
const modeHint = document.querySelector("#mode-hint");
const answer = document.querySelector("#answer");
const sources = document.querySelector("#sources");
const accuseForm = document.querySelector("#accuse-form");
const verdict = document.querySelector("#verdict");
function renderSources(items = []) {
sources.replaceChildren();
if (!items.length) {
const empty = document.createElement("p");
empty.className = "empty";
empty.textContent = "No evidence retrieved.";
sources.append(empty);
return;
}
items.forEach((source) => {
const card = document.createElement("article");
card.className = "source-card";
const title = document.createElement("strong");
title.textContent = `${source.id}: ${source.title}`;
const text = document.createElement("p");
text.textContent = source.text;
card.append(title, text);
sources.append(card);
});
}
async function readJson(response) {
const data = await response.json();
if (!response.ok) throw new Error(data.error || "The request failed.");
return data;
}
modeSelect.addEventListener("change", () => {
modeHint.textContent = modeSelect.value === "keyword"
? "Keyword mode only matches words that appear in a clue."
: "RAG mode retrieves clues by meaning and asks AI to explain only those clues.";
});
seedButton.addEventListener("click", async () => {
seedButton.disabled = true;
seedStatus.textContent = "Embedding and indexing nine clues...";
try {
const response = await fetch("/api/seed", { method: "POST" });
const data = await readJson(response);
seedStatus.textContent = data.message;
} catch (error) {
seedStatus.textContent = error.message;
} finally {
seedButton.disabled = false;
}
});
askForm.addEventListener("submit", async (event) => {
event.preventDefault();
const question = String(new FormData(askForm).get("question") || "").trim();
const endpoint = modeSelect.value === "keyword" ? "/api/keyword" : "/api/investigate";
askStatus.textContent = "Searching the case file...";
answer.textContent = "Working...";
renderSources([]);
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ question })
});
const data = await readJson(response);
answer.textContent = data.answer;
renderSources(data.sources);
askStatus.textContent = `${data.sources.length} clue(s) retrieved.`;
} catch (error) {
answer.textContent = "The investigation request failed.";
askStatus.textContent = error.message;
}
});
accuseForm.addEventListener("submit", (event) => {
event.preventDefault();
const suspect = String(new FormData(accuseForm).get("suspect") || "");
if (!suspect) verdict.textContent = "Choose a suspect before filing the accusation.";
else if (suspect === "Mara Vale") verdict.textContent = "Case solved. Mara's statement conflicts with her badge access, and her signed-out key opened the case [E-01, E-02, E-04].";
else verdict.textContent = `${suspect} is not supported by the strongest combined evidence. Recheck the statement, access log, and key registry.`;
});
This reference includes the seed button handler plus the retrieval-mode endpoint selection.
- Refresh the local detective board.
- Select Keyword in the Retrieval mode field.
- Enter Which insider's private credential disproves the story they told? in the Question field.
- Click Investigate.
Keyword mode still shows No clue uses those exact words with no citation cards. That recreates the exact-word limitation.
- Select RAG in the Retrieval mode field.
Before you investigate again, do you expect the unchanged wording to retrieve the same empty result or evidence connected by meaning?
- Click Investigate again.
You should now see retrieved citation cards about Mara's statement and her badge access. Look for E-01 and E-02 among the results.
Did RAG return no citations?
Wait a few more seconds after seeding the index. Click Index evidence again if the earlier request failed.
Confirm that both embedding calls use pooling: "cls". Check that RAG mode sends the question to /api/investigate.
Help me debug missing semantic citations.
That closes the retrieval gap: the same paraphrase now finds evidence by meaning. Next up, you will constrain an AI detective to explain only those retrieved clues.
Ground the Detective and Go Live
Your Cloudflare Vectorize retriever now connects the paraphrased question to relevant evidence. It returns the Mara clues that exact-word search misses.
Retrieved clues become useful only when Workers AI is constrained to explain them with citations. Unsupported claims also need a fixed refusal.
This step adds prompt grounding to the detective. You will then publish the complete game with Cloudflare Workers.
In this step, get ready to:
- Ground every generated answer in retrieved case evidence.
- Complete the browser accusation workflow.
- Deploy the complete mystery game to a public URL.
Ground answers in retrieved clues
Retrieval-augmented generation supplies relevant evidence to a generation model as context. A strict system instruction limits what the model can claim from that context.
Why ground the model?
Semantic retrieval decides which clues are relevant to the question. The generation model turns those clues into a concise explanation.
A grounding instruction defines the permitted evidence. It also gives the model an exact response for questions the case file cannot support.
- In src/index.js, find the model declaration near the top of the file.
- Locate this line:
const EMBEDDING_MODEL = "@cf/baai/bge-small-en-v1.5";
What are you locating?
The existing constant identifies the model that converts evidence text into vectors. The generation model belongs beside it because both models power the investigation pipeline.
- Add the generation model directly below the existing declaration so the top of src/index.js looks like this:
const EMBEDDING_MODEL = "@cf/baai/bge-small-en-v1.5";
const GENERATION_MODEL = "@cf/meta/llama-3.2-3b-instruct";
Why use two models?
The embedding model represents meaning as vectors for retrieval. The generation model explains the retrieved clues in natural language.
- In investigate(question, env), locate the final return statement after the sources mapping.
- Replace that final return statement with this grounded generation block:
if (!sources.length) return { answer: "The case file does not prove that.", sources };
const context = sources.map((source) => `[${source.id}] ${source.title}: ${source.text}`).join("\n");
const generated = await env.AI.run(GENERATION_MODEL, {
messages: [
{
role: "system",
content: "You are the evidence analyst for a fictional museum mystery. Use only the CASE EVIDENCE supplied by the user. Do not add facts. If the evidence is insufficient, answer exactly: The case file does not prove that. Give a concise answer of at most 120 words. Cite clue IDs in square brackets. Do not announce the culprit; help the player evaluate evidence."
},
{ role: "user", content: `Question: ${question}\n\nCASE EVIDENCE:\n${context}` }
],
max_tokens: 220,
temperature: 0.2
});
return { answer: generated.response || "The case file does not prove that.", sources };
What does this code do?
- The empty-source check returns The case file does not prove that. when retrieval finds no evidence.
- The context string labels every retrieved clue with its square-bracket ID.
- The system message permits only the supplied case evidence. It also prevents the analyst from announcing the culprit.
- The generation settings keep the answer concise. The final return sends the answer plus its supporting sources to the browser.
- Save src/index.js.
Before you test the grounded version, which clue IDs do you expect the answer to use?
- Switch back to the local game from earlier.
- Select RAG in the Retrieval mode menu.
- Enter Which insider's private credential disproves the story they told? in the Question field.
- Click Investigate.
You should see a concise answer that references square-bracket clue IDs. Citation cards should show Mara's statement plus her badge access.
Is the answer missing or unsupported?
- Check that GENERATION_MODEL matches the declaration shown above.
- Check that the generation block remains inside investigate(question, env).
- Confirm that the local terminal still shows the Worker running.
Help me debug the grounded Workers AI response.
✔️ Awesome, I've got everything!
Your grounded Worker code is complete.
ⓧ I'd like to double check the full code
const EMBEDDING_MODEL = "@cf/baai/bge-small-en-v1.5";
const GENERATION_MODEL = "@cf/meta/llama-3.2-3b-instruct";
const EVIDENCE = [
{ id: "E-01", title: "Curator statement", text: "Mara Vale said she left the museum through the west entrance at 11:30 PM and did not return that night." },
{ id: "E-02", title: "Gallery access log", text: "At 11:42 PM, Gallery C's staff door opened with badge M-01, the personal badge assigned to curator Mara Vale." },
{ id: "E-03", title: "Display case report", text: "The Astral Compass case was opened at 11:49 PM with its brass override key. No glass was broken and the alarm seal was not cut." },
{ id: "E-04", title: "Override key registry", text: "Two brass override keys exist. One remained sealed in the security office. The second was issued to Mara Vale and was still signed out when the theft was reported." },
{ id: "E-05", title: "Camera outage report", text: "Gallery cameras went dark from 11:47 PM to 11:55 PM after the archive-corridor breaker was switched off by hand." },
{ id: "E-06", title: "Staff corridor map", text: "The staff archive corridor links the west entrance, breaker cabinet, and Gallery C without crossing the public lobby cameras." },
{ id: "E-07", title: "Guard radio log", text: "Guard Theo Grant's radio checked in from the east gate at 11:40 PM, 11:45 PM, 11:50 PM, and 11:55 PM." },
{ id: "E-08", title: "Conservation lab log", text: "Lina Chen's badge opened the sealed conservation lab at 11:36 PM and logged out at 12:04 AM. The lab is in the north wing." },
{ id: "E-09", title: "Donor travel receipt", text: "Felix Rowe's rideshare receipt records a museum pickup at 11:18 PM and a drop-off across town at 11:34 PM." }
];
const STOP_WORDS = new Set(["about", "after", "could", "their", "there", "these", "those", "which", "whose", "would"]);
function json(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json; charset=utf-8" }
});
}
async function readQuestion(request) {
const body = await request.json();
return String(body.question || "").trim();
}
function keywordSearch(question) {
const words = question.toLowerCase().match(/[a-z0-9]+/g) || [];
const tokens = words.filter((word) => word.length > 3 && !STOP_WORDS.has(word));
return EVIDENCE.map((clue) => {
const haystack = `${clue.title} ${clue.text}`.toLowerCase();
const score = tokens.filter((token) => haystack.includes(token)).length;
return { ...clue, score };
})
.filter((clue) => clue.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 5);
}
async function seedEvidence(env) {
const documents = EVIDENCE.map((clue) => `${clue.title}. ${clue.text}`);
const embeddings = await env.AI.run(EMBEDDING_MODEL, { text: documents, pooling: "cls" });
const vectors = EVIDENCE.map((clue, index) => ({
id: clue.id,
values: embeddings.data[index],
metadata: { title: clue.title, text: clue.text }
}));
const mutation = await env.VECTOR_INDEX.upsert(vectors);
return json({
count: vectors.length,
mutationId: mutation.mutationId,
message: "Evidence queued. Wait a few seconds before investigating."
});
}
async function investigate(question, env) {
const embeddedQuestion = await env.AI.run(EMBEDDING_MODEL, { text: [question], pooling: "cls" });
const matches = await env.VECTOR_INDEX.query(embeddedQuestion.data[0], { topK: 5, returnMetadata: "all" });
const sources = (matches.matches || []).map((match) => ({
id: match.id,
title: String(match.metadata?.title || "Untitled clue"),
text: String(match.metadata?.text || ""),
score: match.score
}));
if (!sources.length) return { answer: "The case file does not prove that.", sources };
const context = sources.map((source) => `[${source.id}] ${source.title}: ${source.text}`).join("\n");
const generated = await env.AI.run(GENERATION_MODEL, {
messages: [
{
role: "system",
content: "You are the evidence analyst for a fictional museum mystery. Use only the CASE EVIDENCE supplied by the user. Do not add facts. If the evidence is insufficient, answer exactly: The case file does not prove that. Give a concise answer of at most 120 words. Cite clue IDs in square brackets. Do not announce the culprit; help the player evaluate evidence."
},
{ role: "user", content: `Question: ${question}\n\nCASE EVIDENCE:\n${context}` }
],
max_tokens: 220,
temperature: 0.2
});
return { answer: generated.response || "The case file does not prove that.", sources };
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
try {
if (url.pathname === "/api/health") return json({ ok: true, case: "Case File 404" });
if (url.pathname === "/api/seed" && request.method === "POST") return await seedEvidence(env);
if (url.pathname === "/api/keyword" && request.method === "POST") {
const question = await readQuestion(request);
if (!question) return json({ error: "Enter a question first." }, 400);
const sources = keywordSearch(question);
return json({
answer: sources.length ? `Exact-word search found ${sources.length} possible clue(s).` : "No clue uses those exact words. Keyword search cannot connect this paraphrase to the case file.",
sources,
mode: "keyword"
});
}
if (url.pathname === "/api/investigate" && request.method === "POST") {
const question = await readQuestion(request);
if (!question) return json({ error: "Enter a question first." }, 400);
return json(await investigate(question, env));
}
if (url.pathname.startsWith("/api/")) return json({ error: "API route not found." }, 404);
return env.ASSETS.fetch(request);
} catch (error) {
return json({ error: String(error?.message || error) }, 500);
}
}
};
Complete the browser workflow
The question form already renders loading messages plus returned citation cards. The final interaction now needs to turn the player's selected suspect into a visible verdict.
- In public/app.js, scroll to the bottom of the file.
- Add this accusation handler below the question-form listener:
accuseForm.addEventListener("submit", (event) => {
event.preventDefault();
const suspect = String(new FormData(accuseForm).get("suspect") || "");
if (!suspect) verdict.textContent = "Choose a suspect before filing the accusation.";
else if (suspect === "Mara Vale") verdict.textContent = "Case solved. Mara's statement conflicts with her badge access, and her signed-out key opened the case [E-01, E-02, E-04].";
else verdict.textContent = `${suspect} is not supported by the strongest combined evidence. Recheck the statement, access log, and key registry.`;
});
What does this code do?
- The submit listener keeps the accusation inside the browser page.
- An empty selection asks the player to choose a suspect.
- Mara Vale produces the solved verdict with E-01, E-02, plus E-04.
- Every other selection points the player back to the strongest evidence categories.
- Save public/app.js.
- Return to the local game in your browser.
- Select Mara Vale from the Suspect menu.
- Click Submit accusation.
You should see a verdict beginning with Case solved. The verdict should reference E-01, E-02, plus E-04.
Does the accusation stay unchanged?
- Check that the listener is outside the question-form listener.
- Check that accuseForm still selects #accuse-form.
- Check that verdict still selects #verdict.
Help me debug the accusation form.
✔️ Awesome, I've got everything!
Your browser workflow now supports the complete investigation loop.
ⓧ I'd like to double check the full code
const seedButton = document.querySelector("#seed-button");
const seedStatus = document.querySelector("#seed-status");
const askForm = document.querySelector("#ask-form");
const askStatus = document.querySelector("#ask-status");
const modeSelect = document.querySelector("#mode");
const modeHint = document.querySelector("#mode-hint");
const answer = document.querySelector("#answer");
const sources = document.querySelector("#sources");
const accuseForm = document.querySelector("#accuse-form");
const verdict = document.querySelector("#verdict");
function renderSources(items = []) {
sources.replaceChildren();
if (!items.length) {
const empty = document.createElement("p");
empty.className = "empty";
empty.textContent = "No evidence retrieved.";
sources.append(empty);
return;
}
items.forEach((source) => {
const card = document.createElement("article");
card.className = "source-card";
const title = document.createElement("strong");
title.textContent = `${source.id}: ${source.title}`;
const text = document.createElement("p");
text.textContent = source.text;
card.append(title, text);
sources.append(card);
});
}
async function readJson(response) {
const data = await response.json();
if (!response.ok) throw new Error(data.error || "The request failed.");
return data;
}
modeSelect.addEventListener("change", () => {
modeHint.textContent = modeSelect.value === "keyword"
? "Keyword mode only matches words that appear in a clue."
: "RAG mode retrieves clues by meaning and asks AI to explain only those clues.";
});
seedButton.addEventListener("click", async () => {
seedButton.disabled = true;
seedStatus.textContent = "Embedding and indexing nine clues...";
try {
const response = await fetch("/api/seed", { method: "POST" });
const data = await readJson(response);
seedStatus.textContent = data.message;
} catch (error) {
seedStatus.textContent = error.message;
} finally {
seedButton.disabled = false;
}
});
askForm.addEventListener("submit", async (event) => {
event.preventDefault();
const question = String(new FormData(askForm).get("question") || "").trim();
const endpoint = modeSelect.value === "keyword" ? "/api/keyword" : "/api/investigate";
askStatus.textContent = "Searching the case file...";
answer.textContent = "Working...";
renderSources([]);
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ question })
});
const data = await readJson(response);
answer.textContent = data.answer;
renderSources(data.sources);
askStatus.textContent = `${data.sources.length} clue(s) retrieved.`;
} catch (error) {
answer.textContent = "The investigation request failed.";
askStatus.textContent = error.message;
}
});
accuseForm.addEventListener("submit", (event) => {
event.preventDefault();
const suspect = String(new FormData(accuseForm).get("suspect") || "");
if (!suspect) verdict.textContent = "Choose a suspect before filing the accusation.";
else if (suspect === "Mara Vale") verdict.textContent = "Case solved. Mara's statement conflicts with her badge access, and her signed-out key opened the case [E-01, E-02, E-04].";
else verdict.textContent = `${suspect} is not supported by the strongest combined evidence. Recheck the statement, access log, and key registry.`;
});
Deploy and verify the public case
Deployment publishes the Worker API plus the static detective board as one application. The same remote evidence index supports both local development and the public game.
Before you deploy, do you expect the public URL to serve only the interface or the API routes too?
- Deploy the Worker from the case-file-404 terminal by running this command:
npm run deploy
What does this command do?
The npm script runs Wrangler's deployment command for the configuration in wrangler.jsonc. Wrangler uploads the Worker plus the files in public/.
The deployment output includes the public workers.dev URL. That address serves the browser interface plus every /api/* route.
- Record the printed public URL here: your workers.dev URL.
- Open your workers.dev URL in your browser.
That is the deployment complete. Your detective board now has a public address backed by the same Worker API.
Did the deployment fail?
- Confirm that the terminal is inside the case-file-404 folder.
- Confirm that wrangler.jsonc still names the case-file-404-evidence index.
- Confirm that your Cloudflare sign-in remains active.
Help me troubleshoot the Cloudflare Worker deployment.
Before you run the public investigation, which mode do you expect to fail on the paraphrase?
- Click Index evidence on the public game.
- Wait a few seconds for the nine vectors to become queryable.
- Select Keyword in the Retrieval mode menu.
- Click Investigate with the planned question still in the form.
You should see No clue uses those exact words in the finding. You should see no citation cards.
- Select RAG in the Retrieval mode menu.
- Click Investigate again.
You should see a grounded answer with square-bracket clue IDs. The retrieved-evidence area should show citation cards for Mara's statement plus her badge access.
- Select Mara Vale from the Suspect menu.
- Click Submit accusation.
You should see the solved verdict with E-01, E-02, plus E-04. Your public game now demonstrates keyword failure, semantic retrieval, grounded generation, visible citations, plus an evidence-backed accusation.
Does the public investigation fail?
- Wait a few more seconds after indexing before submitting the RAG question.
- Refresh the public page if it still shows an older browser workflow.
- Deploy again after confirming that both changed files are saved.
Help me debug the public investigation flow.
Secret mission
Visualize Retrieval Similarity
Your citations reveal which clues the retriever selected. Add numeric similarity scores and proportional bars so you can see how each paraphrase changes the ranking.
Clean Up Your Resources
Clean Up Your Resources
Choose the cleanup option that fits what you want to do next. At this project's scale, the deployed Cloudflare Worker, Workers AI calls, and Cloudflare Vectorize index stay within their verified free allowances. The remote index remains stored until you delete it.
Resources you used:
- The case-file-404 Cloudflare Worker with its static assets at its public workers.dev URL.
- The case-file-404-evidence Cloudflare Vectorize index containing nine 384-dimensional evidence vectors.
- The local case-file-404 folder with its source files plus the generated package-lock.json file.
Workers AI calls do not leave a separate resource to delete.
Keep everything running
No action is needed. This option fits ongoing demonstrations or future edits.
- Keep the public workers.dev URL available for future demonstrations.
- Leave the case-file-404-evidence index in place so RAG questions keep retrieving the seeded clues.
- Keep the local case-file-404 folder for future edits to the similarity display.
Pause - I'll come back to this later
Pausing frees your terminal while preserving the public deployment. The remote evidence index remains seeded for your next session.
- Switch back to the terminal where the local development server is running.
- Press Ctrl+C to stop the local server.
- Close the browser tab showing the local case room.
That gives your machine a clean stopping point. You'll see the terminal return to its command prompt while the public game stays available.
Delete - I don't want to use this again
Deleting these resources is permanent. Your local code remains untouched until the final folder cleanup.
Each deletion script asks Wrangler for confirmation while it runs.
- Switch back to the terminal for the case-file-404 folder.
- Delete the deployed Worker by running this script:
npm run worker:delete
What does the Worker script delete?
The worker:delete script runs Wrangler against the Worker named in wrangler.jsonc.
It removes the deployed case-file-404 Worker plus its static assets from the public URL.
- Confirm the deletion when Wrangler prompts you.
- Verify that the terminal returns to its command prompt without an error.
Worker deletion did not finish?
Make sure the terminal is inside the case-file-404 folder. Confirm that Wrangler is still signed in to the Cloudflare account that owns the Worker.
Help me troubleshoot the Worker deletion.
- Delete the remote Vectorize index by running this script:
npm run index:delete
What does the Index script delete?
The index:delete script targets case-file-404-evidence.
It removes the Vectorize index plus all nine stored evidence vectors.
- Confirm the deletion when Wrangler prompts you.
- Verify that the terminal returns to its command prompt without an error.
Index deletion did not finish?
Confirm that the index name is case-file-404-evidence. Check that Wrangler is signed in to the account that owns the index.
Help me troubleshoot the Vectorize deletion.
Cloud cleanup complete. The public game no longer serves requests from its former URL.
- Press the Windows key to open system search.
- Type File Explorer into the search field.
- Press Enter to open File Explorer.
- Locate the existing case-file-404 folder in the place where you saved it.
- Right-click the case-file-404 folder to open its context menu.
- Choose the delete action from the context menu.
- Empty the Recycle Bin to remove the folder permanently.
- Confirm that case-file-404 no longer appears in its original location.
Cleanup finished. Every listed project resource is now removed.
Nice Work!
Nice Work!
Case closed! Your public Case File 404 game now takes players from a brittle keyword search to grounded answers backed by visible clues.
What you learned:
- Built a responsive detective game where players inspect four suspects. Players can question nine evidence records. They can submit an evidence-backed accusation.
- Created a deliberate keyword retrieval failure with a paraphrased question. Used semantic search to retrieve clues by meaning. Stored nine 384-dimensional embeddings in Cloudflare Vectorize.
- Built a grounded retrieval-augmented generation workflow with Cloudflare Workers AI. Constrained each answer to retrieved evidence. Displayed supporting clue IDs beside the answer. Deployed the interface and API together on Cloudflare Workers.
- Secret Mission: Added three-decimal cosine similarity scores to every citation. Built proportional similarity bars that reveal ranking changes across paraphrased questions.
Ready to quiz yourself?