Las ruletas parecen un ejercicio de cinco minutos: un Math.random(), un canvas que gira y listo. Pero en cuanto alguien la usa para algo que importa (elegir quién presenta en clase, repartir premios en un directo, sortear turnos en el equipo), aparecen las preguntas incómodas: ¿es realmente aleatoria?, ¿cómo doy más probabilidad a una opción sin hacer trampas?, ¿por qué la animación a veces "cae" en una opción y el resultado dice otra?
En este artículo vamos a construir una ruleta con pesos en JavaScript puro, separando tres piezas que conviene no mezclar:
- Generar números aleatorios de calidad.
- Elegir un ganador según los pesos.
- Dibujar y animar la ruleta para que se detenga exactamente en ese ganador.
1. Números aleatorios: crypto.getRandomValues
Math.random() está bien para un juego, pero no da garantías: la especificación no exige ningún algoritmo concreto y no está pensado para resultados que alguien pueda querer predecir. Todos los navegadores modernos (y Node 19+) exponen crypto.getRandomValues, que usa el generador criptográfico del sistema.
El problema es que devuelve enteros, no decimales. Así obtenemos un decimal uniforme en [0, 1) con 53 bits de precisión, igual que un double:
function randomFloat() {
const buf = new Uint32Array(2);
crypto.getRandomValues(buf);
const hi = buf[0] >>> 5; // 27 bits
const lo = buf[1] >>> 6; // 26 bits
return (hi * 67108864 + lo) / 9007199254740992; // (hi * 2^26 + lo) / 2^53
}
Si lo que necesitas es un entero entre 0 y max - 1 (por ejemplo, para un dado o una ruleta sin pesos), no uses x % max sin más: si 2^32 no es múltiplo de max, los primeros valores salen un poco más a menudo (el famoso modulo bias). La solución es descartar los valores de la "cola" que sobra:
function randomInt(max) {
if (!Number.isInteger(max) || max <= 0 || max > 2 ** 32) {
throw new RangeError('max fuera de rango');
}
const limit = Math.floor(2 ** 32 / max) * max; // mayor múltiplo de max
const buf = new Uint32Array(1);
let x;
do {
crypto.getRandomValues(buf);
x = buf[0];
} while (x >= limit); // rechazo: casi nunca repite
return x % max;
}
2. Selección ponderada
Cada opción tiene un peso. Ana con peso 1 y Luis con peso 2 significa que Luis tiene el doble de probabilidad que Ana. La técnica clásica es la de los pesos acumulados: imagina los pesos puestos uno detrás de otro en una recta, lanza un punto al azar sobre esa recta y mira en qué tramo cae.
function pickWeighted(items) {
const total = items.reduce((s, it) => s + it.weight, 0);
if (!(total > 0) || items.some(it => !(it.weight >= 0))) {
throw new Error('Pesos no válidos');
}
let r = randomFloat() * total;
for (let i = 0; i < items.length; i++) {
r -= items[i].weight;
if (r < 0) return i;
}
return items.length - 1; // protección ante errores de redondeo
}
Algunos detalles que evitan errores sutiles:
-
Validar los pesos. Un peso negativo o
NaNrompe la suma y la ruleta "funciona" pero sesgada. Mejor fallar con un error claro. -
El último
return. Con decimales, la resta puede dejarren0.0000000001tras el último elemento. Devolver el último índice evita unundefined. - Peso 0 = opción desactivada. Es útil para "quitar" a alguien que ya salió sin borrarlo de la lista.
Para listas enormes (cientos de miles de opciones) conviene precalcular el array acumulado y hacer búsqueda binaria, pero para una ruleta visible en pantalla el bucle lineal es más que suficiente.
¿Funciona? Una prueba rápida con 400.000 tiradas:
const items = [
{ label: 'Ana', weight: 1 },
{ label: 'Luis', weight: 2 },
{ label: 'Marta', weight: 1 },
];
const counts = [0, 0, 0];
for (let i = 0; i < 400000; i++) counts[pickWeighted(items)]++;
console.log(counts.map(c => (c / 400000).toFixed(3)));
// → [ '0.250', '0.500', '0.250' ]
3. Dibujar la ruleta en canvas
Cada segmento ocupa un ángulo proporcional a su peso. Calculamos los segmentos una vez y los reutilizamos para dibujar y para la animación:
const TAU = Math.PI * 2;
function segments(items) {
const total = items.reduce((s, it) => s + it.weight, 0);
let start = 0;
return items.map(it => {
const size = (it.weight / total) * TAU;
const seg = { start, size };
start += size;
return seg;
});
}
function drawWheel(ctx, items, rotation) {
const { width } = ctx.canvas;
const r = width / 2;
const segs = segments(items);
ctx.clearRect(0, 0, width, width);
ctx.save();
ctx.translate(r, r);
ctx.rotate(rotation);
segs.forEach((seg, i) => {
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, r - 4, seg.start, seg.start + seg.size);
ctx.closePath();
ctx.fillStyle = `hsl(${(i * 360) / items.length}, 70%, 60%)`;
ctx.fill();
// texto en el centro del segmento
ctx.save();
ctx.rotate(seg.start + seg.size / 2);
ctx.fillStyle = '#111';
ctx.font = '16px system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(items[i].label, r - 16, 6);
ctx.restore();
});
ctx.restore();
// puntero fijo arriba
ctx.beginPath();
ctx.moveTo(r - 10, 0);
ctx.lineTo(r + 10, 0);
ctx.lineTo(r, 22);
ctx.fillStyle = '#222';
ctx.fill();
}
4. La animación: primero el resultado, luego el giro
Aquí está el error más común: girar la ruleta con una velocidad aleatoria y después mirar dónde se ha parado. Eso mezcla la física de la animación con la probabilidad, y los pesos dejan de cumplirse (o el texto del ganador no coincide con lo que ve la gente).
La forma correcta es al revés: elegimos el ganador con pickWeighted y calculamos el ángulo final para que el puntero quede dentro de su segmento. Añadimos un desplazamiento aleatorio dentro del segmento (sin tocar los bordes) para que no caiga siempre en el centro, y unas cuantas vueltas completas para el efecto:
const POINTER = -Math.PI / 2; // el puntero está arriba
function targetRotation(current, seg, vueltas = 5) {
const offset = (randomFloat() * 0.8 + 0.1) * seg.size; // 10 %–90 % del segmento
const punto = seg.start + offset;
const delta = (((POINTER - punto - current) % TAU) + TAU) % TAU;
return current + vueltas * TAU + delta;
}
function spin(ctx, items, state, onDone) {
const winner = pickWeighted(items);
const from = state.rotation;
const to = targetRotation(from, segments(items)[winner]);
const duration = 4000;
const t0 = performance.now();
const easeOutCubic = t => 1 - Math.pow(1 - t, 3);
function frame(now) {
const t = Math.min((now - t0) / duration, 1);
state.rotation = from + (to - from) * easeOutCubic(t);
drawWheel(ctx, items, state.rotation);
if (t < 1) requestAnimationFrame(frame);
else onDone(items[winner]);
}
requestAnimationFrame(frame);
}
Y para usarlo:
<canvas id="wheel" width="360" height="360"></canvas>
<button id="go">Girar</button>
<script>
const ctx = document.getElementById('wheel').getContext('2d');
const items = [
{ label: 'Ana', weight: 1 },
{ label: 'Luis', weight: 2 },
{ label: 'Marta', weight: 1 },
];
const state = { rotation: 0 };
drawWheel(ctx, items, state.rotation);
document.getElementById('go').onclick = () =>
spin(ctx, items, state, w => alert(`Ha salido: ${w.label}`));
</script>
He comprobado la lógica con 2.000 giros simulados: en todos, el segmento bajo el puntero coincide con el índice que devolvió pickWeighted.
5. Notas sobre justicia (lo que de verdad pregunta la gente)
- Que el resultado se decida antes de animar no es trampa: es exactamente lo que hace un sorteo físico (la bola ya ha caído cuando la ves rodar). Lo importante es que la decisión use un generador de calidad y respete los pesos.
- Haz visibles los pesos. Si una opción tiene peso 2, dibújala con el doble de ancho. La ruleta de arriba lo hace automáticamente, y así nadie siente que hay algo oculto.
- "Sin repetición" es otra regla, no otro azar. Para que no salga dos veces la misma persona, pon su peso a 0 tras salir (o elimínala). No vuelvas a tirar "hasta que salga otra cosa": eso cambia las probabilidades de forma difícil de explicar.
- Desactiva el botón durante el giro. Un doble clic que lanza dos sorteos a la vez genera discusiones.
- Para sorteos públicos, guarda un registro: lista de participantes, pesos, fecha y resultado. Si alguien pregunta, puedes enseñarlo.
-
Accesibilidad: anuncia el resultado en texto (por ejemplo, en un elemento con
aria-live="polite"), no solo con la animación.
Si prefieres ver una ruleta con pesos ya montada para usarla en clase o en un directo (porcentajes por opción, colores, sonido y duración del giro, sin registro), tienes esta ruleta al azar en español.
¿Lo harías de otra forma? Me interesa sobre todo cómo resolvéis la búsqueda binaria con pesos que cambian en cada tirada y si alguien ha probado el alias method en un caso real. Os leo en los comentarios.
Top comments (0)