Percent Market
You run the shop: set the discounts, ring up the tax, and keep customers happy. Every pricing decision is percent math — against the clock.
How to play
You get 60 seconds and 3 lives. Each customer brings a pricing puzzle —
sale prices, tax totals, tips, price gains,
successive discounts, and simple interest.
Type a dollar amount like 64.80 (the $ is optional).
- 1A correct answer scores +10 points. Streaks of 4 or more earn a bonus of +2 per streak level beyond 3 — keep the streak alive.
- 2A wrong answer costs 1 life, resets your streak, and shows a short explanation of why the correct answer is right.
- 3The game ends when the timer hits 0 or you lose all 3 lives. Puzzles start single-step, then ramp to discount-then-tax combos and interest.
Best streak 0
/* Percent Market — core game logic (pure, no DOM). * UMD: node (require) + browser (window.PercentMarketGame). * Money in integer cents. ALL answers are dollar amounts (one parser, * zero ambiguity). API mirrors the shared page_game.js glue: * mulberry32(seed), difficultyFor(qIndex), genQuestion(rng, asked, qIndex), * parseAnswer(raw), answerEquals(correct, parsed), correctDisplay(correct), * newGameState(), scoreStep(s, ok), tickTimer(s), accuracyPct(s). * Every amount is constructed to be an exact cent. */ (function (root, factory) { var api = factory(); if (typeof module === 'undefined') { root.PercentMarketGame = api; } else { module.exports = api; } })(typeof self !== 'undefined' ? self : this, function () { 'use strict';
var PCT = '%';
function mulberry32(seed) { var a = seed >>> 0; return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; var t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function randInt(rng, lo, hi) { return lo + Math.floor(rng() * (hi - lo + 1)); } function pick(rng, arr) { return arr[Math.floor(rng() * arr.length)]; }
function moneyStr(cents) { return '$' + (cents / 100).toFixed(2); } function priceStr(cents) { return cents % 100 === 0 ? '$' + (cents / 100) : moneyStr(cents); } function pctOfCents(p, cents) { return Math.round(p * cents / 100); }
function difficultyFor(qIndex) { if (qIndex < 5) return 0; if (qIndex < 12) return 1; return 2; } /* ---- question builders (all answers in cents) ---- band 0: single-step discount / tip / tax band 1: + percent-change gain, successive percents band 2: + tax-on-sale, simple interest */ function qDiscount(rng) { var pr = pick(rng, [[80, 25], [200, 15], [120, 30], [90, 20], [75, 40], [50, 10], [100, 10], [70, 30], [110, 20], [130, 25]]); var cents = pr[0] * 100, p = pr[1]; var sale = cents - pctOfCents(p, cents); return { kind: 'discount', cents: cents, p: p, correct: sale, typeLabel: 'Sale price', prompt: 'A customer buys a ' + priceStr(cents) + ' jacket at ' + p + PCT + ' off. What is the sale price?', why: 'Discount = ' + (p / 100) + ' × ' + priceStr(cents) + ' = ' + moneyStr(pctOfCents(p, cents)) + '. Sale price = ' + priceStr(cents) + ' − ' + moneyStr(pctOfCents(p, cents)) + ' = ' + moneyStr(sale) + '.' }; } function qTip(rng) { var pr = pick(rng, [[60, 15], [75, 20], [40, 18], [50, 20], [88, 15], [45, 20], [65, 15], [70, 18]]); var cents = pr[0] * 100, p = pr[1]; var total = cents + pctOfCents(p, cents); return { kind: 'tip', cents: cents, p: p, correct: total, typeLabel: 'Bill + tip total', prompt: 'The bill is ' + priceStr(cents) + ' and the customer leaves a ' + p + PCT + ' tip. What total is due?', why: 'Tip = ' + (p / 100) + ' × ' + priceStr(cents) + ' = ' + moneyStr(pctOfCents(p, cents)) + '. Total = ' + priceStr(cents) + ' + ' + moneyStr(pctOfCents(p, cents)) + ' = ' + moneyStr(total) + '.' }; } function qTax(rng) { var pr = pick(rng, [[45, 8], [90, 6], [120, 8], [75, 10], [200, 5], [110, 7], [95, 8], [130, 5]]); var cents = pr[0] * 100, r = pr[1]; var total = cents + pctOfCents(r, cents); return { kind: 'tax', cents: cents, r: r, correct: total, typeLabel: 'Total with tax', prompt: 'A ' + priceStr(cents) + ' meal has ' + r + PCT + ' sales tax. What is the total?', why: 'Tax = ' + (r / 100) + ' × ' + priceStr(cents) + ' = ' + moneyStr(pctOfCents(r, cents)) + '. Total = ' + priceStr(cents) + ' + ' + moneyStr(pctOfCents(r, cents)) + ' = ' + moneyStr(total) + '.' }; } function qGain(rng) { var pr = pick(rng, [[40, 52], [50, 65], [20, 25], [80, 100], [200, 250], [100, 130], [60, 72]]); var a = pr[0] * 100, b = pr[1] * 100; var gain = b - a, pct = Math.round(gain / a * 100); return { kind: 'gain', a: a, b: b, pct: pct, correct: gain, typeLabel: 'Price gain', prompt: 'Your stock rises from ' + priceStr(a) + ' to ' + priceStr(b) + ' (' + pct + PCT + ' up). How much did it gain?', why: 'Gain = new − old = ' + priceStr(b) + ' − ' + priceStr(a) + ' = ' + moneyStr(gain) + '.' + ' (That is ' + pct + PCT + ' of the original ' + priceStr(a) + '.)' }; } function qSucc(rng) { var t = pick(rng, [[60, 25, 20], [80, 25, 20], [100, 20, 10], [200, 50, 20], [120, 25, 40], [150, 10, 15], [90, 30, 10]]); var cents = t[0] * 100, up = t[1], down = t[2]; var mid = Math.round(cents * (1 + up / 100)); var fin = Math.round(mid * (1 - down / 100)); return { kind: 'succ', cents: cents, up: up, down: down, correct: fin, typeLabel: 'Successive percents', prompt: 'You raise a ' + priceStr(cents) + ' item by ' + up + PCT + ', then take ' + down + PCT + ' off the new price. What is the final price?', why: 'After the raise: ' + priceStr(cents) + ' × ' + (1 + up / 100) + ' = ' + moneyStr(mid) + '. After the cut: ' + moneyStr(mid) + ' × ' + (1 - down / 100) + ' = ' + moneyStr(fin) + '.' + ' Percents do not undo each other!' }; } function qTaxSale(rng) { var t = pick(rng, [[80, 25, 8], [200, 15, 6], [120, 30, 8], [60, 50, 10], [100, 20, 6], [90, 10, 8]]); var cents = t[0] * 100, d = t[1], r = t[2]; var sale = cents - pctOfCents(d, cents); var total = sale + pctOfCents(r, sale); return { kind: 'taxsale', cents: cents, d: d, r: r, correct: total, typeLabel: 'Discount then tax', prompt: 'A ' + priceStr(cents) + ' jacket is ' + d + PCT + ' off, plus ' + r + PCT + ' tax on the sale price. What is the total?', why: 'Sale = ' + priceStr(cents) + ' − ' + moneyStr(pctOfCents(d, cents)) + ' = ' + moneyStr(sale) + '. Tax = ' + (r / 100) + ' × ' + moneyStr(sale) + ' = ' + moneyStr(pctOfCents(r, sale)) + '. Total = ' + moneyStr(total) + '. (Tax goes on the sale price, not the original!)' }; } function qInterest(rng) { var t = pick(rng, [[100000, 5, 2], [50000, 4, 3], [200000, 3, 2], [80000, 5, 1], [120000, 4, 2], [60000, 5, 2]]); var princ = t[0], r = t[1], y = t[2]; var interest = Math.round(princ * r / 100 * y); return { kind: 'interest', princ: princ, r: r, y: y, correct: interest, typeLabel: 'Simple interest', prompt: '' + moneyStr(princ) + ' at ' + r + PCT + ' simple interest for ' + y + ' year' + (y === 1 ? '' : 's') + '. How much interest?', why: 'Interest = principal × rate × time = ' + moneyStr(princ) + ' × ' + (r / 100) + ' × ' + y + ' = ' + moneyStr(interest) + '.' }; }
function buildQuestion(rng, level) { var pool; if (level === 0) pool = [qDiscount, qTip, qTax]; else if (level === 1) pool = [qDiscount, qTip, qTax, qGain, qSucc, qGain, qSucc]; else pool = [qDiscount, qTip, qTax, qGain, qSucc, qTaxSale, qInterest, qTaxSale, qInterest]; var q = pick(rng, pool)(rng); q.key = q.kind + '|' + q.correct + '|' + (q.cents || q.a || q.princ || 0); q.difficulty = level; return q; }
function genQuestion(rng, asked, qIndex) {
var level = difficultyFor(qIndex);
var q = null, guard = 0;
do {
q = buildQuestion(rng, level);
guard++;
} while (!(!asked.has(q.key) || !(guard < 60)));
asked.add(q.key);
return q;
}
function parseAnswer(raw) {
var s = String(raw).trim().replace(/[$,\s]+/g, '');
if (!/^[+-]?\d+(\.\d{1,2})?$/.test(s)) return null;
var c = Math.round(parseFloat(s) * 100);
if (!isFinite(c)) return null;
return c;
}
function answerEquals(correct, parsed) { return !(parsed === null || parsed !== correct); }
function correctDisplay(c) { return moneyStr(c); }
function newGameState() {
return {
score: 0, streak: 0, bestStreak: 0,
lives: 3, correct: 0, answered: 0,
timeLeft: 60, qIndex: 0,
over: false, overReason: ''
};
}
function scoreStep(s, ok) {
var points = 0;
if (ok) {
s.streak += 1;
s.correct += 1;
if (s.streak > s.bestStreak) s.bestStreak = s.streak;
var bonus = (s.streak > 3) ? 2 * (s.streak - 3) : 0;
points = 10 + bonus;
s.score += points;
} else {
s.lives -= 1;
s.streak = 0;
}
s.answered += 1;
if (s.lives <= 0) { s.over = true; s.overReason = 'lives'; }
return { points: points, gameOver: s.over, reason: s.overReason };
}
function tickTimer(s) {
if (s.over) return true;
s.timeLeft -= 1;
if (s.timeLeft <= 0) {
s.timeLeft = 0;
s.over = true;
s.overReason = 'time';
return true;
}
return false;
}
function accuracyPct(s) {
if (s.answered === 0) return 0;
return Math.round((s.correct / s.answered) * 100);
}
return {
mulberry32: mulberry32,
difficultyFor: difficultyFor,
genQuestion: genQuestion,
buildQuestion: buildQuestion,
parseAnswer: parseAnswer,
answerEquals: answerEquals,
correctDisplay: correctDisplay,
newGameState: newGameState,
scoreStep: scoreStep,
tickTimer: tickTimer,
accuracyPct: accuracyPct,
moneyStr: moneyStr,
priceStr: priceStr,
pctOfCents: pctOfCents
};
});
/* Shared Fibo sprint-game UI wiring (browser only).
Config (inline script before this file):
window.FIBO_GAME = {
core: 'IntegerGame', // window global exposing the game core
totalTime: 60,
answerPlaceholder: 'e.g. -13',
hintLineHTML: 'Accepts integers like -13.',
invalidHTML: 'Enter an integer like -13.'
}
Core API (pure, node-tested): newGameState(), mulberry32(seed), genQuestion(rng, asked, qIndex),
parseAnswer(raw), answerEquals(correct, parsed), scoreStep(s, ok), tickTimer(s), accuracyPct(s),
correctDisplay(correct).
*/
(function () {
'use strict';
var CFG = window.FIBO_GAME || {};
var CORE = window[CFG.core || ''];
if (!CORE) { return; }
var TOTAL = CFG.totalTime || 60;
function $(id) { return document.getElementById(id); }
var HEART_SVG = '';
var startScreen = $('startScreen'), gameScreen = $('gameScreen'), overScreen = $('overScreen'); var startBtn = $('startBtn'), againBtn = $('againBtn'); var answerForm = $('answerForm'), answerInput = $('answerInput'), submitBtn = $('submitBtn'); var feedback = $('feedback');
var state, rng, asked, current, timerId, locked;
function show(screen) { [startScreen, gameScreen, overScreen].forEach(function (s) { s.classList.remove('show'); }); screen.classList.add('show'); }
function renderHearts() { var box = $('livesVal'); box.innerHTML = ''; for (var i = 0; i < 3; i++) { var span = document.createElement('span'); span.innerHTML = HEART_SVG; var svg = span.firstChild; if (i >= state.lives) { svg.classList.add('lost'); } box.appendChild(svg); } box.setAttribute('aria-label', state.lives + (state.lives === 1 ? ' life' : ' lives') + ' remaining'); }
function renderHud() { $('timerVal').textContent = state.timeLeft; $('scoreVal').textContent = state.score; $('streakVal').textContent = state.streak; $('bestStreakHud').textContent = 'Best streak ' + state.bestStreak; $('qCount').textContent = 'Question ' + (state.qIndex + 1); $('timerBox').classList.toggle('timer-low', state.timeLeft <= 10); $('timeFill').style.width = (state.timeLeft / TOTAL * 100) + '%'; renderHearts(); } function nextQuestion() { locked = false; current = CORE.genQuestion(rng, asked, state.qIndex); state.qIndex += 1; $('qType').textContent = current.typeLabel; $('qPrompt').innerHTML = current.prompt; feedback.className = 'feedback'; feedback.innerHTML = ''; answerInput.value = ''; answerInput.disabled = false; submitBtn.disabled = false; renderHud(); setTimeout(function () { answerInput.focus(); }, 60); } function stopTimer() { if (timerId) { clearInterval(timerId); timerId = null; } } function startTimer() { stopTimer(); timerId = setInterval(function () { var expired = CORE.tickTimer(state); renderHud(); if (expired) { endGame(); } }, 1000); } function startGame() { state = CORE.newGameState(); state.timeLeft = TOTAL; rng = CORE.mulberry32(Date.now() % 2147483647); asked = new Set(); show(gameScreen); nextQuestion(); startTimer(); } function endGame() { stopTimer(); state.over = true; var title = $('overTitle'), reason = $('overReason'); if (state.overReason === 'lives') { title.textContent = 'Out of lives!'; reason.textContent = 'You used all 3 lives. The streak bonus rewards careful answers \u2014 take a breath and sprint again.'; } else { title.textContent = 'Time\u2019s up!'; reason.textContent = TOTAL + ' seconds on the clock. Your best streak shows how far careful speed can take you.'; } $('finalScore').textContent = state.score; $('finalBest').textContent = state.bestStreak; $('finalAnswered').textContent = state.answered; $('finalAcc').textContent = CORE.accuracyPct(state) + '%'; show(overScreen); setTimeout(function () { againBtn.focus(); }, 60); } function onSubmit(ev) { ev.preventDefault(); if (locked || state.over) { return; } var parsed = CORE.parseAnswer(answerInput.value); var ok = CORE.answerEquals(current.correct, parsed); var res = CORE.scoreStep(state, ok); locked = true; answerInput.disabled = true; submitBtn.disabled = true; var fbClass, title, body; if (ok) { fbClass = 'correct'; title = 'Correct! +' + res.points + ' points'; body = (state.streak >= 4) ? 'Streak of ' + state.streak + ' \u2014 streak bonus applied (+' + (2 * (state.streak - 3)) + ').' : 'Keep the streak going for bonus points.'; } else { fbClass = 'wrong'; title = 'Not quite \u2014 1 life lost.'; if (parsed === null) { body = '' + (CFG.invalidHTML || 'That answer could not be read.') + ' ' + '' + current.why + ''; } else { body = 'The correct answer is ' + CORE.correctDisplay(current.correct) + '. ' + '' + current.why + ''; } } feedback.className = 'feedback show ' + fbClass; feedback.innerHTML = '
'; renderHud();
if (res.gameOver) { setTimeout(endGame, 1400); } else { setTimeout(nextQuestion, ok ? 1100 : 2400); } }
if (CFG.answerPlaceholder) { answerInput.placeholder = CFG.answerPlaceholder; } if (CFG.hintLineHTML) { $('hintLine').innerHTML = CFG.hintLineHTML; } startBtn.addEventListener('click', startGame); againBtn.addEventListener('click', startGame); answerForm.addEventListener('submit', onSubmit); })();