DEV Community

Rueda Decide
Rueda Decide

Posted on

Cómo hacer una ruleta aleatoria con pesos en JavaScript

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:

  1. Generar números aleatorios de calidad.
  2. Elegir un ganador según los pesos.
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Algunos detalles que evitan errores sutiles:

  • Validar los pesos. Un peso negativo o NaN rompe la suma y la ruleta "funciona" pero sesgada. Mejor fallar con un error claro.
  • El último return. Con decimales, la resta puede dejar r en 0.0000000001 tras el último elemento. Devolver el último índice evita un undefined.
  • 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' ]
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)