• 01 - Critter Models 02
  • 02 - VantaJS Shaders
  • 03 - App Bundle 02
  • 04 - Cathedrals
  • 05 - App Bundle 01
  • 06 - Retroscopic
  • 07 - Games
  • 08 - Voronoi
  • 09 - Terrain
  • 10 - Critter Models 01
  • 11 - Chromatophores
  • 12 - Conformal Maps
  • 13 - Echo Logix
  • 14 - Music
  • 15 - Colour Theory
MIKE'S NOTEBOOK
Notes
Number:
Project:
07Games
└ Rockfall└ Speak & Math└ Space Invaders└ Apache└ Asteroids└ Molly Roger└ Carrom└ Chess└ Connect 4└ Anagrams
08Voronoi
└ 001└ 002└ 003└ 004└ 005
09Terrain
└ Simple Polygon└ Simple Flyover└ Infinite Flyover 01└ Infinite Flyover 02└ Hexagonal Plane└ Mountain Ridges
10Critter Models 01
└ Wireframe Spider└ Wireframe Fly└ Wireframe Scorpion└ Wireframe Mosquito└ Wireframe Bee└ Wireframe Butterfly└ Wireframe Locust
11Chromatophores
└ 01└ 02└ 03└ 04
12Conformal Maps
└ 01└ 02└ 03└ 04└ 05
02 / 03

