Biased Roulette
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fortune Wheel</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Cormorant+Garamond:wght@300;500&display=swap" rel="stylesheet" />
<style>
:root {
--gold: #c9a84c;
--gold-light: #f0d080;
--gold-dim: #7a6020;
--bg: #0a0a0f;
--surface: #13131a;
--surface2: #1c1c28;
--text: #e8dfc8;
--text-dim: #8a7e60;
--red: #8b1a1a;
--green: #1a4a2e;
--navy: #1a2040;
--purple: #3a1a4a;
--teal: #0f3a3a;
--crimson: #6b1020;
--glow: rgba(201, 168, 76, 0.4);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Cormorant Garamond', Georgia, serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 16px;
overflow-x: hidden;
}
/* Subtle noise texture overlay */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
opacity: 0.5;
}
.wrapper {
position: relative;
z-index: 1;
width: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
}
header {
text-align: center;
}
header h1 {
font-family: 'Playfair Display', serif;
font-size: clamp(2rem, 6vw, 3rem);
font-weight: 900;
letter-spacing: 0.08em;
background: linear-gradient(135deg, var(--gold-light) 0%, var(--gold) 50%, var(--gold-dim) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-transform: uppercase;
}
header p {
color: var(--text-dim);
font-size: 0.9rem;
letter-spacing: 0.2em;
text-transform: uppercase;
margin-top: 4px;
}
/* Controls */
.controls {
background: var(--surface);
border: 1px solid rgba(201,168,76,0.15);
border-radius: 4px;
padding: 20px 24px;
width: 100%;
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 80px;
}
.field label {
font-size: 0.7rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--gold);
font-family: 'Cormorant Garamond', serif;
font-weight: 500;
}
.field input[type="number"] {
background: var(--surface2);
border: 1px solid rgba(201,168,76,0.2);
color: var(--text);
font-family: 'Playfair Display', serif;
font-size: 1.3rem;
padding: 8px 12px;
border-radius: 3px;
width: 100%;
outline: none;
transition: border-color 0.2s;
-moz-appearance: textfield;
}
.field input::-webkit-outer-spin-button,
.field input::-webkit-inner-spin-button { -webkit-appearance: none; }
.field input:focus { border-color: var(--gold); }
.range-sep {
color: var(--text-dim);
font-size: 1.4rem;
padding-bottom: 10px;
font-family: 'Playfair Display', serif;
}
.btn-build {
background: transparent;
border: 1px solid var(--gold);
color: var(--gold);
font-family: 'Cormorant Garamond', serif;
font-size: 0.75rem;
letter-spacing: 0.2em;
text-transform: uppercase;
padding: 10px 18px;
border-radius: 3px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
white-space: nowrap;
align-self: flex-end;
}
.btn-build:hover { background: var(--gold); color: var(--bg); }
/* Wheel area */
.wheel-area {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
width: 100%;
}
.wheel-container {
position: relative;
width: clamp(280px, 80vw, 420px);
height: clamp(280px, 80vw, 420px);
}
/* Pointer / arrow */
.pointer {
position: absolute;
top: -14px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
filter: drop-shadow(0 0 6px var(--gold));
}
.pointer svg { display: block; }
/* Glow ring behind canvas */
.wheel-glow {
position: absolute;
inset: -10px;
border-radius: 50%;
background: radial-gradient(circle, rgba(201,168,76,0.08) 60%, transparent 75%);
pointer-events: none;
transition: opacity 0.4s;
opacity: 0;
}
.wheel-glow.active { opacity: 1; }
canvas {
border-radius: 50%;
display: block;
width: 100%;
height: 100%;
}
/* Center hub */
.hub {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 40px; height: 40px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #e8d090, #8a6018);
border: 3px solid #1a1410;
box-shadow: 0 0 12px rgba(0,0,0,0.8), 0 0 6px rgba(201,168,76,0.3);
z-index: 5;
}
/* Spin button */
.btn-spin {
position: relative;
background: linear-gradient(135deg, #8a6018 0%, var(--gold) 50%, #8a6018 100%);
border: none;
color: #0a0a0f;
font-family: 'Playfair Display', serif;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
padding: 14px 48px;
border-radius: 3px;
cursor: pointer;
box-shadow: 0 4px 20px rgba(201,168,76,0.25);
transition: transform 0.1s, box-shadow 0.2s, opacity 0.2s;
overflow: hidden;
}
.btn-spin::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, transparent 30%, rgba(255,255,255,0.2) 50%, transparent 70%);
transform: translateX(-100%);
transition: transform 0.5s;
}
.btn-spin:hover::before { transform: translateX(100%); }
.btn-spin:hover { box-shadow: 0 6px 30px rgba(201,168,76,0.45); transform: translateY(-1px); }
.btn-spin:active { transform: translateY(0); }
.btn-spin:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
/* Result display */
.result-card {
background: var(--surface);
border: 1px solid rgba(201,168,76,0.2);
border-radius: 4px;
padding: 18px 32px;
text-align: center;
width: 100%;
min-height: 78px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
transition: border-color 0.4s;
}
.result-card.highlight { border-color: var(--gold); }
.result-label {
font-size: 0.65rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--text-dim);
}
.result-value {
font-family: 'Playfair Display', serif;
font-size: clamp(2.2rem, 8vw, 3.5rem);
font-weight: 900;
color: var(--gold-light);
line-height: 1;
text-shadow: 0 0 30px rgba(240,208,128,0.4);
transition: opacity 0.3s;
}
.result-sub {
font-size: 0.75rem;
color: var(--text-dim);
letter-spacing: 0.1em;
}
.placeholder-msg {
color: var(--text-dim);
font-size: 0.85rem;
letter-spacing: 0.1em;
font-style: italic;
}
/* Error */
.error-msg {
color: #e05050;
font-size: 0.8rem;
letter-spacing: 0.1em;
text-align: center;
min-height: 18px;
}
/* Note about 3 */
.note {
font-size: 0.72rem;
color: var(--text-dim);
letter-spacing: 0.08em;
text-align: center;
line-height: 1.6;
}
.note span { color: var(--gold); }
/* Spinning animation on wheel */
@keyframes pulseGold {
0%, 100% { box-shadow: 0 0 18px rgba(201,168,76,0.2); }
50% { box-shadow: 0 0 40px rgba(201,168,76,0.6); }
}
.result-card.highlight { animation: pulseGold 1.5s ease-in-out 3; }
/* Divider */
.divider {
width: 100%;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(201,168,76,0.2), transparent);
}
</style>
</head>
<body>
<div class="wrapper">
<header>
<h1>Fortune Wheel</h1>
<p>Spin & Discover Your Number</p>
</header>
<div class="controls">
<div class="field">
<label>Lower Bound</label>
<input type="number" id="lb" value="1" step="1" />
</div>
<div class="range-sep">—</div>
<div class="field">
<label>Upper Bound</label>
<input type="number" id="ub" value="8" step="1" />
</div>
<button class="btn-build" onclick="buildWheel()">Build</button>
</div>
<div class="error-msg" id="errorMsg"></div>
<div class="wheel-area">
<div class="wheel-container" id="wheelContainer">
<div class="wheel-glow" id="wheelGlow"></div>
<!-- Pointer arrow at top -->
<div class="pointer">
<svg width="24" height="28" viewBox="0 0 24 28" fill="none">
<path d="M12 28 L0 4 Q12 0 24 4 Z" fill="#c9a84c"/>
<path d="M12 26 L2 6 Q12 2 22 6 Z" fill="#f0d080"/>
</svg>
</div>
<canvas id="wheelCanvas"></canvas>
<div class="hub"></div>
</div>
<button class="btn-spin" id="spinBtn" onclick="spin()" disabled>SPIN</button>
</div>
<div class="divider"></div>
<div class="result-card" id="resultCard">
<p class="placeholder-msg">Build your wheel & spin to reveal</p>
</div>
<p class="note">
Numbers from <span id="noteRange">—</span> each have an equal chance
· <span>3</span> always carries a <span>+20% boost</span> when in range
</p>
</div>
<script>
// ─── State ──────────────────────────────────────────────────────────────────
let wheelData = null; // { segments, numbers, probs }
let currentRotation = 0; // current total rotation in radians
let isSpinning = false;
let animFrame = null;
const SEGMENT_COLORS = [
['#6b1010','#9b2020'], // deep red
['#1a3a1a','#2a5a2a'], // forest green
['#0f1f4a','#1a3070'], // navy
['#2a0f4a','#441880'], // purple
['#0f2f3a','#175060'], // teal
['#3a1a0a','#6a3010'], // brown
['#1a0a2a','#302050'], // indigo
['#1f1a0f','#40381a'], // olive-dark
];
// ─── Build Wheel ─────────────────────────────────────────────────────────────
function buildWheel() {
const lbEl = document.getElementById('lb');
const ubEl = document.getElementById('ub');
const errEl = document.getElementById('errorMsg');
const lb = parseInt(lbEl.value);
const ub = parseInt(ubEl.value);
errEl.textContent = '';
if (isNaN(lb) || isNaN(ub)) { errEl.textContent = 'Please enter valid integers.'; return; }
if (lb > ub) { errEl.textContent = 'Lower bound must be ≤ upper bound.'; return; }
if (ub - lb > 49) { errEl.textContent = 'Range too large — please keep it within 50 numbers.'; return; }
const numbers = [];
for (let i = lb; i <= ub; i++) numbers.push(i);
const N = numbers.length;
const has3 = numbers.includes(3);
// Build probabilities
let probs;
if (!has3 || N === 1) {
probs = numbers.map(() => 1 / N);
} else {
const p3 = 0.20;
const pOther = 0.80 / (N - 1);
probs = numbers.map(n => n === 3 ? p3 : pOther);
}
// Build segments (angles start at -π/2 = top, go clockwise)
let angle = -Math.PI / 2;
const segments = numbers.map((n, i) => {
const sweep = probs[i] * 2 * Math.PI;
const mid = angle + sweep / 2;
const seg = { number: n, prob: probs[i], startAngle: angle, endAngle: angle + sweep, midAngle: mid, colorIdx: i % SEGMENT_COLORS.length };
angle += sweep;
return seg;
});
wheelData = { numbers, probs, segments };
currentRotation = 0;
document.getElementById('noteRange').textContent = `${lb} to ${ub}`;
document.getElementById('spinBtn').disabled = false;
document.getElementById('resultCard').className = 'result-card';
document.getElementById('resultCard').innerHTML = '<p class="placeholder-msg">Ready — hit SPIN!</p>';
drawWheel(0);
}
// ─── Draw ────────────────────────────────────────────────────────────────────
function drawWheel(rotation) {
if (!wheelData) return;
const canvas = document.getElementById('wheelCanvas');
const container = document.getElementById('wheelContainer');
const size = container.clientWidth;
canvas.width = size * devicePixelRatio;
canvas.height = size * devicePixelRatio;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(devicePixelRatio, devicePixelRatio);
const cx = size / 2;
const cy = size / 2;
const R = size / 2 - 8; // outer radius
const innerR = R * 0.12; // hub hole
ctx.clearRect(0, 0, size, size);
// Outer gold ring
ctx.beginPath();
ctx.arc(cx, cy, R + 6, 0, Math.PI * 2);
const goldRing = ctx.createRadialGradient(cx, cy, R, cx, cy, R + 6);
goldRing.addColorStop(0, '#8a6018');
goldRing.addColorStop(0.5, '#c9a84c');
goldRing.addColorStop(1, '#f0d080');
ctx.fillStyle = goldRing;
ctx.fill();
// Draw each segment
wheelData.segments.forEach((seg, i) => {
const start = seg.startAngle + rotation;
const end = seg.endAngle + rotation;
const [dark, light] = SEGMENT_COLORS[seg.colorIdx];
// Fill
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, R, start, end);
ctx.closePath();
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, R);
grad.addColorStop(0, light);
grad.addColorStop(1, dark);
ctx.fillStyle = grad;
ctx.fill();
// Subtle border between segments
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.lineWidth = 1.5;
ctx.stroke();
// Label
const labelR = R * 0.68;
const midAngle = (start + end) / 2;
const lx = cx + labelR * Math.cos(midAngle);
const ly = cy + labelR * Math.sin(midAngle);
ctx.save();
ctx.translate(lx, ly);
ctx.rotate(midAngle + Math.PI / 2);
const sweep = seg.endAngle - seg.startAngle;
const fontSize = Math.max(9, Math.min(sweep * R * 0.38, R * 0.22));
ctx.font = `bold ${fontSize}px 'Playfair Display', Georgia, serif`;
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 4;
// Special gold color for 3
if (seg.number === 3 && wheelData.numbers.includes(3)) {
ctx.fillStyle = '#f0d080';
ctx.shadowColor = 'rgba(0,0,0,0.9)';
}
ctx.fillText(String(seg.number), 0, 0);
ctx.restore();
// Gold dot at segment start radius (decorative tick)
const tickX = cx + (R - 4) * Math.cos(start);
const tickY = cy + (R - 4) * Math.sin(start);
ctx.beginPath();
ctx.arc(tickX, tickY, 2, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(201,168,76,0.5)';
ctx.fill();
});
// Inner dark circle
ctx.beginPath();
ctx.arc(cx, cy, innerR * 2, 0, Math.PI * 2);
ctx.fillStyle = '#0d0d14';
ctx.fill();
ctx.strokeStyle = '#c9a84c';
ctx.lineWidth = 2;
ctx.stroke();
}
// ─── Weighted random pick ─────────────────────────────────────────────────────
function pickWinner() {
const { numbers, probs } = wheelData;
let r = Math.random();
for (let i = 0; i < numbers.length; i++) {
r -= probs[i];
if (r <= 0) return i;
}
return numbers.length - 1;
}
// ─── Spin ─────────────────────────────────────────────────────────────────────
function spin() {
if (isSpinning || !wheelData) return;
isSpinning = true;
const spinBtn = document.getElementById('spinBtn');
const resultCard = document.getElementById('resultCard');
const glow = document.getElementById('wheelGlow');
spinBtn.disabled = true;
resultCard.className = 'result-card';
resultCard.innerHTML = '<p class="placeholder-msg" style="animation: none;">Spinning…</p>';
// Pick winner
const winIdx = pickWinner();
const winner = wheelData.segments[winIdx];
// We want the pointer (at top = -π/2 absolute) to land on the winner's midAngle.
// After adding `currentRotation`, the displayed midAngle is: winner.midAngle + currentRotation
// We want that to equal -π/2 + 2πk for some integer k.
// So additional spin needed: targetRotation = -π/2 - winner.midAngle - currentRotation (mod 2π)
// Then add extra full spins for drama.
const EXTRA_SPINS = 6 + Math.floor(Math.random() * 4); // 6–9 full rotations
const rawTarget = -Math.PI / 2 - winner.midAngle - currentRotation;
const normalised = ((rawTarget % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
const totalSpin = normalised + EXTRA_SPINS * 2 * Math.PI;
const startRotation = currentRotation;
const endRotation = currentRotation + totalSpin;
const duration = 4000 + Math.random() * 1500; // 4–5.5 s
const startTime = performance.now();
glow.classList.add('active');
function easeOut(t) {
// Cubic ease-out with a slight bounce feel
return 1 - Math.pow(1 - t, 3.5);
}
function animate(now) {
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const eased = easeOut(t);
currentRotation = startRotation + (endRotation - startRotation) * eased;
drawWheel(currentRotation);
if (t < 1) {
animFrame = requestAnimationFrame(animate);
} else {
// Done
currentRotation = endRotation;
isSpinning = false;
spinBtn.disabled = false;
showResult(winner);
}
}
animFrame = requestAnimationFrame(animate);
}
// ─── Show Result ──────────────────────────────────────────────────────────────
function showResult(winner) {
const resultCard = document.getElementById('resultCard');
const glow = document.getElementById('wheelGlow');
const is3 = winner.number === 3;
const pct = (winner.prob * 100).toFixed(1);
resultCard.innerHTML = `
<span class="result-label">The wheel has spoken</span>
<span class="result-value">${winner.number}</span>
<span class="result-sub">${is3 ? '★ Lucky 3 · ' : ''}${pct}% probability</span>
`;
resultCard.className = 'result-card highlight';
setTimeout(() => glow.classList.remove('active'), 3000);
}
// ─── Init ─────────────────────────────────────────────────────────────────────
window.addEventListener('DOMContentLoaded', () => {
// Draw a placeholder empty disc
const canvas = document.getElementById('wheelCanvas');
const container = document.getElementById('wheelContainer');
const size = container.clientWidth || 380;
canvas.width = size * devicePixelRatio;
canvas.height = size * devicePixelRatio;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(devicePixelRatio, devicePixelRatio);
const cx = size / 2, cy = size / 2, R = size / 2 - 8;
// Gold ring
ctx.beginPath();
ctx.arc(cx, cy, R + 6, 0, Math.PI * 2);
const gr = ctx.createRadialGradient(cx, cy, R, cx, cy, R + 6);
gr.addColorStop(0, '#8a6018'); gr.addColorStop(0.5, '#c9a84c'); gr.addColorStop(1, '#f0d080');
ctx.fillStyle = gr; ctx.fill();
// Dark disc
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.fillStyle = '#13131a'; ctx.fill();
// Center text
ctx.font = `italic ${Math.floor(R * 0.14)}px 'Cormorant Garamond', Georgia, serif`;
ctx.fillStyle = 'rgba(201,168,76,0.4)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Set bounds & build', cx, cy);
// Build default wheel
buildWheel();
});
window.addEventListener('resize', () => {
if (wheelData) drawWheel(currentRotation);
});
</script>
</body>
</html>