@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;700&family=Press+Start+2P&display=swap');

$color-lighter-gray: #F3F4F6;
$color-light-gray: #D1D5DB;
$color-gray: #9CA3AF;
$color-dark-gray: #6B7280;
$color-darker-gray: #1F2937;
$color-yellow: #FDE68A;
$color-red: #F87171;
$color-dark-red: #EF4444;
$color-green: #6EE7B7;
$color-blue: #3B82F6;
$cell-size: 2.5rem;

body, html {
  min-width: 100vw;
  min-height: 100vh;
  font-family: 'Roboto Mono', monospace;
}

body {
  display: flex;
  align-items: center;
  justify-content: center;
}

.app {
  display: flex;
  align-items: center;
  
  &.done {
    .table {pointer-events: none}
    input.correct {background-color: $color-green}
    input.incorrect {background-color: $color-red}
  }
}

.table {
  display: grid;
  grid-template-columns: repeat(var(--cols), $cell-size);
  grid-auto-rows: $cell-size;
  grid-gap: 1px;
  border-radius: 4px;
  background-color: gray;
  border: 1px solid gray;
  overflow: hidden;
  box-shadow: 
    0 0 5px rgba(0,0,0,0.2), 
    0 0 15px rgba(0,0,0,0.2);
  
  .cell {
    display: flex;
    align-items: center;
    justify-content: center;
    background-color: white;
    color: $color-gray;
    
    &.header {
      background-color: $color-light-gray;
      font-weight: bold;
      color: black;
      
      &.highlight {
        font-size: 1.3rem;
        color: $color-blue;
      }
    }
    
    &.highlight, 
    &.highlight input {
      background-color: $color-yellow;
    }
    
    input {
      text-align: center;
      width: 100%;
      height: 100%;
      border: none;
      background: $color-lighter-gray;
      outline: none;
      padding: 0;
      font-size: 1rem;
      font-weight: bold;
      box-shadow: 
        inset 0 0 2px rgba(0,0,0,0.2),
        inset 0 0 10px rgba(0,0,0,0.1);
      -moz-appearance: textfield;

      &::-webkit-outer-spin-button,
      &::-webkit-inner-spin-button {
        -webkit-appearance: none;
      }
      
      &:focus {
        background-color: $color-yellow;
      }
    }
  }
}

@mixin range-thumb() {
  border: 1px solid $color-darker-gray;
  height: 20px;
  width: 16px;
  border-radius: 3px;
  background: $color-lighter-gray;
  cursor: pointer;
}

@mixin range-track() {
  width: 100%;
  height: 6px;
  cursor: pointer;
  background: $color-light-gray;
  border-radius: 3px;
}

.controls {
  background-color: $color-darker-gray;
  color: $color-lighter-gray;
  padding: 10px;
  margin-left: 10px;
  border-radius: 5px;
  margin-bottom: 10px;
  display: flex;
  flex-direction: column;
  
  &.disabled {
    opacity: 0.7;
    pointer-events: none;
  }
  
  label {
    display: flex;
    align-items: center;
    font-size: 1.2rem;
    text-transform: uppercase;
    
    span {
      margin-right: 5px;
      font-size: 0.8rem;
      color: $color-gray;
    }
  }
  
  input {
    -webkit-appearance: none;
    width: 100%;
    background: transparent;
    height: 20px;
    margin: 5px 0;
  }
  
  input::-webkit-slider-thumb {
    -webkit-appearance: none;
    margin-top: -7px;
    @include range-thumb();
  }
  
  input::-moz-range-thumb {
    @include range-thumb();
  }
  
  input::-webkit-slider-runnable-track {
    @include range-track();
  }
  
  input::-moz-range-track {
    @include range-track();
  }
}

.panel {
  background-color: $color-darker-gray;
  color: $color-lighter-gray;
  padding: 10px;
  margin-left: 10px;
  border-radius: 5px;
  
  .timer {
    font-size: 1.4rem;
    text-align: center;
    margin: 5px 0 15px;
    font-family: 'Press Start 2P', cursive;
    color: $color-red;
    text-shadow: 0 0 3px $color-dark-red;
  }
  
  .grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-gap: 10px;
  }
  
  .panel-item {
    display: flex;
    flex-direction: column;
    font-size: 1.5rem;
    align-items: center;
    margin-top: 10px;
    transition: all 0.3s ease-out;
    
    span {
      font-size: 0.8rem;
      color: $color-gray;
      margin-bottom: 5px;
      text-transform: uppercase;
    }
  }
  
  button {
    background-color: $color-green;
    border: none;
    border-radius: 4px;
    width: 100%;
    padding: 10px;
    font-size: 1.3rem;
    text-align: center;
    margin-top: 15px;
    cursor: pointer;
  }
}
View Compiled
import React, {useState, useEffect, useRef, memo} from 'https://cdn.skypack.dev/react';
import ReactDOM from 'https://cdn.skypack.dev/react-dom';

const random = (min, max) => Math.floor(Math.random() * (max - min) + min);

