Between Two Ticks
Ten times a second, sixty times a second
Every frame drawn so far has been a picture of a world that had already stopped. The client built its valley, took it forward a fixed number of ticks, and drew whatever state came back. Nothing moved while the frame was being painted, because there was nothing left to move.
A world with people in it does not stop. It advances ten times a second and keeps advancing, while the window in front of it refreshes about sixty times a second on its own schedule. The simulation owns every position the world has ever had. A frame may draw a position between two of them, as long as nothing stores that position, nothing decides anything from it, and every frame landing exactly on a tick draws that tick's own picture, pixel for pixel.
Ten ticks a second makes a tick 100 milliseconds long. Sixty frames a second makes a frame every 16.7 milliseconds. Six frames fit inside one tick, near enough, and during five of them the world is doing nothing at all: it did its work when the tick began and does no more until the tick is over.
A walker crosses one cell per tick and a cell is sixteen pixels, so five frames in a row put that walker on exactly the same pixel and the sixth puts it sixteen pixels away. Sixteen pixels every hundred milliseconds is 160 pixels a second.
The patch of The Hollow on this page is 192 pixels across, so a walker crosses it in a little over a second. That is quick, and it arrives in ten shoves. The eye reads a chain of shoves as teleporting, not as walking.
Two answers are honest. The first draws only when the world changed: correct, cheap, and visibly jerky at ten changes a second. The second draws the walker part of the way between where it was and where it is, which looks like walking and puts a position on the screen that the world never held.
Both are defensible. Only one of them can lie about the world, and the rule stops it. The work has four parts: a frame loop handed the current instant, a pair of positions per entity, a walk cycle timed off the clock, and a contract that hashes the frames the code claims are exact while letting go of the rest.
Drawing only when something happened
The loop keeps two wall-clock numbers. now is the instant this frame is
being drawn for. due is the instant at which the newer of the two states
the renderer holds is fully arrived. Both are counted from the moment the world started,
and neither is fetched from the operating system inside the loop: now
arrives as an argument. That one decision is what makes every run on this page
reproducible, and it costs nothing, because a window with a real clock can pass on what
it read.
// cmd/worldc/main.go
// client is the frame loop: the world, the pair of snapshots the
// renderer blends between, and the wall-clock instant at which the
// newer of the two is fully on screen.
type client struct {
w *sim.World
sheet *render.Sheet
cur []render.Snap // this tick's snapshot, one entry per entity
seen map[sim.EntityID]render.Snap // last tick's, by ID
due time.Duration // when cur is fully arrived
}
// frame brings the world up to the instant now and reports how far
// through the current tick that instant falls: just above 0 immediately
// after a tick, exactly 1 on the next one.
func (c *client) frame(now time.Duration) (float64, error) {
for now > c.due {
if err := c.step(); err != nil {
return 0, err
}
c.due += sim.TickDuration
}
return 1 - float64(c.due-now)/float64(sim.TickDuration), nil
}
// instant is the wall-clock time of frame k at fps frames a second,
// computed from whole nanoseconds so that a frame which ought to land
// on a tick boundary lands on it exactly.
func instant(k, fps int) time.Duration {
return time.Duration(int64(k) * int64(time.Second) / int64(fps))
}
The comparison in that loop is strict, and the strictness is the interesting part. A
frame drawn for exactly 100 milliseconds does not step the world, so due
stays at 100 milliseconds and the fraction returned is exactly 1: the newest state,
arrived, nothing blended. A frame drawn a microsecond later steps the world, pushes
due out to 200 milliseconds, and the fraction returned is very nearly 0.
The picture is continuous across that seam because both frames draw the same
positions, once as the end of one interval and once as the start of the next.
One consequence matters: the simulation runs a tick ahead of the picture. Tick 5 is computed the moment the clock passes 400 milliseconds, and it is shown progressively until 500. The renderer therefore always holds both ends of the interval it is drawing, which is the only way to draw between them at all.
// cmd/worldc/main.go
// snapshot copies every entity's cell into world pixels and pairs it
// with where the same ID stood last tick.
func (c *client) snapshot() {
next := make([]render.Snap, 0, c.w.Population())
for _, e := range c.w.Roster() {
s := render.Snap{ID: uint64(e.ID), Col: render.CellStand,
CurX: e.At.X * Tile, CurY: e.At.Y * Tile}
p := c.seen[e.ID]
s.PrevX, s.PrevY = p.CurX, p.CurY
next = append(next, s)
}
c.cur = next
c.seen = make(map[sim.EntityID]render.Snap, len(next))
for _, s := range next {
c.seen[sim.EntityID(s.ID)] = s
}
}
// at is presentation time: the moment of sim time the frame claims to
// show, counted in ticks. On a boundary it is a whole number, and a
// whole number is a moment the world actually had.
func (c *client) at(alpha float64) float64 {
return float64(c.w.Tick()) - 1 + alpha
}
// draw paints one frame at a given alpha. Rendering on the tick is the
// same call with alpha rounded down: the state that has fully arrived,
// which is the previous snapshot everywhere except exactly on a
// boundary.
func (c *client) draw(b *render.Buffer, alpha float64, blend bool) {
if !blend {
alpha = math.Floor(alpha)
}
paintGround(b, c.w.Ground)
p := c.at(alpha)
for _, s := range c.cur {
x, y := s.At(alpha)
b.Blit(c.sheet, c.sheet.Frame(pose(s, p), 0), x, y)
}
}
$ go run ./cmd/worldc -mode tick
worldc 0.0.4 seed 12: 12x8 cells of 16 = 192x128 pixels, 10 ticks a second, 60 frames a second
frame ms alpha world #1 #3 #3 pose frame
0 0.0 1.000 0.000 160, 96 96, 64 stand d07b2a7e8246
1 16.7 0.000 0.000 160, 96 96, 64 stand d07b2a7e8246=
2 33.3 0.000 0.000 160, 96 96, 64 stand d07b2a7e8246=
3 50.0 0.000 0.000 160, 96 96, 64 stand d07b2a7e8246=
4 66.7 0.000 0.000 160, 96 96, 64 stand d07b2a7e8246=
5 83.3 0.000 0.000 160, 96 96, 64 stand d07b2a7e8246=
6 100.0 1.000 1.000 144, 96 80, 64 step 0807d1ffd611
7 116.7 0.000 1.000 144, 96 80, 64 step 0807d1ffd611=
8 133.3 0.000 1.000 144, 96 80, 64 step 0807d1ffd611=
9 150.0 0.000 1.000 144, 96 80, 64 step 0807d1ffd611=
10 166.7 0.000 1.000 144, 96 80, 64 step 0807d1ffd611=
11 183.3 0.000 1.000 144, 96 80, 64 step 0807d1ffd611=
12 200.0 1.000 2.000 144,112 64, 64 step 2d5f83def756
13 frames drawn, 10 of them identical to the frame before
Thirteen frames covering a fifth of a second, and three pictures. The
= marks a frame whose hash equals the one above it, and ten of the
thirteen carry it. Walker 3 sits on world pixel 96 for six frames and then stands on
80; walker 1 holds 160 for six frames and then holds 144. Nobody is between.
The world column is the number to keep an eye on for the rest of the
chapter. It reports presentation time: the moment of sim time the frame is claiming
to show, counted in ticks. Rendering on the tick prints whole numbers there and
nothing else, and that is exactly the property being paid for. Every frame is a
picture of a moment the world computed. There is no frame in this run whose contents
could be argued with, because each one is a state the simulation can be asked about
by number.
Plenty of good games render on the tick and let the tick rate carry the smoothness: run the simulation at 60 and the question never comes up. This world cannot. Ten ticks a second is what a valley full of plants, creatures and villagers costs on one machine, and raising it to sixty would multiply every load in the book by six to buy smoothness for one walker. The rate stays at ten. The renderer deals with it.
The fraction of a tick that has gone by
Take frame 4 of the run above. It is drawn for 66.7 milliseconds, and the tick it falls inside is due at 100. The tick is 100 milliseconds long, so the part still to come is (100 − 66.7) / 100 = 0.333 of it, and the part gone by is 1 − 0.333 = 0.667. That second number is alpha, and it is every piece of wall-clock information the renderer is allowed to have.
Now the position. Walker 3 stood at cell (6, 4) at the end of tick 0 and at cell (5, 4) at the end of tick 1. Sixteen pixels to a cell puts those at world pixel 96 and world pixel 80, both on row 64. Across, the trip is 80 − 96 = −16 pixels. Down, it is 64 − 64 = 0.
x = 96 + round(−16 × 0.667) = 96 + round(−10.67) = 96 − 11 = 85
y = 64 + round(0 × 0.667) = 64
85 is what the blended run prints on that row. Now check the two ends, because the ends are what the volume's proofs stand on. At alpha 0 the multiplication is zero and the answer is 96, the older position itself. At alpha 1 the multiplication is −16 exactly and the answer is 80, the newer position itself. Neither end rounds anything, so neither end can drift by a pixel.
Written once, for two numbers a and b and a fraction α:
blend = a + round((b − a) × α)
// internal/render/snap.go
// Snap is one moving thing as the renderer sees it: where it stood at
// the end of the previous tick, where it stands at the end of this one,
// and which cell of the sheet it is drawn from. It is a copy of two
// moments of the world, never the world itself, and nothing that reads
// it may write back.
type Snap struct {
ID uint64
PrevX, PrevY int // world pixels at the end of the previous tick
CurX, CurY int // world pixels at the end of this tick
Col int // the sheet column this thing is drawn from
Fresh bool // nothing was here last tick: there is no previous
}
// Moving reports whether this thing goes anywhere across the tick.
func (s Snap) Moving() bool { return !s.Fresh && (s.PrevX != s.CurX || s.PrevY != s.CurY) }
// At is where to draw this thing when a fraction alpha of the tick has
// elapsed: 0 puts it where it was, 1 puts it where it is, and every
// value between names a pixel the world never held. A thing with no
// previous position is drawn where it is, at every alpha.
func (s Snap) At(alpha float64) (int, int) {
if s.Fresh {
return s.CurX, s.CurY
}
return blend(s.PrevX, s.CurX, alpha), blend(s.PrevY, s.CurY, alpha)
}
// blend walks alpha of the way from a to b and rounds to a whole pixel,
// half a pixel away from zero. At alpha 1 the multiplication is exact
// and the answer is b itself, which is the property the frame hashes
// in this volume are checked against.
func blend(a, b int, alpha float64) int {
return a + int(math.Round(float64(b-a)*alpha))
}
The type lives in render and holds no simulation types, which keeps the
package's promise intact: it still does not know what a world is. What it knows is
that something was at one pixel and is now at another. The client is where a cell
becomes a pixel, because that multiplication by sixteen belongs to whoever decided the
tiles were sixteen pixels wide.
The legs need the same treatment. A two-frame walk swapped once a tick would swap ten times a second, in lockstep with the position, and the animation would inherit the stutter it is supposed to cover. So the pose is a function of presentation time as well, and presentation time runs smoothly between ticks even though the world does not.
// cmd/worldc/main.go
// StrideMillis is how long one pose of the two-frame walk holds, in
// milliseconds of presentation time. It is not a whole number of ticks
// on purpose: legs that swapped exactly once a tick would read as part
// of the world's clock instead of as motion.
const StrideMillis = 60
// pose picks the sheet cell for one walker at presentation time p,
// measured in ticks. A walker going nowhere this tick stands still; one
// that is moving swaps feet every StrideMillis of presentation time,
// which on a tick boundary is a fact about the tick number alone.
func pose(s render.Snap, p float64) int {
if !s.Moving() {
return render.CellStand
}
ms := p * float64(sim.TickDuration/time.Millisecond)
if int(ms/StrideMillis)%2 == 0 {
return render.CellStand
}
return render.CellStep
}
$ go run ./cmd/worldc
worldc 0.0.4 seed 12: 12x8 cells of 16 = 192x128 pixels, 10 ticks a second, 60 frames a second
frame ms alpha world #1 #3 #3 pose frame
0 0.0 1.000 0.000 160, 96 96, 64 stand d07b2a7e8246
1 16.7 0.167 0.167 157, 96 93, 64 stand 0a81a908ee2e
2 33.3 0.333 0.333 155, 96 91, 64 stand 5757ead056ef
3 50.0 0.500 0.500 152, 96 88, 64 stand 4a25c00ce8af
4 66.7 0.667 0.667 149, 96 85, 64 step b83e7563afcc
5 83.3 0.833 0.833 147, 96 83, 64 step 4b9b75bef616
6 100.0 1.000 1.000 144, 96 80, 64 step 0807d1ffd611
7 116.7 0.167 1.167 144, 99 77, 64 step fea7924e0f8f
8 133.3 0.333 1.333 144,101 75, 64 stand 897e59b4f3c4
9 150.0 0.500 1.500 144,104 72, 64 stand c66b52034402
10 166.7 0.667 1.667 144,107 69, 64 stand d60f3bbffb78
11 183.3 0.833 1.833 144,109 67, 64 step ff989c9e4c23
12 200.0 1.000 2.000 144,112 64, 64 step 2d5f83def756
13 frames drawn, 0 of them identical to the frame before
Thirteen frames, thirteen pictures, and the two hashes that matter are unchanged.
Frame 6 is still 0807d1ffd611 and frame 12 is still
2d5f83def756: the same two frames the tick renderer drew, byte for byte,
with five new ones inserted between each pair. Blending did not move anything the
world had decided. It filled in the gaps.
Walker 1 shows the second axis doing the same work. Its first tick goes west and its second goes south, so the x column steps 160, 157, 155, 152, 149, 147, 144 and then stops dead while the y column takes over: 96, 99, 101, 104, 107, 109, 112. Each column is the same subtraction and the same rounding, run twice.
Now say what these frames are. Walker 3 was never at world pixel 93. There is no tick you could name in which the simulation had it there, no event log line recording it, no query that would return it. It is a picture drawn from two real positions and a reading of the operating system's clock, put on the glass for sixteen milliseconds and discarded. That is fine, and it is only fine because of what does not happen next: the number 93 is not written into any entity, is not sent anywhere, and decides nothing. Collision, blocking, feeding, combat and every other law still runs on cells at whole ticks. The renderer is allowed an opinion about what the moment looked like. It has no vote on what happened.
Figure 22.1 — the world moves twice in this picture and the window paints seven times. Only the ends of the line are answers to the question “where was the walker?”
The walker that came from the corner
Everything above assumed both ends exist. Give the world something that was not there last tick and the assumption fails, quietly, in the one place a snapshot is built. The demo has a fourth walker join on tick 8, at cell (10, 1), which is world pixel (160, 16).
// cmd/worldc/main.go — the two lines in snapshot that pair the ticks
p := c.seen[e.ID]
s.PrevX, s.PrevY = p.CurX, p.CurY
$ go run ./cmd/worldc -mode spawn -naive -from 41 -frames 49
worldc 0.0.4 seed 12: 12x8 cells of 16 = 192x128 pixels, 10 ticks a second, 60 frames a second
walker 4 joins the world on tick 8, at cell 10,1, world pixel 160,16
frame ms alpha world #3 #4 #4 pose frame
41 683.3 0.833 6.833 109, 64 - - a1ba31809a5a
42 700.0 1.000 7.000 112, 64 - - 629091383072
43 716.7 0.167 7.167 109, 64 27, 3 step 7ed6021b902c
44 733.3 0.333 7.333 107, 64 53, 5 stand 61d54f261007
45 750.0 0.500 7.500 104, 64 80, 8 stand 8e80c5c37b61
46 766.7 0.667 7.667 101, 64 107, 11 stand 188b6a97df5c
47 783.3 0.833 7.833 99, 64 133, 13 step a1d0b724e51a
48 800.0 1.000 8.000 96, 64 160, 16 step d970767e3034
49 816.7 0.167 8.167 93, 64 160, 19 step 140d096d05d6
Walker 4 appears at world pixel (27, 3), up in the north-west corner, and reaches (160, 16) five frames after it appears. Extend that line back to alpha 0 and it starts at (0, 0): the whole width of the map inside one tick, 160 pixels in a hundred milliseconds, ten times what anything in this world is allowed to move, with the legs going the entire way. Walker 3 in the column beside it is walking normally, so the blend arithmetic is not broken in general. Something about this one entity is different.
The corner names the bug on its own. Walker 4 was placed at cell (10, 1) and has
never been anywhere else, so the one position it certainly did not come from is the
opposite end of the map. A pair of coordinates that are exactly zero on both axes,
arrived at by no arithmetic at all, is a zero value, and there is precisely one place
a zero value can enter this program. c.seen[e.ID] is a map lookup;
walker 4's ID has no entry in last tick's map because walker 4 did not exist last
tick; and a Go map hands back the zero value of its element type for a key it does not
hold. The zero Snap stands at world pixel (0, 0). The lookup's second
return value, the one that says whether the key was there, was never asked for.
Note what frame 48 does, because it decides how much the contract at the end of this chapter can promise. At alpha 1 the blend returns the newer position exactly, whatever the older one was, so walker 4 is drawn at (160, 16) in this broken run as well. Its position is right on every tick boundary and wrong on every frame between. The only thing the boundary frame gets wrong is the legs: the renderer thinks this walker moved, so it strides. On a sprite sheet with one pose instead of two, the boundary frames of the broken client would be byte-identical to the boundary frames of the correct one.
The fix is the honest answer to the question the map was asked. An entity with no previous position does not have one, and no arithmetic can invent it: it has been in the world for zero ticks and has therefore travelled nowhere.
// cmd/worldc/main.go — the lookup answers both questions now
p, ok := c.seen[e.ID]
if ok {
s.PrevX, s.PrevY = p.CurX, p.CurY
} else {
s.Fresh = true
}
$ go run ./cmd/worldc -mode spawn -from 41 -frames 49
worldc 0.0.4 seed 12: 12x8 cells of 16 = 192x128 pixels, 10 ticks a second, 60 frames a second
walker 4 joins the world on tick 8, at cell 10,1, world pixel 160,16
frame ms alpha world #3 #4 #4 pose frame
41 683.3 0.833 6.833 109, 64 - - a1ba31809a5a
42 700.0 1.000 7.000 112, 64 - - 629091383072
43 716.7 0.167 7.167 109, 64 160, 16 stand a2556c8cd999
44 733.3 0.333 7.333 107, 64 160, 16 stand 1e96c1a63963
45 750.0 0.500 7.500 104, 64 160, 16 stand a629eb4439dc
46 766.7 0.667 7.667 101, 64 160, 16 stand 83d97546865b
47 783.3 0.833 7.833 99, 64 160, 16 stand a7b4e8af9909
48 800.0 1.000 8.000 96, 64 160, 16 stand 11de22a8398d
49 816.7 0.167 8.167 93, 64 160, 19 step 140d096d05d6
Walker 4 stands still for the tick it arrived in, then walks like everything else from
frame 49 onward. The Fresh flag lasts exactly one tick: the next snapshot
finds its ID in seen and pairs it normally. Compare frame 48 across the
two runs and the hash moved from d970767e3034 to
11de22a8398d, which is one walker's legs and nothing else.
The same hole opens whenever an entity's identity changes underneath its position, and entities in this world will do that in more ways than being born: dying, being removed and re-added, teleporting through a gate, arriving from somewhere that keeps its own IDs. Every one of them wants the same answer, which is to say that a position with no predecessor is not a movement.
Alpha 1 frames are tick frames
Volume 1 made a run provable by diffing its event log; this volume has been making renders provable by hashing the framebuffer. Interpolation looks at first like the end of that, because most of the frames a running client draws now depend on when the operating system happened to ask for them. The rule from the first page is what saves it, and the saving is narrow and exact: a frame drawn at alpha 1 contains no blended pixel anywhere, because the blend at alpha 1 returns the newer position itself. Such a frame is a pure function of the tick. It is therefore the frame the tick renderer would have drawn, and it can be hashed.
$ go run ./cmd/worldc -mode boundary
tick frames: 20 ticks, 21 hashes, one per tick
60 fps for 2.0s: 121 frames, 21 of them exactly on a tick boundary
144 fps for 2.0s: 289 frames, 5 of them exactly on a tick boundary
tick tick frame 60 fps 144 fps
0 d07b2a7e8246 d07b2a7e8246 d07b2a7e8246
5 12e2e01bf713 12e2e01bf713 12e2e01bf713
10 857a3b0740f1 857a3b0740f1 857a3b0740f1
15 45e636bed8b1 45e636bed8b1 45e636bed8b1
20 967abe368082 967abe368082 967abe368082
60 fps: all 21 boundary frames match the tick frame they land on: true
60 fps: mid-tick frames that match any tick frame: 0 of 100
144 fps: all 5 boundary frames match the tick frame they land on: true
144 fps: mid-tick frames that match any tick frame: 11 of 284
Three runs of the same seeded world: one that never interpolates, one at 60 frames a second and one at 144. The first produces 21 hashes, one per tick. The other two produce 121 and 289 frames, of which 21 and 5 land exactly on a tick, and every one of those agrees with the tick frame it landed on. Interpolation added 100 pictures at 60 frames a second and changed none of the 21 that were already there.
The count of 5 is the honest awkwardness of the technique and belongs in the open. A frame at 144 frames a second falls at k × 1,000,000,000 / 144 nanoseconds, and landing on a tick means that number is a multiple of 100,000,000, which needs k to be a multiple of 72. So a 144 Hz display gives you one exact frame every five ticks, half a second apart, and at 60 Hz you get one every six frames because 60 divides ten times a second cleanly. A contract that demanded a boundary frame per tick would be demanding something about the reader's monitor.
The last line of each pair is the other half of the honesty. At 60 frames a second not one of the 100 mid-tick frames coincides with any tick frame, which is what a renderer inventing positions should look like. At 144 frames a second, 11 of them do, and that has a plain cause. The last frame before a boundary falls somewhere in the 6.9 milliseconds ahead of it, and whenever that gap comes in under 3.1 milliseconds, alpha is above 0.969, and 0.969 of sixteen pixels rounds up to sixteen. The picture is identical. The claim is not. Looking like a tick frame and being one are different facts, and only the second is something the code can state.
$ go run ./cmd/worldc -mode clock -frames 60 (wall-clock figures measured on the author's machine; your run will differ) 61 frames at 60 fps in 1.02s of wall time; the world reached tick 11 alpha at the first eight frames: 0.171 0.338 0.507 0.675 0.839 0.008 0.167 0.341 frames that landed exactly on a tick boundary: 0 of 61 distinct frames drawn: 61
Zero. Not one frame in a real second of real frames landed on a tick, and none ever
will: the odds of a hardware refresh coinciding with a tick deadline to the nanosecond
do not need computing. Those alphas are also nothing like the tidy sixths of the
scripted runs, because a display does not refresh at exactly 60 Hz and a
time.Ticker does not fire at exactly 16.666 milliseconds.
This does not weaken the contract; it explains its terms. The guarantee is a property
of the drawing code, not of anybody's monitor: if alpha is 1, the frame is the
tick's own frame. Checking that needs a clock you control, which is what
instant is for, and it is why now comes into the loop as an
argument. The wall-clock run is here to be watched, not hashed.
// internal/render/snap_test.go
// TestNothingLeavesTheSegment asks at a thousand alphas across the tick
// whether the drawn pixel ever sits outside the box the two true
// positions bound. A renderer may invent a position between them; it may
// not invent one beyond them.
func TestNothingLeavesTheSegment(t *testing.T) {
for _, s := range segments {
for i := 0; i <= 1000; i++ {
alpha := float64(i) / 1000
x, y := s.At(alpha)
if !between(x, s.PrevX, s.CurX) || !between(y, s.PrevY, s.CurY) {
t.Fatalf("%+v at alpha %.3f drew %d,%d, outside its own segment", s, alpha, x, y)
}
}
}
}
$ go test -count=1 -v -run 'Test(TheEnds|Nothing|AThing)' ./internal/render/
=== RUN TestTheEndsAreExact
--- PASS: TestTheEndsAreExact (0.00s)
=== RUN TestNothingLeavesTheSegment
--- PASS: TestNothingLeavesTheSegment (0.00s)
=== RUN TestAThingWithNoPastDoesNotMove
--- PASS: TestAThingWithNoPastDoesNotMove (0.00s)
PASS
ok theworld/internal/render 0.002s
Three claims, none of which mentions a frame rate. The ends are exact, so a boundary frame is a tick frame. Nothing leaves the segment, so the fiction stays inside the bounds the two real positions set. A thing with no past does not move, so a newcomer never comes in from the corner. The first two hold for any two positions at all; the third is the failure box, kept.
Step back from walkers and the pattern is the one every system with two clocks arrives at. A slow authoritative process decides what is true, on its own schedule, in whole steps. A fast presentation process has to say something at moments the slow one never spoke about. It has exactly two choices: repeat the last thing that was said, or construct something plausible between the last two. Both are legitimate. What separates a system you can reason about from one you cannot is whether the constructed value is ever allowed to flow backwards into the authority.
Say that concretely, because the temptation is real and the seam appears in several disguises. A monitoring graph draws a line between two samples and does not claim the machine was at 43% at a moment nobody measured. A video codec invents frames between keyframes and never edits the keyframes. Across a network, the same seam does the same job with the same rule: blend for the eye, and let the authority settle every question that has consequences. The rule holds because it is about direction, not about pixels. Truth may flow into the picture. The picture may never flow back into the truth.
The reason all of this stays checkable was settled back in volume 1, by giving the simulation a fixed tick and a counter instead of a duration and a stopwatch. Because sim time is a count, the two ends of any interval are states with names. Because they have names, a frame can be checked against one. A world whose positions were computed from measured elapsed time would have no two ends to blend between, no exact frames to hash, and no way to say what a picture was a picture of.
Checkpoint
- Given a tick rate and a frame rate, work out how many frames fall inside one tick and how far an entity moving one cell per tick jumps between them.
- Compute alpha from two wall-clock instants and a tick budget, and blend a pair of world pixels by hand to the same whole pixel the client prints.
- Say what a mid-tick frame is a picture of, and name the three things the client must never do with the position it drew there.
- Given an entity gliding out of the top-left corner, look for a map lookup whose second return value was thrown away, not for a bug in the arithmetic.
- Explain why hashing only alpha-1 frames is a real contract and not a loophole, and why a wall-clock run produces none of them.
- Given a frame rate, predict how often a frame lands exactly on a tick by asking whether the rate divides the tick rate.
Exercise 1: thirty frames a second. Before running
anything, write down the alphas a 30 Hz client takes inside one tick and where
walker 3 lands at each. Then check with
go run ./cmd/worldc -fps 30 -frames 6.
Three frames per tick, at alpha 0.333, 0.667 and 1.000, putting walker 3 on world
pixel 91, 85 and 80. Every third frame lands on a tick, because 30 divided by ten
ticks a second is a whole number. The stronger check is the hash column: the seven
lines of the 30 Hz run are the even-numbered lines of the 60 Hz run,
d07b2a7e8246, 5757ead056ef, b83e7563afcc
and the rest, in the same order. Halving the frame rate did not produce different
pictures. It produced a subset of the same ones, which is what it means for the
frames to be a function of presentation time.
Exercise 2: how much the contract would have caught. Run
the newcomer both ways with -mode spawn and compare only the frames at
alpha 1. Which of them differ, and what does that say about what a boundary hash
proves?
Frame 48 is the only boundary frame inside the newcomer's first tick, and it
differs: d970767e3034 broken against 11de22a8398d fixed.
Every position on it is identical in both runs, since the blend at alpha 1 ignores
the previous position entirely. The whole difference is walker 4's pose, because
the broken client believes it moved and draws the striding cell.
So the contract caught this bug through the legs, by luck of the sprite sheet
having two poses and the walk cycle asking whether a thing moved. Replace
pose with return render.CellStand and the two runs
produce identical boundary hashes while one of them still flings newcomers across
the map at ten times the speed limit. A contract that binds the tick boundaries
does not bind what happens between them, and the frames between them are the ones
a player is looking at for five sixths of the time.
Exercise 3: a frame rate that lands on every tick. Add 200
to the list of frame rates in boundary and predict, before running it,
how many of its frames land exactly on a tick across the twenty ticks.
Twenty-one, the same as 60 Hz. Frame k falls at k × 1,000,000,000 / 200 = k × 5,000,000 nanoseconds, and a tick boundary is a multiple of 100,000,000, so k must be a multiple of 20. Twenty frames per tick, one of them exact, twenty-one including frame 0.
The general rule falls out of the same division. Frame k lands on a tick when k × 10 is a multiple of the frame rate, so a rate that divides evenly by ten hits every tick and a rate that does not hits one every few. Try 75, which is a real refresh rate: 75 divided by ten is not whole, the smallest k that works is 15, and you get an exact frame every other tick.