The Fixed Tick
Fixed ticks
Stop typing and the world does nothing. The rule that changes that is the volume's spine: the world advances in whole, equal ticks and counts them; wall-clock time is only the pace at which those ticks are delivered, and no law inside the world may read it.
The grid holds its terrain until something calls Set. Entities sit where
they were spawned until something calls Move. Last chapter's hardened
methods refuse bad requests, but every one of them waits for a caller, and so far that
caller has been main, poking the world by hand.
A server does not get poked. It runs for months with nobody watching, and the world inside it has to keep going: water finds a low place, a creature burns energy, somebody's crop finishes at four in the morning.
The two loops most people write first are both wrong here. The first runs the world as fast as the processor allows, so a faster machine gets a faster world and a plant that took a minute to grow on your laptop takes eleven seconds on the server you deploy to.
The second measures how long the last pass around the loop took and scales every rate by that number. Game engines call it variable timestep, and it computes the world's motion from a measurement of your hardware's mood.
Both loops let the machine's speed leak into the world's physics. Sim time is a number the world owns; wall time is a fact about your machine. Keep them apart and one seed produces one world on a laptop, on a server, and at four hundred times speed.
Frame time and tick time
Before any of this touches sim, watch the leak in arithmetic small enough
to check by hand. One walker moves east at three cells per second.
Written the variable-timestep way, each pass around the loop adds three cells times however many seconds that pass took: a machine drawing 144 frames a second adds 3 × 1/144, one drawing 48 adds 3 × 1/48.
Written the fixed way, every tick adds the same 0.3 cells, because the tick rate is a constant the program chose and not a property of the hardware.
// cmd/worldd/main.go — a throwaway main: one walker, three machines
package main
import "fmt"
// byFrame moves a walker east at 3 cells per second, once per frame,
// on a machine that produces a frame every dt seconds.
func byFrame(name string, dt float64, frames int) {
pos := 0.0
for i := 0; i < frames; i++ {
pos += 3.0 * dt
}
fmt.Printf("%-13s %3d frames of %.4fs -> x = %.4f\n", name, frames, dt, pos)
}
// byTick moves the same walker 0.3 cells per tick, ticks being the
// only unit it knows about.
func byTick(name string, ticks int) {
pos := 0.0
for i := 0; i < ticks; i++ {
pos += 0.3
}
fmt.Printf("%-13s %3d ticks -> x = %.4f\n", name, ticks, pos)
}
func main() {
fmt.Println("motion measured in frames:")
byFrame("fast machine", 1.0/144, 100)
byFrame("normal", 1.0/60, 100)
byFrame("busy laptop", 1.0/48, 100)
fmt.Println("motion measured in ticks:")
byTick("fast machine", 100)
byTick("normal", 100)
byTick("busy laptop", 100)
}
$ go run ./cmd/worldd
motion measured in frames:
fast machine 100 frames of 0.0069s -> x = 2.0833
normal 100 frames of 0.0167s -> x = 5.0000
busy laptop 100 frames of 0.0208s -> x = 6.2500
motion measured in ticks:
fast machine 100 ticks -> x = 30.0000
normal 100 ticks -> x = 30.0000
busy laptop 100 ticks -> x = 30.0000
The top three lines are three different worlds. Same code, same walker, same hundred passes around the loop, and the walker lands on column 2.08, column 5, or column 6.25 depending on nothing but who ran it.
Nobody wrote a bug; the loop was asked the wrong question. The damage also compounds: put a wall at column 6 and the busy laptop's walker is through it while the fast machine's walker is nowhere near, so the runs disagree about events and not merely positions.
The bottom three lines are one world described three times. Ticks do not know what a second is, so there is nothing in them for hardware to change.
The frame-based version was trying to be honest, and smooth motion at a fixed real-world speed is the right trade for drawing a picture. It is the wrong trade for deciding what happened.
This book keeps both in their places: the simulation counts ticks, and a client can interpolate between them for the eye.
Pick the rate first; everything else is division. This book runs at ten ticks per second of sim time, so thirty ticks is three seconds of world, a minute is 600 ticks, and a day is 60 × 60 × 24 × 10 = 864,000 ticks. Going the other way, a creature that should take two seconds to cross a cell takes 20 ticks.
Written as a formula, with r the tick rate, t a count of ticks, and s a duration in seconds of sim time:
s = t / r and t = s × r
Rates convert the same way: a speed of v cells per second is v / r cells per tick, so three cells a second at ten ticks a second is stage 1's 0.3. Do the conversion once, when you write the law, and store the per-tick number; a law that divides by the tick rate at run time is one edit away from dividing by a measured duration instead.
Sim time is a uint64, holding about
1.8 × 1019 ticks: at ten a second, roughly 58 billion years of
world before it wraps. The counter will not be what fails.
rtsvSoil touching water
A counter that increments and changes nothing is impossible to debug, so the world gets its first law in the same breath as its first heartbeat.
Water spreads: any soil cell touching water becomes water next tick, the crudest model of a pond overflowing and enough to see.
The second law is smaller. Walkers drift one cell west each tick and stay put when the
ground refuses them, which is last chapter's ErrBlocked being asked a
question by something other than main.
// internal/sim/world.go — World gains one field and one reader
type World struct {
Ground *Grid
tick uint64 // sim time: how many ticks this world has taken
ents map[EntityID]*Entity
nextID EntityID
}
// Tick is how many ticks of sim time this world has taken.
func (w *World) Tick() uint64 { return w.tick }
// internal/sim/tick.go
package sim
import (
"errors"
"fmt"
"time"
)
// TickRate is how many ticks of sim time one second of wall time is
// meant to hold.
const TickRate = 10
// TickDuration is the wall-clock budget for one tick.
const TickDuration = time.Second / TickRate
// Step advances the world by exactly one tick.
func (w *World) Step() error {
w.tick++
if err := w.spreadWater(); err != nil {
return fmt.Errorf("tick %d: spread water: %w", w.tick, err)
}
if err := w.driftWalkers(); err != nil {
return fmt.Errorf("tick %d: drift walkers: %w", w.tick, err)
}
return nil
}
// driftWalkers steps every walker one cell west, in ID order. A
// walker the ground refuses stays where it is.
func (w *World) driftWalkers() error {
for _, e := range w.Roster() {
if e.Kind != Walker {
continue
}
err := w.Move(e.ID, e.At.Offset(-1, 0))
switch {
case err == nil:
case errors.Is(err, ErrBlocked), errors.Is(err, ErrOutOfBounds):
// nowhere to go this tick; the walker stays
default:
return err
}
}
return nil
}
Three decisions are packed into that small file. TickRate is a constant
declared once, in the package that owns the world's laws, so no caller can hand the
simulation a different one and get a different history.
TickDuration is derived from it instead of typed out as
100 * time.Millisecond. It is the only time value in the
file, existing for the loop's benefit and not the world's.
Step increments the counter first, before any law runs, so a
law reading w.tick sees the number of the tick it is computing. The
world's first step is tick 1, and nothing that happens in it gets stamped with the 0
the world sat at before it began.
The field is lowercase and the reader is the method Tick(), for the
reason chapter 5 made the entity map private: a counter anyone outside the package can
assign to is a counter that can be made to disagree with the history it labels.
driftWalkers walks Roster, not the entity map, which cashes
in chapter 5's lesson: map order is randomized, roster order is by ID.
With walkers that only drift west, the order looks harmless. When two entities want the same cell, order decides who gets it, and a world whose outcomes follow map iteration order disagrees with itself between runs.
The two tolerated error kinds are a decision too. An entity that cannot move is ordinary, so those are absorbed here; anything else is a bug and goes up to whoever runs the loop.
That leaves spreadWater, which is four lines of obvious code and one of the
sharpest mistakes in simulation work. Write the obvious version first.
// internal/sim/tick.go — the obvious spread: scan, and flood as you go
var neighbours = []Coord{{X: 1, Y: 0}, {X: -1, Y: 0}, {X: 0, Y: 1}, {X: 0, Y: -1}}
func (w *World) spreadWater() error {
g := w.Ground
for y := 0; y < g.H; y++ {
for x := 0; x < g.W; x++ {
c := Coord{X: x, Y: y}
t, err := g.At(c)
if err != nil {
return err
}
if t != Soil {
continue
}
for _, d := range neighbours {
n, err := g.At(c.Offset(d.X, d.Y))
if err != nil {
continue // off the grid holds no water
}
if n == Water {
if err := g.Set(c, Water); err != nil {
return err
}
break
}
}
}
}
return nil
}
// cmd/worldd/main.go — seed 5's valley, and exactly one tick
w := sim.NewWorld(sim.Generate(12, 8, 5))
fmt.Printf("tick %d\n%s", w.Tick(), w.Render())
w.Step()
fmt.Printf("tick %d\n%s", w.Tick(), w.Render())
$ go run ./cmd/worldd
tick 0
############
#..........#
#..........#
#.~~~~~....#
#.~~~~~~...#
#...~~~~...#
#...~......#
############
tick 1
############
#..........#
#.~~~~~~~~~#
#~~~~~~~~~~#
#~~~~~~~~~~#
#~~~~~~~~~~#
#~~~~~~~~~~#
############
One tick, and the pond ate the valley: not one cell of spread but everything the water could eventually reach, all at once.
The clue is the row that survived. Row 1, along the north rim, is still dry while row 2 and everything south and east of it is water, so the flood went south and east and refused to go north, and no line of code mentions a direction.
Directional bias with no direction in the code means the bias came from visiting order, and the only ordering here is the scan: row 0 first, then row 1, each row west to east.
Reaching row 2, the scan checked the cell below, found the pond in row 3, and flooded row 2 immediately. Continuing east, each cell it had just flooded was already water when its eastern neighbour was examined, so the flood raced ahead of the scan inside one pass.
Row 1 was scanned before any of that, when its southern neighbours were still soil, so it kept its ground.
Name the bug precisely, because the name is the fix: the tick had no width. Reading and writing one grid in a single pass lets a cell's new value become another cell's input in the same tick, so "next tick" and "later in this loop" collapse into one moment.
A tick has to be an instant every law sees identically, which means deciding from the state as it stood at the start and applying the changes afterward.
// internal/sim/tick.go — the fix: collect, then apply
// spreadWater floods every soil cell that touched water at the start
// of this tick, and no cell that only touches water because of it.
func (w *World) spreadWater() error {
g := w.Ground
flooding := make([]Coord, 0, 16)
for y := 0; y < g.H; y++ {
for x := 0; x < g.W; x++ {
c := Coord{X: x, Y: y}
t, err := g.At(c)
if err != nil {
return err
}
if t != Soil {
continue
}
for _, d := range neighbours {
n, err := g.At(c.Offset(d.X, d.Y))
if err != nil {
continue // off the grid holds no water
}
if n == Water {
flooding = append(flooding, c)
break
}
}
}
}
for _, c := range flooding {
if err := g.Set(c, Water); err != nil {
return err
}
}
return nil
}
// cmd/worldd/main.go — one shrub, one walker, six ticks by hand
w := sim.NewWorld(sim.Generate(12, 8, 5))
w.Spawn(sim.Shrub, sim.Coord{X: 9, Y: 1})
w.Spawn(sim.Walker, sim.Coord{X: 10, Y: 6})
for i := 0; i < 6; i++ {
fmt.Printf("tick %d\n%s", w.Tick(), w.Render())
if err := w.Step(); err != nil {
fmt.Println("step:", err)
return
}
}
fmt.Printf("tick %d\n%s", w.Tick(), w.Render())
$ go run ./cmd/worldd (ticks 0 through 3 of 6) tick 0 ############ #........o.# #..........# #.~~~~~....# #.~~~~~~...# #...~~~~...# #...~.....@# ############ tick 1 ############ #........o.# #.~~~~~....# #~~~~~~~...# #~~~~~~~~..# #.~~~~~~~..# #..~~~~~.@.# ############ tick 2 ############ #.~~~~~..o.# #~~~~~~~...# #~~~~~~~~..# #~~~~~~~~~.# #~~~~~~~~~.# #.~~~~~~~@.# ############ tick 3 ############ #~~~~~~~.o.# #~~~~~~~~..# #~~~~~~~~~.# #~~~~~~~~~~# #~~~~~~~~~~# #~~~~~~~~@.# ############
Now the water moves like water: one cell in every direction, once per tick, the front advancing evenly out of the pond chapter 4's seed drew, north rim on the same schedule as south.
Two costs bought that. The scan visits every cell whether or not anything near it is wet, fine at 96 cells and ruinous at a million. The slice of pending changes is a second copy of part of the world, the standard price of simultaneity.
Watch the walker too, the first thing in this book stopped by an event and not by an argument. It starts at column 10 and drifts to column 9 on tick 1.
On tick 2 its step west would enter water that arrived this tick,
Move returns ErrBlocked, driftWalkers absorbs
it, and it stands at column 9 while the flood closes around it. Nothing coordinated
the two. They met because they share a tick.
The shrub is the honest wart. It stands at tick 6 with water on every side, because
nothing re-checks an entity's ground after that ground changes: allows
runs when a request is made, and the shrub never made one.
That is a real gap, and it is the sort a world can only see once it has a tick to see things in.
The scheduled loop
Step is the whole simulation. What is left is delivery: calling it ten
times a second, without main counting to six. The first attempt writes
itself, and it is nearly right.
// cmd/worldd/main.go — the sleeping loop, timed
// load stands in for the work later volumes will do inside a tick.
const load = 30 * time.Millisecond
func main() {
w := sim.NewWorld(sim.Generate(40, 20, 5))
start := time.Now()
for w.Tick() < 30 {
if err := w.Step(); err != nil {
fmt.Println("step:", err)
return
}
time.Sleep(load)
time.Sleep(sim.TickDuration)
}
fmt.Printf("%d ticks at %d/s should take %.2fs\n", w.Tick(), sim.TickRate,
float64(w.Tick())/sim.TickRate)
fmt.Printf("wall time actually spent: %.2fs\n", time.Since(start).Seconds())
}
$ go run ./cmd/worldd (timings measured on the author's machine; yours will differ) 30 ticks at 10/s should take 3.00s wall time actually spent: 3.91s
Three seconds of sim time took 3.91 seconds to deliver. The loop sleeps a full tick after doing the tick's work, so every iteration costs the budget plus the work, and the error accumulates: thirty ticks, thirty helpings of 30 ms, nine tenths of a second behind.
That 30 ms is padding standing in for ecosystem, creature, and villager work inside
Step. Run this for a day and the world is hours behind the wall clock,
perfectly correct about its own history and useless as a place anyone can visit.
time.NewTicker(d) returns a ticker that delivers the current time on
its C field every d, on a schedule fixed from the moment
it was created, not from when you last looked. The expression
<-ticker.C takes the next delivery, waiting if none is ready and
returning immediately if one is already waiting, and
defer ticker.Stop() releases it. That is every part of it this chapter
needs; the machinery underneath is chapter 9's subject. Full reference:
pkg.go.dev/time#Ticker.
// cmd/worldd/main.go — the loop on a ticker
// step does one tick's work: the world, then the padding.
func step(w *sim.World) {
if err := w.Step(); err != nil {
panic(err)
}
time.Sleep(load)
}
func main() {
w := sim.NewWorld(sim.Generate(40, 20, 5))
ticker := time.NewTicker(sim.TickDuration)
defer ticker.Stop()
start := time.Now()
for w.Tick() < 30 {
<-ticker.C
step(w)
}
fmt.Printf("%d ticks at %d/s should take %.2fs\n", w.Tick(), sim.TickRate,
float64(w.Tick())/sim.TickRate)
fmt.Printf("wall time actually spent: %.2fs\n", time.Since(start).Seconds())
}
$ go run ./cmd/worldd (timings measured on the author's machine; yours will differ) 30 ticks at 10/s should take 3.00s wall time actually spent: 3.03s
Same work, same padding, and the drift is gone: 3.03 seconds instead of 3.91. The difference is what each loop counts from.
The sleeping loop measures forward from whenever it happened to finish, so every delay it suffers is added to the next deadline permanently. The ticker's deadlines were laid down in advance, one every 100 ms from the moment it was made, so early work waits and long work eats slack that was already allocated.
Nothing about the world changed, only its delivery.
One property matters more than the timing. If a tick's work badly overruns its budget, the ticker does not queue the missed beats and fire them in a burst; it drops them and delivers only the most recent.
The loop then runs slower in wall time and the world falls behind real people, a
capacity problem you can measure and fix. Catching up instead, by running extra ticks,
would call Step at a rate set by how overloaded the machine is: the leak
this chapter closes.
Figure 7.1: the loop absorbs every irregularity of real time so that the world above it sees a row of identical steps.
The tick number
Run one seeded world for a hundred ticks twice: paced at ten ticks a second with the 30 ms load, then unpaced, as fast as the processor will go. Record the water count after every tick and compare the two hundred-number traces.
// internal/sim/terrain.go — a number to compare runs by
// Count is how many cells of the grid hold terrain t.
func (g *Grid) Count(t Terrain) int {
n := 0
for _, c := range g.cells {
if c == t {
n++
}
}
return n
}
// cmd/worldd/main.go — one hundred ticks, twice, at two speeds
const (
seed = 5
ticks = 100
load = 30 * time.Millisecond
)
// run advances a fresh world for n ticks, recording the water count
// after every one. If paced, it runs at TickRate; otherwise it runs
// as fast as the machine allows.
func run(n int, paced bool) ([]int, time.Duration) {
w := sim.NewWorld(sim.Generate(40, 20, seed))
trace := make([]int, 0, n)
var ticker *time.Ticker
if paced {
ticker = time.NewTicker(sim.TickDuration)
defer ticker.Stop()
}
start := time.Now()
for w.Tick() < uint64(n) {
if paced {
<-ticker.C
time.Sleep(load)
}
if err := w.Step(); err != nil {
panic(err)
}
trace = append(trace, w.Ground.Count(sim.Water))
}
return trace, time.Since(start)
}
func main() {
fast, fastWall := run(ticks, false)
slow, slowWall := run(ticks, true)
fmt.Printf("unpaced: %d ticks in %.1fms of wall time\n", len(fast),
float64(fastWall.Microseconds())/1000)
fmt.Printf("paced: %d ticks in %.2fs of wall time\n", len(slow),
slowWall.Seconds())
fmt.Println("water counts identical, all", ticks, "ticks:", slices.Equal(fast, slow))
fmt.Println("water at ticks 1, 5, 20, 100:",
fast[0], fast[4], fast[19], fast[99])
}
$ go run ./cmd/worldd (wall times measured on the author's machine; yours will differ) unpaced: 100 ticks in 0.4ms of wall time paced: 100 ticks in 10.03s of wall time water counts identical, all 100 ticks: true water at ticks 1, 5, 20, 100: 33 137 625 684
Twenty-five thousand times the wall time, and not one number out of place. Both runs were 33 water cells into the flood at tick 1, both had filled all 684 cells of the valley floor by tick 100, and both passed through the same counts in the same order between.
The wall-clock figures are the only lines that change on your machine.
Fixing the timestep turns the tick into an address: every state the world has been in is reachable by a number, and that number means the same thing on every machine, at every speed, in every run from that seed.
It is what makes "tick 4,312" a phrase with a referent. A log line, a replay test, or a saved record only works if sim time is a count instead of a measurement.
The unpaced run is also the whole trick behind accelerated time: deliver ticks as fast as the machine can while nobody is looking, slow to real time when a player arrives, identical history either way. A world that ran a thousand years while you slept and one you watched for an hour differ only in how many ticks they took.
The tick rate never appears in spreadWater, which floods once per call and
has no idea how often it is called. It does not appear in driftWalkers or in
Step.
Every law is written per tick, and only the loop in main knows what a second
is. Hold that line and the rate stays tunable: raise it to 20 for a finer-grained world,
drop it to 5 to carry twice as much world per core, no law edited either way.
Cross it once and finding your way back means auditing every law you have written.
Checkpoint
- State what
Tick()reports: steps the world has taken, never seconds anyone lived through. - Say why stage 1's 144-frame and 48-frame runs put the walker in two different places, and why the tick-based runs cannot.
- Convert between the two units in both directions at a rate of 10: 30 ticks is 3 seconds, a two-second action is 20 ticks, a day is 864,000 ticks.
- Explain why writing into the grid during the spread scan flooded the valley in one tick, and why deciding first and applying afterward advances the front one cell.
- Say what a
time.Tickerdoes that atime.Sleeploop does not, and why dropping a missed beat is the right behaviour for this program. - Defend the boundary that keeps
timeout ofinternal/simexcept as the loop's budget constant.
Exercise 1: the tick that dries out. Add a second law to
Step: on every tenth tick, one water cell chosen by the scan's first
hit reverts to soil. Print the water count each tick for 20 ticks and find the
ticks where the count moves the wrong way.
Guard it with if w.tick%10 == 0, which is true on ticks 10 and
20 because Step counted before it called any law, and pick the
victim in the same decide-then-apply two passes spreadWater uses.
The count still climbs, since one drying cell cannot outpace an advancing
front, but ticks 10 and 20 land one cell short of where they would otherwise
be: on the 40-by-20 valley, 322 and 624 instead of stage 6's 323 and 625. Run
it twice and both traces lose their cell in the same place, which is only true
because "every tenth tick" is counted, not timed.
Exercise 2: change the rate, keep the world. Set
TickRate to 40 and run the paced loop for 30 ticks again. Predict
both the wall time and the final water count before you run it, then explain
which one you got wrong and why.
The water count is unchanged, because no law reads the rate: 30 ticks of flooding is 30 ticks of flooding. The wall time is the interesting half. At 40 ticks a second the budget is 25 ms, and 30 ms of padding does not fit inside it, so the loop cannot keep the schedule and the run lands near 0.9 seconds instead of the 0.75 the arithmetic promises. That is stage 5's capacity signal: the world stayed correct and the machine told you it is out of room.
Exercise 3: catch the leak in review. Somebody proposes
if time.Since(spawnedAt) > 2*time.Second { grow() } inside a
plant law. Write the two-sentence review comment that rejects it, and the
replacement line.
The replacement is a stored tick: record plantedTick when the
plant appears, then if w.tick-p.plantedTick >= 20 { grow() } at
a rate of 10. The comment writes itself from this chapter: a law calling
time.Since grows the plant after two seconds of the
operator's time, so one seed gives different worlds on a loaded server,
under accelerated time, and in chapter 10's replay test. Run both under stage
6's unpaced loop and only one still works, because a hundred unpaced ticks take
under a millisecond and the wall-clock plant never grows.