The real puzzles search spaces up to 2160 keys. That is unusable here, so this demo shrinks everything down to a toy keyspace of just a few bits — small enough to search in your browser, big enough to feel the pattern.
<p class="hint">Puzzle #<b>n</b> hides its private key somewhere in the range [2^(n-1), 2^n − 1]. Slide n and watch the range — and the search — double in size every step.</p>
<div class="controls">
<label for="nrange">Puzzle number n:</label>
<input type="range" id="nrange" min="1" max="16" value="6" step="1">
<span id="nval" class="nval">6</span>
</div>
<div class="range-box">
<div class="range-line" id="rangeLine"></div>
<div class="range-caption" id="rangeCaption"></div>
</div>
<div class="btns">
<button id="search" type="button">Search</button>
<button id="reveal" type="button" class="ghost">Reveal key</button>
<button id="reset" type="button" class="ghost">New secret</button>
</div>
<div class="status" id="status">Ready. Press Search to hunt for the hidden key.</div>
<div class="progress-wrap">
<div class="progress-bar" id="progressBar"></div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.controls { display: flex; align-items: center; gap: .6rem; margin-bottom: .8rem; flex-wrap: wrap; }
.controls label { font-size: .85rem; font-weight: 600; color: #1d3557; }
input[type="range"] { flex: 1 1 160px; max-width: 220px; accent-color: #1d3557; }
.nval { font: 700 15px ui-monospace, monospace; color: #1d3557; min-width: 1.6em; text-align: center; }
.range-box { background: #eef2f5; border: 1px solid #cdd9e3; border-radius: 8px; padding: .6rem .8rem; margin-bottom: .8rem; }
.range-line { font: 700 13px ui-monospace, monospace; color: #1d3557; word-break: break-all; }
.range-caption { font-size: .8rem; color: #555; margin-top: .3rem; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .7rem; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: default; }
.status { font-size: 1rem; font-weight: 600; margin: .4rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.progress-wrap { background: #e3e7ea; border-radius: 6px; height: 10px; overflow: hidden; }
.progress-bar { height: 100%; width: 0%; background: linear-gradient(90deg,#1d3557,#e63946); transition: width .05s linear; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Toy model of the real Bitcoin puzzle transaction: puzzle #n's private key k satisfies
// 2^(n-1) <= k < 2^n. Real puzzles reach n = 160; here n stays small enough to brute-force live.
let n = 6;
let secretKey = null;
let searching = false;
let timer = null;
const nrange = document.getElementById('nrange');
const nval = document.getElementById('nval');
const rangeLine = document.getElementById('rangeLine');
const rangeCaption = document.getElementById('rangeCaption');
const searchBtn = document.getElementById('search');
const revealBtn = document.getElementById('reveal');
const resetBtn = document.getElementById('reset');
const statusEl = document.getElementById('status');
const progressBar = document.getElementById('progressBar');
// Inclusive [lo, hi] range and its size for a given puzzle number.
function bounds(nn) {
const lo = Math.pow(2, nn - 1);
const hi = Math.pow(2, nn) - 1;
return { lo, hi, size: hi - lo + 1 };
}
function pickSecret(nn) {
const { lo, hi } = bounds(nn);
return lo + Math.floor(Math.random() * (hi - lo + 1));
}
function setStatus(t, cls) {
statusEl.textContent = t;
statusEl.className = 'status ' + (cls || '');
}
function renderRange() {
const { lo, hi, size } = bounds(n);
rangeLine.textContent = '[' + lo + ' .. ' + hi + ']';
rangeCaption.textContent = i18nFmt('Puzzle #%n% range has %size% candidate key(s) — this is what brute force must search.', { n: n, size: size });
}
function resetAll() {
searching = false;
if (timer) { clearInterval(timer); timer = null; }
secretKey = pickSecret(n);
progressBar.style.width = '0%';
setStatus('Ready. Press Search to hunt for the hidden key.', '');
searchBtn.disabled = false;
revealBtn.disabled = false;
}
// Linear brute force in small batches per animation tick, so the doubling is visible, not instant.
function search() {
if (searching) return;
searching = true;
searchBtn.disabled = true;
revealBtn.disabled = true;
const { lo, hi, size } = bounds(n);
let candidate = lo;
let tries = 0;
const stepsPerTick = Math.max(1, Math.ceil(size / 400));
setStatus('Searching candidates one by one...', '');
timer = setInterval(() => {
for (let i = 0; i < stepsPerTick && candidate <= hi; i++) {
tries++;
if (candidate === secretKey) {
clearInterval(timer);
timer = null;
searching = false;
progressBar.style.width = '100%';
setStatus(i18nFmt('Found key %key% after %tries% of %size% candidates checked.', { key: secretKey, tries: tries, size: size }), 'ok');
searchBtn.disabled = false;
revealBtn.disabled = false;
return;
}
candidate++;
}
progressBar.style.width = Math.min(100, (tries / size) * 100) + '%';
if (candidate > hi) {
clearInterval(timer);
timer = null;
searching = false;
searchBtn.disabled = false;
revealBtn.disabled = false;
}
}, 16);
}
function reveal() {
setStatus(i18nFmt('The hidden key was %key%.', { key: secretKey }), 'ok');
}
nrange.addEventListener('input', () => {
n = parseInt(nrange.value, 10);
nval.textContent = String(n);
renderRange();
resetAll();
});
searchBtn.addEventListener('click', search);
revealBtn.addEventListener('click', reveal);
resetBtn.addEventListener('click', resetAll);
renderRange();
resetAll();
<p class="hint">Puzzle #<b>n</b> hides its private key somewhere in the range [2^(n-1), 2^n − 1]. Slide n and watch the range — and the search — double in size every step.</p>
<div class="controls">
<label for="nrange">Puzzle number n:</label>
<input type="range" id="nrange" min="1" max="16" value="6" step="1">
<span id="nval" class="nval">6</span>
</div>
<div class="range-box">
<div class="range-line" id="rangeLine"></div>
<div class="range-caption" id="rangeCaption"></div>
</div>
<div class="btns">
<button id="search" type="button">Search</button>
<button id="reveal" type="button" class="ghost">Reveal key</button>
<button id="reset" type="button" class="ghost">New secret</button>
</div>
<div class="status" id="status">Ready. Press Search to hunt for the hidden key.</div>
<div class="progress-wrap">
<div class="progress-bar" id="progressBar"></div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.controls { display: flex; align-items: center; gap: .6rem; margin-bottom: .8rem; flex-wrap: wrap; }
.controls label { font-size: .85rem; font-weight: 600; color: #1d3557; }
input[type="range"] { flex: 1 1 160px; max-width: 220px; accent-color: #1d3557; }
.nval { font: 700 15px ui-monospace, monospace; color: #1d3557; min-width: 1.6em; text-align: center; }
.range-box { background: #eef2f5; border: 1px solid #cdd9e3; border-radius: 8px; padding: .6rem .8rem; margin-bottom: .8rem; }
.range-line { font: 700 13px ui-monospace, monospace; color: #1d3557; word-break: break-all; }
.range-caption { font-size: .8rem; color: #555; margin-top: .3rem; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .7rem; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: default; }
.status { font-size: 1rem; font-weight: 600; margin: .4rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.progress-wrap { background: #e3e7ea; border-radius: 6px; height: 10px; overflow: hidden; }
.progress-bar { height: 100%; width: 0%; background: linear-gradient(90deg,#1d3557,#e63946); transition: width .05s linear; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Toy model of the real Bitcoin puzzle transaction: puzzle #n's private key k satisfies
// 2^(n-1) <= k < 2^n. Real puzzles reach n = 160; here n stays small enough to brute-force live.
let n = 6;
let secretKey = null;
let searching = false;
let timer = null;
const nrange = document.getElementById('nrange');
const nval = document.getElementById('nval');
const rangeLine = document.getElementById('rangeLine');
const rangeCaption = document.getElementById('rangeCaption');
const searchBtn = document.getElementById('search');
const revealBtn = document.getElementById('reveal');
const resetBtn = document.getElementById('reset');
const statusEl = document.getElementById('status');
const progressBar = document.getElementById('progressBar');
// Inclusive [lo, hi] range and its size for a given puzzle number.
function bounds(nn) {
const lo = Math.pow(2, nn - 1);
const hi = Math.pow(2, nn) - 1;
return { lo, hi, size: hi - lo + 1 };
}
function pickSecret(nn) {
const { lo, hi } = bounds(nn);
return lo + Math.floor(Math.random() * (hi - lo + 1));
}
function setStatus(t, cls) {
statusEl.textContent = t;
statusEl.className = 'status ' + (cls || '');
}
function renderRange() {
const { lo, hi, size } = bounds(n);
rangeLine.textContent = '[' + lo + ' .. ' + hi + ']';
rangeCaption.textContent = i18nFmt('Puzzle #%n% range has %size% candidate key(s) — this is what brute force must search.', { n: n, size: size });
}
function resetAll() {
searching = false;
if (timer) { clearInterval(timer); timer = null; }
secretKey = pickSecret(n);
progressBar.style.width = '0%';
setStatus('Ready. Press Search to hunt for the hidden key.', '');
searchBtn.disabled = false;
revealBtn.disabled = false;
}
// Linear brute force in small batches per animation tick, so the doubling is visible, not instant.
function search() {
if (searching) return;
searching = true;
searchBtn.disabled = true;
revealBtn.disabled = true;
const { lo, hi, size } = bounds(n);
let candidate = lo;
let tries = 0;
const stepsPerTick = Math.max(1, Math.ceil(size / 400));
setStatus('Searching candidates one by one...', '');
timer = setInterval(() => {
for (let i = 0; i < stepsPerTick && candidate <= hi; i++) {
tries++;
if (candidate === secretKey) {
clearInterval(timer);
timer = null;
searching = false;
progressBar.style.width = '100%';
setStatus(i18nFmt('Found key %key% after %tries% of %size% candidates checked.', { key: secretKey, tries: tries, size: size }), 'ok');
searchBtn.disabled = false;
revealBtn.disabled = false;
return;
}
candidate++;
}
progressBar.style.width = Math.min(100, (tries / size) * 100) + '%';
if (candidate > hi) {
clearInterval(timer);
timer = null;
searching = false;
searchBtn.disabled = false;
revealBtn.disabled = false;
}
}, 16);
}
function reveal() {
setStatus(i18nFmt('The hidden key was %key%.', { key: secretKey }), 'ok');
}
nrange.addEventListener('input', () => {
n = parseInt(nrange.value, 10);
nval.textContent = String(n);
renderRange();
resetAll();
});
searchBtn.addEventListener('click', search);
revealBtn.addEventListener('click', reveal);
resetBtn.addEventListener('click', resetAll);
renderRange();
resetAll();
Pick a puzzle number n: the hidden key lives somewhere in [2n−1,2n−1], a range of exactly 2n−1 candidates. Press Search and the computer checks candidates one by one until it hits the secret. Slide n up by one and watch the candidate count — and the number of tries the search needs on average — double. That doubling, not any weakness in the cryptography, is the entire difficulty of the real puzzle.
Comments
Loading comments...