const getMissing = (size, difficulty) => {
  const missing = new Map();
  while(missing.size < size * size * difficulty - 1) {
    missing.set(`${random(1, size + 1)}.${random(1, size + 1)}`, null);
  }
  return missing;
};

const Cell = memo(({r, c, focused, children}) => (
  <div className={'cell' + (r === 0 || c === 0 ? ' header' : '') + ((focused.r === r && focused.c > c) || (focused.c === c && focused.r > r) ? ' highlight' : '')}>
    {children}
  </div>
));

const EmptyCell = memo(({r, c, missing, onChange, onFocus}) => {
  const value = missing.get(`${r}.${c}`);
  return (
    <input 
      type='number'
      className={value === r * c ? 'correct' : 'incorrect'}
      onFocus={() => onFocus({r, c})} 
      value={value || ''} 
      onChange={e => {
        const map = new Map(missing);
        map.set(`${r}.${c}`, parseInt(e.target.value));
        onChange(map);
      }}/>
  );
});

const NumberCell = memo(({r, c}) => {
  if (c === 0 && r === 0) return 'X';
  if (r === 0) return c;
  if (c === 0) return r;
  return r * c;
});

const Board = memo(({size, missing, focused, onFocus, onChange}) => (
  <div className='table' style={{'--cols': size + 1}}>
    {[...new Array(size + 1)].map((_, r) => [...new Array(size + 1)].map((__, c) => (
      <Cell key={`${c}.${r}`} {...{r, c, focused}}>
        {c * r !== 0 && missing.has(`${r}.${c}`) 
          ? <EmptyCell {...{r, c, missing, onChange, onFocus}}/> 
          : <NumberCell {...{r, c}}/>
        }
      </Cell>
    )))}
  </div>
));

const Timer = ({run, time, setTime}) => {
  const initial = useRef(Date.now());
  const interval = useRef();
  const format = num => String(num).padStart(2, '0')
  useEffect(() => {
    if (run) {
      initial.current = Date.now();
      interval.current = setInterval(() => {
        setTime(Math.floor((Date.now() - initial.current)/1000));
      }, 100);
    } else {
      clearInterval(interval.current);
    }
    return () => clearInterval(interval.current);
  }, [run]);
  
  return (
    <div className='timer'>
      {format(Math.floor(time / 60))}:{format(time % 60)}
    </div>
  );
}

const PanelItem = ({label, text}) => (
  <div className='panel-item'><span>{label}</span>{text}</div>
);

const Panel = ({size, difficulty, missing, done, setDone}) => {
  const [time, setTime] = useState(0);
  const [score, setScore] = useState(null);
  const onClick = e => {
    setDone(d => !d);
    if (!done) {
      let correct = 0;
      missing.forEach((value, key) => {
        const s = key.split('.');
        correct += parseInt(s[0]) * parseInt(s[1]) === value;
      });
      const coefficient = correct * size * (1 + difficulty);
      const score = Math.ceil(coefficient + Math.pow(coefficient, 2) / Math.max(1, time));
      setScore(score);
      party.sparkles(e.target, {count: 50});
    } else {
      setScore(null);
    }
  };
  
  return (
    <div className='panel'>
      <Timer run={!done} {...{time, setTime}}/>
      <div className='grid'>
        <PanelItem label='Remaining' text={Array.from(missing.values()).filter(v => v === null).length}/>
        <PanelItem label='Score' text={score !== null ? score : '----'}/>
      </div>
      <button onClick={onClick}>{done ? 'Start' : "I'm Done!"}</button>
    </div>
  );
};

const Controls = ({done, difficulty, setDifficulty, size, setSize}) => {
  const [v, s] = useState(size);
  const onSizeChange = e => setSize(parseInt(e.target.value));
  return (
    <div className={'controls' + (done ? '' : ' disabled')}>
      <label><span>Difficulty:</span> {difficulty * 100}%</label>
      <input type='range' value={difficulty * 100} onChange={e => setDifficulty(parseInt(e.target.value) / 100)} min={10} max={90} step={10}/>
      <label><span>Board Size:</span> {v}X{v}</label>
      <input type='range' value={v} onChange={e => s(e.target.value)} onMouseUp={onSizeChange} onTouchEnd={onSizeChange} min={3} max={30} step={1}/>
    </div>
  );
};

const App = () => {
  const [done, setDone] = useState(true);
  const [focused, setFocused] = useState({});
  const [missing, setMissing] = useState(new Map());
  const [difficulty, setDifficulty] = useState(0.3);
  const [size, setSize] = useState(8);

  useEffect(() => {
    setFocused({});
    if (!done) {
      setMissing(getMissing(size, difficulty));
    }
  }, [done]);
  
  return (
    <div className={'app' + (done ? ' done' : '')}>
      <Board {...{size, missing, focused, onFocus: setFocused, onChange: setMissing}}/>
      <div>
        <Controls {...{done, difficulty, setDifficulty, size, setSize}}/>
        <Panel {...{size, difficulty, missing, done, setDone}}/>
      </div>
    </div>
  );
}

ReactDOM.render(
  <App/>,
  document.body
);
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdn.jsdelivr.net/npm/party-js@latest/bundle/party.min.js