From e9eb33b3bb329cdb9a30beccd01157d39de4e588 Mon Sep 17 00:00:00 2001 From: Oscar Armstrong Date: Sat, 17 Oct 2026 04:47:10 +0000 Subject: [PATCH] Fix Line memory coverage scoring with a 125 percent ink allowance --- docs/MAINTENANCE.md | 19 +++++++++++++++ public/about.html | 2 +- public/focus.html | 32 ++++++++++++++++++-------- public/index.html | 32 ++++++++++++++++++-------- public/night-lab.html | 32 ++++++++++++++++++-------- public/studio.html | 32 ++++++++++++++++++-------- src/design-options/about.html | 2 +- src/design-options/generated-games.js | 2 +- src/design-options/how-to-play.html | 4 ++-- src/design-options/player-storage.js | 1 + src/design-options/round-feedback.js | 2 +- src/design-options/scoring.js | 23 +++++++++++++++---- tests/browser-overlap-scoring.cjs | 12 +++++++--- tests/verify-trace-coverage.cjs | 33 +++++++++++++++++++++++++++ 14 files changed, 178 insertions(+), 50 deletions(-) create mode 100644 tests/verify-trace-coverage.cjs diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md index 4369e33..3dbb52b 100644 --- a/docs/MAINTENANCE.md +++ b/docs/MAINTENANCE.md @@ -117,3 +117,22 @@ bounds, curves, and thin-line rendering. score. The target's dashed center distinguishes it from the player's stroke. - Color memory keeps its wheel-distance scoring. Internal `colour`/`memory` identifiers and historical question text stay intact; UI uses American spelling. + +## Line memory coverage revision (October 17, 2026) + +New practice and daily dates from October 17 freeze `traceScoringVersion: 3` +alongside `scoringVersion: 2`. With I = intersecting stroke area, T = target +area, and G = unique drawn area, score is +`round(100 * clamp(I/T - max(0, G/T - 1.25), 0, 1))`. +There is no extra-ink penalty through 125% of target area. Each percentage point +of target area beyond that costs one point, with no power curve. A 4-unit offset +of equal-width strokes now loses uncovered-target points without an additional +outside-target penalty. Identical masks, raster resolution, and stroke width +preserve viewport/DPR independence. Feedback shows target coverage and any +actual penalty, capped to the available base points. + +Do not infer this version from the current date during scoring. Unversioned +historical motion questions retain path distance; v2 without the trace marker +retains intersection-over-union, including all October 16 daily and saved rounds. +Other question objects and game rules are unchanged. Unknown trace versions and +trace markers on other games are rejected on restore. diff --git a/public/about.html b/public/about.html index 7e65b9d..831f5d5 100644 --- a/public/about.html +++ b/public/about.html @@ -50,7 +50,7 @@

How scoring works

All new practice rounds and daily challenges from October 16, 2026 use linear scoring. Each game measures error against its own zero-point bound, then awards 100 times the remaining closeness. Using 10% of that allowance earns 90 points; 25% earns 75; half earns 50. An exact match earns 100, and reaching the bound earns zero.

For the new linear scoring version, 3D view reaches zero points at 90° of rotation error; Angles reaches zero at 180°. A 45° miss earns 50 points in 3D view, while a 90° miss earns 50 in Angles.

Size it up scores width and height independently, each worth up to 50 points, then rounds the combined score. A 5% miss in both dimensions earns 90/100. A 50% error in one dimension earns zero for that dimension. Earlier daily rounds and saved questions retain their original scoring.

-

Perspective measures your miss against the inner box: 40% of its diagonal earns zero. How long? reaches zero at 50% shorter or longer than the target; a 25% miss earns 50 points. Line memory uses equal-width strokes: your score is the percentage of their combined area that overlaps. Extra marks count against you. These rules apply to the new scoring version.

+

Perspective measures your miss against the inner box: 40% of its diagonal earns zero. How long? reaches zero at 50% shorter or longer than the target; a 25% miss earns 50 points. From October 17 (and in new practice rounds), Line memory starts with the percentage of the target stroke covered. Painted area up to 1.25 times the target’s area has no penalty; each extra 1% of target area beyond that deducts one point. October 16 and saved overlap-scoring rounds keep their original shared-area ÷ combined-area rule. These rules apply to the new scoring version.

The game’s “How scoring works” instructions list the bounds for each puzzle. Your results show small comparisons so you can see where you were close.

A little more practice?

Open the Practice menu to pick an individual game or a fresh mixed five. Daily Five is the only daily challenge; individual games are practice-only. Trivia practice uses questions from previous Daily Fives, keeping today’s, upcoming, and unused trivia out of the practice pool. As the archive grows, sessions offer up to five different past questions.

diff --git a/public/focus.html b/public/focus.html index e7aa424..8d90779 100644 --- a/public/focus.html +++ b/public/focus.html @@ -55,7 +55,7 @@

How points work

For example: in Angles, the allowance is 180°. A 45° miss uses a quarter of it, so you earn 75 points.

A smaller error number doesn’t always mean more points. Degrees, years, wheel distance, and split percentages measure different things. Compare your miss with that game’s allowance. For example, 5.5° off in 3D view earns 94 points; a 3.6 percentage-point split error in Half & Half earns 82.

-

Scores are rounded to whole points. Size it up averages width and height accuracy; Line memory awards the percentage of stroke area that overlaps. Their rules are below.

+

Scores are rounded to whole points. Size it up averages width and height accuracy; Line memory starts with target coverage and deducts points only for excess painted area. Their rules are below.

These rules apply to new practice rounds and Daily Fives dated October 16, 2026 onward. Earlier challenges and saved rounds keep their original scoring.

@@ -63,7 +63,7 @@

The games & their allowances

Open a game for the controls, scoring rule, and an example.

3D view 90° rotation

Study the view for three seconds. Drag the shape to turn it, then use the outer handle to tilt it back to the view you remember.

We measure the smallest 3D rotation from your view to the target. 90° off earns zero; 45° off earns 50. Errors across the three rotation axes combine into one rotation distance.

Half & Half 20 percentage points

Drag the cut or its handles to split the total filled area equally. All shapes count together; empty space doesn’t count.

50/50 earns 100; 60/40 earns 50; 70/30 or worse earns zero. A 53.6/46.4 split is 3.6 percentage points from even and earns 82 points.

-
Line memory Stroke overlap

Study the line for three seconds, then trace it in one stroke. Your line and the target have the same thickness. Clear to try drawing again before you lock in.

Your score is the area shared by both strokes divided by the total area covered by either stroke. 75% overlap earns 75 points; no overlap earns zero. Missing sections and extra scribbles lower your score. Speed and direction don’t matter. On reveal, a dashed center marks the target.

+
Line memory Target coverage · 25% extra ink allowed

Study the line for three seconds, then trace it in one stroke. Your line and the target have the same thickness. Clear to try drawing again before you lock in.

Your base score is the percentage of the target stroke you cover. Covering 75% earns 75 points. Small misplaced marks don’t cost extra points unless your total painted area exceeds 1.25× the target’s area.

Beyond that allowance, each extra 1% of target area deducts one point. For example, 80% coverage with 1.35× the target’s area earns 80 − 10 = 70 points. Scores stop at zero. We count unique painted area, so retracing the same spot adds no ink. Speed and direction don’t matter. On reveal, a dashed center marks the target.

This rule applies to new practice rounds and Daily Fives from October 17, 2026. October 16 and previously saved overlap-scoring rounds keep their original shared-area ÷ combined-area rule.

Perspective 40% of the inner diagonal

Tap where the receding lines would meet: the vanishing point.

Your miss is the straight-line distance from your point to the answer, measured against the inner box’s corner-to-corner diagonal. A miss of 40% of that diagonal earns zero; 20% earns 50.

Size it up 50% error per dimension

Start with one square. Drag the corner to make the requested number of squares wide and tall. The shaded starting square stays fixed as your reference. The opposite corner stays anchored.