SPEAK AND-MATH - INTERACTIVE ARCADE GAME SOURCE CODE

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Speak and Math</title>
<style>
  :root{
    --case-blue:#1d5fb0;
    --case-blue-dark:#123f78;
    --case-blue-deep:#0c2c56;
    --panel-navy:#0f345f;
    --orange:#f5821f;
    --orange-dark:#c25f0a;
    --key-cream:#f3ecdc;
    --key-cream-edge:#cbc2ab;
    --key-red:#e23b2e;
    --key-teal:#1fa79a;
    --vfd-bg:#04120c;
    --vfd-glow:#77ffd2;
    --vfd-dim:#123d2f;
    --text-light:#eaf2ff;
  }
  *{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
  html,body{margin:0;padding:0;}
  body{
    min-height:100vh;
    background:transparent;
    font-family:"Arial Narrow", "Helvetica Neue", Arial, sans-serif;
    display:flex;
    align-items:flex-start;
    justify-content:center;
    padding:185px 24px 48px;
    color:#12161f;
  }
  @media (prefers-color-scheme: dark) {
    body { color:var(--text-light); }
  }

  /* Fullscreen Mode Styling */
  body.in-fullscreen { padding: 48px 24px !important; align-items: center !important; }

  .machine-wrap{width:min(640px,100%);}

  .handle{
    width:62%;
    height:58px;
    margin:0 auto -14px;
    border:14px solid var(--orange);
    border-bottom:none;
    border-radius:34px 34px 0 0;
    box-shadow:
      inset 0 4px 0 rgba(255,255,255,.35),
      inset 0 -6px 0 var(--orange-dark),
      0 6px 10px rgba(0,0,0,.35);
    position:relative;
    z-index:0;
  }

  .case{
    position:relative;
    z-index:1;
    background:linear-gradient(180deg,#2a72c8 0%, var(--case-blue) 22%, var(--case-blue-dark) 100%);
    border-radius:34px;
    padding:22px 24px 30px;
    box-shadow:
      inset 0 3px 0 rgba(255,255,255,.28),
      inset 0 -10px 18px rgba(0,0,0,.35),
      inset 8px 0 14px rgba(255,255,255,.06),
      inset -8px 0 14px rgba(0,0,0,.25),
      0 22px 40px rgba(0,0,0,.55);
  }
  .case::after{
    content:"";
  }
  .screw{
    position:absolute;width:14px;height:14px;border-radius:50%;
    background:radial-gradient(circle at 35% 30%, #9db6d6, #45608a 60%, #223a5c);
    box-shadow:inset 0 1px 2px rgba(0,0,0,.6);
  }
  .screw::after{
    content:"";position:absolute;left:2px;right:2px;top:50%;height:2px;margin-top:-1px;
    background:#132844;border-radius:2px;transform:rotate(35deg);
  }
  .screw.tl{top:14px;left:16px;} .screw.tr{top:14px;right:16px;}
  .screw.bl{bottom:14px;left:16px;} .screw.br{bottom:14px;right:16px;}

  .topbar{
    display:flex;align-items:center;justify-content:space-between;
    gap:14px;margin:2px 6px 14px;
  }
  .logo{
    background:var(--orange);
    color:#fff;
    font-weight:900;
    font-family:Arial, Helvetica, sans-serif;
    font-style:italic;
    letter-spacing:.5px;
    padding:7px 16px 8px;
    border-radius:12px;
    font-size:clamp(18px,4vw,26px);
    text-shadow:0 2px 0 rgba(0,0,0,.25);
    box-shadow:inset 0 2px 0 rgba(255,255,255,.4), inset 0 -3px 0 var(--orange-dark), 0 3px 6px rgba(0,0,0,.3);
    white-space:nowrap;
  }
  .logo small{font-size:.5em;font-weight:700;display:block;font-style:normal;letter-spacing:2px;margin-top:-2px;}
  .speaker{
    flex:1;max-width:210px;height:56px;border-radius:14px;
    background:
      repeating-linear-gradient(90deg, #0c2c56 0 6px, #1a4a86 6px 12px);
    box-shadow:inset 0 3px 8px rgba(0,0,0,.6), inset 0 -1px 0 rgba(255,255,255,.15);
  }

  .display-bezel{
    background:linear-gradient(180deg,#0a1f3c,#122c52);
    border-radius:16px;
    padding:12px 14px;
    box-shadow:inset 0 4px 10px rgba(0,0,0,.7), inset 0 -1px 0 rgba(255,255,255,.12);
    margin:0 4px 16px;
  }
  .vfd{
    position:relative;
    background:
      radial-gradient(120% 160% at 50% 0%, #0a2418 0%, var(--vfd-bg) 60%);
    border-radius:8px;
    min-height:86px;
    padding:14px 16px 10px;
    overflow:hidden;
    box-shadow:inset 0 0 24px rgba(0,0,0,.9);
  }
  .vfd::before{
    content:"";position:absolute;inset:0;
    background:linear-gradient(115deg, rgba(255,255,255,.10) 0%, rgba(255,255,255,0) 28%);
    pointer-events:none;
  }
  .vfd::after{
    content:"";position:absolute;inset:0;
    background:repeating-linear-gradient(0deg, rgba(0,0,0,.22) 0 2px, transparent 2px 4px);
    pointer-events:none;mix-blend-mode:multiply;
  }
  .vfd-main{
    font-family:"Courier New", ui-monospace, Menlo, monospace;
    font-weight:700;
    font-size:clamp(24px,6vw,40px);
    letter-spacing:.14em;
    color:var(--vfd-glow);
    text-shadow:0 0 6px rgba(119,255,210,.9), 0 0 18px rgba(119,255,210,.45);
    min-height:1.2em;
    white-space:nowrap;
    overflow:hidden;
  }
  .vfd-sub{
    font-family:"Courier New", ui-monospace, monospace;
    font-size:clamp(11px,2.6vw,14px);
    letter-spacing:.22em;
    color:var(--vfd-glow);
    opacity:.75;
    text-shadow:0 0 5px rgba(119,255,210,.7);
    margin-top:6px;
    text-transform:uppercase;
    min-height:1.1em;
    white-space:nowrap;overflow:hidden;
  }
  .vfd.off .vfd-main,.vfd.off .vfd-sub{color:var(--vfd-dim);text-shadow:none;opacity:.5;}
  .vfd-status{
    position:absolute;top:8px;right:12px;display:flex;gap:10px;
    font-family:Arial, sans-serif;font-size:9px;letter-spacing:1px;color:var(--vfd-glow);
  }
  .lamp{display:flex;align-items:center;gap:4px;opacity:.25;transition:opacity .2s;}
  .lamp.on{opacity:1;text-shadow:0 0 6px rgba(119,255,210,.8);}
  .lamp::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 6px currentColor;}

  .panel{
    background:linear-gradient(180deg,#144073,var(--panel-navy));
    border-radius:20px;
    padding:16px 16px 18px;
    box-shadow:inset 0 3px 8px rgba(0,0,0,.55), inset 0 -1px 0 rgba(255,255,255,.1);
  }
  .section-label{
    font-size:10px;letter-spacing:2.5px;text-transform:uppercase;
    color:#9fc0e8;margin:2px 4px 8px;font-weight:700;
  }

  .activity-row{
    display:grid;
    grid-template-columns:repeat(5,1fr);
    gap:10px;
    margin-bottom:16px;
  }

  button.key{
    appearance:none;border:none;cursor:pointer;
    font-family:"Arial Narrow", Arial, sans-serif;
    border-radius:12px;
    padding:10px 4px 12px;
    font-weight:800;
    letter-spacing:.4px;
    color:#20242c;
    background:linear-gradient(180deg,#fff 0%, var(--key-cream) 45%, #e2d8c2 100%);
    box-shadow:
      inset 0 2px 0 rgba(255,255,255,.9),
      inset 0 -4px 0 var(--key-cream-edge),
      0 4px 0 #8d8570,
      0 7px 10px rgba(0,0,0,.45);
    transition:transform .05s, box-shadow .05s, filter .15s;
    user-select:none;
    line-height:1.05;
  }
  button.key:active, button.key.pressed{
    transform:translateY(3px);
    box-shadow:
      inset 0 2px 0 rgba(255,255,255,.7),
      inset 0 -2px 0 var(--key-cream-edge),
      0 1px 0 #8d8570,
      0 2px 4px rgba(0,0,0,.4);
  }
  button.key:focus-visible{outline:3px solid #ffd27a;outline-offset:2px;}

  .key .big{display:block;font-size:clamp(15px,3.6vw,20px);}
  .key .tiny{display:block;font-size:9px;letter-spacing:1px;margin-top:2px;font-weight:700;color:#5a5343;text-transform:uppercase;}

  .key.orange{
    color:#fff;
    background:linear-gradient(180deg,#ffb066 0%, var(--orange) 45%, #d96f10 100%);
    box-shadow:
      inset 0 2px 0 rgba(255,255,255,.55),
      inset 0 -4px 0 var(--orange-dark),
      0 4px 0 #7c3f05,
      0 7px 10px rgba(0,0,0,.45);
    text-shadow:0 1px 0 rgba(0,0,0,.25);
  }
  .key.orange .tiny{color:#ffe6cc;}
  .key.red{
    color:#fff;
    background:linear-gradient(180deg,#ff8a7e 0%, var(--key-red) 45%, #b32418 100%);
    box-shadow:inset 0 2px 0 rgba(255,255,255,.5), inset 0 -4px 0 #8f1d13, 0 4px 0 #5c0f08, 0 7px 10px rgba(0,0,0,.45);
    text-shadow:0 1px 0 rgba(0,0,0,.3);
  }
  .key.red .tiny{color:#ffd9d4;}
  .key.teal{
    color:#fff;
    background:linear-gradient(180deg,#63d9cc 0%, var(--key-teal) 45%, #147d73 100%);
    box-shadow:inset 0 2px 0 rgba(255,255,255,.5), inset 0 -4px 0 #0e6058, 0 4px 0 #093f3a, 0 7px 10px rgba(0,0,0,.45);
    text-shadow:0 1px 0 rgba(0,0,0,.3);
  }
  .key.teal .tiny{color:#d5fbf6;}
  .key.navy{
    color:#fff;
    background:linear-gradient(180deg,#4d7fc0 0%, #2a5a9c 45%, #1c4278 100%);
    box-shadow:inset 0 2px 0 rgba(255,255,255,.4), inset 0 -4px 0 #14315a, 0 4px 0 #0b2140, 0 7px 10px rgba(0,0,0,.45);
    text-shadow:0 1px 0 rgba(0,0,0,.3);
  }
  .key.navy .tiny{color:#cfe1f8;}

  .main-grid{
    display:grid;
    grid-template-columns:repeat(5,1fr);
    gap:10px;
  }
  .key.wide{grid-column:span 2;}

  .machine.off button.key{filter:saturate(.7) brightness(.92);}

  .foot{
    text-align:center;
    margin-top:18px;
    font-size:11.5px;
    letter-spacing:.4px;
    color:#8ea3c4;
  }
  .foot b{color:#c8d8f2;}

  @media (max-width:480px){
    .case{padding:16px 12px 22px;border-radius:26px;}
    .activity-row{gap:7px;}
    .main-grid{gap:7px;}
    button.key{border-radius:10px;padding:9px 2px 11px;}
    .speaker{height:44px;}
    .handle{height:44px;border-width:11px;}
  }
  @media (prefers-reduced-motion:reduce){
    button.key{transition:none;}
  }
</style>
</head>
<body>

<div class="machine-wrap machine off" id="machine">
  <div class="handle" aria-hidden="true"></div>
  <div class="case">
    <div class="screw tl"></div><div class="screw tr"></div>
    <div class="screw bl"></div><div class="screw br"></div>

    <div class="topbar">
      <div class="logo">Speak &amp; Math<small>TALKING LEARNING AID</small></div>
      <div class="speaker" aria-hidden="true"></div>
    </div>

    <div class="display-bezel">
      <div class="vfd off" id="vfd">
        <div class="vfd-status">
          <span class="lamp" id="lampLevel">LEVEL 1</span>
          <span class="lamp" id="lampScore">PROB 0/10</span>
        </div>
        <div class="vfd-main" id="displayMain">&nbsp;</div>
        <div class="vfd-sub" id="displaySub">&nbsp;</div>
      </div>
    </div>

    <div class="panel">
      <div class="section-label">Activities</div>
      <div class="activity-row">
        <button class="key orange" data-act="solve"><span class="big">SOLVE</span><span class="tiny">It</span></button>
        <button class="key orange" data-act="gl"><span class="big">&gt; &lt;</span><span class="tiny">Greater / Less</span></button>
        <button class="key orange" data-act="word"><span class="big">WORD</span><span class="tiny">Problems</span></button>
        <button class="key orange" data-act="write"><span class="big">WRITE</span><span class="tiny">It</span></button>
        <button class="key orange" data-act="stumper"><span class="big">NUMBER</span><span class="tiny">Stumper</span></button>
      </div>

      <div class="section-label">Keyboard</div>
      <div class="main-grid">
        <button class="key teal" data-k="on"><span class="big">ON</span><span class="tiny">Go</span></button>
        <button class="key" data-k="7"><span class="big">7</span></button>
        <button class="key" data-k="8"><span class="big">8</span></button>
        <button class="key" data-k="9"><span class="big">9</span></button>
        <button class="key navy" data-k="div"><span class="big">÷</span></button>

        <button class="key navy" data-k="level"><span class="big">LEVEL</span><span class="tiny">1·2·3</span></button>
        <button class="key" data-k="4"><span class="big">4</span></button>
        <button class="key" data-k="5"><span class="big">5</span></button>
        <button class="key" data-k="6"><span class="big">6</span></button>
        <button class="key navy" data-k="mul"><span class="big">×</span></button>

        <button class="key navy" data-k="repeat"><span class="big">REPEAT</span><span class="tiny">Say again</span></button>
        <button class="key" data-k="1"><span class="big">1</span></button>
        <button class="key" data-k="2"><span class="big">2</span></button>
        <button class="key" data-k="3"><span class="big">3</span></button>
        <button class="key navy" data-k="sub"><span class="big">−</span></button>

        <button class="key red" data-k="off"><span class="big">OFF</span></button>
        <button class="key" data-k="gt"><span class="big">&gt;</span><span class="tiny">Greater</span></button>
        <button class="key" data-k="0"><span class="big">0</span></button>
        <button class="key" data-k="lt"><span class="big">&lt;</span><span class="tiny">Less</span></button>
        <button class="key navy" data-k="add"><span class="big">+</span></button>

        <button class="key red" data-k="erase"><span class="big">ERASE</span></button>
        <button class="key teal wide" data-k="enter"><span class="big">ENTER</span></button>
        <button class="key navy wide" data-k="eq"><span class="big">=</span></button>
      </div>
    </div>
  </div>
  <p class="foot">A fan-made homage to the classic 1980 talking math tutor. <b>Turn your sound on</b> — it speaks!
  Keyboard works too: digits, <b>+ − * /</b>, <b>&lt; &gt;</b>, <b>Enter</b>, <b>Backspace</b> = erase, <b>R</b> = repeat, <b>L</b> = level, <b>F</b> = fullscreen.</p>
</div>

<script>
(function(){
  "use strict";

  let ac = null;
  function audio(){
    if(!ac){ try{ ac = new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} }
    if(ac && ac.state === "suspended") ac.resume();
    return ac;
  }
  function tone(freq, dur, type, vol, when){
    const ctx = audio(); if(!ctx) return;
    const t0 = ctx.currentTime + (when||0);
    const o = ctx.createOscillator();
    const g = ctx.createGain();
    o.type = type||"square";
    o.frequency.setValueAtTime(freq, t0);
    g.gain.setValueAtTime(0.0001, t0);
    g.gain.exponentialRampToValueAtTime(vol||0.12, t0+0.01);
    g.gain.exponentialRampToValueAtTime(0.0001, t0+dur);
    o.connect(g).connect(ctx.destination);
    o.start(t0); o.stop(t0+dur+0.02);
  }
  const sfx = {
    key(){ tone(1150, .06, "square", .07); },
    enter(){ tone(880,.07,"square",.09); tone(1320,.09,"square",.08,.07); },
    right(){ [660,880,1100,1470].forEach((f,i)=>tone(f,.11,"triangle",.12,i*.09)); },
    wrong(){ tone(160,.28,"sawtooth",.14); tone(120,.3,"sawtooth",.12,.05); },
    fanfare(){ [523,659,784,1046,784,1046].forEach((f,i)=>tone(f,.14,"triangle",.13,i*.12)); },
    power(){ [400,600,800,1200].forEach((f,i)=>tone(f,.09,"square",.1,i*.07)); },
    off(){ [900,600,350].forEach((f,i)=>tone(f,.09,"square",.1,i*.06)); }
  };

  const speech = {
    voice:null,
    pick(){
      const vs = window.speechSynthesis ? speechSynthesis.getVoices() : [];
      this.voice = vs.find(v=>/en[-_]/i.test(v.lang) && /male|daniel|david|alex|fred/i.test(v.name))
                || vs.find(v=>/^en/i.test(v.lang)) || vs[0] || null;
    },
    say(text, cb){
      if(!window.speechSynthesis){ if(cb) setTimeout(cb, 300); return; }
      speechSynthesis.cancel();
      const u = new SpeechSynthesisUtterance(text);
      if(this.voice) u.voice = this.voice;
      u.pitch = 0.55;
      u.rate  = 0.85;
      u.volume = 1;
      if(cb){ u.onend = cb; u.onerror = cb; }
      speechSynthesis.speak(u);
    },
    stop(){ if(window.speechSynthesis) speechSynthesis.cancel(); }
  };
  if(window.speechSynthesis){
    speech.pick();
    speechSynthesis.onvoiceschanged = ()=>speech.pick();
  }

  const $ = s=>document.querySelector(s);
  const machineEl = $("#machine");
  const vfd = $("#vfd");
  const dMain = $("#displayMain");
  const dSub  = $("#displaySub");
  const lampLevel = $("#lampLevel");
  const lampScore = $("#lampScore");

  function show(main, sub){
    dMain.textContent = (main===undefined||main===null||main==="") ? "\u00a0" : main;
    if(sub !== undefined) dSub.textContent = sub==="" ? "\u00a0" : sub;
  }
  function updateLamps(){
    lampLevel.textContent = "LEVEL " + S.level;
    lampScore.textContent = "PROB " + Math.min(S.probNum, ROUND) + "/" + ROUND;
    lampLevel.classList.toggle("on", S.power);
    lampScore.classList.toggle("on", S.power && !!S.activity);
  }

  const rnd = (a,b)=>Math.floor(Math.random()*(b-a+1))+a;
  const pick = arr=>arr[rnd(0,arr.length-1)];
  const OPSYM = {add:"+", sub:"−", mul:"×", div:"÷"};
  const OPWORD = {add:"plus", sub:"minus", mul:"times", div:"divided by"};
  const ROUND = 10;

  const S = {
    power:false,
    activity:null,
    level:1,
    probNum:0,
    correctCount:0,
    tries:0,
    entry:"",
    answer:null,
    promptSpeech:"",
    displayProblem:"",
    locked:false,
    stump:null
  };

  function genSolve(){
    const L = S.level;
    let op, a, b;
    if(L===1){
      op = pick(["add","sub"]);
      a = rnd(1,9); b = rnd(1,9);
      if(op==="sub" && b>a) [a,b]=[b,a];
    }else if(L===2){
      op = pick(["add","sub","mul"]);
      if(op==="mul"){ a=rnd(2,9); b=rnd(2,9); }
      else { a=rnd(10,99); b=rnd(1,99); if(op==="sub" && b>a) [a,b]=[b,a]; }
    }else{
      op = pick(["add","sub","mul","div"]);
      if(op==="mul"){ a=rnd(3,12); b=rnd(3,19); }
      else if(op==="div"){ b=rnd(2,12); const q=rnd(2,12); a=b*q; }
      else { a=rnd(25,999); b=rnd(10,999); if(op==="sub" && b>a) [a,b]=[b,a]; }
    }
    const ans = op==="add"?a+b: op==="sub"?a-b: op==="mul"?a*b: a/b;
    S.answer = ans;
    S.displayProblem = a+" "+OPSYM[op]+" "+b+" = ";
    S.promptSpeech = a+" "+OPWORD[op]+" "+b+" equals?";
    show(S.displayProblem, "SOLVE IT · TYPE ANSWER, ENTER");
    speech.say(S.promptSpeech);
  }

  function genGL(){
    const L = S.level;
    const hi = L===1?20 : L===2?200 : 5000;
    let a = rnd(0,hi), b = rnd(0,hi);
    while(a===b) b = rnd(0,hi);
    S.answer = a>b ? ">" : "<";
    S.displayProblem = a+" ? "+b;
    S.promptSpeech = "Is "+a+" greater than, or less than "+b+"?";
    show(S.displayProblem, "PRESS  >  OR  <");
    speech.say(S.promptSpeech);
  }

  function genWrite(){
    const L = S.level;
    const n = L===1 ? rnd(3,99) : L===2 ? rnd(100,999) : rnd(1000,99999);
    S.answer = n;
    S.displayProblem = "";
    S.promptSpeech = "Write the number "+n+".";
    show("_", "WRITE IT · TYPE WHAT I SAY");
    speech.say(S.promptSpeech);
  }

  function genWord(){
    const L = S.level;
    let a,b,ans,text,op;
    const T1 = [
      (a,b)=>["You have "+a+" apples. A friend gives you "+b+" more. How many apples do you have?", a+b],
      (a,b)=>["There are "+a+" birds in a tree. "+b+" fly away. How many birds are left?", a-b],
      (a,b)=>["You read "+a+" pages, then "+b+" more pages. How many pages did you read?", a+b],
      (a,b)=>["A box holds "+a+" crayons. You take out "+b+". How many crayons stay in the box?", a-b]
    ];
    const T2 = [
      (a,b)=>["Stickers come in packs of "+a+". You buy "+b+" packs. How many stickers is that?", a*b],
      (a,b)=>["A bus has "+a+" seats. "+b+" seats are empty. How many seats are taken?", a-b],
      (a,b)=>["You save "+a+" pennies each day for "+b+" days. How many pennies do you save?", a*b],
      (a,b)=>["A book has "+a+" pages. You have read "+b+". How many pages are left?", a-b]
    ];
    const T3 = [
      (a,b)=>["There are "+(a*b)+" cookies shared equally among "+b+" friends. How many cookies does each friend get?", a],
      (a,b)=>["A train travels "+a+" miles every hour for "+b+" hours. How far does it travel?", a*b],
      (a,b)=>["You have "+(a*b)+" marbles in bags of "+b+". How many bags do you have?", a],
      (a,b)=>["Tickets cost "+a+" dollars each. What do "+b+" tickets cost?", a*b]
    ];
    if(L===1){ a=rnd(2,9); b=rnd(1,9); if(b>a)[a,b]=[b,a]; [text,ans]=pick(T1)(a,b); }
    else if(L===2){ a=rnd(3,12); b=rnd(2,9); if(b>a)[a,b]=[b,a]; [text,ans]=pick(T2)(a,b); }
    else { a=rnd(3,12); b=rnd(2,9); [text,ans]=pick(T3)(a,b); }
    S.answer = ans;
    S.displayProblem = "";
    S.promptSpeech = text;
    show("?", "WORD PROBLEM · LISTEN, THEN TYPE");
    speech.say(text);
  }

  function genStumper(){
    const digits = S.level===1?3 : S.level===2?4 : 5;
    const lo = Math.pow(10,digits-1), hi = Math.pow(10,digits)-1;
    S.stump = { secret:rnd(lo,hi), digits:digits, known:Array(digits).fill("_"), guesses:0 };
    S.answer = S.stump.secret;
    S.displayProblem = "";
    S.promptSpeech = "I am thinking of a "+digits+" digit number. Guess my number.";
    show(S.stump.known.join(" "), "NUMBER STUMPER · GUESS + ENTER");
    speech.say(S.promptSpeech);
  }

  const GENS = {solve:genSolve, gl:genGL, write:genWrite, word:genWord, stumper:genStumper};
  const ACTNAME = {solve:"Solve it", gl:"Greater than, less than", word:"Word problems", write:"Write it", stumper:"Number stumper"};

  function startActivity(act){
    S.activity = act;
    S.probNum = 0;
    S.correctCount = 0;
    S.stump = null;
    speech.say("Here is "+ACTNAME[act]+", level "+S.level+".", ()=>nextProblem());
    show("", ACTNAME[act].toUpperCase()+" · LEVEL "+S.level);
    updateLamps();
  }

  function nextProblem(){
    if(!S.power || !S.activity) return;
    S.probNum++;
    S.tries = 0;
    S.entry = "";
    updateLamps();
    if(S.probNum > ROUND){ finishRound(); return; }
    GENS[S.activity]();
  }

  function finishRound(){
    const pct = Math.round(S.correctCount/ROUND*100);
    S.locked = true;
    sfx.fanfare();
    show("SCORE "+pct+"%", S.correctCount+" OF "+ROUND+" CORRECT");
    let line = "Here is your score. "+S.correctCount+" out of "+ROUND+" correct. That is "+pct+" percent. ";
    line += pct===100 ? "Perfect score! You are a math whiz!"
          : pct>=80 ? "Great job!"
          : pct>=50 ? "Good work. Keep practicing."
          : "Nice try. Let's practice some more.";
    speech.say(line, ()=>{
      S.locked = false;
      S.activity = null;
      updateLamps();
      show("PICK AN ACTIVITY","OR PRESS LEVEL");
    });
  }

  function redrawEntry(){
    if(S.activity==="stumper" && S.stump){
      const g = S.entry.padEnd(S.stump.digits,"_").slice(0,S.stump.digits);
      show(g.split("").join(" "), undefined);
    }else if(S.activity==="solve"){
      show(S.displayProblem + (S.entry||"_"));
    }else{
      show(S.entry||"_");
    }
  }

  function handleCorrect(){
    S.correctCount++;
    S.locked = true;
    sfx.right();
    const praise = pick(["That is correct.","Correct!","You are right.","That is right. Super!","Correct. Well done!"]);
    if(S.activity==="solve") show(S.displayProblem + S.answer, "CORRECT!");
    else if(S.activity==="gl") show(S.displayProblem.replace("?", S.answer), "CORRECT!");
    else show(String(S.answer), "CORRECT!");
    speech.say(praise, ()=>{ S.locked=false; nextProblem(); });
  }

  function handleWrong(){
    S.tries++;
    sfx.wrong();
    if(S.tries===1){
      S.entry = "";
      speech.say("Wrong. Try again.", ()=>{
        if(S.activity==="solve") show(S.displayProblem+"_","TRY AGAIN");
        else if(S.activity==="gl") show(S.displayProblem,"TRY AGAIN · > OR <");
        else show("_","TRY AGAIN");
      });
      show(S.activity==="solve" ? S.displayProblem+"_" : (S.activity==="gl" ? S.displayProblem : "_"), "TRY AGAIN");
    }else{
      S.locked = true;
      const ansText = S.answer===">" ? "greater than" : S.answer==="<" ? "less than" : String(S.answer);
      if(S.activity==="solve") show(S.displayProblem + S.answer, "THE ANSWER");
      else if(S.activity==="gl") show(S.displayProblem.replace("?", S.answer), "THE ANSWER");
      else show(String(S.answer), "THE ANSWER");
      speech.say("Wrong. The answer is "+ansText+".", ()=>{ S.locked=false; nextProblem(); });
    }
  }

  function checkStumperGuess(){
    const st = S.stump;
    if(!st) return;
    if(S.entry.length !== st.digits){
      sfx.wrong();
      speech.say("Guess a "+st.digits+" digit number.");
      return;
    }
    st.guesses++;
    const guess = parseInt(S.entry,10);
    const secretStr = String(st.secret);
    for(let i=0;i<st.digits;i++){
      if(S.entry[i]===secretStr[i]) st.known[i]=secretStr[i];
    }
    if(guess===st.secret){
      S.correctCount++;
      S.locked = true;
      sfx.right();
      show(secretStr.split("").join(" "), "GOT IT IN "+st.guesses+" GUESSES");
      speech.say("You got it! My number was "+st.secret+". You took "+st.guesses+" guesses.", ()=>{
        S.locked=false; nextProblem();
      });
    }else{
      sfx.wrong();
      const hint = guess>st.secret ? "too big" : "too small";
      S.entry = "";
      show(st.known.join(" "), "YOUR GUESS WAS "+hint.toUpperCase());
      speech.say(String(guess)+" is "+hint+". Try again.");
    }
  }

  function onEnter(){
    if(!S.power || S.locked || !S.activity) return;
    if(S.activity==="gl"){ sfx.wrong(); speech.say("Press the greater than, or less than key."); return; }
    if(S.activity==="stumper"){ sfx.enter(); checkStumperGuess(); return; }
    if(S.entry===""){ sfx.wrong(); return; }
    sfx.enter();
    const val = parseInt(S.entry,10);
    S.entry = "";
    if(val === S.answer) handleCorrect(); else handleWrong();
  }

  function onCompare(sym){
    if(!S.power || S.locked) return;
    if(S.activity!=="gl"){ sfx.key(); return; }
    sfx.enter();
    if(sym === S.answer) handleCorrect(); else handleWrong();
  }

  function press(k){
    if(k==="on"){
      audio();
      if(!S.power){
        S.power = true;
        machineEl.classList.remove("off");
        vfd.classList.remove("off");
        sfx.power();
        show("HELLO","PICK AN ACTIVITY");
        speech.say("Hello. This is math time! Pick an activity, or press level.");
        updateLamps();
      }else if(S.activity && !S.locked){
        speech.stop(); nextProblem();
      }
      return;
    }
    if(k==="off"){
      if(!S.power) return;
      speech.stop();
      sfx.off();
      S.power=false; S.activity=null; S.entry=""; S.stump=null;
      machineEl.classList.add("off");
      vfd.classList.add("off");
      show("",""); updateLamps();
      return;
    }
    if(!S.power){ return; }

    if(k==="level"){
      if(S.locked) return;
      sfx.key();
      S.level = S.level%3 + 1;
      updateLamps();
      speech.say("Level "+S.level+".");
      if(S.activity){ startActivity(S.activity); }
      else show("LEVEL "+S.level, "PICK AN ACTIVITY");
      return;
    }
    if(k==="repeat"){
      if(S.promptSpeech && S.activity && !S.locked){ sfx.key(); speech.say(S.promptSpeech); }
      return;
    }
    if(k==="erase"){
      if(S.locked) return;
      sfx.key();
      S.entry="";
      if(S.activity) redrawEntry();
      return;
    }
    if(k==="enter" || k==="eq"){ onEnter(); return; }
    if(k==="gt"){ onCompare(">"); return; }
    if(k==="lt"){ onCompare("<"); return; }
    if(k==="add"||k==="sub"||k==="mul"||k==="div"){ sfx.key(); return; }

    if(/^[0-9]$/.test(k)){
      if(S.locked || !S.activity){ sfx.key(); return; }
      if(S.activity==="gl"){ sfx.key(); return; }
      sfx.key();
      const maxLen = S.activity==="stumper" && S.stump ? S.stump.digits : 7;
      if(S.entry.length < maxLen) S.entry += k;
      redrawEntry();
      return;
    }
  }

  document.querySelectorAll("[data-act]").forEach(btn=>{
    btn.addEventListener("click", ()=>{
      if(!S.power){ sfx.wrong(); return; }
      if(S.locked) return;
      audio(); sfx.enter(); speech.stop();
      startActivity(btn.dataset.act);
    });
  });
  document.querySelectorAll("[data-k]").forEach(btn=>{
    btn.addEventListener("click", ()=>press(btn.dataset.k));
  });

  const KEYMAP = {
    "Enter":"enter","=":"eq","Backspace":"erase","Delete":"erase",
    ">":"gt","<":"lt","+":"add","-":"sub","*":"mul","/":"div",
    "r":"repeat","R":"repeat","l":"level","L":"level",
    "o":"on","O":"on"
  };
  document.addEventListener("keydown", e=>{
    if(e.metaKey||e.ctrlKey||e.altKey) return;
    if(e.key === "f" || e.key === "F"){
      toggleFullscreen();
      e.preventDefault();
      return;
    }
    let k = null;
    if(/^[0-9]$/.test(e.key)) k = e.key;
    else if(KEYMAP[e.key]) k = KEYMAP[e.key];
    if(k){
      e.preventDefault();
      press(k);
      const btn = document.querySelector('[data-k="'+k+'"]');
      if(btn){ btn.classList.add("pressed"); setTimeout(()=>btn.classList.remove("pressed"),120); }
    }
  });

  function toggleFullscreen(){
    const isFull = document.fullscreenElement || document.webkitFullscreenElement || window.innerHeight >= window.screen.height - 10;
    if(!isFull){
      const elem = document.documentElement;
      if(elem.requestFullscreen) elem.requestFullscreen().catch(err=>console.log(err));
      else if(elem.webkitRequestFullscreen) elem.webkitRequestFullscreen();
    } else {
      if(document.exitFullscreen) document.exitFullscreen();
      else if(document.webkitExitFullscreen) document.webkitExitFullscreen();
    }
  }
  function updateFullscreenState(){
    const isFull = !!(document.fullscreenElement || document.webkitFullscreenElement || window.innerHeight >= window.screen.height - 10);
    document.body.classList.toggle("in-fullscreen", isFull);
  }
  document.addEventListener("fullscreenchange", updateFullscreenState);
  document.addEventListener("webkitfullscreenchange", updateFullscreenState);
  window.addEventListener("resize", updateFullscreenState);

  show("PRESS ON","");
  updateLamps();
})();
</script>
</body>
</html>
MikeHiggins.me.uk LinkedIn GitHub Bluesky Contra Upwork
© 2026 Mike Higgins. All rights reserved.