Build a Cognitive Reaction Game
Build a browser game that captures OODA timing data for ML analysis.
Introduction
30 Second Summary
Every click you make involves a chain of cognitive steps. You see something, process what it is, decide what to do, and then act. Most people never measure how fast each of those steps actually takes.
In this project, you will build a browser-based reaction game that measures your cognitive timing with sub-millisecond precision. The game captures structured data that breaks down your reaction into distinct phases, ready to feed into a machine learning pipeline.
What You'll Build
A fast-paced shape-clicking game that captures OODA (Observe, Orient, Decide, Act) timing telemetry using high-precision browser APIs.
By the end of this project, you'll have:
- A playable reaction game that spawns colored shapes in random positions and measures your click speed across 10 rounds.
- A reusable JavaScript state machine pattern that cleanly manages game flow through five distinct phases.
- A structured JSON export containing per-trial OODA-decomposed timing data ready to feed into a Python ML backend.
- Secret Mission: Add a countdown timer mode that forces faster decisions and creates timeout events in your data.
Are there any prerequisites?
You need basic familiarity with HTML, CSS, and JavaScript (variables, functions, event listeners). No mastery required.
Before We Start
Before We Start...
Set Up Your Project
Every game starts with a foundation. Before writing any game logic, you need a project structure and a local server that lets you preview changes instantly in your browser.
In this step, you'll create your project files and connect them with HTML boilerplate. Then you'll start a Python development server so you can see your game take shape in real time.
In this step, get ready to:
- Create the project folder and three game files.
- Set up HTML boilerplate linking your CSS and JavaScript.
- Start a local dev server and verify the page loads.
Create the project folder and files
You need a dedicated folder on your Desktop to keep all three game files together. This makes it easy to find your project later and to serve it with a local web server.
- In VS Code, open the integrated terminal by pressing Ctrl+` (Control and the backtick key).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir ooda-reaction-game
cd ooda-reaction-game
- Create the three game files by running this command:
touch index.html style.css game.js
- Confirm the files exist by running:
ls
You should see index.html, style.css, and game.js in the output.
Don't see all three files?
Make sure you typed the touch command with spaces between each filename, not commas. Run ls again after re-running the touch command to confirm.
help me debug missing files after running touch
Open the folder in VS Code and add the HTML boilerplate
Opening the folder as a workspace gives VS Code full visibility of your project files in the sidebar. Then you'll add the HTML skeleton that ties your CSS and JavaScript together.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to Desktop, select the ooda-reaction-game folder, and click Open.
VS Code reloads with your project folder in the Explorer sidebar. You should see all three files listed there.
- In the Explorer sidebar, click index.html to open it in the editor.
- Paste the following HTML boilerplate into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OODA Reaction Game</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<script src="game.js"></script>
</body>
</html>
What does this code do?
This is the skeleton that connects your three files together. The browser reads index.html first, then loads everything it references.
- The <link rel="stylesheet" href="style.css"> tag tells the browser to load your stylesheet for visual styling.
- The <script src="game.js"></script> tag loads your game logic. It sits at the bottom of <body> so the page structure loads before the script runs.
- Save index.html by pressing Ctrl+S.
File not saving?
Check that the file tab in VS Code shows index.html and not a generic Untitled name. If you accidentally created a new file, close it and click index.html in the Explorer sidebar instead.
help me save my index.html file in VS Code
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's what index.html should look like right now:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OODA Reaction Game</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<script src="game.js"></script>
</body>
</html>
The style.css and game.js files should exist but remain empty for now.
Start the local dev server
Browsers need a web server to load linked files like CSS and JavaScript correctly. Python includes a built-in HTTP server that does exactly this with a single command.
- Open a new terminal in VS Code by pressing Ctrl+`.
- Start the local development server by running this command:
python3 -m http.server 8080
What does this command do?
python3 -m http.server 8080 tells Python to run its built-in HTTP server module. It serves all files in the current directory on port 8080.
This means your browser can now load index.html along with any files it references (like style.css and game.js) just like a real web server would.
You should see a message like Serving HTTP on 0.0.0.0 port 8080 in the terminal. This means the server is running.
- Open your web browser.
- Navigate to http://localhost:8080 in the address bar.
You should see a blank white page. This is correct. Your HTML loaded successfully but there's no visible content yet.
Why is the page blank?
Your <body> tag only contains the <script> reference to game.js, which is still empty. You'll add visible game elements in the next step.
Page not loading?
Make sure the terminal still shows the server running. If you see a command prompt instead, the server stopped. Run python3 -m http.server 8080 again.
Check that you're in the ooda-reaction-game folder when you start the server. Run pwd to confirm your current location ends with ooda-reaction-game.
help me get my Python HTTP server working
Keep this terminal running
The server needs to stay running for your browser to load the page. When you need to run other commands later, open a second terminal tab in VS Code instead of stopping the server.
Your project skeleton is in place and your dev server is live. Next up, you'll build the game board UI with a dark-themed play area and neon-accented elements.
Build the Game Board UI
Your project files are ready and the dev server is running. Now you need a visual interface for your reaction game.
The game has three distinct screens: a start screen where you launch the game, a game board where shapes appear, and a results screen showing your performance. In this step, you will build all three screens and give them a dark, neon-accented look using pure CSS.
In this step, get ready to:
- Build the three-screen HTML structure with show/hide toggling.
- Style the dark-themed game board and neon-accented UI elements.
- Create CSS-only shapes that JavaScript will spawn later.
Create the three-screen HTML structure
The game uses a single-page pattern where only one screen is visible at a time. Two of the three screens start with a .hidden CSS class that hides them. JavaScript will toggle this class to switch between screens.
- In VS Code, open index.html from the Explorer sidebar.
- Find the <script src="game.js"></script> line.
- Paste the following HTML structure directly above that script tag:
<div id="app">
<div id="start-screen">
<h1>OODA Reaction Game</h1>
<p class="description">Shapes will appear on the board. Click the correct shape as fast as you can!</p>
<p class="subtitle">This game measures your OODA loop: how fast you Observe, Orient, Decide, and Act.</p>
<button id="start-btn">Start Game (10 Rounds)</button>
</div>
<div id="game-area" class="hidden">
<div id="game-header">
<div id="target-prompt"></div>
<div id="round-counter"></div>
</div>
<div id="game-board"></div>
</div>
<div id="results-screen" class="hidden">
<h2>Session Complete</h2>
<div id="results-summary"></div>
<div id="results-actions">
<button id="export-btn">Export OODA Data (JSON)</button>
<button id="restart-btn">Play Again</button>
</div>
</div>
</div>
What does this HTML do?
- #start-screen is visible by default. It shows the game title, description, and a start button.
- #game-area starts hidden. It contains the prompt telling you which shape to click, a round counter, and the #game-board where shapes will spawn.
- #results-screen also starts hidden. It displays your stats and buttons to export data or play again.
- The class="hidden" attribute on #game-area and #results-screen keeps them invisible until JavaScript removes that class.
- Save index.html.
- Refresh your browser at http://localhost:8080. You should see the title "OODA Reaction Game", a description paragraph, and a purple "Start Game (10 Rounds)" button. The text is unstyled (default browser fonts on a white background).
Don't see any text in the browser?
- Make sure your new HTML is above the script tag, not below it. The structure should sit between the opening <body> and <script src="game.js">.
- Check that you saved the file. Unsaved changes won't appear in the browser.
Help me debug my HTML structure.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on to styling.
ⓧ 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>OODA Reaction Game</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<div id="start-screen">
<h1>OODA Reaction Game</h1>
<p class="description">Shapes will appear on the board. Click the correct shape as fast as you can!</p>
<p class="subtitle">This game measures your OODA loop: how fast you Observe, Orient, Decide, and Act.</p>
<button id="start-btn">Start Game (10 Rounds)</button>
</div>
<div id="game-area" class="hidden">
<div id="game-header">
<div id="target-prompt"></div>
<div id="round-counter"></div>
</div>
<div id="game-board"></div>
</div>
<div id="results-screen" class="hidden">
<h2>Session Complete</h2>
<div id="results-summary"></div>
<div id="results-actions">
<button id="export-btn">Export OODA Data (JSON)</button>
<button id="restart-btn">Play Again</button>
</div>
</div>
</div>
<script src="game.js"></script>
</body>
</html>
Style the dark-themed game board and UI elements
The game uses a dark background with neon purple accents to reduce eye strain and keep focus on the colorful shapes. You will style the layout, the 400px-tall game board, and all the UI elements in one stylesheet.
- Open style.css in VS Code.
- Add the base reset, layout, and game board styles by pasting this CSS:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0a0a0f;
color: #e0e0e0;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
#app {
width: 100%;
max-width: 820px;
padding: 2rem;
text-align: center;
}
.hidden {
display: none;
}
/* Start Screen */
#start-screen h1 {
font-size: 2rem;
color: #7c3aed;
margin-bottom: 1rem;
}
#start-screen .description {
font-size: 1.1rem;
color: #ccc;
margin-bottom: 0.5rem;
}
#start-screen .subtitle {
font-size: 0.9rem;
color: #888;
margin-bottom: 2rem;
}
/* Game Area */
#game-header {
margin-bottom: 1rem;
}
#target-prompt {
font-size: 1.8rem;
font-weight: bold;
color: #fbbf24;
min-height: 2.5rem;
}
#round-counter {
font-size: 0.9rem;
color: #888;
margin-top: 0.25rem;
}
#game-board {
position: relative;
width: 100%;
height: 400px;
background: #1a1a2e;
border-radius: 12px;
border: 2px solid #2a2a4e;
overflow: hidden;
cursor: crosshair;
}
What does this CSS do?
- The * reset removes default browser margins and padding so you have full control over spacing.
- body uses flexbox to center the entire app vertically and horizontally against a near-black background.
- .hidden { display: none; } is the toggle class. JavaScript adds and removes it to show/hide screens.
- #game-board is the 400px-tall play area. It uses position: relative so shapes can be absolutely positioned inside it.
- Save style.css and refresh your browser.
- You should see a dark background with the purple title "OODA Reaction Game", lighter description text, and the start button. The page looks like a proper dark-mode app now.
Still seeing a white background?
- Confirm that your index.html has the link tag in the head section. Look for the line that contains href="style.css" between the <head> and </head> tags.
- Check that your style.css file is in the same folder as index.html (not nested inside a subfolder).
Help me debug my CSS not loading.
- Now add the button, results, and feedback styles below the game board CSS in style.css:
/* Buttons */
button {
background: #7c3aed;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
margin: 0.5rem;
transition: background 0.2s;
}
button:hover {
background: #6d28d9;
}
/* Results */
#results-screen h2 {
color: #7c3aed;
margin-bottom: 1rem;
}
#results-summary {
text-align: left;
margin: 1.5rem 0;
padding: 1.5rem;
background: #1a1a2e;
border-radius: 8px;
border: 1px solid #2a2a4e;
}
#results-summary p {
margin: 0.5rem 0;
font-size: 0.95rem;
}
.stat-label {
color: #888;
}
.stat-value {
color: #7c3aed;
font-weight: bold;
}
/* Feedback */
.feedback {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.4rem;
font-weight: bold;
}
.feedback-correct {
color: #10b981;
}
.feedback-incorrect {
color: #ef4444;
}
What does this CSS do?
- The button styles give all buttons a purple background with rounded corners and a hover darkening effect.
- #results-summary creates a dark card where your performance stats will appear after all rounds complete.
- The .feedback class centers reaction-time text in the middle of the game board. Green means correct, red means wrong.
- Save style.css and refresh your browser.
- The start button should now be a rounded purple rectangle. Hover over it and you should see it darken slightly.
Button still looks like a default browser button?
- Make sure you pasted the button CSS below the game board CSS, not inside another rule block. Each selector should start at the left margin, not indented inside braces.
- Look for any missing closing braces from the previous section. A missing brace will break all rules that follow.
Help me figure out why my button styles aren't applying.
Create CSS-only shapes for the game pieces
The game spawns circles, squares, and triangles on the board. Instead of using images, you can create all three shapes with pure CSS. JavaScript will add these classes to div elements dynamically during gameplay.
- Add the shape styles at the bottom of style.css:
/* Shapes */
.shape {
position: absolute;
cursor: pointer;
transition: transform 0.1s;
}
.shape:hover {
transform: scale(1.15);
}
.shape-circle {
border-radius: 50%;
}
.shape-square {
border-radius: 4px;
}
.shape-triangle {
width: 0 !important;
height: 0 !important;
border-left: 30px solid transparent;
border-right: 30px solid transparent;
border-bottom: 52px solid currentColor;
background: none !important;
}
How do CSS-only triangles work?
The triangle trick uses CSS borders. When a box has zero width and height, only its borders are visible. Making the left and right borders transparent and giving the bottom border a solid color creates a triangle pointing upward.
The .shape base class makes every shape absolutely positioned (so JavaScript can place them anywhere on the board) and adds a hover scale-up effect for visual feedback.
- Save style.css.
You won't see any shapes on the page yet. JavaScript will create shape elements and add them to the game board in Step 3.
Seeing a CSS syntax error in DevTools?
- Open your browser's DevTools (press F12) and check the Console tab for any errors. A missing brace or semicolon from a previous section can break the shape rules.
- Verify that the last rule before the shapes section has a closing brace. Each CSS rule must be fully closed before the next one starts.
Help me find a CSS syntax error in my stylesheet.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0a0a0f;
color: #e0e0e0;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
#app {
width: 100%;
max-width: 820px;
padding: 2rem;
text-align: center;
}
.hidden {
display: none;
}
/* Start Screen */
#start-screen h1 {
font-size: 2rem;
color: #7c3aed;
margin-bottom: 1rem;
}
#start-screen .description {
font-size: 1.1rem;
color: #ccc;
margin-bottom: 0.5rem;
}
#start-screen .subtitle {
font-size: 0.9rem;
color: #888;
margin-bottom: 2rem;
}
/* Game Area */
#game-header {
margin-bottom: 1rem;
}
#target-prompt {
font-size: 1.8rem;
font-weight: bold;
color: #fbbf24;
min-height: 2.5rem;
}
#round-counter {
font-size: 0.9rem;
color: #888;
margin-top: 0.25rem;
}
#game-board {
position: relative;
width: 100%;
height: 400px;
background: #1a1a2e;
border-radius: 12px;
border: 2px solid #2a2a4e;
overflow: hidden;
cursor: crosshair;
}
/* Shapes */
.shape {
position: absolute;
cursor: pointer;
transition: transform 0.1s;
}
.shape:hover {
transform: scale(1.15);
}
.shape-circle {
border-radius: 50%;
}
.shape-square {
border-radius: 4px;
}
.shape-triangle {
width: 0 !important;
height: 0 !important;
border-left: 30px solid transparent;
border-right: 30px solid transparent;
border-bottom: 52px solid currentColor;
background: none !important;
}
/* Buttons */
button {
background: #7c3aed;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
margin: 0.5rem;
transition: background 0.2s;
}
button:hover {
background: #6d28d9;
}
/* Results */
#results-screen h2 {
color: #7c3aed;
margin-bottom: 1rem;
}
#results-summary {
text-align: left;
margin: 1.5rem 0;
padding: 1.5rem;
background: #1a1a2e;
border-radius: 8px;
border: 1px solid #2a2a4e;
}
#results-summary p {
margin: 0.5rem 0;
font-size: 0.95rem;
}
.stat-label {
color: #888;
}
.stat-value {
color: #7c3aed;
font-weight: bold;
}
/* Feedback */
.feedback {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.4rem;
font-weight: bold;
}
.feedback-correct {
color: #10b981;
}
.feedback-incorrect {
color: #ef4444;
}
Your game board UI is complete. The HTML structure gives you three toggleable screens, the CSS provides a polished dark theme, and the shape classes are ready for JavaScript to use. Next up, you will build the state machine that drives the entire game flow.
Implement the Game State Machine
You have a sharp-looking game board with styled shapes, buttons, and screens. The problem is that none of it does anything yet.
In this step, you'll write the JavaScript engine that drives the entire game. The engine uses a state machine pattern to move through five distinct phases: idle, pre-trial, active, feedback, and complete. Each phase controls what the player can do and what happens next.
In this step, get ready to:
- Build the state machine that drives the game through 10 rounds.
- Spawn shapes with randomized positions and collision avoidance.
- Wire up click handlers that track correctness, display feedback, and advance rounds.
Build the state machine with a nextRound() function
A state machine is a pattern where your program can only be in ONE defined state at a time, and transitions between states follow strict rules. For a game, this prevents bugs like clicking shapes during a feedback animation or starting a new round mid-click.
Why a state machine instead of boolean flags?
You could track game phases with booleans like isPlaying and isShowingFeedback. But booleans multiply into impossible combinations (what if both are true?).
A single state variable that holds one of five valid strings eliminates entire categories of bugs. This is the same pattern used in game engines and UI frameworks like React's useReducer.
- Open game.js in VS Code. The file is currently empty.
- Add the game configuration, state variables, DOM references, and event listeners by pasting this code:
// === CONFIGURATION ===
const CONFIG = {
totalRounds: 10,
shapeSizePx: 60,
shapes: ['circle', 'square', 'triangle'],
colors: ['#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6'],
shapesPerRound: 4,
feedbackDurationMs: 800,
preTrialDelayMs: 1000,
};
// === GAME STATE ===
let state = 'idle';
let currentRound = 0;
let targetShape = '';
let stimulusTime = 0;
let firstMoveTime = 0;
let hasMovedThisTrial = false;
let trials = [];
// === DOM REFERENCES ===
const startScreen = document.getElementById('start-screen');
const gameArea = document.getElementById('game-area');
const resultsScreen = document.getElementById('results-screen');
const gameBoard = document.getElementById('game-board');
const targetPrompt = document.getElementById('target-prompt');
const roundCounter = document.getElementById('round-counter');
const resultsSummary = document.getElementById('results-summary');
const startBtn = document.getElementById('start-btn');
const exportBtn = document.getElementById('export-btn');
const restartBtn = document.getElementById('restart-btn');
// === EVENT LISTENERS ===
startBtn.addEventListener('click', startGame);
exportBtn.addEventListener('click', exportData);
restartBtn.addEventListener('click', startGame);
gameBoard.addEventListener('pointermove', onPointerMove);
What does this code do?
- CONFIG holds all tunable game parameters in one place. Changing totalRounds or shapesPerRound here changes the entire game without touching any logic.
- The state variable starts at 'idle' and transitions through five phases as the game progresses.
- DOM references are cached once at load time so you don't call getElementById repeatedly during gameplay.
- The pointermove listener on the game board will capture the first mouse movement after shapes appear. This is the raw Observe signal for your OODA data.
- Below the event listeners, add the two core game flow functions by pasting this code:
// === GAME FLOW ===
function startGame() {
trials = [];
currentRound = 0;
startScreen.classList.add('hidden');
resultsScreen.classList.add('hidden');
gameArea.classList.remove('hidden');
nextRound();
}
function nextRound() {
if (currentRound >= CONFIG.totalRounds) {
endGame();
return;
}
state = 'pre-trial';
currentRound++;
hasMovedThisTrial = false;
firstMoveTime = 0;
gameBoard.innerHTML = '';
roundCounter.textContent = `Round ${currentRound} / ${CONFIG.totalRounds}`;
targetPrompt.textContent = 'Get ready...';
setTimeout(() => {
spawnShapes();
}, CONFIG.preTrialDelayMs);
}
What does this code do?
- startGame() resets all state, hides the start/results screens, reveals the game area, then kicks off the first round.
- nextRound() is the heart of the state machine. It checks if all rounds are done (transition to 'complete'), otherwise sets state to 'pre-trial', shows "Get ready...", then spawns shapes after a 1-second delay.
- The setTimeout pause between rounds gives the player a moment to reset their focus before the next stimulus appears.
- Save game.js.
You won't see a change yet. The game needs the shape spawning and click handling functions before it can run.
Implement spawnShapes() with collision avoidance
Each round, four shapes need to appear at random positions on the board. If they overlap, the game becomes unplayable. You need collision avoidance logic that retries positions until shapes are spaced apart.
- Below the nextRound() function, add the spawnShapes() function by pasting this code:
function spawnShapes() {
targetShape = CONFIG.shapes[Math.floor(Math.random() * CONFIG.shapes.length)];
targetPrompt.textContent = `Click the ${targetShape.toUpperCase()}`;
const positions = generatePositions(CONFIG.shapesPerRound);
const shapeSet = generateShapeSet(targetShape, CONFIG.shapesPerRound);
shapeSet.forEach((shape, index) => {
const element = createShapeElement(shape.type, shape.color, positions[index]);
element.dataset.shapeType = shape.type;
element.addEventListener('pointerdown', onShapeClick);
gameBoard.appendChild(element);
});
stimulusTime = performance.now();
state = 'active';
}
What does this code do?
- A random target shape is chosen (circle, square, or triangle) and displayed as a prompt like "Click the TRIANGLE".
- Each shape element gets a dataset.shapeType attribute so the click handler knows which shape was clicked.
- performance.now() records the exact moment shapes appear. This is the stimulus onset time that all reaction measurements are calculated from.
- The state transitions to 'active' AFTER shapes are rendered, so clicks are only accepted once everything is visible.
- Now add the position generation and collision-avoidance helpers. Paste this code below spawnShapes():
// === SHAPE GENERATION ===
function generatePositions(count) {
const positions = [];
const boardWidth = gameBoard.clientWidth;
const boardHeight = gameBoard.clientHeight;
const padding = CONFIG.shapeSizePx;
for (let i = 0; i < count; i++) {
let attempts = 0;
let pos;
do {
pos = {
x: padding + Math.random() * (boardWidth - padding * 2 - CONFIG.shapeSizePx),
y: padding + Math.random() * (boardHeight - padding * 2 - CONFIG.shapeSizePx),
};
attempts++;
} while (isTooClose(pos, positions, CONFIG.shapeSizePx * 1.8) && attempts < 50);
positions.push(pos);
}
return positions;
}
function isTooClose(pos, existing, minDist) {
return existing.some(p => {
const dx = pos.x - p.x;
const dy = pos.y - p.y;
return Math.sqrt(dx * dx + dy * dy) < minDist;
});
}
How does collision avoidance work?
generatePositions() tries random coordinates for each shape. If a new position is too close to an existing one (within 1.8x the shape size), it retries up to 50 times.
isTooClose() uses the Pythagorean theorem to measure the distance between two points. If the distance is less than minDist, the shapes would overlap.
- Add the shape creation utilities below isTooClose() by pasting this code:
function generateShapeSet(target, count) {
const shapes = [];
const targetColor = CONFIG.colors[Math.floor(Math.random() * CONFIG.colors.length)];
shapes.push({ type: target, color: targetColor });
const distractorTypes = CONFIG.shapes.filter(s => s !== target);
for (let i = 1; i < count; i++) {
const type = distractorTypes[Math.floor(Math.random() * distractorTypes.length)];
const color = CONFIG.colors[Math.floor(Math.random() * CONFIG.colors.length)];
shapes.push({ type, color });
}
// Shuffle array
for (let i = shapes.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shapes[i], shapes[j]] = [shapes[j], shapes[i]];
}
return shapes;
}
function createShapeElement(type, color, position) {
const el = document.createElement('div');
el.classList.add('shape', `shape-${type}`);
if (type === 'triangle') {
el.style.color = color;
el.style.borderBottomColor = color;
} else {
el.style.backgroundColor = color;
el.style.width = `${CONFIG.shapeSizePx}px`;
el.style.height = `${CONFIG.shapeSizePx}px`;
}
el.style.left = `${position.x}px`;
el.style.top = `${position.y}px`;
return el;
}
What does this code do?
- generateShapeSet() creates 1 target shape plus 3 distractors (shapes that are NOT the target). It shuffles the array so the target isn't always first.
- createShapeElement() builds a DOM element for each shape. Triangles use CSS border tricks (no width/height), while circles and squares use backgroundColor and sized dimensions.
- Save game.js.
You won't see a change yet. The game still needs click handlers and feedback before it can complete a round.
Wire up click handlers, feedback, and results
When a player clicks a shape, the game needs to check correctness, record precise timing data, show feedback, and either advance to the next round or end the session.
- Below createShapeElement(), add the timing and click handling functions by pasting this code:
// === TIMING & DATA CAPTURE ===
function onPointerMove() {
if (state === 'active' && !hasMovedThisTrial) {
firstMoveTime = performance.now();
hasMovedThisTrial = true;
}
}
function onShapeClick(event) {
if (state !== 'active') return;
const clickTime = performance.now();
const clickedShape = event.currentTarget.dataset.shapeType;
const correct = clickedShape === targetShape;
const totalMs = clickTime - stimulusTime;
const observeMs = hasMovedThisTrial ? firstMoveTime - stimulusTime : totalMs;
const processMs = hasMovedThisTrial ? clickTime - firstMoveTime : 0;
const trial = {
round: currentRound,
targetShape: targetShape,
clickedShape: clickedShape,
correct: correct,
timestamps: {
stimulus: stimulusTime,
firstMove: hasMovedThisTrial ? firstMoveTime : null,
click: clickTime,
},
ooda: {
observe_ms: Math.round(observeMs * 100) / 100,
process_ms: Math.round(processMs * 100) / 100,
total_ms: Math.round(totalMs * 100) / 100,
},
};
trials.push(trial);
showFeedback(correct, totalMs);
}
What does this code do?
- onPointerMove() captures the FIRST mouse movement after shapes appear. This is the Observe proxy: the moment your brain registered the stimulus and started moving.
- onShapeClick() guards against clicks in non-active states, then records three OODA timing values: observe_ms (stimulus to first move), process_ms (first move to click), and total_ms (stimulus to click).
- Each trial object stores the raw timestamps AND the calculated OODA decomposition. This structured data is what Part 2's ML model will consume.
Why performance.now() instead of Date.now()?
performance.now() returns a floating-point timestamp with sub-millisecond precision (up to 100 microseconds in standard browser contexts). It uses a monotonic clock that is unaffected by system clock adjustments.
Date.now() only has 1ms integer precision and can jump backwards if the system clock is corrected. For reaction time measurement, that coarseness hides meaningful cognitive differences.
- Add the feedback display function below onShapeClick() by pasting this code:
function showFeedback(correct, reactionTime) {
state = 'feedback';
gameBoard.innerHTML = '';
const feedback = document.createElement('div');
feedback.classList.add('feedback');
if (correct) {
feedback.classList.add('feedback-correct');
feedback.textContent = `\u2713 ${Math.round(reactionTime)} ms`;
} else {
feedback.classList.add('feedback-incorrect');
feedback.textContent = `\u2717 Wrong shape!`;
}
gameBoard.appendChild(feedback);
setTimeout(() => {
nextRound();
}, CONFIG.feedbackDurationMs);
}
What does this code do?
- The state immediately transitions to 'feedback', which prevents additional clicks from registering.
- Correct clicks show a green checkmark with the reaction time in milliseconds. Incorrect clicks show a red X with "Wrong shape!".
- After 800ms of feedback, nextRound() is called again, continuing the loop until all 10 rounds complete.
- Add the end-game and data export functions below showFeedback() by pasting this code:
function endGame() {
state = 'complete';
gameArea.classList.add('hidden');
resultsScreen.classList.remove('hidden');
const correctTrials = trials.filter(t => t.correct);
const totalTrials = trials.length;
const avgReaction = correctTrials.length > 0
? Math.round(correctTrials.reduce((sum, t) => sum + t.ooda.total_ms, 0) / correctTrials.length)
: 0;
const bestReaction = correctTrials.length > 0
? Math.round(Math.min(...correctTrials.map(t => t.ooda.total_ms)))
: 0;
const accuracy = Math.round((correctTrials.length / totalTrials) * 100);
const avgObserve = correctTrials.length > 0
? Math.round(correctTrials.reduce((sum, t) => sum + t.ooda.observe_ms, 0) / correctTrials.length)
: 0;
resultsSummary.innerHTML = `
<p><span class="stat-label">Accuracy:</span> <span class="stat-value">${accuracy}%</span> (${correctTrials.length}/${totalTrials})</p>
<p><span class="stat-label">Avg Reaction Time:</span> <span class="stat-value">${avgReaction} ms</span></p>
<p><span class="stat-label">Best Reaction Time:</span> <span class="stat-value">${bestReaction} ms</span></p>
<p><span class="stat-label">Avg Observe (first move):</span> <span class="stat-value">${avgObserve} ms</span></p>
<p style="margin-top: 1rem; color: #666; font-size: 0.85rem;">Export the JSON below to feed into the OODA Engine (Part 2).</p>
`;
}
// === DATA EXPORT ===
function exportData() {
const correctTrials = trials.filter(t => t.correct);
const sessionData = {
sessionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
config: CONFIG,
summary: {
totalTrials: trials.length,
correctTrials: correctTrials.length,
accuracy: correctTrials.length / trials.length,
avgReactionMs: correctTrials.length > 0
? Math.round(correctTrials.reduce((s, t) => s + t.ooda.total_ms, 0) / correctTrials.length)
: 0,
avgObserveMs: correctTrials.length > 0
? Math.round(correctTrials.reduce((s, t) => s + t.ooda.observe_ms, 0) / correctTrials.length)
: 0,
},
trials: trials,
};
const blob = new Blob([JSON.stringify(sessionData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ooda-session-${sessionData.sessionId.slice(0, 8)}.json`;
a.click();
URL.revokeObjectURL(url);
}
What does this code do?
- endGame() calculates four summary stats from all correct trials: accuracy percentage, average reaction time, best reaction time, and average observe time (first movement).
- exportData() packages the entire session (metadata, config, summary, and per-trial timing arrays) into a structured JSON file and triggers a browser download. This is the data contract that Part 2's FastAPI backend will ingest.
- crypto.randomUUID() generates a unique session ID so each game session is individually trackable.
- Save game.js and refresh your browser at http://localhost:8080.
- Click Start Game (10 Rounds). You should see "Get ready..." for one second, then four colored shapes appear on the board with a yellow prompt like "Click the TRIANGLE".
- Click the correct shape. You should see a green checkmark with your reaction time in milliseconds.
- Play through all 10 rounds. After the final round, the results screen should appear showing your accuracy, average reaction time, best reaction time, and average observe time.
Shapes not appearing?
- Open your browser's DevTools console (press F12 or Cmd+Option+I on macOS). Look for red error messages pointing to a specific line number.
- Make sure the function order matches: spawnShapes() must appear AFTER nextRound() and BEFORE the shape generation helpers. JavaScript hoists function declarations, but missing helper functions will throw reference errors.
- Check that your game.js file is saved. Unsaved changes in VS Code show a dot on the file tab.
Help me debug why my shapes aren't appearing on the game board.
✔️ Awesome, I've got everything!
Great. Make sure you've saved game.js and that the game plays through all 10 rounds with feedback after each click.
ⓧ I'd like to double check the full code
Here is the complete game.js file. Compare it against yours and fix any differences.
// === CONFIGURATION ===
const CONFIG = {
totalRounds: 10,
shapeSizePx: 60,
shapes: ['circle', 'square', 'triangle'],
colors: ['#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6'],
shapesPerRound: 4,
feedbackDurationMs: 800,
preTrialDelayMs: 1000,
};
// === GAME STATE ===
let state = 'idle';
let currentRound = 0;
let targetShape = '';
let stimulusTime = 0;
let firstMoveTime = 0;
let hasMovedThisTrial = false;
let trials = [];
// === DOM REFERENCES ===
const startScreen = document.getElementById('start-screen');
const gameArea = document.getElementById('game-area');
const resultsScreen = document.getElementById('results-screen');
const gameBoard = document.getElementById('game-board');
const targetPrompt = document.getElementById('target-prompt');
const roundCounter = document.getElementById('round-counter');
const resultsSummary = document.getElementById('results-summary');
const startBtn = document.getElementById('start-btn');
const exportBtn = document.getElementById('export-btn');
const restartBtn = document.getElementById('restart-btn');
// === EVENT LISTENERS ===
startBtn.addEventListener('click', startGame);
exportBtn.addEventListener('click', exportData);
restartBtn.addEventListener('click', startGame);
gameBoard.addEventListener('pointermove', onPointerMove);
// === GAME FLOW ===
function startGame() {
trials = [];
currentRound = 0;
startScreen.classList.add('hidden');
resultsScreen.classList.add('hidden');
gameArea.classList.remove('hidden');
nextRound();
}
function nextRound() {
if (currentRound >= CONFIG.totalRounds) {
endGame();
return;
}
state = 'pre-trial';
currentRound++;
hasMovedThisTrial = false;
firstMoveTime = 0;
gameBoard.innerHTML = '';
roundCounter.textContent = `Round ${currentRound} / ${CONFIG.totalRounds}`;
targetPrompt.textContent = 'Get ready...';
setTimeout(() => {
spawnShapes();
}, CONFIG.preTrialDelayMs);
}
function spawnShapes() {
targetShape = CONFIG.shapes[Math.floor(Math.random() * CONFIG.shapes.length)];
targetPrompt.textContent = `Click the ${targetShape.toUpperCase()}`;
const positions = generatePositions(CONFIG.shapesPerRound);
const shapeSet = generateShapeSet(targetShape, CONFIG.shapesPerRound);
shapeSet.forEach((shape, index) => {
const element = createShapeElement(shape.type, shape.color, positions[index]);
element.dataset.shapeType = shape.type;
element.addEventListener('pointerdown', onShapeClick);
gameBoard.appendChild(element);
});
stimulusTime = performance.now();
state = 'active';
}
function showFeedback(correct, reactionTime) {
state = 'feedback';
gameBoard.innerHTML = '';
const feedback = document.createElement('div');
feedback.classList.add('feedback');
if (correct) {
feedback.classList.add('feedback-correct');
feedback.textContent = `✓ ${Math.round(reactionTime)} ms`;
} else {
feedback.classList.add('feedback-incorrect');
feedback.textContent = `✗ Wrong shape!`;
}
gameBoard.appendChild(feedback);
setTimeout(() => {
nextRound();
}, CONFIG.feedbackDurationMs);
}
function endGame() {
state = 'complete';
gameArea.classList.add('hidden');
resultsScreen.classList.remove('hidden');
const correctTrials = trials.filter(t => t.correct);
const totalTrials = trials.length;
const avgReaction = correctTrials.length > 0
? Math.round(correctTrials.reduce((sum, t) => sum + t.ooda.total_ms, 0) / correctTrials.length)
: 0;
const bestReaction = correctTrials.length > 0
? Math.round(Math.min(...correctTrials.map(t => t.ooda.total_ms)))
: 0;
const accuracy = Math.round((correctTrials.length / totalTrials) * 100);
const avgObserve = correctTrials.length > 0
? Math.round(correctTrials.reduce((sum, t) => sum + t.ooda.observe_ms, 0) / correctTrials.length)
: 0;
resultsSummary.innerHTML = `
<p><span class="stat-label">Accuracy:</span> <span class="stat-value">${accuracy}%</span> (${correctTrials.length}/${totalTrials})</p>
<p><span class="stat-label">Avg Reaction Time:</span> <span class="stat-value">${avgReaction} ms</span></p>
<p><span class="stat-label">Best Reaction Time:</span> <span class="stat-value">${bestReaction} ms</span></p>
<p><span class="stat-label">Avg Observe (first move):</span> <span class="stat-value">${avgObserve} ms</span></p>
<p style="margin-top: 1rem; color: #666; font-size: 0.85rem;">Export the JSON below to feed into the OODA Engine (Part 2).</p>
`;
}
// === SHAPE GENERATION ===
function generatePositions(count) {
const positions = [];
const boardWidth = gameBoard.clientWidth;
const boardHeight = gameBoard.clientHeight;
const padding = CONFIG.shapeSizePx;
for (let i = 0; i < count; i++) {
let attempts = 0;
let pos;
do {
pos = {
x: padding + Math.random() * (boardWidth - padding * 2 - CONFIG.shapeSizePx),
y: padding + Math.random() * (boardHeight - padding * 2 - CONFIG.shapeSizePx),
};
attempts++;
} while (isTooClose(pos, positions, CONFIG.shapeSizePx * 1.8) && attempts < 50);
positions.push(pos);
}
return positions;
}
function isTooClose(pos, existing, minDist) {
return existing.some(p => {
const dx = pos.x - p.x;
const dy = pos.y - p.y;
return Math.sqrt(dx * dx + dy * dy) < minDist;
});
}
function generateShapeSet(target, count) {
const shapes = [];
const targetColor = CONFIG.colors[Math.floor(Math.random() * CONFIG.colors.length)];
shapes.push({ type: target, color: targetColor });
const distractorTypes = CONFIG.shapes.filter(s => s !== target);
for (let i = 1; i < count; i++) {
const type = distractorTypes[Math.floor(Math.random() * distractorTypes.length)];
const color = CONFIG.colors[Math.floor(Math.random() * CONFIG.colors.length)];
shapes.push({ type, color });
}
// Shuffle array
for (let i = shapes.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shapes[i], shapes[j]] = [shapes[j], shapes[i]];
}
return shapes;
}
function createShapeElement(type, color, position) {
const el = document.createElement('div');
el.classList.add('shape', `shape-${type}`);
if (type === 'triangle') {
el.style.color = color;
el.style.borderBottomColor = color;
} else {
el.style.backgroundColor = color;
el.style.width = `${CONFIG.shapeSizePx}px`;
el.style.height = `${CONFIG.shapeSizePx}px`;
}
el.style.left = `${position.x}px`;
el.style.top = `${position.y}px`;
return el;
}
// === TIMING & DATA CAPTURE ===
function onPointerMove() {
if (state === 'active' && !hasMovedThisTrial) {
firstMoveTime = performance.now();
hasMovedThisTrial = true;
}
}
function onShapeClick(event) {
if (state !== 'active') return;
const clickTime = performance.now();
const clickedShape = event.currentTarget.dataset.shapeType;
const correct = clickedShape === targetShape;
const totalMs = clickTime - stimulusTime;
const observeMs = hasMovedThisTrial ? firstMoveTime - stimulusTime : totalMs;
const processMs = hasMovedThisTrial ? clickTime - firstMoveTime : 0;
const trial = {
round: currentRound,
targetShape: targetShape,
clickedShape: clickedShape,
correct: correct,
timestamps: {
stimulus: stimulusTime,
firstMove: hasMovedThisTrial ? firstMoveTime : null,
click: clickTime,
},
ooda: {
observe_ms: Math.round(observeMs * 100) / 100,
process_ms: Math.round(processMs * 100) / 100,
total_ms: Math.round(totalMs * 100) / 100,
},
};
trials.push(trial);
showFeedback(correct, totalMs);
}
// === DATA EXPORT ===
function exportData() {
const correctTrials = trials.filter(t => t.correct);
const sessionData = {
sessionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
config: CONFIG,
summary: {
totalTrials: trials.length,
correctTrials: correctTrials.length,
accuracy: correctTrials.length / trials.length,
avgReactionMs: correctTrials.length > 0
? Math.round(correctTrials.reduce((s, t) => s + t.ooda.total_ms, 0) / correctTrials.length)
: 0,
avgObserveMs: correctTrials.length > 0
? Math.round(correctTrials.reduce((s, t) => s + t.ooda.observe_ms, 0) / correctTrials.length)
: 0,
},
trials: trials,
};
const blob = new Blob([JSON.stringify(sessionData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ooda-session-${sessionData.sessionId.slice(0, 8)}.json`;
a.click();
URL.revokeObjectURL(url);
}
Your game engine is fully operational. You can play through 10 rounds, see your reaction times, and view session stats. Next up, you'll add precision timing capture and structured JSON export so this data is ready for the ML pipeline in Part 2.
Add Precision Timing and OODA Data Capture
Your game can spawn shapes and detect clicks, but it doesn't capture when things happen at a precise level. The shapes appear, you click, and the game moves on. No timing data is recorded.
In this step, you'll add the data capture layer that turns your game into a cognitive measurement tool. Using performance.now() for sub-millisecond precision, you'll record exactly when the stimulus appears, when your hand first moves, and when you click. Then you'll package all of that into a structured JSON file ready for a machine learning pipeline.
In this step, get ready to:
- Record high-precision timestamps for stimulus onset and first mouse movement.
- Calculate per-trial OODA decomposition (Observe, Process, Total) from the raw timestamps.
- Build a JSON export function that packages all session data into a downloadable file.
Record the first mouse movement as an Observe proxy
The OODA loop starts with Observe. In a reaction game, the closest proxy for "observation complete" is the moment your hand starts moving toward the target.
Why track mouse movement?
The gap between stimulus appearance and your first mouse movement represents how long your brain took to process the visual scene and decide to act. A longer gap means more cognitive load (more shapes to scan, harder to identify the target).
By capturing this "observe" phase separately from the total reaction time, Part 2's ML model can learn patterns in your cognitive processing speed.
- In game.js, scroll down to the // === TIMING & DATA CAPTURE === section comment.
- Add the onPointerMove function below it:
// === TIMING & DATA CAPTURE ===
function onPointerMove() {
// Only record the FIRST movement during an active trial
if (state === 'active' && !hasMovedThisTrial) {
firstMoveTime = performance.now();
hasMovedThisTrial = true;
}
}
What does this code do?
- The guard state === 'active' && !hasMovedThisTrial ensures we only capture the very first movement after shapes appear. Any subsequent mouse movements are ignored.
- performance.now() returns a high-resolution timestamp in milliseconds with up to 100 microsecond precision. Far more accurate than Date.now() which only gives whole milliseconds.
- The hasMovedThisTrial flag is reset to false every round in nextRound(), so each trial gets a fresh measurement.
- You won't see a visible change yet. The next function uses this timestamp to calculate the OODA breakdown.
Calculate per-trial OODA decomposition on each click
When the player clicks a shape, the game needs to calculate three timing values: how long it took to start moving (Observe), how long from first movement to click (Process), and the total reaction time.
- Directly below your onPointerMove function, add the onShapeClick function:
function onShapeClick(event) {
if (state !== 'active') return;
// Capture the exact moment of click
const clickTime = performance.now();
const clickedShape = event.currentTarget.dataset.shapeType;
const correct = clickedShape === targetShape;
// Calculate OODA timing breakdown
const totalMs = clickTime - stimulusTime;
const observeMs = hasMovedThisTrial ? firstMoveTime - stimulusTime : totalMs;
const processMs = hasMovedThisTrial ? clickTime - firstMoveTime : 0;
// Build the trial data object
const trial = {
round: currentRound,
targetShape: targetShape,
clickedShape: clickedShape,
correct: correct,
timestamps: {
stimulus: stimulusTime,
firstMove: hasMovedThisTrial ? firstMoveTime : null,
click: clickTime,
},
ooda: {
observe_ms: Math.round(observeMs * 100) / 100,
process_ms: Math.round(processMs * 100) / 100,
total_ms: Math.round(totalMs * 100) / 100,
},
};
trials.push(trial);
showFeedback(correct, totalMs);
}
What does this code do?
- The if (state !== 'active') return guard prevents clicks during feedback or pre-trial phases from being recorded.
- observe_ms measures stimulus-to-first-movement. If the player never moved their mouse (clicked instantly), it defaults to the total time.
- process_ms measures first-movement-to-click. This captures the Orient + Decide + Act phases combined.
- Math.round(value * 100) / 100 rounds each timing value to 2 decimal places for clean data.
- The trial object stores both raw timestamps and computed OODA values. Raw timestamps allow recomputation later if needed.
- Save game.js and refresh your browser.
- Click **Start Game (10 Rounds)** and play through all 10 rounds. You should see your reaction time in milliseconds flash on screen after each correct click.
Not seeing reaction times after clicking?
- Open the browser console (F12) and look for errors. A common issue is a typo in the function name. The event listener in Step 3 uses onShapeClick (camelCase, no underscore).
- Make sure onShapeClick is placed after the // === TIMING & DATA CAPTURE === comment. JavaScript hoists function declarations, but misplaced code can still cause confusion.
Why aren't my shape clicks registering reaction times?
Build the JSON export function
After 10 rounds, the player lands on the results screen. The Export OODA Data (JSON) button needs to package all trial data into a structured file download. This JSON format is the API contract that Part 2's ML backend will consume.
- Below your onShapeClick function, find the // === DATA EXPORT === comment and add the exportData function:
// === DATA EXPORT ===
function exportData() {
const correctTrials = trials.filter(t => t.correct);
// Build the complete session payload
const sessionData = {
sessionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
config: CONFIG,
summary: {
totalTrials: trials.length,
correctTrials: correctTrials.length,
accuracy: correctTrials.length / trials.length,
avgReactionMs: correctTrials.length > 0
? Math.round(correctTrials.reduce((s, t) => s + t.ooda.total_ms, 0) / correctTrials.length)
: 0,
avgObserveMs: correctTrials.length > 0
? Math.round(correctTrials.reduce((s, t) => s + t.ooda.observe_ms, 0) / correctTrials.length)
: 0,
},
trials: trials,
};
// Trigger a file download
const blob = new Blob([JSON.stringify(sessionData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ooda-session-${sessionData.sessionId.slice(0, 8)}.json`;
a.click();
URL.revokeObjectURL(url);
}
What does this code do?
- crypto.randomUUID() generates a unique session ID so each exported file can be identified.
- The summary object gives quick aggregate stats (accuracy, average reaction, average observe time) without needing to recompute from raw trials.
- The full trials array is included with every raw timestamp and OODA breakdown, giving the ML model complete per-trial data.
- URL.createObjectURL() creates a temporary download link from the JSON blob. After the click triggers the download, revokeObjectURL() frees the memory.
- Save game.js and refresh your browser.
- Play through all 10 rounds.
- On the results screen, click Export OODA Data (JSON). A JSON file should download to your computer with a name like ooda-session-a3f2b1c9.json.
- Open the downloaded file in VS Code to verify it contains your session data, config, summary stats, and per-trial OODA arrays.
What should the exported JSON look like?
Your JSON file contains a sessionId, an ISO timestamp, the full game config, a summary with accuracy and averages, and a trials array. Each trial entry has the round number, which shape was targeted vs clicked, whether it was correct, raw timestamps, and the OODA decomposition in milliseconds.
Export button not downloading a file?
- Make sure you played through all 10 rounds first. The trials array is empty until you complete a session.
- Check the console for errors. A common issue is a typo in crypto.randomUUID(). This API requires a secure context (localhost counts).
- If your browser blocks the download, check for a popup blocker notification in the address bar.
Why is my JSON export not working?
Why performance.now() instead of Date.now()?
performance.now() uses a monotonic clock that is not affected by system clock adjustments. It returns floating-point milliseconds with up to 100 microsecond resolution in standard browser contexts.
Date.now() returns whole milliseconds and can jump forward or backward if the system clock is synced. For reaction time measurement, that level of noise would corrupt your OODA data.
✔️ Awesome, I've got everything!
Great. Double check you've saved game.js and that your exported JSON has timing data in it.
ⓧ I'd like to double check the full code
Here is the complete game.js file. Compare yours against it:
// === CONFIGURATION ===
const CONFIG = {
totalRounds: 10,
shapeSizePx: 60,
shapes: ['circle', 'square', 'triangle'],
colors: ['#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6'],
shapesPerRound: 4,
feedbackDurationMs: 800,
preTrialDelayMs: 1000,
};
// === GAME STATE ===
let state = 'idle';
let currentRound = 0;
let targetShape = '';
let stimulusTime = 0;
let firstMoveTime = 0;
let hasMovedThisTrial = false;
let trials = [];
// === DOM REFERENCES ===
const startScreen = document.getElementById('start-screen');
const gameArea = document.getElementById('game-area');
const resultsScreen = document.getElementById('results-screen');
const gameBoard = document.getElementById('game-board');
const targetPrompt = document.getElementById('target-prompt');
const roundCounter = document.getElementById('round-counter');
const resultsSummary = document.getElementById('results-summary');
const startBtn = document.getElementById('start-btn');
const exportBtn = document.getElementById('export-btn');
const restartBtn = document.getElementById('restart-btn');
// === EVENT LISTENERS ===
startBtn.addEventListener('click', startGame);
exportBtn.addEventListener('click', exportData);
restartBtn.addEventListener('click', startGame);
gameBoard.addEventListener('pointermove', onPointerMove);
// === GAME FLOW ===
function startGame() {
trials = [];
currentRound = 0;
startScreen.classList.add('hidden');
resultsScreen.classList.add('hidden');
gameArea.classList.remove('hidden');
nextRound();
}
function nextRound() {
if (currentRound >= CONFIG.totalRounds) {
endGame();
return;
}
state = 'pre-trial';
currentRound++;
hasMovedThisTrial = false;
firstMoveTime = 0;
gameBoard.innerHTML = '';
roundCounter.textContent = `Round ${currentRound} / ${CONFIG.totalRounds}`;
targetPrompt.textContent = 'Get ready...';
setTimeout(() => {
spawnShapes();
}, CONFIG.preTrialDelayMs);
}
function spawnShapes() {
targetShape = CONFIG.shapes[Math.floor(Math.random() * CONFIG.shapes.length)];
targetPrompt.textContent = `Click the ${targetShape.toUpperCase()}`;
const positions = generatePositions(CONFIG.shapesPerRound);
const shapeSet = generateShapeSet(targetShape, CONFIG.shapesPerRound);
shapeSet.forEach((shape, index) => {
const element = createShapeElement(shape.type, shape.color, positions[index]);
element.dataset.shapeType = shape.type;
element.addEventListener('pointerdown', onShapeClick);
gameBoard.appendChild(element);
});
stimulusTime = performance.now();
state = 'active';
}
function showFeedback(correct, reactionTime) {
state = 'feedback';
gameBoard.innerHTML = '';
const feedback = document.createElement('div');
feedback.classList.add('feedback');
if (correct) {
feedback.classList.add('feedback-correct');
feedback.textContent = `\u2713 ${Math.round(reactionTime)} ms`;
} else {
feedback.classList.add('feedback-incorrect');
feedback.textContent = `\u2717 Wrong shape!`;
}
gameBoard.appendChild(feedback);
setTimeout(() => {
nextRound();
}, CONFIG.feedbackDurationMs);
}
function endGame() {
state = 'complete';
gameArea.classList.add('hidden');
resultsScreen.classList.remove('hidden');
const correctTrials = trials.filter(t => t.correct);
const totalTrials = trials.length;
const avgReaction = correctTrials.length > 0
? Math.round(correctTrials.reduce((sum, t) => sum + t.ooda.total_ms, 0) / correctTrials.length)
: 0;
const bestReaction = correctTrials.length > 0
? Math.round(Math.min(...correctTrials.map(t => t.ooda.total_ms)))
: 0;
const accuracy = Math.round((correctTrials.length / totalTrials) * 100);
const avgObserve = correctTrials.length > 0
? Math.round(correctTrials.reduce((sum, t) => sum + t.ooda.observe_ms, 0) / correctTrials.length)
: 0;
resultsSummary.innerHTML = `
<p><span class="stat-label">Accuracy:</span> <span class="stat-value">${accuracy}%</span> (${correctTrials.length}/${totalTrials})</p>
<p><span class="stat-label">Avg Reaction Time:</span> <span class="stat-value">${avgReaction} ms</span></p>
<p><span class="stat-label">Best Reaction Time:</span> <span class="stat-value">${bestReaction} ms</span></p>
<p><span class="stat-label">Avg Observe (first move):</span> <span class="stat-value">${avgObserve} ms</span></p>
<p style="margin-top: 1rem; color: #666; font-size: 0.85rem;">Export the JSON below to feed into the OODA Engine (Part 2).</p>
`;
}
// === SHAPE GENERATION ===
function generatePositions(count) {
const positions = [];
const boardWidth = gameBoard.clientWidth;
const boardHeight = gameBoard.clientHeight;
const padding = CONFIG.shapeSizePx;
for (let i = 0; i < count; i++) {
let attempts = 0;
let pos;
do {
pos = {
x: padding + Math.random() * (boardWidth - padding * 2 - CONFIG.shapeSizePx),
y: padding + Math.random() * (boardHeight - padding * 2 - CONFIG.shapeSizePx),
};
attempts++;
} while (isTooClose(pos, positions, CONFIG.shapeSizePx * 1.8) && attempts < 50);
positions.push(pos);
}
return positions;
}
function isTooClose(pos, existing, minDist) {
return existing.some(p => {
const dx = pos.x - p.x;
const dy = pos.y - p.y;
return Math.sqrt(dx * dx + dy * dy) < minDist;
});
}
function generateShapeSet(target, count) {
const shapes = [];
const targetColor = CONFIG.colors[Math.floor(Math.random() * CONFIG.colors.length)];
shapes.push({ type: target, color: targetColor });
const distractorTypes = CONFIG.shapes.filter(s => s !== target);
for (let i = 1; i < count; i++) {
const type = distractorTypes[Math.floor(Math.random() * distractorTypes.length)];
const color = CONFIG.colors[Math.floor(Math.random() * CONFIG.colors.length)];
shapes.push({ type, color });
}
// Shuffle array
for (let i = shapes.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shapes[i], shapes[j]] = [shapes[j], shapes[i]];
}
return shapes;
}
function createShapeElement(type, color, position) {
const el = document.createElement('div');
el.classList.add('shape', `shape-${type}`);
if (type === 'triangle') {
el.style.color = color;
el.style.borderBottomColor = color;
} else {
el.style.backgroundColor = color;
el.style.width = `${CONFIG.shapeSizePx}px`;
el.style.height = `${CONFIG.shapeSizePx}px`;
}
el.style.left = `${position.x}px`;
el.style.top = `${position.y}px`;
return el;
}
// === TIMING & DATA CAPTURE ===
function onPointerMove() {
if (state === 'active' && !hasMovedThisTrial) {
firstMoveTime = performance.now();
hasMovedThisTrial = true;
}
}
function onShapeClick(event) {
if (state !== 'active') return;
const clickTime = performance.now();
const clickedShape = event.currentTarget.dataset.shapeType;
const correct = clickedShape === targetShape;
const totalMs = clickTime - stimulusTime;
const observeMs = hasMovedThisTrial ? firstMoveTime - stimulusTime : totalMs;
const processMs = hasMovedThisTrial ? clickTime - firstMoveTime : 0;
const trial = {
round: currentRound,
targetShape: targetShape,
clickedShape: clickedShape,
correct: correct,
timestamps: {
stimulus: stimulusTime,
firstMove: hasMovedThisTrial ? firstMoveTime : null,
click: clickTime,
},
ooda: {
observe_ms: Math.round(observeMs * 100) / 100,
process_ms: Math.round(processMs * 100) / 100,
total_ms: Math.round(totalMs * 100) / 100,
},
};
trials.push(trial);
showFeedback(correct, totalMs);
}
// === DATA EXPORT ===
function exportData() {
const correctTrials = trials.filter(t => t.correct);
const sessionData = {
sessionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
config: CONFIG,
summary: {
totalTrials: trials.length,
correctTrials: correctTrials.length,
accuracy: correctTrials.length / trials.length,
avgReactionMs: correctTrials.length > 0
? Math.round(correctTrials.reduce((s, t) => s + t.ooda.total_ms, 0) / correctTrials.length)
: 0,
avgObserveMs: correctTrials.length > 0
? Math.round(correctTrials.reduce((s, t) => s + t.ooda.observe_ms, 0) / correctTrials.length)
: 0,
},
trials: trials,
};
const blob = new Blob([JSON.stringify(sessionData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ooda-session-${sessionData.sessionId.slice(0, 8)}.json`;
a.click();
URL.revokeObjectURL(url);
}
Your reaction game is now a complete cognitive data collection tool. Every click generates structured OODA timing data, and the export gives you a clean JSON file ready for machine learning. In Part 2, you'll feed this data into a FastAPI backend with River for real-time online learning.
Secret mission
Countdown Timer Mode
Your game currently gives players unlimited time to find the target shape. In a real cognitive assessment, time pressure is what separates fast decision-makers from slow ones. Add a countdown timer that forces players to click within 2 seconds or lose the round.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally in your browser, so there are no ongoing costs. Decide whether to keep your files for reference, stop the local server to free up your terminal, or delete everything entirely.
Resources you used:
- The running Python HTTP server (python3 -m http.server 8080 process in your terminal).
- Your project folder on the Desktop containing index.html, style.css, and game.js.
- Any exported JSON session files in your Downloads folder.
Keep everything running
No action needed. Choose this if you're still actively playing the game, tweaking the code, or planning to build Part 2 soon.
- Your local server stays running in the terminal.
- Your project files remain on the Desktop, ready to open in VS Code at any time.
- Your exported JSON files stay in your Downloads folder for Part 2.
Pause - I'll come back to this later
Stop the local server to free up your terminal. Your project files stay on disk so you can restart in seconds.
- Switch to the terminal where python3 -m http.server 8080 is running.
- Press Ctrl+C to stop the server.
- When you want to resume, open a terminal in your project folder and run python3 -m http.server 8080 again.
Delete - I don't want to use this again
Remove all project files and stop the server. This frees up disk space and cleans your Desktop.
- Switch to the terminal where python3 -m http.server 8080 is running.
- Press Ctrl+C to stop the server.
- Delete the project folder by running:
cd ~/Desktop
rm -rf ooda-reaction-game
- Delete any exported JSON session files from your Downloads folder. They have filenames starting with ooda-session-.
Nice Work!
Nice Work!
You just built a cognitive data collection tool disguised as a game. Your OODA Reaction Game captures sub-millisecond timing data across every trial, ready to feed into a machine learning backend.
You've learned how to:
- Build a complete browser game with zero external dependencies using pure HTML, CSS, and JavaScript.
- Implement a state machine pattern (idle, pre-trial, active, feedback, complete) to manage complex game flow cleanly.
- Capture OODA-decomposed timing data (Observe, Process, Total) per trial using performance.now() and export it as structured JSON ready for a Python ML backend.
- Secret Mission: Added a timed mode with a 2-second countdown that generates timeout events for richer ML training data.
Ready to quiz yourself?