Width and height each contribute up to 50 points. A dimension that’s 50% too big or small earns zero for that dimension. 5% off in both dimensions earns 90 points. One exact dimension and one 50% off earns 50. A wide guess can’t cancel a short one. The reveal overlays the correct grid on your dashed outline.

Rhythm ⅔× to 1½× the tempo

Watch five silent flashes, then tap five beats at the same pace. Your tap intervals determine your tempo.

Zero points at two-thirds or one-and-a-half times the target tempo. We compare proportional tempo changes, so reciprocal changes get equal scores. For a target of 100 BPM, about 82 or 122 BPM earns 50 points; 66⅔ or 150 BPM earns zero.

@@ -272,6 +272,7 @@

The games & their allowances

for(const q of game.deck){ if(!q||!['view3d','balance','halfhalf','motion','perspective','proportion','rhythm','angle','colour','time','timeline','duration'].includes(q.type)||typeof q.title!=='string'||q.title.length>200||typeof q.skill!=='string'||q.skill.length>100||!finiteTree(q))return false; if(q.scoringVersion!==undefined&&q.scoringVersion!==2)return false; + if(q.traceScoringVersion!==undefined&&(q.traceScoringVersion!==3||q.type!=='motion'||q.scoringVersion!==2))return false; if(q.scoringVersion===2&&q.type==='time'&&q.timeScoringVersion!==2)return false; if(q.scoringVersion===2&&q.type==='timeline'&&q.timelineScoringVersion!==2)return false; if(q.source&&(!Array.isArray(q.source)||!/^https:\/\//.test(q.source[1])))return false; @@ -458,7 +459,7 @@

The games & their allowances

case 'angle':return {value:number(angularDistance(value,q.answer)),unit:'°',label:'Angle error'}; case 'halfhalf':return {value:number(halfError(value,q)*100),unit:'',label:'Split error',note:'percentage points'}; case 'balance':return q.fruit?{value:number(halfError(value,q)*100),unit:'',label:'Split error',note:'percentage points'}:percent(Math.abs(value-q.answer)/balanceWidth(q)*100,'Balance error','of the shape’s width'); - case 'motion':if(q.scoringVersion===2)return percent(traceOverlap(value,q.answer)*100,'Line overlap','shared area ÷ combined area');return percent(traceError(value,q.answer)/500*100,q.type==='motion'?'Path error':'Position error','of the play-area diagonal'); + case 'motion':if(usesTraceCoverage(q)){const trace=traceCoverageResult(value,q.answer);return percent(trace.coverage*100,'Target covered',trace.penalty>0?'−'+number(Math.min(trace.coverage,trace.penalty)*100)+' pts for excess ink':'No excess-ink penalty');}if(q.scoringVersion===2)return percent(traceOverlap(value,q.answer)*100,'Line overlap','shared area ÷ combined area');return percent(traceError(value,q.answer)/500*100,q.type==='motion'?'Path error':'Position error','of the play-area diagonal'); case 'perspective':return percent(pointError(value,q.answer)/perspectiveDiagonal(q)*100,'Position error',q.scoringVersion===2?'of the inner-box diagonal':'of the play-area diagonal'); case 'time':return {value:Math.abs(value-q.answer).toFixed(2),unit:'s',label:'Timing error',note:(Math.abs(value-q.answer)<.005?'Spot on':valueThe games & their allowances function makeDeck(day,game=mini){ const key=typeof day==='function'?String(Math.floor(day()*4294967296)):String(day); const deck=makeDeckForSeed(key,game); - return !validDay(key)||dayNumber(key)>=dayNumber(LINEAR_SCORING_START)?deck.map(q=>({...q,scoringVersion:2,...(q.fruit?{explain:q.explain.replace('53/47 split earns 72 points','53/47 split earns 85 points').replace('60/40 split earns 25 points','60/40 split earns 50 points')}:{})})):deck; + return !validDay(key)||dayNumber(key)>=dayNumber(LINEAR_SCORING_START)?deck.map(q=>({...q,scoringVersion:2,...(q.type==='motion'&&(!validDay(key)||dayNumber(key)>=dayNumber(TRACE_COVERAGE_START))?{traceScoringVersion:3}:{}),...(q.fruit?{explain:q.explain.replace('53/47 split earns 72 points','53/47 split earns 85 points').replace('60/40 split earns 25 points','60/40 split earns 50 points')}:{})})):deck; } function makeDeckForSeed(day,game=mini){ if(game==='halfhalf')game='balance'; @@ -1115,6 +1116,9 @@

The games & their allowances

const PERSPECTIVE_CORNERS=[[20,20],[380,20],[380,280],[20,280]]; const PERSPECTIVE_INNER_DIAGONAL=Math.hypot(360,260)*PERSPECTIVE_INNER_SCALE; const TRACE_STROKE_WIDTH=12; +const TRACE_COVERAGE_START='2026-10-17'; +const TRACE_AREA_ALLOWANCE=1.25; +function usesTraceCoverage(q){return q.type==='motion'&&q.traceScoringVersion===3;} // Fixed half-unit sampling keeps scores independent of screen size and device DPR. // Each round-capped stroke is the union of capsules around its line segments. function traceMask(points){ @@ -1130,11 +1134,21 @@

The games & their allowances

} return mask; } +function traceAreas(points,target){ + if(!Array.isArray(points)||points.length<2||!Array.isArray(target)||target.length<2)return {intersection:0,drawn:0,target:0}; + const a=traceMask(points),b=traceMask(target);let intersection=0,drawn=0,targetArea=0; + for(let i=0;iThe games & their allowances case 'angle':error=angularDistance(value,q.answer);break; case 'timeline':return Math.abs(value-q.answer)/timelineErrorBound(q); case 'time':return Math.abs(value-q.answer)/timeErrorBound(q); - case 'motion':if(q.scoringVersion===2)return 1-traceOverlap(value,q.answer);if(value.length<2)return Infinity;error=traceError(value,q.answer);break; + case 'motion':if(usesTraceCoverage(q))return 1-traceCoverageResult(value,q.answer).closeness;if(q.scoringVersion===2)return 1-traceOverlap(value,q.answer);if(value.length<2)return Infinity;error=traceError(value,q.answer);break; case 'perspective':return pointError(value,q.answer)/(.4*perspectiveDiagonal(q)); case 'proportion':if(isSizeQuestion(q)){const e=sizeErrors(value,q);return (e.width+e.height)/(2*SIZE_ERROR_BOUND);}error=proportionError(value,q.answer);break; case 'colour':error=wheelDistance(value,q.answer);break; diff --git a/public/index.html b/public/index.html index e7aa424..8d90779 100644 --- a/public/index.html +++ b/public/index.html @@ -55,7 +55,7 @@

How points work

For example: in Angles, the allowance is 180°. A 45° miss uses a quarter of it, so you earn 75 points.

A smaller error number doesn’t always mean more points. Degrees, years, wheel distance, and split percentages measure different things. Compare your miss with that game’s allowance. For example, 5.5° off in 3D view earns 94 points; a 3.6 percentage-point split error in Half & Half earns 82.

-

Scores are rounded to whole points. Size it up averages width and height accuracy; Line memory awards the percentage of stroke area that overlaps. Their rules are below.

+

Scores are rounded to whole points. Size it up averages width and height accuracy; Line memory starts with target coverage and deducts points only for excess painted area. Their rules are below.

These rules apply to new practice rounds and Daily Fives dated October 16, 2026 onward. Earlier challenges and saved rounds keep their original scoring.

@@ -63,7 +63,7 @@

The games & their allowances

Open a game for the controls, scoring rule, and an example.

3D view 90° rotation

Study the view for three seconds. Drag the shape to turn it, then use the outer handle to tilt it back to the view you remember.

We measure the smallest 3D rotation from your view to the target. 90° off earns zero; 45° off earns 50. Errors across the three rotation axes combine into one rotation distance.

Half & Half 20 percentage points

Drag the cut or its handles to split the total filled area equally. All shapes count together; empty space doesn’t count.

50/50 earns 100; 60/40 earns 50; 70/30 or worse earns zero. A 53.6/46.4 split is 3.6 percentage points from even and earns 82 points.

-
Line memory Stroke overlap

Study the line for three seconds, then trace it in one stroke. Your line and the target have the same thickness. Clear to try drawing again before you lock in.

Your score is the area shared by both strokes divided by the total area covered by either stroke. 75% overlap earns 75 points; no overlap earns zero. Missing sections and extra scribbles lower your score. Speed and direction don’t matter. On reveal, a dashed center marks the target.

+
Line memory Target coverage · 25% extra ink allowed

Study the line for three seconds, then trace it in one stroke. Your line and the target have the same thickness. Clear to try drawing again before you lock in.

Your base score is the percentage of the target stroke you cover. Covering 75% earns 75 points. Small misplaced marks don’t cost extra points unless your total painted area exceeds 1.25× the target’s area.

Beyond that allowance, each extra 1% of target area deducts one point. For example, 80% coverage with 1.35× the target’s area earns 80 − 10 = 70 points. Scores stop at zero. We count unique painted area, so retracing the same spot adds no ink. Speed and direction don’t matter. On reveal, a dashed center marks the target.

This rule applies to new practice rounds and Daily Fives from October 17, 2026. October 16 and previously saved overlap-scoring rounds keep their original shared-area ÷ combined-area rule.

Perspective 40% of the inner diagonal

Tap where the receding lines would meet: the vanishing point.

Your miss is the straight-line distance from your point to the answer, measured against the inner box’s corner-to-corner diagonal. A miss of 40% of that diagonal earns zero; 20% earns 50.

Size it up 50% error per dimension

Start with one square. Drag the corner to make the requested number of squares wide and tall. The shaded starting square stays fixed as your reference. The opposite corner stays anchored.

Width and height each contribute up to 50 points. A dimension that’s 50% too big or small earns zero for that dimension. 5% off in both dimensions earns 90 points. One exact dimension and one 50% off earns 50. A wide guess can’t cancel a short one. The reveal overlays the correct grid on your dashed outline.

Rhythm ⅔× to 1½× the tempo

Watch five silent flashes, then tap five beats at the same pace. Your tap intervals determine your tempo.

Zero points at two-thirds or one-and-a-half times the target tempo. We compare proportional tempo changes, so reciprocal changes get equal scores. For a target of 100 BPM, about 82 or 122 BPM earns 50 points; 66⅔ or 150 BPM earns zero.

@@ -272,6 +272,7 @@

The games & their allowances

for(const q of game.deck){ if(!q||!['view3d','balance','halfhalf','motion','perspective','proportion','rhythm','angle','colour','time','timeline','duration'].includes(q.type)||typeof q.title!=='string'||q.title.length>200||typeof q.skill!=='string'||q.skill.length>100||!finiteTree(q))return false; if(q.scoringVersion!==undefined&&q.scoringVersion!==2)return false; + if(q.traceScoringVersion!==undefined&&(q.traceScoringVersion!==3||q.type!=='motion'||q.scoringVersion!==2))return false; if(q.scoringVersion===2&&q.type==='time'&&q.timeScoringVersion!==2)return false; if(q.scoringVersion===2&&q.type==='timeline'&&q.timelineScoringVersion!==2)return false; if(q.source&&(!Array.isArray(q.source)||!/^https:\/\//.test(q.source[1])))return false; @@ -458,7 +459,7 @@

The games & their allowances

case 'angle':return {value:number(angularDistance(value,q.answer)),unit:'°',label:'Angle error'}; case 'halfhalf':return {value:number(halfError(value,q)*100),unit:'',label:'Split error',note:'percentage points'}; case 'balance':return q.fruit?{value:number(halfError(value,q)*100),unit:'',label:'Split error',note:'percentage points'}:percent(Math.abs(value-q.answer)/balanceWidth(q)*100,'Balance error','of the shape’s width'); - case 'motion':if(q.scoringVersion===2)return percent(traceOverlap(value,q.answer)*100,'Line overlap','shared area ÷ combined area');return percent(traceError(value,q.answer)/500*100,q.type==='motion'?'Path error':'Position error','of the play-area diagonal'); + case 'motion':if(usesTraceCoverage(q)){const trace=traceCoverageResult(value,q.answer);return percent(trace.coverage*100,'Target covered',trace.penalty>0?'−'+number(Math.min(trace.coverage,trace.penalty)*100)+' pts for excess ink':'No excess-ink penalty');}if(q.scoringVersion===2)return percent(traceOverlap(value,q.answer)*100,'Line overlap','shared area ÷ combined area');return percent(traceError(value,q.answer)/500*100,q.type==='motion'?'Path error':'Position error','of the play-area diagonal'); case 'perspective':return percent(pointError(value,q.answer)/perspectiveDiagonal(q)*100,'Position error',q.scoringVersion===2?'of the inner-box diagonal':'of the play-area diagonal'); case 'time':return {value:Math.abs(value-q.answer).toFixed(2),unit:'s',label:'Timing error',note:(Math.abs(value-q.answer)<.005?'Spot on':valueThe games & their allowances function makeDeck(day,game=mini){ const key=typeof day==='function'?String(Math.floor(day()*4294967296)):String(day); const deck=makeDeckForSeed(key,game); - return !validDay(key)||dayNumber(key)>=dayNumber(LINEAR_SCORING_START)?deck.map(q=>({...q,scoringVersion:2,...(q.fruit?{explain:q.explain.replace('53/47 split earns 72 points','53/47 split earns 85 points').replace('60/40 split earns 25 points','60/40 split earns 50 points')}:{})})):deck; + return !validDay(key)||dayNumber(key)>=dayNumber(LINEAR_SCORING_START)?deck.map(q=>({...q,scoringVersion:2,...(q.type==='motion'&&(!validDay(key)||dayNumber(key)>=dayNumber(TRACE_COVERAGE_START))?{traceScoringVersion:3}:{}),...(q.fruit?{explain:q.explain.replace('53/47 split earns 72 points','53/47 split earns 85 points').replace('60/40 split earns 25 points','60/40 split earns 50 points')}:{})})):deck; } function makeDeckForSeed(day,game=mini){ if(game==='halfhalf')game='balance'; @@ -1115,6 +1116,9 @@

The games & their allowances

const PERSPECTIVE_CORNERS=[[20,20],[380,20],[380,280],[20,280]]; const PERSPECTIVE_INNER_DIAGONAL=Math.hypot(360,260)*PERSPECTIVE_INNER_SCALE; const TRACE_STROKE_WIDTH=12; +const TRACE_COVERAGE_START='2026-10-17'; +const TRACE_AREA_ALLOWANCE=1.25; +function usesTraceCoverage(q){return q.type==='motion'&&q.traceScoringVersion===3;} // Fixed half-unit sampling keeps scores independent of screen size and device DPR. // Each round-capped stroke is the union of capsules around its line segments. function traceMask(points){ @@ -1130,11 +1134,21 @@

The games & their allowances

} return mask; } +function traceAreas(points,target){ + if(!Array.isArray(points)||points.length<2||!Array.isArray(target)||target.length<2)return {intersection:0,drawn:0,target:0}; + const a=traceMask(points),b=traceMask(target);let intersection=0,drawn=0,targetArea=0; + for(let i=0;iThe games & their allowances case 'angle':error=angularDistance(value,q.answer);break; case 'timeline':return Math.abs(value-q.answer)/timelineErrorBound(q); case 'time':return Math.abs(value-q.answer)/timeErrorBound(q); - case 'motion':if(q.scoringVersion===2)return 1-traceOverlap(value,q.answer);if(value.length<2)return Infinity;error=traceError(value,q.answer);break; + case 'motion':if(usesTraceCoverage(q))return 1-traceCoverageResult(value,q.answer).closeness;if(q.scoringVersion===2)return 1-traceOverlap(value,q.answer);if(value.length<2)return Infinity;error=traceError(value,q.answer);break; case 'perspective':return pointError(value,q.answer)/(.4*perspectiveDiagonal(q)); case 'proportion':if(isSizeQuestion(q)){const e=sizeErrors(value,q);return (e.width+e.height)/(2*SIZE_ERROR_BOUND);}error=proportionError(value,q.answer);break; case 'colour':error=wheelDistance(value,q.answer);break; diff --git a/public/night-lab.html b/public/night-lab.html index e7aa424..8d90779 100644 --- a/public/night-lab.html +++ b/public/night-lab.html @@ -55,7 +55,7 @@

How points work

For example: in Angles, the allowance is 180°. A 45° miss uses a quarter of it, so you earn 75 points.

A smaller error number doesn’t always mean more points. Degrees, years, wheel distance, and split percentages measure different things. Compare your miss with that game’s allowance. For example, 5.5° off in 3D view earns 94 points; a 3.6 percentage-point split error in Half & Half earns 82.

-

Scores are rounded to whole points. Size it up averages width and height accuracy; Line memory awards the percentage of stroke area that overlaps. Their rules are below.

+

Scores are rounded to whole points. Size it up averages width and height accuracy; Line memory starts with target coverage and deducts points only for excess painted area. Their rules are below.

These rules apply to new practice rounds and Daily Fives dated October 16, 2026 onward. Earlier challenges and saved rounds keep their original scoring.

@@ -63,7 +63,7 @@

The games & their allowances

Open a game for the controls, scoring rule, and an example.

3D view 90° rotation

Study the view for three seconds. Drag the shape to turn it, then use the outer handle to tilt it back to the view you remember.

We measure the smallest 3D rotation from your view to the target. 90° off earns zero; 45° off earns 50. Errors across the three rotation axes combine into one rotation distance.

Half & Half 20 percentage points

Drag the cut or its handles to split the total filled area equally. All shapes count together; empty space doesn’t count.

50/50 earns 100; 60/40 earns 50; 70/30 or worse earns zero. A 53.6/46.4 split is 3.6 percentage points from even and earns 82 points.

-
Line memory Stroke overlap

Study the line for three seconds, then trace it in one stroke. Your line and the target have the same thickness. Clear to try drawing again before you lock in.

Your score is the area shared by both strokes divided by the total area covered by either stroke. 75% overlap earns 75 points; no overlap earns zero. Missing sections and extra scribbles lower your score. Speed and direction don’t matter. On reveal, a dashed center marks the target.

+
Line memory Target coverage · 25% extra ink allowed

Study the line for three seconds, then trace it in one stroke. Your line and the target have the same thickness. Clear to try drawing again before you lock in.

Your base score is the percentage of the target stroke you cover. Covering 75% earns 75 points. Small misplaced marks don’t cost extra points unless your total painted area exceeds 1.25× the target’s area.

Beyond that allowance, each extra 1% of target area deducts one point. For example, 80% coverage with 1.35× the target’s area earns 80 − 10 = 70 points. Scores stop at zero. We count unique painted area, so retracing the same spot adds no ink. Speed and direction don’t matter. On reveal, a dashed center marks the target.

This rule applies to new practice rounds and Daily Fives from October 17, 2026. October 16 and previously saved overlap-scoring rounds keep their original shared-area ÷ combined-area rule.

Perspective 40% of the inner diagonal

Tap where the receding lines would meet: the vanishing point.

Your miss is the straight-line distance from your point to the answer, measured against the inner box’s corner-to-corner diagonal. A miss of 40% of that diagonal earns zero; 20% earns 50.

Size it up 50% error per dimension

Start with one square. Drag the corner to make the requested number of squares wide and tall. The shaded starting square stays fixed as your reference. The opposite corner stays anchored.

Width and height each contribute up to 50 points. A dimension that’s 50% too big or small earns zero for that dimension. 5% off in both dimensions earns 90 points. One exact dimension and one 50% off earns 50. A wide guess can’t cancel a short one. The reveal overlays the correct grid on your dashed outline.

Rhythm ⅔× to 1½× the tempo

Watch five silent flashes, then tap five beats at the same pace. Your tap intervals determine your tempo.

Zero points at two-thirds or one-and-a-half times the target tempo. We compare proportional tempo changes, so reciprocal changes get equal scores. For a target of 100 BPM, about 82 or 122 BPM earns 50 points; 66⅔ or 150 BPM earns zero.

@@ -272,6 +272,7 @@

The games & their allowances

for(const q of game.deck){ if(!q||!['view3d','balance','halfhalf','motion','perspective','proportion','rhythm','angle','colour','time','timeline','duration'].includes(q.type)||typeof q.title!=='string'||q.title.length>200||typeof q.skill!=='string'||q.skill.length>100||!finiteTree(q))return false; if(q.scoringVersion!==undefined&&q.scoringVersion!==2)return false; + if(q.traceScoringVersion!==undefined&&(q.traceScoringVersion!==3||q.type!=='motion'||q.scoringVersion!==2))return false; if(q.scoringVersion===2&&q.type==='time'&&q.timeScoringVersion!==2)return false; if(q.scoringVersion===2&&q.type==='timeline'&&q.timelineScoringVersion!==2)return false; if(q.source&&(!Array.isArray(q.source)||!/^https:\/\//.test(q.source[1])))return false; @@ -458,7 +459,7 @@

The games & their allowances

case 'angle':return {value:number(angularDistance(value,q.answer)),unit:'°',label:'Angle error'}; case 'halfhalf':return {value:number(halfError(value,q)*100),unit:'',label:'Split error',note:'percentage points'}; case 'balance':return q.fruit?{value:number(halfError(value,q)*100),unit:'',label:'Split error',note:'percentage points'}:percent(Math.abs(value-q.answer)/balanceWidth(q)*100,'Balance error','of the shape’s width'); - case 'motion':if(q.scoringVersion===2)return percent(traceOverlap(value,q.answer)*100,'Line overlap','shared area ÷ combined area');return percent(traceError(value,q.answer)/500*100,q.type==='motion'?'Path error':'Position error','of the play-area diagonal'); + case 'motion':if(usesTraceCoverage(q)){const trace=traceCoverageResult(value,q.answer);return percent(trace.coverage*100,'Target covered',trace.penalty>0?'−'+number(Math.min(trace.coverage,trace.penalty)*100)+' pts for excess ink':'No excess-ink penalty');}if(q.scoringVersion===2)return percent(traceOverlap(value,q.answer)*100,'Line overlap','shared area ÷ combined area');return percent(traceError(value,q.answer)/500*100,q.type==='motion'?'Path error':'Position error','of the play-area diagonal'); case 'perspective':return percent(pointError(value,q.answer)/perspectiveDiagonal(q)*100,'Position error',q.scoringVersion===2?'of the inner-box diagonal':'of the play-area diagonal'); case 'time':return {value:Math.abs(value-q.answer).toFixed(2),unit:'s',label:'Timing error',note:(Math.abs(value-q.answer)<.005?'Spot on':valueThe games & their allowances function makeDeck(day,game=mini){ const key=typeof day==='function'?String(Math.floor(day()*4294967296)):String(day); const deck=makeDeckForSeed(key,game); - return !validDay(key)||dayNumber(key)>=dayNumber(LINEAR_SCORING_START)?deck.map(q=>({...q,scoringVersion:2,...(q.fruit?{explain:q.explain.replace('53/47 split earns 72 points','53/47 split earns 85 points').replace('60/40 split earns 25 points','60/40 split earns 50 points')}:{})})):deck; + return !validDay(key)||dayNumber(key)>=dayNumber(LINEAR_SCORING_START)?deck.map(q=>({...q,scoringVersion:2,...(q.type==='motion'&&(!validDay(key)||dayNumber(key)>=dayNumber(TRACE_COVERAGE_START))?{traceScoringVersion:3}:{}),...(q.fruit?{explain:q.explain.replace('53/47 split earns 72 points','53/47 split earns 85 points').replace('60/40 split earns 25 points','60/40 split earns 50 points')}:{})})):deck; } function makeDeckForSeed(day,game=mini){ if(game==='halfhalf')game='balance'; @@ -1115,6 +1116,9 @@

The games & their allowances

const PERSPECTIVE_CORNERS=[[20,20],[380,20],[380,280],[20,280]]; const PERSPECTIVE_INNER_DIAGONAL=Math.hypot(360,260)*PERSPECTIVE_INNER_SCALE; const TRACE_STROKE_WIDTH=12; +const TRACE_COVERAGE_START='2026-10-17'; +const TRACE_AREA_ALLOWANCE=1.25; +function usesTraceCoverage(q){return q.type==='motion'&&q.traceScoringVersion===3;} // Fixed half-unit sampling keeps scores independent of screen size and device DPR. // Each round-capped stroke is the union of capsules around its line segments. function traceMask(points){ @@ -1130,11 +1134,21 @@

The games & their allowances

} return mask; } +function traceAreas(points,target){ + if(!Array.isArray(points)||points.length<2||!Array.isArray(target)||target.length<2)return {intersection:0,drawn:0,target:0}; + const a=traceMask(points),b=traceMask(target);let intersection=0,drawn=0,targetArea=0; + for(let i=0;iThe games & their allowances case 'angle':error=angularDistance(value,q.answer);break; case 'timeline':return Math.abs(value-q.answer)/timelineErrorBound(q); case 'time':return Math.abs(value-q.answer)/timeErrorBound(q); - case 'motion':if(q.scoringVersion===2)return 1-traceOverlap(value,q.answer);if(value.length<2)return Infinity;error=traceError(value,q.answer);break; + case 'motion':if(usesTraceCoverage(q))return 1-traceCoverageResult(value,q.answer).closeness;if(q.scoringVersion===2)return 1-traceOverlap(value,q.answer);if(value.length<2)return Infinity;error=traceError(value,q.answer);break; case 'perspective':return pointError(value,q.answer)/(.4*perspectiveDiagonal(q)); case 'proportion':if(isSizeQuestion(q)){const e=sizeErrors(value,q);return (e.width+e.height)/(2*SIZE_ERROR_BOUND);}error=proportionError(value,q.answer);break; case 'colour':error=wheelDistance(value,q.answer);break; diff --git a/public/studio.html b/public/studio.html index e7aa424..8d90779 100644 --- a/public/studio.html +++ b/public/studio.html @@ -55,7 +55,7 @@

How points work

For example: in Angles, the allowance is 180°. A 45° miss uses a quarter of it, so you earn 75 points.

A smaller error number doesn’t always mean more points. Degrees, years, wheel distance, and split percentages measure different things. Compare your miss with that game’s allowance. For example, 5.5° off in 3D view earns 94 points; a 3.6 percentage-point split error in Half & Half earns 82.

-

Scores are rounded to whole points. Size it up averages width and height accuracy; Line memory awards the percentage of stroke area that overlaps. Their rules are below.

+

Scores are rounded to whole points. Size it up averages width and height accuracy; Line memory starts with target coverage and deducts points only for excess painted area. Their rules are below.

These rules apply to new practice rounds and Daily Fives dated October 16, 2026 onward. Earlier challenges and saved rounds keep their original scoring.

@@ -63,7 +63,7 @@

The games & their allowances

Open a game for the controls, scoring rule, and an example.

3D view 90° rotation

Study the view for three seconds. Drag the shape to turn it, then use the outer handle to tilt it back to the view you remember.

We measure the smallest 3D rotation from your view to the target. 90° off earns zero; 45° off earns 50. Errors across the three rotation axes combine into one rotation distance.

Half & Half 20 percentage points

Drag the cut or its handles to split the total filled area equally. All shapes count together; empty space doesn’t count.

50/50 earns 100; 60/40 earns 50; 70/30 or worse earns zero. A 53.6/46.4 split is 3.6 percentage points from even and earns 82 points.

-
Line memory Stroke overlap

Study the line for three seconds, then trace it in one stroke. Your line and the target have the same thickness. Clear to try drawing again before you lock in.

Your score is the area shared by both strokes divided by the total area covered by either stroke. 75% overlap earns 75 points; no overlap earns zero. Missing sections and extra scribbles lower your score. Speed and direction don’t matter. On reveal, a dashed center marks the target.

+
Line memory Target coverage · 25% extra ink allowed

Study the line for three seconds, then trace it in one stroke. Your line and the target have the same thickness. Clear to try drawing again before you lock in.

Your base score is the percentage of the target stroke you cover. Covering 75% earns 75 points. Small misplaced marks don’t cost extra points unless your total painted area exceeds 1.25× the target’s area.

Beyond that allowance, each extra 1% of target area deducts one point. For example, 80% coverage with 1.35× the target’s area earns 80 − 10 = 70 points. Scores stop at zero. We count unique painted area, so retracing the same spot adds no ink. Speed and direction don’t matter. On reveal, a dashed center marks the target.

This rule applies to new practice rounds and Daily Fives from October 17, 2026. October 16 and previously saved overlap-scoring rounds keep their original shared-area ÷ combined-area rule.

Perspective 40% of the inner diagonal

Tap where the receding lines would meet: the vanishing point.

Your miss is the straight-line distance from your point to the answer, measured against the inner box’s corner-to-corner diagonal. A miss of 40% of that diagonal earns zero; 20% earns 50.

Size it up 50% error per dimension

Start with one square. Drag the corner to make the requested number of squares wide and tall. The shaded starting square stays fixed as your reference. The opposite corner stays anchored.

Width and height each contribute up to 50 points. A dimension that’s 50% too big or small earns zero for that dimension. 5% off in both dimensions earns 90 points. One exact dimension and one 50% off earns 50. A wide guess can’t cancel a short one. The reveal overlays the correct grid on your dashed outline.

Rhythm ⅔× to 1½× the tempo

Watch five silent flashes, then tap five beats at the same pace. Your tap intervals determine your tempo.

Zero points at two-thirds or one-and-a-half times the target tempo. We compare proportional tempo changes, so reciprocal changes get equal scores. For a target of 100 BPM, about 82 or 122 BPM earns 50 points; 66⅔ or 150 BPM earns zero.

@@ -272,6 +272,7 @@

The games & their allowances

for(const q of game.deck){ if(!q||!['view3d','balance','halfhalf','motion','perspective','proportion','rhythm','angle','colour','time','timeline','duration'].includes(q.type)||typeof q.title!=='string'||q.title.length>200||typeof q.skill!=='string'||q.skill.length>100||!finiteTree(q))return false; if(q.scoringVersion!==undefined&&q.scoringVersion!==2)return false; + if(q.traceScoringVersion!==undefined&&(q.traceScoringVersion!==3||q.type!=='motion'||q.scoringVersion!==2))return false; if(q.scoringVersion===2&&q.type==='time'&&q.timeScoringVersion!==2)return false; if(q.scoringVersion===2&&q.type==='timeline'&&q.timelineScoringVersion!==2)return false; if(q.source&&(!Array.isArray(q.source)||!/^https:\/\//.test(q.source[1])))return false; @@ -458,7 +459,7 @@

The games & their allowances

case 'angle':return {value:number(angularDistance(value,q.answer)),unit:'°',label:'Angle error'}; case 'halfhalf':return {value:number(halfError(value,q)*100),unit:'',label:'Split error',note:'percentage points'}; case 'balance':return q.fruit?{value:number(halfError(value,q)*100),unit:'',label:'Split error',note:'percentage points'}:percent(Math.abs(value-q.answer)/balanceWidth(q)*100,'Balance error','of the shape’s width'); - case 'motion':if(q.scoringVersion===2)return percent(traceOverlap(value,q.answer)*100,'Line overlap','shared area ÷ combined area');return percent(traceError(value,q.answer)/500*100,q.type==='motion'?'Path error':'Position error','of the play-area diagonal'); + case 'motion':if(usesTraceCoverage(q)){const trace=traceCoverageResult(value,q.answer);return percent(trace.coverage*100,'Target covered',trace.penalty>0?'−'+number(Math.min(trace.coverage,trace.penalty)*100)+' pts for excess ink':'No excess-ink penalty');}if(q.scoringVersion===2)return percent(traceOverlap(value,q.answer)*100,'Line overlap','shared area ÷ combined area');return percent(traceError(value,q.answer)/500*100,q.type==='motion'?'Path error':'Position error','of the play-area diagonal'); case 'perspective':return percent(pointError(value,q.answer)/perspectiveDiagonal(q)*100,'Position error',q.scoringVersion===2?'of the inner-box diagonal':'of the play-area diagonal'); case 'time':return {value:Math.abs(value-q.answer).toFixed(2),unit:'s',label:'Timing error',note:(Math.abs(value-q.answer)<.005?'Spot on':valueThe games & their allowances function makeDeck(day,game=mini){ const key=typeof day==='function'?String(Math.floor(day()*4294967296)):String(day); const deck=makeDeckForSeed(key,game); - return !validDay(key)||dayNumber(key)>=dayNumber(LINEAR_SCORING_START)?deck.map(q=>({...q,scoringVersion:2,...(q.fruit?{explain:q.explain.replace('53/47 split earns 72 points','53/47 split earns 85 points').replace('60/40 split earns 25 points','60/40 split earns 50 points')}:{})})):deck; + return !validDay(key)||dayNumber(key)>=dayNumber(LINEAR_SCORING_START)?deck.map(q=>({...q,scoringVersion:2,...(q.type==='motion'&&(!validDay(key)||dayNumber(key)>=dayNumber(TRACE_COVERAGE_START))?{traceScoringVersion:3}:{}),...(q.fruit?{explain:q.explain.replace('53/47 split earns 72 points','53/47 split earns 85 points').replace('60/40 split earns 25 points','60/40 split earns 50 points')}:{})})):deck; } function makeDeckForSeed(day,game=mini){ if(game==='halfhalf')game='balance'; @@ -1115,6 +1116,9 @@

The games & their allowances

const PERSPECTIVE_CORNERS=[[20,20],[380,20],[380,280],[20,280]]; const PERSPECTIVE_INNER_DIAGONAL=Math.hypot(360,260)*PERSPECTIVE_INNER_SCALE; const TRACE_STROKE_WIDTH=12; +const TRACE_COVERAGE_START='2026-10-17'; +const TRACE_AREA_ALLOWANCE=1.25; +function usesTraceCoverage(q){return q.type==='motion'&&q.traceScoringVersion===3;} // Fixed half-unit sampling keeps scores independent of screen size and device DPR. // Each round-capped stroke is the union of capsules around its line segments. function traceMask(points){ @@ -1130,11 +1134,21 @@

The games & their allowances

} return mask; } +function traceAreas(points,target){ + if(!Array.isArray(points)||points.length<2||!Array.isArray(target)||target.length<2)return {intersection:0,drawn:0,target:0}; + const a=traceMask(points),b=traceMask(target);let intersection=0,drawn=0,targetArea=0; + for(let i=0;iThe games & their allowances case 'angle':error=angularDistance(value,q.answer);break; case 'timeline':return Math.abs(value-q.answer)/timelineErrorBound(q); case 'time':return Math.abs(value-q.answer)/timeErrorBound(q); - case 'motion':if(q.scoringVersion===2)return 1-traceOverlap(value,q.answer);if(value.length<2)return Infinity;error=traceError(value,q.answer);break; + case 'motion':if(usesTraceCoverage(q))return 1-traceCoverageResult(value,q.answer).closeness;if(q.scoringVersion===2)return 1-traceOverlap(value,q.answer);if(value.length<2)return Infinity;error=traceError(value,q.answer);break; case 'perspective':return pointError(value,q.answer)/(.4*perspectiveDiagonal(q)); case 'proportion':if(isSizeQuestion(q)){const e=sizeErrors(value,q);return (e.width+e.height)/(2*SIZE_ERROR_BOUND);}error=proportionError(value,q.answer);break; case 'colour':error=wheelDistance(value,q.answer);break; diff --git a/src/design-options/about.html b/src/design-options/about.html index 2fd04df..a18167c 100644 --- a/src/design-options/about.html +++ b/src/design-options/about.html @@ -31,7 +31,7 @@

How scoring works

All new practice rounds and daily challenges from October 16, 2026 use linear scoring. Each game measures error against its own zero-point bound, then awards 100 times the remaining closeness. Using 10% of that allowance earns 90 points; 25% earns 75; half earns 50. An exact match earns 100, and reaching the bound earns zero.

For the new linear scoring version, 3D view reaches zero points at 90° of rotation error; Angles reaches zero at 180°. A 45° miss earns 50 points in 3D view, while a 90° miss earns 50 in Angles.

Size it up scores width and height independently, each worth up to 50 points, then rounds the combined score. A 5% miss in both dimensions earns 90/100. A 50% error in one dimension earns zero for that dimension. Earlier daily rounds and saved questions retain their original scoring.

-

Perspective measures your miss against the inner box: 40% of its diagonal earns zero. How long? reaches zero at 50% shorter or longer than the target; a 25% miss earns 50 points. Line memory uses equal-width strokes: your score is the percentage of their combined area that overlaps. Extra marks count against you. These rules apply to the new scoring version.

+

Perspective measures your miss against the inner box: 40% of its diagonal earns zero. How long? reaches zero at 50% shorter or longer than the target; a 25% miss earns 50 points. From October 17 (and in new practice rounds), Line memory starts with the percentage of the target stroke covered. Painted area up to 1.25 times the target’s area has no penalty; each extra 1% of target area beyond that deducts one point. October 16 and saved overlap-scoring rounds keep their original shared-area ÷ combined-area rule. These rules apply to the new scoring version.

The game’s “How scoring works” instructions list the bounds for each puzzle. Your results show small comparisons so you can see where you were close.

A little more practice?

Open the Practice menu to pick an individual game or a fresh mixed five. Daily Five is the only daily challenge; individual games are practice-only. Trivia practice uses questions from previous Daily Fives, keeping today’s, upcoming, and unused trivia out of the practice pool. As the archive grows, sessions offer up to five different past questions.

diff --git a/src/design-options/generated-games.js b/src/design-options/generated-games.js index f88e2fd..669923f 100644 --- a/src/design-options/generated-games.js +++ b/src/design-options/generated-games.js @@ -31,7 +31,7 @@ function syncGameURL(game,mode){ function makeDeck(day,game=mini){ const key=typeof day==='function'?String(Math.floor(day()*4294967296)):String(day); const deck=makeDeckForSeed(key,game); - return !validDay(key)||dayNumber(key)>=dayNumber(LINEAR_SCORING_START)?deck.map(q=>({...q,scoringVersion:2,...(q.fruit?{explain:q.explain.replace('53/47 split earns 72 points','53/47 split earns 85 points').replace('60/40 split earns 25 points','60/40 split earns 50 points')}:{})})):deck; + return !validDay(key)||dayNumber(key)>=dayNumber(LINEAR_SCORING_START)?deck.map(q=>({...q,scoringVersion:2,...(q.type==='motion'&&(!validDay(key)||dayNumber(key)>=dayNumber(TRACE_COVERAGE_START))?{traceScoringVersion:3}:{}),...(q.fruit?{explain:q.explain.replace('53/47 split earns 72 points','53/47 split earns 85 points').replace('60/40 split earns 25 points','60/40 split earns 50 points')}:{})})):deck; } function makeDeckForSeed(day,game=mini){ if(game==='halfhalf')game='balance'; diff --git a/src/design-options/how-to-play.html b/src/design-options/how-to-play.html index 28b9532..b01e7e7 100644 --- a/src/design-options/how-to-play.html +++ b/src/design-options/how-to-play.html @@ -13,7 +13,7 @@

How points work

For example: in Angles, the allowance is 180°. A 45° miss uses a quarter of it, so you earn 75 points.

A smaller error number doesn’t always mean more points. Degrees, years, wheel distance, and split percentages measure different things. Compare your miss with that game’s allowance. For example, 5.5° off in 3D view earns 94 points; a 3.6 percentage-point split error in Half & Half earns 82.

-

Scores are rounded to whole points. Size it up averages width and height accuracy; Line memory awards the percentage of stroke area that overlaps. Their rules are below.

+

Scores are rounded to whole points. Size it up averages width and height accuracy; Line memory starts with target coverage and deducts points only for excess painted area. Their rules are below.

These rules apply to new practice rounds and Daily Fives dated October 16, 2026 onward. Earlier challenges and saved rounds keep their original scoring.

@@ -21,7 +21,7 @@

The games & their allowances

Open a game for the controls, scoring rule, and an example.

3D view 90° rotation

Study the view for three seconds. Drag the shape to turn it, then use the outer handle to tilt it back to the view you remember.

We measure the smallest 3D rotation from your view to the target. 90° off earns zero; 45° off earns 50. Errors across the three rotation axes combine into one rotation distance.

Half & Half 20 percentage points

Drag the cut or its handles to split the total filled area equally. All shapes count together; empty space doesn’t count.

50/50 earns 100; 60/40 earns 50; 70/30 or worse earns zero. A 53.6/46.4 split is 3.6 percentage points from even and earns 82 points.

-
Line memory Stroke overlap

Study the line for three seconds, then trace it in one stroke. Your line and the target have the same thickness. Clear to try drawing again before you lock in.

Your score is the area shared by both strokes divided by the total area covered by either stroke. 75% overlap earns 75 points; no overlap earns zero. Missing sections and extra scribbles lower your score. Speed and direction don’t matter. On reveal, a dashed center marks the target.

+
Line memory Target coverage · 25% extra ink allowed

Study the line for three seconds, then trace it in one stroke. Your line and the target have the same thickness. Clear to try drawing again before you lock in.

Your base score is the percentage of the target stroke you cover. Covering 75% earns 75 points. Small misplaced marks don’t cost extra points unless your total painted area exceeds 1.25× the target’s area.

Beyond that allowance, each extra 1% of target area deducts one point. For example, 80% coverage with 1.35× the target’s area earns 80 − 10 = 70 points. Scores stop at zero. We count unique painted area, so retracing the same spot adds no ink. Speed and direction don’t matter. On reveal, a dashed center marks the target.

This rule applies to new practice rounds and Daily Fives from October 17, 2026. October 16 and previously saved overlap-scoring rounds keep their original shared-area ÷ combined-area rule.

Perspective 40% of the inner diagonal

Tap where the receding lines would meet: the vanishing point.

Your miss is the straight-line distance from your point to the answer, measured against the inner box’s corner-to-corner diagonal. A miss of 40% of that diagonal earns zero; 20% earns 50.

Size it up 50% error per dimension

Start with one square. Drag the corner to make the requested number of squares wide and tall. The shaded starting square stays fixed as your reference. The opposite corner stays anchored.

Width and height each contribute up to 50 points. A dimension that’s 50% too big or small earns zero for that dimension. 5% off in both dimensions earns 90 points. One exact dimension and one 50% off earns 50. A wide guess can’t cancel a short one. The reveal overlays the correct grid on your dashed outline.

Rhythm ⅔× to 1½× the tempo

Watch five silent flashes, then tap five beats at the same pace. Your tap intervals determine your tempo.

Zero points at two-thirds or one-and-a-half times the target tempo. We compare proportional tempo changes, so reciprocal changes get equal scores. For a target of 100 BPM, about 82 or 122 BPM earns 50 points; 66⅔ or 150 BPM earns zero.

diff --git a/src/design-options/player-storage.js b/src/design-options/player-storage.js index 3fbfbb9..5a513e4 100644 --- a/src/design-options/player-storage.js +++ b/src/design-options/player-storage.js @@ -43,6 +43,7 @@ function validSavedGame(game){ for(const q of game.deck){ if(!q||!['view3d','balance','halfhalf','motion','perspective','proportion','rhythm','angle','colour','time','timeline','duration'].includes(q.type)||typeof q.title!=='string'||q.title.length>200||typeof q.skill!=='string'||q.skill.length>100||!finiteTree(q))return false; if(q.scoringVersion!==undefined&&q.scoringVersion!==2)return false; + if(q.traceScoringVersion!==undefined&&(q.traceScoringVersion!==3||q.type!=='motion'||q.scoringVersion!==2))return false; if(q.scoringVersion===2&&q.type==='time'&&q.timeScoringVersion!==2)return false; if(q.scoringVersion===2&&q.type==='timeline'&&q.timelineScoringVersion!==2)return false; if(q.source&&(!Array.isArray(q.source)||!/^https:\/\//.test(q.source[1])))return false; diff --git a/src/design-options/round-feedback.js b/src/design-options/round-feedback.js index 85562b5..af88e95 100644 --- a/src/design-options/round-feedback.js +++ b/src/design-options/round-feedback.js @@ -6,7 +6,7 @@ function roundErrorDisplay(q,value){ case 'angle':return {value:number(angularDistance(value,q.answer)),unit:'°',label:'Angle error'}; case 'halfhalf':return {value:number(halfError(value,q)*100),unit:'',label:'Split error',note:'percentage points'}; case 'balance':return q.fruit?{value:number(halfError(value,q)*100),unit:'',label:'Split error',note:'percentage points'}:percent(Math.abs(value-q.answer)/balanceWidth(q)*100,'Balance error','of the shape’s width'); - case 'motion':if(q.scoringVersion===2)return percent(traceOverlap(value,q.answer)*100,'Line overlap','shared area ÷ combined area');return percent(traceError(value,q.answer)/500*100,q.type==='motion'?'Path error':'Position error','of the play-area diagonal'); + case 'motion':if(usesTraceCoverage(q)){const trace=traceCoverageResult(value,q.answer);return percent(trace.coverage*100,'Target covered',trace.penalty>0?'−'+number(Math.min(trace.coverage,trace.penalty)*100)+' pts for excess ink':'No excess-ink penalty');}if(q.scoringVersion===2)return percent(traceOverlap(value,q.answer)*100,'Line overlap','shared area ÷ combined area');return percent(traceError(value,q.answer)/500*100,q.type==='motion'?'Path error':'Position error','of the play-area diagonal'); case 'perspective':return percent(pointError(value,q.answer)/perspectiveDiagonal(q)*100,'Position error',q.scoringVersion===2?'of the inner-box diagonal':'of the play-area diagonal'); case 'time':return {value:Math.abs(value-q.answer).toFixed(2),unit:'s',label:'Timing error',note:(Math.abs(value-q.answer)<.005?'Spot on':value{const r=c.getBoundingClientRect(),scale=Math.min(r.width/400,r.height/300);return deck[0].answer.map(p=>({x:r.x+(r.width-400*scale)/2+(p.x+3)*scale,y:r.y+(r.height-300*scale)/2+p.y*scale}));}); const session=await page.context().newCDPSession(page);await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[points[0]]});for(const p of points.slice(1))await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[p]});await session.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});await session.detach(); await page.screenshot({path:output+'/line-play-'+theme+'.png'});await page.locator('#trace-lock').click(); - const result=await page.evaluate(()=>({score:results[0].score,check:Math.round(100*traceOverlap(results[0].guess,deck[0].answer)),saved:JSON.stringify(results)}));assert.equal(result.score,result.check);assert(result.score>40&&result.score<100); - assert(await page.locator('.round-result-error').innerText().then(s=>s.includes('Line overlap'))); + const result=await page.evaluate(()=>({score:results[0].score,check:Math.round(100*traceCoverageResult(results[0].guess,deck[0].answer).closeness),saved:JSON.stringify(results)}));assert.equal(result.score,result.check);assert(result.score>40&&result.score<100); + assert(await page.locator('.round-result-error').innerText().then(s=>s.includes('Target covered'))); await page.screenshot({path:output+'/line-reveal-'+theme+'.png'}); for(const viewport of [{width:320,height:667},{width:768,height:1024}]){await page.setViewportSize(viewport);await page.waitForTimeout(40);assert.equal(await page.evaluate(()=>JSON.stringify(results)),result.saved);assert(await page.evaluate(()=>document.body.scrollWidth<=innerWidth));} await page.setViewportSize({width:390,height:844});await page.selectOption('#theme-select',theme==='focus'?'night-lab':'focus');assert.equal(await page.evaluate(()=>JSON.stringify(results)),result.saved); + await page.setViewportSize({width:375,height:667}); + await page.evaluate(()=>{deck=makeDeck('practice:excess-ink','motion');deck[0]={...deck[0],answer:[{x:100,y:150},{x:200,y:150}]};index=0;results=[];render();tracePicker(deck[0]);reveal([{x:100,y:150},{x:250,y:150}]);}); + assert((await page.locator('.round-result-error').innerText()).includes('pts for excess ink'));assert.equal(await page.evaluate(()=>results[0].score),79); + assert(await page.locator('#next').evaluate(b=>b.getBoundingClientRect().bottom<=innerHeight));await page.screenshot({path:output+'/line-penalty-'+theme+'.png'}); + await page.evaluate(()=>{deck=makeDeck('2026-10-16','motion');index=0;results=[];render();tracePicker(deck[0]);reveal(deck[0].answer.map(p=>({x:p.x+4,y:p.y})));});assert((await page.locator('.round-result-error').innerText()).includes('Line overlap'),'October 16 keeps old feedback'); + await page.setViewportSize({width:390,height:844}); await page.goto('http://127.0.0.1:4175/?game=memory&mode=practice');await page.locator('#game-menu-toggle').click();const menu=await page.locator('#game-menu').innerText();assert(menu.includes('Color memory'));assert(!/colour/i.test(menu));await page.keyboard.press('Escape');assert(!/colour/i.test(await page.locator('#playarea').innerText())); await page.goto('http://127.0.0.1:4175/?game=perspective&mode=practice');await page.evaluate(()=>{deck=makeDeck('practice:inner-box','perspective');index=0;results=[];render();reveal({x:deck[0].answer.x+PERSPECTIVE_INNER_DIAGONAL*.2,y:deck[0].answer.y});});assert.equal(await page.evaluate(()=>results[0].score),50);assert((await page.locator('.round-result-error').innerText()).includes('inner-box'));await page.screenshot({path:output+'/perspective-reveal-'+theme+'.png'}); } - assert.deepEqual(errors,[]);console.log('Verified mobile thick-stroke preview/play/reveal with real touch, matching overlap score, resize/theme persistence, Color menu/prompt spelling, and inner-box Perspective feedback in both themes.'); + assert.deepEqual(errors,[]);console.log('Verified mobile thick-stroke preview/play/reveal with real touch, matching coverage score, excess-ink penalty and historical feedback, resize/theme persistence, Color menu/prompt spelling, and inner-box Perspective feedback in both themes.'); }finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/verify-trace-coverage.cjs b/tests/verify-trace-coverage.cjs new file mode 100644 index 0000000..655c8c6 --- /dev/null +++ b/tests/verify-trace-coverage.cjs @@ -0,0 +1,33 @@ +const fs=require('fs'),vm=require('vm'),assert=require('assert'); +const source=fs.readFileSync('src/design-options/game-themed.js','utf8').replace(/const demoGame=new URLSearchParams[\s\S]*$/,''); +const el={addEventListener(){},classList:{toggle(){}},dataset:{}}; +const ctx=vm.createContext({document:{getElementById(){return el},addEventListener(){},querySelectorAll(){return[]}},Date,Math,Number,setTimeout,clearTimeout,performance});vm.runInContext(source,ctx);const run=s=>vm.runInContext(s,ctx); +const target=[{x:100,y:150},{x:200,y:150}];ctx.testQ={type:'motion',answer:target,scoringVersion:2,traceScoringVersion:3}; +const check=points=>{ctx.testGuess=points;return {score:run('scoreFor(testGuess,testQ)'),...run('traceCoverageResult(testGuess,testQ.answer)')}}; +assert.equal(check(target).score,100); +assert.equal(check([...target].reverse()).score,100); +assert.equal(check([{x:100,y:150},{x:150,y:150},{x:200,y:150}]).score,100); +assert.equal(check([...target,...target.slice().reverse(),...target]).score,100,'repeated ink is counted once'); +assert.equal(check([]).score,0);assert.equal(check([target[0]]).score,0); +const misplaced=check(target.map(p=>({...p,y:p.y+4})));assert.equal(misplaced.penalty,0,'ordinary misplaced trace gets no ink penalty');assert(misplaced.score>=65&&misplaced.score<=70,'only uncovered target counts'); +ctx.shifted=target.map(p=>({...p,y:p.y+4}));assert(misplaced.score>run('scoreFor(shifted,{...testQ,traceScoringVersion:undefined})')+10,'fix double penalty for ordinary offsets'); +const under=check([{x:100,y:150},{x:225,y:150}]);assert(under.areaRatio>1.2&&under.areaRatio<1.25);assert.equal(under.score,100,'extra ink within allowance costs nothing');assert.equal(under.penalty,0); +const over=check([{x:100,y:150},{x:250,y:150}]);assert.equal(over.coverage,1);assert(over.penalty>0);assert(over.score>=78&&over.score<=80,'full coverage with about 146% ink deducts about 21 points'); +// Sweep actual rasterized strokes across the 125% boundary. Penalty starts +// only after the boundary and grows by 1 point per extra 1% of target area. +let last=100,below=false,above=false;for(let end=220;end<=245;end++){ + const result=check([{x:100,y:150},{x:end,y:150}]);assert(result.score<=last);last=result.score; + if(result.areaRatio<=1.25){below=true;assert.equal(result.penalty,0);assert.equal(result.score,100);} + else{above=true;assert(result.penalty>0);assert(Math.abs(result.penalty-(result.areaRatio-1.25))<1e-12);} +}assert(below&&above); +const partial=check([{x:100,y:150},{x:150,y:150}]);assert.equal(partial.penalty,0);assert(partial.score>=50&&partial.score<=55); +assert.equal(check(target.map(p=>({...p,y:p.y+12}))).score,0); +const scribble=[];for(let y=0;y<=300;y+=6){scribble.push({x:(y/6)%2?400:0,y},{x:(y/6)%2?0:400,y});}assert.equal(check(scribble).score,0,'board scribble earns zero even with full coverage'); +assert.equal(check([{x:0,y:150},{x:400,y:150}]).score,0,'excess ink clamps to zero'); +for(const y of [0,300]){const edge=[{x:0,y},{x:400,y}];ctx.testQ.answer=edge;assert.equal(check(edge).score,100);}ctx.testQ.answer=target; +for(const day of ['2026-10-15','2026-10-16']){ctx.day=day;assert(run("makeDeck(day,'motion').every(q=>q.traceScoringVersion===undefined)"));} +for(const day of ['2026-10-17','practice:coverage']){ctx.day=day;assert(run("makeDeck(day,'motion').every(q=>q.traceScoringVersion===3&&q.scoringVersion===2)"));} +ctx.saved=run("makeDeck('2026-10-17','motion')");assert(run('validSavedGame({deck:saved,results:[]})'));assert(!run('validSavedGame({deck:saved.map(q=>({...q,traceScoringVersion:4})),results:[]})'));assert(!run("validSavedGame({deck:makeDeck('practice:angle','angle').map(q=>({...q,traceScoringVersion:3})),results:[]})")); +assert.equal(run("roundErrorDisplay(testQ,testQ.answer).label"),'Target covered');assert.equal(run("roundErrorDisplay(testQ,testQ.answer).note"),'No excess-ink penalty');ctx.oversized=[{x:100,y:150},{x:250,y:150}];assert(run("roundErrorDisplay(testQ,oversized).note.includes('pts for excess ink')")); +assert(fs.readFileSync('src/design-options/how-to-play.html','utf8').includes('1.25× the target’s area')); +console.log('Verified target coverage, 125% unique-ink allowance and threshold crossing, linear excess penalty, ordinary offsets, partial traces, retracing, speed/direction invariance, edges, board scribbles, dated generation and saved-version compatibility.');