These are the key technical choices that worked so well, I reused them over and over. Getting them right once made everything easier afterward.
Bilateral Coordination Test · Sessions 7–9 → Session 17
The camera was flipped, and I tried to flip it again in code. This double-flip was a mistake — the dots appeared in the wrong place on screen.
// WRONG — double mirror
ctx.save();
ctx.scale(-1, 1);
ctx.translate(-canvas.width, 0);
// draw dots here
ctx.restore();
Result: my hand appeared on the wrong side of the screen.
I tried different fixes, but they all failed. The problem: the browser camera was already flipped by default, but I didn't understand that yet.
// STILL WRONG — still fighting
// the symptom not the cause
video.style.transform = 'scaleX(-1)';
ctx.scale(-1, 1);
// Now triple-mirrored
Result: each fix made it worse. The problem was unfixed after 3 attempts.
I realized the browser flips the camera by default. So I flipped the video in CSS, then adjusted the math to match. Simple once I understood the root cause.
/* CSS — mirror video only */
video { transform: scaleX(-1); }
// JS — adjust coordinate math
// Don't mirror the canvas at all
const x = (1 - landmarks[8].x) * width;
const y = landmarks[8].y * height;
// landmarks[8] = index fingertip
// .x is 0-1 from camera's perspective
// (1-x) flips it to match mirrored video
Result: worked perfectly on all devices. I reused this exact pattern later with no issues.
All Tests · Session 8 → Sessions 12–17
Each test had its own way of tracking what's happening (welcome screen, practice, test, results). No two tests worked the same way. Every new test meant starting from scratch.
// Session 2: no phase variable at all
// Just boolean flags
let gameStarted = false;
let testRunning = false;
let showingResults = false;
// Unclear what combination of
// these flags means what state
Result: every test worked differently. Confusing to debug and maintain.
I created a standard way for all tests to track their state: welcome → practice → test → results. All tests now use the same structure and naming.
// GAME_STANDARD — single source of truth
let phase = 'welcome';
// Valid values: welcome | practice
// test | results
function setPhase(p) {
phase = p;
document.body.dataset.phase = p;
// CSS hides/shows sections via
// [data-phase="welcome"] .welcome-screen
}
// Ready screen = UI overlay only
// phase stays 'practice' during countdown
Result: every new test now uses the same structure. Much easier to build and debug.
Added more rules to the standard: how buttons work on the results screen, what happens when someone closes the tab. Updated all tests to follow the new rules.
/* GAME_STANDARD Part 8 — Results Buttons */
/* Primary: "Play Again" — resets to welcome */
/* Secondary: "View History" — goes to history.html */
/* Exit modal: shown on back navigation */
.results-actions {
display: flex;
gap: 0.875rem;
justify-content: center;
margin-top: 2rem;
}
/* Exit confirmation prevents data loss */
window.addEventListener('beforeunload', (e) => {
if (phase === 'test') {
e.preventDefault();
return e.returnValue = '';
}
});
Result: all tests now handle buttons and exits the same way. Consistent user experience.
Bilateral Coordination Test · Sessions 8–9 → Session 16
Just count the dots. It doesn't matter which hand collects them. Simple to build, but doesn't actually measure what a bilateral test should measure.
// v1 — total score only
let totalScore = 0;
function collectDot(dot) {
totalScore++;
// No hand attribution
// No way to know asymmetry
}
Result: someone using only their right hand would have the same score as someone using both hands equally. That's not useful.
Track which hand collects each dot. The main number is now the symmetry ratio: how balanced are the hands? A 96% ratio means both hands worked equally.
// v2 — per-hand scoring
let leftScore = 0;
let rightScore = 0;
function collectDot(dot, hand) {
if (hand === 'Left') leftScore++;
if (hand === 'Right') rightScore++;
}
// Symmetry: how balanced are the hands?
// min/max = 1.0 at perfect symmetry
function getSymmetry() {
if (!leftScore || !rightScore) return 0;
return Math.min(leftScore, rightScore) /
Math.max(leftScore, rightScore);
}
Result: now I can see if someone's using both hands equally or favoring one side.
Users wanted to see their progress over time. Added graphs showing left vs. right hand performance. Blue for left hand, red for right hand.
// v3 — Chart.js visualization
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: dates,
datasets: [
{
label: 'Left Hand',
data: leftScores,
borderColor: '#3b82f6', // blue
backgroundColor: 'rgba(59,130,246,0.08)'
},
{
label: 'Right Hand',
data: rightScores,
borderColor: '#dc2626', // red
backgroundColor: 'rgba(220,38,38,0.08)'
}
]
}
});
// Toggle: symmetry % view
function showSymmetryView() {
const symmetry = leftScores.map((l, i) =>
Math.round(Math.min(l, rightScores[i]) /
Math.max(l, rightScores[i]) * 100)
);
chart.data.datasets[0].data = symmetry;
chart.update();
}
Result: users can track how their hand coordination improves over time. That's what Baseline is all about.
Supabase Backend · Session 10–11 → Session 19
The database had security enabled but no rules written. Data couldn't be saved or loaded. It looked like it was working, but nothing was actually stored.
-- v1: RLS enabled but no policies
-- Result: all queries silently fail
-- Table appears empty to all users
-- No INSERT policy → results not saved
-- No SELECT policy → history shows nothing
-- Bug was invisible because no error thrown
Result: the history page always showed no data, even though I thought I saved results.
I added security rules, but I used the wrong data type. The rules looked correct but actually didn't work.
-- v2: policies written but type mismatch
-- user_id column is TEXT
-- auth.uid() returns UUID
-- No implicit cast → policy always fails
CREATE POLICY "user_sees_own_data"
ON test_results FOR SELECT
USING (auth.uid() = user_id);
-- ↑ UUID vs TEXT — false every time
-- No error thrown — just empty results
-- Same broken pattern on all 3 tables:
-- test_results, users, sessions
Result: the data looked protected, but it wasn't. The professor caught this in a security review.
Fixed the data type mismatch. Now the rules actually work and each user only sees their own data.
-- v3: explicit cast fixes the type mismatch
-- auth.uid() → UUID
-- user_id → TEXT needs ::uuid cast
CREATE POLICY "user_sees_own_results"
ON test_results FOR SELECT
USING (auth.uid() = user_id::uuid);
-- ↑ explicit cast
CREATE POLICY "user_inserts_own_results"
ON test_results FOR INSERT
WITH CHECK (auth.uid() = user_id::uuid);
-- Applied to all 3 tables:
-- test_results, users, sessions
-- Verified: each user now sees ONLY
-- their own rows. Isolation confirmed.
Result: each user now sees only their own data. Secure.
Supabase Integration · Session 11 → Sessions 12–19
I could have just copied working code into my project without understanding it.
// WRONG APPROACH — cargo cult integration
// Paste code without understanding
// Learn through trial and error
Result: it would work, but I wouldn't understand why. Future bugs would be impossible to fix.
Instead, I paused and asked myself: how does a backend work? I spent time learning the basic idea: frontend sends data → backend receives it → database stores it. Only then did I build it. Took 30 extra minutes but was worth it.
// CORRECT APPROACH — understand first
// Frontend sends test results to API
// API authenticates user + passes to database
// Database stores result with user_id
// RLS policy ensures users only see their data
//
// Three-tier model becomes your mental model
// Future bugs become explicable
Result: I understood how the whole system works. Types matter. Security policies matter. This understanding stuck with me.
Months later in Session 19, the professor found a security bug. Because I understood the backend from Session 11, I could diagnose it instantly: the data types didn't match. Once I fixed it, everything worked. Without that Session 11 understanding, the bug would have been invisible.
-- Fixed RLS policy (Session 19)
CREATE POLICY "user_sees_own_data"
ON test_results FOR SELECT
USING (auth.uid() = user_id::uuid);
-- ↑ UUID cast
-- This fix was possible because I understood
-- the architecture from Session 11
Result: Fixed in minutes. That's what understanding does for you.
Rhythm Synchronization Test · Session 15 → Sessions 16–17
I tried to detect taps by looking at the frequencies in the audio. This is how you identify a note or sound type.
// WRONG for transient detection
analyser.getByteFrequencyData(dataArray);
let hasClap = dataArray.slice(80, 120) // percussion freq band
.reduce((max, val) => val > max ? val : max) > 50;
// Problem: short taps don't build frequency content
Result: it didn't work. The tap was too fast and quiet for frequency analysis to pick up.
I realized a tap is just a loud moment in the audio. I switched to looking at the raw sound wave and finding moments that are really loud.
// CORRECT for transient detection
analyser.getByteTimeDomainData(dataArray);
let maxVal = 0;
for (let i = 0; i < dataArray.length; i++) {
const val = Math.abs(dataArray[i] - 128) / 128.0;
if (val > maxVal) maxVal = val;
}
// Threshold: 0.04 (normalized amplitude)
// Lockout: 200ms to prevent double-counts
if (maxVal > 0.04 && now - lastTapTime > 200) {
recordTap(now);
lastTapTime = now;
}
Result: worked instantly. Users could tap and see the system respond.
I used the same approach for every test that needed to detect sound. It works reliably even with background noise or different microphones.
// Same peak detection pattern used for:
// - Rhythm sync tap detection
// - Reaction time audio cue confirmation
// - Future attention/auditory tests
//
// Principle: transient events (taps, clicks, alerts)
// use time-domain analysis; sustained tones use
// frequency-domain analysis
Result: every test that needs sound detection uses the same reliable method.
Rhythm Synchronization Test · Session 15 → Sessions 17–19
I wrote functions scattered throughout the code. The buttons couldn't find them. The Start button was broken.
// SCATTERED approach — functions lost
Result: buttons didn't work. Everything felt fragile.
I put everything into one object called `app`. All the state lives there. All the functions live there. Buttons can find them instantly.
// CENTRALIZED approach — single source of truth
const app = {
// State
currentState: 'welcome',
stageIndex: 0,
tapsDetected: 0,
// Methods
startPractice() { ... },
recordTap(tapTime) { ... },
setState(newState) { ... }
};
Result: all buttons work. Everything is clear and organized.
All three tests now use the same pattern. Every test has its own `app` object that works the same way. Makes them easy to understand and fix.
// Consistent pattern across all tests:
// const app = {
// phase: 'welcome',
// startGame: function() { ... },
// saveResults: function() { ... }
// }
//
// Benefits:
// - Debugging is straightforward (print app to see all state)
// - Events are predictable (everything goes through app methods)
// - No scope conflicts (all state in one place)
// - Scales to complex games (inventory, score tracking, multiplayer)
Result: the platform is predictable and easy to maintain. Adding features and fixing bugs is straightforward.