Seeded Randomness
Drawing repeatable random numbers
This chapter adds a seeded random source and a dots lab. The lab draws three panels from one seed: a flat scatter, a weighted set of bars and a normal cloud.
Press R and the lab draws seed 1 again. The picture and printed line match because the seed names the same sequence of draws. Press Right and the seed becomes 2. That gives a different sequence, and that sequence is repeatable too.
Use one generator, seed it once from a number a person can type and pass it by hand to code that draws random values. Choose the distribution for the quantity being made. The worked failure uses a generator nobody seeded and nobody passes.
Random distributions
A generator hands out one thing: a draw, a number from 0 up to but not including 1,
each as likely as any other. Everything else is arithmetic on draws. A number
spread flat across a range is one draw lerped into the range, as chapter 15's
Lerp does it: a draw of 0.55 across 0..96 is 52.8. Three hundred such
numbers have a mean near the middle, 48, and a spread, the standard deviation,
near the range's width divided by the square root of twelve, 96 / 3.4641 =
27.7128; that is what a flat distribution's spread is, and the lab prints its
sample's beside it.
A weighted pick from a table is one draw and one walk. Weights 1, 2, 3 and 4 total 10, so a draw of 0.55 scaled by the total is 5.5; walk the table subtracting each weight until the number is smaller than the weight in hand: 5.5 is not under 1, so subtract, 4.5; not under 2, subtract, 2.5; under 3, so the third outcome. The fourth outcome is four times as likely as the first, and over three hundred picks the counts come out near 30, 60, 90 and 120, with a scatter of a few either side, because three hundred is not many: the first outcome's count has a spread of about 5, so a count of 19 or 41 turns up once in twenty seeds or so.
A number bunched round a middle is the normal distribution, and the recipe that makes one from flat draws is Box–Muller: two draws u1 and u2, a radius r = √(−2 ln(1 − u1)) and an angle θ = 2πu2, and the point at that radius and angle has two coordinates, r cos θ and r sin θ, each of which is a normal number with mean 0 and spread 1. With u1 = 0.5 and u2 = 0.25 the radius is √(−2 ln 0.5) = √1.3863 = 1.1774 and the angle is a quarter turn, so the two normals are 0 and 1.1774; scaled to a mean of 50 and a spread of 15 they are 50 and 67.66. Two draws make two normals, so the recipe keeps the second for the next call and a normal costs one draw on average; that is the reason the lab's three hundred pairs of normals take six hundred draws and not twelve hundred. The logarithm takes 1 − u1 and not u1 because a draw can be exactly 0 and can never be exactly 1, and the logarithm of 0 is not a number.
// internal/vec/random.go — create
package vec
import (
"math"
"math/rand/v2"
)
// Source is one random number generator, seeded once and passed by hand
// to everything that draws from it, so that the same seed gives the same
// numbers in the same order however many things share it. It counts its
// draws, and it keeps the spare half of a Box–Muller pair.
type Source struct {
rng *rand.Rand
Draws int // uniform numbers taken from the generator so far
spare float64 // the second normal of the last pair, if unused
hasSpare bool
}
// NewSource seeds a PCG generator, the same kind Pong serves and Snake
// places food with.
func NewSource(seed uint64) *Source {
return &Source{rng: rand.New(rand.NewPCG(seed, 0))}
}
// Float is one draw: a number in [0, 1), counted.
func (s *Source) Float() float64 {
s.Draws++
return s.rng.Float64()
}
// Uniform is a number spread evenly across [lo, hi): one draw, lerped.
func (s *Source) Uniform(lo, hi float64) float64 {
return Lerp(lo, hi, s.Float())
}
// Weighted is a table of outcomes with weights, summed once, so that a
// pick is one draw scaled by the total and one walk down the table.
type Weighted struct {
weights []float64
total float64
}
// NewWeighted takes the weights in outcome order; a weight is any
// non-negative number, and the chance of an outcome is its weight over
// the total.
func NewWeighted(weights ...float64) Weighted {
w := Weighted{weights: append([]float64(nil), weights...)}
for _, x := range weights {
w.total += x
}
return w
}
// Pick returns the index of one outcome: a draw scaled to the total falls
// somewhere along the weights laid end to end, and the weight it falls in
// is the answer.
func (w Weighted) Pick(s *Source) int {
r := s.Float() * w.total
for i, x := range w.weights {
if r < x {
return i
}
r -= x
}
return len(w.weights) - 1
}
// Normal is a number bunched round mean with a spread of sd, by
// Box–Muller: two uniform draws make two independent normals, a radius
// from the first and an angle from the second; one is returned and the
// other is kept for the next call, so that a sample costs one draw on
// average.
func (s *Source) Normal(mean, sd float64) float64 {
if s.hasSpare {
s.hasSpare = false
return mean + sd*s.spare
}
u1, u2 := s.Float(), s.Float()
r := math.Sqrt(-2 * math.Log(1-u1)) // 1 − u1 is never 0, so the log is finite
th := 2 * math.Pi * u2
s.spare, s.hasSpare = r*math.Sin(th), true
return mean + sd*r*math.Cos(th)
}
// MeanSpread is the mean of a sample and its standard deviation: the
// square root of the mean squared distance from the mean.
func MeanSpread(xs []float64) (mean, sd float64) {
if len(xs) == 0 {
return 0, 0
}
for _, x := range xs {
mean += x
}
mean /= float64(len(xs))
for _, x := range xs {
sd += (x - mean) * (x - mean)
}
return mean, math.Sqrt(sd / float64(len(xs)))
}
go vet ./...
Source is a pointer type because a generator has state. Every draw
advances it. Copying the generator would copy its place in the sequence and risk
drawing the same values twice.
Pass *Source by hand. A function signature that takes one shows that
the function draws random values and shows which sequence it uses.
Draws counts the flat draws so the lab can check its total.
Weighted stores its total when it is built. A pick then costs one draw,
one multiplication and one walk through the table. The last return gives an answer
even if rounding carries the draw to the end.
Drawing three panels
Extend cmd/motion with the dots lab. The lab draws three panels, each
96 pixels wide and 120 pixels tall. The first panel uses uniform positions. The
second counts weighted picks with weights 1, 2, 3 and 4. The third uses normal
values for a cloud around the middle.
Each burst makes a fresh Source from the current seed and draws the
panels in a fixed order. The -seed flag sets the first seed. R repeats
the same seed, Right moves to the next seed and Left moves back.
// cmd/motion/dots.go — create
package main
import (
"fmt"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"gez/internal/vec"
)
// The dots lab bursts three hundred samples from each of three
// distributions into three panels, all from one seeded source: positions
// spread flat across a panel, a pick from four weighted outcomes shown as
// four bars, and positions bunched round the panel's middle. R bursts
// again from the same seed; Right and Left burst from the next seed and
// the last.
const (
dotsN = 300 // samples a panel
panelW = 96.0 // a panel's width and height in pixels
panelH = 120.0
panelTop = 28.0
panelStep = 104.0 // from one panel's left edge to the next
)
// dotsWeights are the four outcomes' weights: the fourth is four times as
// likely as the first.
var dotsWeights = vec.NewWeighted(1, 2, 3, 4)
// dotsLab keeps the seed it last burst from, what the burst produced, and
// the keys of the last tick, so that a held arrow bursts once.
type dotsLab struct {
seed uint64
uniform []vec.Vec2
counts [4]int
normal []vec.Vec2
draws int
was keys
}
func newDotsLab(seed uint64) *dotsLab {
l := &dotsLab{seed: seed}
l.burst()
return l
}
// burst draws every dot again from a fresh source seeded with the lab's
// seed, and prints what the three panels hold.
func (l *dotsLab) burst() {
s := vec.NewSource(l.seed)
l.uniform, l.normal, l.counts = l.uniform[:0], l.normal[:0], [4]int{}
for i := 0; i < dotsN; i++ {
l.uniform = append(l.uniform, vec.Vec2{X: s.Uniform(0, panelW), Y: s.Uniform(0, panelH)})
}
for i := 0; i < dotsN; i++ {
l.counts[dotsWeights.Pick(s)]++
}
for i := 0; i < dotsN; i++ {
l.normal = append(l.normal, vec.Vec2{X: s.Normal(48, 12), Y: s.Normal(60, 15)})
}
l.draws = s.Draws
um, us, nm, ns := l.stats()
fmt.Printf("seed %d: uniform x mean %.4f spread %.4f (48 and 27.7128 for a flat 0..96); weighted 1:2:3:4 counts %v (30, 60, 90 and 120 expected); normal x mean %.4f spread %.4f (48 and 12 asked for); %d draws\n",
l.seed, um, us, l.counts, nm, ns, l.draws)
}
// stats reads the three panels: the mean and spread of the flat panel's
// x, and of the bunched panel's x.
func (l *dotsLab) stats() (um, us, nm, ns float64) {
xs := make([]float64, dotsN)
for i, p := range l.uniform {
xs[i] = p.X
}
um, us = vec.MeanSpread(xs)
for i, p := range l.normal {
xs[i] = p.X
}
nm, ns = vec.MeanSpread(xs)
return um, us, nm, ns
}
// step bursts on R, and on the tick Right or Left goes down.
func (l *dotsLab) step(k keys) {
switch {
case k.r:
l.burst()
case k.right && !l.was.right:
l.seed++
l.burst()
case k.left && !l.was.left && l.seed > 0:
l.seed--
l.burst()
}
l.was = k
}
func (l *dotsLab) draw(screen *ebiten.Image, h *hud) {
for i, name := range []string{"uniform", "weighted 1:2:3:4", "normal"} {
x0 := 8 + float64(i)*panelStep
vector.StrokeRect(screen, float32(x0), panelTop, panelW, panelH, 1, dimColor, false)
h.text(screen, name, x0, panelTop+panelH+2)
}
for _, p := range l.uniform {
vector.FillRect(screen, float32(8+p.X), float32(panelTop+p.Y), 1, 1, lineColor, false)
}
for i, c := range l.counts {
hgt := float32(c)
vector.FillRect(screen, float32(8+panelStep+float64(i)*24+4), float32(panelTop+panelH)-hgt, 16, hgt, yColor, false)
}
for _, p := range l.normal {
vector.FillRect(screen, float32(8+2*panelStep+p.X), float32(panelTop+p.Y), 1, 1, lineColor, false)
}
}
func (l *dotsLab) lines() (top, bottom string) {
um, us, nm, ns := l.stats()
return fmt.Sprintf("seed %d draws %d counts %v", l.seed, l.draws, l.counts),
fmt.Sprintf("uniform x %.4f +- %.4f normal x %.4f +- %.4f", um, us, nm, ns)
}
// cmd/motion/main.go — extend
var (
labFlag = flag.String("lab", "circle", "the lab to open")
seedFlag = flag.Uint64("seed", 1, "the seed a lab that draws random numbers starts from")
)
// newLab builds the lab named, from the seed: the one place a name
// becomes a lab. Labs that draw no random numbers ignore the seed.
func newLab(name string, seed uint64) (lab, error) {
switch name {
case "circle":
return newCircleLab(), nil
case "arrows":
return newArrowsLab(), nil
case "ship":
return newShipLab(), nil
case "ball":
return newBallLab(), nil
case "ease":
return newEaseLab(), nil
case "dots":
return newDotsLab(seed), nil
}
return nil, fmt.Errorf("no lab called %q", name)
}
func main() {
flag.Parse()
l, err := newLab(*labFlag, *seedFlag)
if err != nil {
log.Fatal(err)
}
h, err := newHUD()
if err != nil {
log.Fatal(err)
}
ebiten.SetWindowSize(960, 540)
ebiten.SetWindowTitle("Motion: " + *labFlag)
ebiten.SetTPS(60)
if err := ebiten.RunGame(&Game{lab: l, hud: h}); err != nil {
log.Fatal(err)
}
}
go vet ./...
go run ./cmd/motion -lab dots
Press R, then Right, then Left:
$ go run ./cmd/motion -lab dots
seed 1: uniform x mean 46.8191 spread 28.6623 (48 and 27.7128 for a flat 0..96); weighted 1:2:3:4 counts [19 66 98 117] (30, 60, 90 and 120 expected); normal x mean 48.1587 spread 11.4405 (48 and 12 asked for); 1500 draws
seed 1: uniform x mean 46.8191 spread 28.6623 (48 and 27.7128 for a flat 0..96); weighted 1:2:3:4 counts [19 66 98 117] (30, 60, 90 and 120 expected); normal x mean 48.1587 spread 11.4405 (48 and 12 asked for); 1500 draws
seed 2: uniform x mean 47.3415 spread 28.7626 (48 and 27.7128 for a flat 0..96); weighted 1:2:3:4 counts [32 66 82 120] (30, 60, 90 and 120 expected); normal x mean 48.1740 spread 12.0402 (48 and 12 asked for); 1500 draws
seed 1: uniform x mean 46.8191 spread 28.6623 (48 and 27.7128 for a flat 0..96); weighted 1:2:3:4 counts [19 66 98 117] (30, 60, 90 and 120 expected); normal x mean 48.1587 spread 11.4405 (48 and 12 asked for); 1500 draws
The first and second lines match, and the first and fourth match. R repeats seed 1.
Left after Right returns to seed 1. Both make a fresh Source and draw
the same fifteen hundred numbers in the same order.
The count is fifteen hundred, not eighteen hundred, because Normal
keeps a spare value. The lab uses six hundred draws for the flat positions, three
hundred for the weighted picks and six hundred for the normal positions.
The printed numbers sit beside the expected values, and they don't equal them. The flat panel's mean is 46.82 against 48. Its spread is 28.66 against 27.71. The weighted counts are 19, 66, 98 and 117 against 30, 60, 90 and 120.
Those gaps are normal for three hundred samples. Seed 2 gives counts of 32, 66, 82 and 120. It is not a better seed; its gaps landed elsewhere.
The panels draw from stored dots and counts, not from the generator. Ebitengine may
call Draw more or fewer times than Update. Drawing from
the generator would change the picture between ticks.
Right and Left burst only on the tick the key goes down. A held arrow would otherwise burst once per tick and skip past the seed the player meant to choose.
math/rand/v2 supplies rand.NewPCG(seed, 0) and
Float64. PCG is a generator whose sequence is fixed by its seed words.
The lab wraps it in Source to count draws and keep the spare normal.
The package also has top-level functions such as rand.Float64() and
rand.Uint64(). They draw from a generator the runtime seeded from the
operating system. The worked failure uses that generator.
Avoiding unseeded randomness
Add a flag for the wrong design: seeding each burst from the package-level generator.
That generator gets its seed from the operating system when the program starts, not
from the -seed flag.
// cmd/motion/dots.go — extend
import (
"flag"
"fmt"
"math/rand/v2"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"gez/internal/vec"
)
// burst draws every dot again from a fresh source seeded with the lab's
// seed, and prints what the three panels hold. Under -global the source
// is seeded from the package's own generator, which nobody seeded, so the
// seed is whatever the machine had: the mistake this chapter shows, kept
// so it can be seen.
func (l *dotsLab) burst() {
seed := l.seed
if *global {
seed = rand.Uint64()
}
s := vec.NewSource(seed)
l.uniform, l.normal, l.counts = l.uniform[:0], l.normal[:0], [4]int{}
for i := 0; i < dotsN; i++ {
l.uniform = append(l.uniform, vec.Vec2{X: s.Uniform(0, panelW), Y: s.Uniform(0, panelH)})
}
for i := 0; i < dotsN; i++ {
l.counts[dotsWeights.Pick(s)]++
}
for i := 0; i < dotsN; i++ {
l.normal = append(l.normal, vec.Vec2{X: s.Normal(48, 12), Y: s.Normal(60, 15)})
}
l.draws = s.Draws
um, us, nm, ns := l.stats()
fmt.Printf("seed %d: uniform x mean %.4f spread %.4f (48 and 27.7128 for a flat 0..96); weighted 1:2:3:4 counts %v (30, 60, 90 and 120 expected); normal x mean %.4f spread %.4f (48 and 12 asked for); %d draws\n",
l.seed, um, us, l.counts, nm, ns, l.draws)
}
var global = flag.Bool("global", false, "seed each burst from the package's own unseeded generator instead of the lab's seed: the mistake, kept so it can be seen")
go vet ./...
go run ./cmd/motion -lab dots
Without the flag, the lab still prints the same lines for the same keys. Under the
flag, one line changes: seed = rand.Uint64(). The program hands that
untyped seed to NewSource.
The printed line still says seed 1 because it prints the lab's field.
That is part of the bug: the program reports a seed it didn't use.
Run go run ./cmd/motion -lab dots -global and press R. Then close it
and run it again. These lines were measured once, on the machine that made this
page's pictures, and yours will differ, which is the failure. The first run
printed:
$ go run ./cmd/motion -lab dots -global
seed 1: uniform x mean 45.9478 spread 28.0429 (48 and 27.7128 for a flat 0..96); weighted 1:2:3:4 counts [34 59 83 124] (30, 60, 90 and 120 expected); normal x mean 48.2034 spread 11.8154 (48 and 12 asked for); 1500 draws
seed 1: uniform x mean 49.9628 spread 28.6668 (48 and 27.7128 for a flat 0..96); weighted 1:2:3:4 counts [33 65 86 116] (30, 60, 90 and 120 expected); normal x mean 48.2717 spread 11.9758 (48 and 12 asked for); 1500 draws
and the second:
$ go run ./cmd/motion -lab dots -global
seed 1: uniform x mean 48.3520 spread 27.6041 (48 and 27.7128 for a flat 0..96); weighted 1:2:3:4 counts [31 59 100 110] (30, 60, 90 and 120 expected); normal x mean 49.2267 spread 11.9585 (48 and 12 asked for); 1500 draws
seed 1: uniform x mean 51.7157 spread 28.2045 (48 and 27.7128 for a flat 0..96); weighted 1:2:3:4 counts [22 53 96 129] (30, 60, 90 and 120 expected); normal x mean 48.2606 spread 11.5068 (48 and 12 asked for); 1500 draws
Four lines print four different pictures, and every line says seed 1. The seed on
the line is the lab's field. The seed that made the numbers came from
rand.Uint64().
Nothing else changed: the recipes, panels and draw count are the same. Put the code back to the default. Make one generator from the typed seed, then pass that generator to every function that draws.
Seeds as sequences
A seeded generator acts like a long sequence of numbers and a position in that sequence. The seed selects the sequence. Two runs that start with the same seed and take the same draws in the same order read the same values.
Two mistakes break that. A second generator uses a different sequence. A different
draw order reads different positions in the same sequence. Passing one
Source by hand prevents the first mistake. Drawing the panels in a fixed
order prevents the second.
The distributions add no extra randomness. Uniform, weighted and normal values are arithmetic on draws from the same sequence. That is why the printed line can compare each sample with the expected mean, spread or count.
Checkpoint
- Turn one draw into a number in any range with a lerp, and say what mean and spread three hundred of them have.
- Pick from a table of weights with one draw and one walk, by hand for a draw of 0.55 through 1, 2, 3 and 4.
- Work Box–Muller for draws of 0.5 and 0.25, and say why two draws give two normals and why a normal costs one draw on average.
- Read the lab's printed line and say which gaps from the expected numbers are what three hundred samples allow.
- Pass a generator by hand and draw in a fixed order, and say which of the two makes the same seed produce the same result.
- Take two runs that both say seed 1 and print different numbers back to a
call of
rand.Uint64()on the unseeded generator.
Exercise 1 — three thousand. Change dotsN to
3000 and read the printed gaps against the expected numbers. Then 30.
At three thousand the flat panel's mean lands within a fraction of a pixel of 48 and the counts within a few per cent of 300, 600, 900 and 1200: the scatter of a count grows with the square root of the sample and the sample grows faster, so the gaps shrink. At thirty the bars can come out in the wrong order altogether, and the "expected" numbers are 3, 6, 9 and 12, which a single seed can miss by half. The picture at three thousand is also a solid block: every pixel of the flat panel is hit.
Exercise 2 — a fourth panel. Draw a cloud whose distance from the panel's centre is a normal of mean 0 and spread 20, at a uniform angle, and compare it with the third panel.
vec.FromAngle(s.Uniform(0, 2*math.Pi)).Scale(s.Normal(0, 20))
added to the centre. The cloud is round like the third panel's but denser at
the centre, because a radius that is normal in one dimension puts more points
in the small inner rings than two independent normals do; the two are
different distributions that both deserve the word "bunched", and a game that
scatters particles picks one on purpose. Draw it after the third panel so the
first three keep their numbers.
Exercise 3 — a loot table. Make the weights 60, 30, 9 and 1, name the four outcomes common, uncommon, rare and legendary, and count how many bursts of three hundred it takes to see a legendary.
vec.NewWeighted(60, 30, 9, 1) and four names in the label. A
legendary is one pick in a hundred, so a burst of three hundred usually has two
or three and sometimes none; walk the seeds with Right and watch the fourth
bar. The printed counts are the table a designer reads to decide whether one
in a hundred is the rate they meant, which is a decision the seed lets them
make from the same three hundred picks every time.