balanced-random
v1.0.0
Published
Stateful random generator that balances draws toward a target probability distribution, reducing unfair-feeling streaks while staying random.
Maintainers
Readme
balanced-random
A stateful random generator that maintains a target probability distribution over a sequence of draws, providing a more stable user experience (UX) compared to pure random (less likely to get "unreasonable" streaks).
Problem
Pure random (Math.random()) can produce long streaks that feel "unfair" to users — like getting 10 heads in a row in a 50/50 coin flip, or seeing the same item appear repeatedly in a card draw.
Solution
balanced-random tracks the historical distribution and gently adjusts the probability of each outcome to prevent extreme streaks, while still maintaining the target probability over the long run.
Features
- Built-in Typescript support
- Isomorphic package: works in Node.js and browsers
- Configurable balance factor (0 = pure random, 1 = round-robin)
- Works with any number of choices, with custom weights
Installation
npm install balanced-randomYou can also install balanced-random with pnpm, yarn, or slnpm
Usage Examples
Boolean (Coin Flip)
import { createRandomBoolean } from 'balanced-random'
// 50/50 by default
const coin = createRandomBoolean()
// Bias with probability of true (0-1); false gets the remainder
const mostlyTrue = createRandomBoolean({ true_weight: 0.8 })
// Or give both sides explicit weights (any non-negative ratio)
const loaded = createRandomBoolean({ true_weight: 8, false_weight: 2 })
for (let i = 0; i < 100; i++) {
console.log(coin.next()) // true or false
}Multiple Outcomes (Loot Box)
import { createRandom } from 'balanced-random'
const loot = createRandom({
elements: [
{ value: 'common', weight: 80 },
{ value: 'rare', weight: 15 },
{ value: 'epic', weight: 4 },
{ value: 'legendary', weight: 1 },
],
})
for (let i = 0; i < 100; i++) {
console.log(loot.next()) // weighted random with balance
}Custom Random Generator
import { createRandom } from 'balanced-random'
import seedrandom from 'seedrandom'
const rng = createRandom({
elements: [
{ value: 'A', weight: 1 },
{ value: 'B', weight: 1 },
],
random_generator: seedrandom('my-seed'),
})Balance Factor
const balanced = createRandom({
elements: [
{ value: 'heads', weight: 1 },
{ value: 'tails', weight: 1 },
],
balance_factor: 0.5, // default
// 0 = pure random (no balance)
// 1 = aggressive balance (round-robin for under-represented outcomes)
})API
createRandomBoolean(options?)
Creates a balanced random boolean generator.
Options:
| Option | Type | Default | Description |
| ------------------ | -------------- | ------------- | ------------------------------------------------------------------------------------------------ |
| true_weight | number | 0.5 | Weight for true. Alone, must be 0-1 (probability); with false_weight, any non-negative ratio |
| false_weight | number | 0.5 | Weight for false. If omitted and true_weight is set, defaults to 1 - true_weight |
| random_generator | () => number | Math.random | Custom random number generator (returns 0-1) |
createRandom(options)
Creates a balanced random generator for any number of outcomes.
Options:
| Option | Type | Default | Description |
| ------------------ | -------------------------------- | ------------- | ------------------------------------------------------------- |
| elements | { value: T, weight: number }[] | required | Array of possible outcomes with their target weights |
| random_generator | () => number | Math.random | Custom random number generator (returns 0-1) |
| balance_factor | number (0-1) | 0.5 | How aggressively to balance. 0 = pure random, 1 = round-robin |
Instance Properties:
| Property | Type | Description |
| ------------ | ----------- | ------------------------------------ |
| next() | T | Returns the next random value |
| draw_count | number | Total number of draws so far |
| elements | Element[] | Array of elements with current state |
Element Properties:
| Property | Type | Description |
| --------------- | -------- | ------------------------------------------ |
| value | T | The outcome value |
| target_weight | number | Normalized target probability |
| acc_count | number | How many times this element has been drawn |
| draw_weight | number | Adjusted probability for next draw |
How It Works
The algorithm tracks "owed draws" — how many times each outcome is under-represented relative to its target probability. Each call to next() runs one cycle:
- Select an outcome using the current
draw_weightvalues - Increment the selected element's
acc_count - Calculate
owe_count = target_count - acc_countfor each element (target_count = target_weight × total draws) - Blend the target probability with the owed ratio for the next draw:
draw_weight = target_weight * (1 - balance_factor) + (owe_count / total_owe) * balance_factor
- Normalize so
draw_weightsums to 1
This creates a distribution that:
- Maintains the target probability over time
- Prevents extreme streaks by favoring under-represented outcomes
- Feels more "fairly random" to users without being deterministic
License
This project is licensed with BSD-2-Clause
This is free, libre, and open-source software. It comes down to four essential freedoms [ref]:
- The freedom to run the program as you wish, for any purpose
- The freedom to study how the program works, and change it so it does your computing as you wish
- The freedom to redistribute copies so you can help others
- The freedom to distribute copies of your modified versions to others
