The World Vol 2 · Drawing the World
ch 15 / 105
Chapter 15

The Window Opens

Pix and the window

Three chapters have gone into the picture and nobody has looked at one yet. A frame is painted by arithmetic, reduced to sixty-four characters, written out as a PNG, and opened afterward in whatever image viewer the machine happens to have.

That is a good way to test a renderer. It is a poor way to run a client, because the thing a client exists for is a person watching, and watching means pixels arriving on glass many times a second while that person presses keys. One package opens the window, hands finished pixels across as bytes, and reports which keys are down using names the client picked; nothing above that package learns which library is underneath, and none of the library's own drawing is ever called.

Two jobs stand between Pix and a person, and neither of them is drawing. The first is getting a rectangle of the screen that this program is allowed to write to, which on Linux means negotiating with a display server, on Windows means Win32, on macOS means Cocoa, and in a browser means a canvas.

The second is finding out which keys are down, which is that same list of platforms again with a different set of names in each. Together they are tens of thousands of lines of code with no opinion whatsoever about what color a pixel should be.

The borrowed code gets a hard border. internal/shell is the only package in the program that imports Ebitengine, internal/input holds the client's own idea of a keyboard, Buffer grows one method, and worldc opens a window on the scene chapter 14 fixed in place. One cost gets named here: the client stops owning the outer loop.

ebiten.RunGame owns the loop

Start by taking the dependency, since everything after this depends on what arrived.

▣ Build · stage 1: the library, and the three methods it asks for
$ go get github.com/hajimehoshi/ebiten/v2
go: downloading github.com/hajimehoshi/ebiten/v2 v2.9.10
go: downloading github.com/hajimehoshi/ebiten v1.12.13
go: downloading golang.org/x/sync v0.21.0
go: downloading github.com/ebitengine/purego v0.9.0
go: downloading golang.org/x/sys v0.44.0
go: downloading github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1
go: downloading github.com/ebitengine/hideconsole v1.0.0
go: downloading github.com/jezek/xgb v1.1.1
go: added github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1
go: added github.com/ebitengine/hideconsole v1.0.0
go: added github.com/ebitengine/purego v0.9.0
go: added github.com/hajimehoshi/ebiten/v2 v2.9.10
go: added github.com/jezek/xgb v1.1.1
go: added golang.org/x/sync v0.21.0
go: added golang.org/x/sys v0.44.0

Seven modules for one window, and the names say what the work is: xgb speaks the X11 protocol, purego calls into the system's own libraries without a C compiler in the way, x/sys is the syscall layer. That is the code this book has no interest in writing, and the exact reason a platform layer is the one piece taken off a shelf.

// cmd/worldc/main.go — a throwaway: the three methods, and nothing else
package main

import (
	"fmt"
	"log"

	"github.com/hajimehoshi/ebiten/v2"
)

// window is the smallest thing that satisfies ebiten.Game: three
// methods, none of which draws anything.
type window struct{ frames int }

func (w *window) Update() error { return nil }

func (w *window) Draw(screen *ebiten.Image) { w.frames++ }

func (w *window) Layout(outsideW, outsideH int) (int, int) { return 160, 96 }

func main() {
	w := &window{}
	ebiten.SetWindowTitle("worldc")
	ebiten.SetWindowSize(800, 480)
	fmt.Println("worldc: a 160x96 frame in an 800x480 window; close it to stop")
	if err := ebiten.RunGame(w); err != nil {
		log.Fatal(err)
	}
	fmt.Println("worldc: window closed after", w.frames, "frames")
}
$ go run ./cmd/worldc   (a window appears; this is the author's screen, and it is the one output this book cannot print for you)
worldc: a 160x96 frame in an 800x480 window; close it to stop
worldc: window closed after 457 frames
$ xwininfo -root -tree | grep ebitengine   (while it is open; the window id and position are this machine's)
        0x60002d "worldc": ("ebitengine-application" "Ebitengine-Application")  800x480+25+62  +560+200

A black rectangle, 800 by 480, with worldc in the title bar, sitting there until it is closed. Nothing was painted, and something still happened 457 times: Draw was called on every frame the display was ready to show, for the ten seconds or so the window was open. Three methods bought all of that. Update is one step of the program's own thinking. Draw is asked for a picture. Layout answers a question the library has and the client did not ask first: how big is your picture, in yclient-owned pixels? Answering 160 by 96 while the window is 800 by 480 is what makes each of client pixels five screen pixels wide, and it means the code above never has to know how large the window is.

Now the part that changed. Volume 1 built a tick loop with a for around a select, and main launched it with go and then went off to wait for a Ctrl-C. Client code was the outermost thing running; time.Ticker was a thing the program called when it wanted one. ebiten.RunGame turns that around. It takes the value, keeps it, and does not return until the window closes, and from then on client code runs only when it is called. The name for the arrangement is inversion of control, and it is a real trade, not a formality.

Four things go away with the loop. Nothing may block inside Update, because the window's frames are queued behind it, so a hundred milliseconds of work there is a hundred milliseconds of frozen picture. The cadence is no longer a number the program chooses: Update runs at a fixed rate the library sets, sixty times a second by default, while Draw runs when the display is ready, and those are two different counts. Draw returns nothing at all, so an error discovered while painting has nowhere to go and has to be carried by hand to a method that can return one. And quitting stops being a channel that gets closed and becomes a value returned from Update.

For a shell, all four are affordable, and the reason is how little sits inside the inverted part. One type, three methods, under a hundred lines, none of which decides anything about the world. The simulation's own loop is untouched: it still owns its ticker, still runs on its own goroutine, and still steps on its own schedule. Two loops with two clocks in one program, and the borrowed one only ever asks for pixels.

Which side of the program owns the loop Two stacked panels. The upper panel shows Volume 1's arrangement: main calls our tick loop, which calls time.Ticker, with our code outermost. The lower panel shows this chapter's arrangement: ebiten.RunGame is called once and does not return, and it calls Update and Draw, which in turn call the client's Step and Draw and finally hand over a framebuffer. A dashed vertical line separates the two methods the library calls from the client's own methods. VOLUME 1 · OUR CODE CALLS THE LIBRARY main our tick loop time.Ticker our code is the outermost thing running VOLUME 2 · THE LIBRARY CALLS OUR CODE ebiten.RunGame does not return until the window closes Update() Draw(screen) client.Step(in) client.Draw(buf) render.Buffer

Figure 15.1: the arrows reverse at the dashed line. Left of it, Ebitengine is imported and the calls come inward; right of it, no package has heard of the library.

Three orders for four bytes

The whole of the presentation API this book takes is one method: screen.WritePixels(pixels []byte). It replaces every pixel of the screen image with the bytes handed to it, four per pixel, red first and alpha last, and it wants exactly four times width times height of them. The frame is a []Color, one uint32 per pixel with alpha in the top byte. Two slices, same pixels, different types, so something has to convert.

The tempting move is to skip the conversion. A uint32 is four bytes already, and 61,440 bytes of framebuffer are sitting in one contiguous run of memory; reinterpreting that run as bytes would cost nothing at all. Before deciding whether that works, look at what those bytes actually are. Take the valley's water, 0xFF4A90D2, and write it three ways.

▣ Build · stage 2: the conversion, and a test that measures it
// internal/render/present.go
package render

import "fmt"

// Pixels writes the buffer into dst as four bytes per pixel in red,
// green, blue, alpha order, which is what a window wants and is not
// how a Color sits in memory. dst must be exactly 4*W*H bytes long;
// the shell allocates it once and hands back the same slice every
// frame.
func (b *Buffer) Pixels(dst []byte) error {
	want := 4 * b.W * b.H
	if len(dst) != want {
		return fmt.Errorf("render: a %dx%d frame needs %d bytes, got %d", b.W, b.H, want, len(dst))
	}
	i := 0
	for _, c := range b.Pix {
		dst[i] = c.R()
		dst[i+1] = c.G()
		dst[i+2] = c.B()
		dst[i+3] = c.A()
		i += 4
	}
	return nil
}
// internal/render/present_test.go

// asWindow reads a run of bytes the way a window reads them: four per
// pixel, red first and alpha last. This is the only place in the book
// that decodes the platform layer's convention, and it exists so a test
// can ask what the window will make of what we sent.
func asWindow(w, h int, pix []byte) *Buffer {
	b := NewBuffer(w, h)
	for i := range b.Pix {
		b.Pix[i] = RGBA(pix[4*i+3], pix[4*i], pix[4*i+1], pix[4*i+2])
	}
	return b
}

func TestOneColorThreeOrders(t *testing.T) {
	c := Water
	var mem, big [4]byte
	binary.NativeEndian.PutUint32(mem[:], uint32(c))
	binary.BigEndian.PutUint32(big[:], uint32(c))

	b := NewBuffer(1, 1)
	b.Set(0, 0, c)
	out := make([]byte, 4)
	if err := b.Pixels(out); err != nil {
		t.Fatal(err)
	}

	t.Logf("the color      0x%08X  alpha %d red %d green %d blue %d", uint32(c), c.A(), c.R(), c.G(), c.B())
	t.Logf("in memory      % x   this machine's byte order", mem)
	t.Logf("big-endian     % x   the order Hash writes and the digits read", big)
	t.Logf("Pixels writes  % x   red, green, blue, alpha", out)
}

func TestTheWindowSeesTheFrameWeDrew(t *testing.T) {
	b := NewBuffer(SceneW, SceneH)
	Scene(b)

	out := make([]byte, 4*b.W*b.H)
	if err := b.Pixels(out); err != nil {
		t.Fatal(err)
	}
	seen := asWindow(b.W, b.H, out)
	if seen.Hash() != b.Hash() {
		d := Diff(b, seen)
		t.Fatalf("the window will not see the frame we drew\n    drew %s\n    sees %s\n    %d of %d pixels differ, first at (%d,%d): %08x arrives as %08x",
			b.Hash(), seen.Hash(), d.Count, SceneW*SceneH, d.At.X, d.At.Y, uint32(d.Want), uint32(d.Got))
	}
	t.Logf("%d pixels, %d bytes across the boundary, pixel hash %s either side", SceneW*SceneH, len(out), b.Hash())
}

func TestPixelsRefusesTheWrongSize(t *testing.T) {
	b := NewBuffer(SceneW, SceneH)
	err := b.Pixels(make([]byte, 4*SceneW*SceneH-4))
	if err == nil {
		t.Fatal("a slice one pixel short was accepted")
	}
	t.Log(err)
}
$ go test -count=1 -v ./internal/render/
=== RUN   TestOneColorThreeOrders
    present_test.go:33: the color      0xFF4A90D2  alpha 255 red 74 green 144 blue 210
    present_test.go:34: in memory      d2 90 4a ff   this machine's byte order
    present_test.go:35: big-endian     ff 4a 90 d2   the order Hash writes and the digits read
    present_test.go:36: Pixels writes  4a 90 d2 ff   red, green, blue, alpha
--- PASS: TestOneColorThreeOrders (0.00s)
=== RUN   TestTheWindowSeesTheFrameWeDrew
    present_test.go:53: 15360 pixels, 61440 bytes across the boundary, pixel hash e43efe3a1756660ea0a4f2fb3e5493164646059f2c516c7335b7968b9aee0626 either side
--- PASS: TestTheWindowSeesTheFrameWeDrew (0.00s)
=== RUN   TestPixelsRefusesTheWrongSize
    present_test.go:62: render: a 160x96 frame needs 61440 bytes, got 61436
--- PASS: TestPixelsRefusesTheWrongSize (0.00s)
PASS
ok  	theworld/internal/render	0.003s

The water has three byte orders, no two of them the same. In memory it reads d2 90 4a ff: this processor stores the low byte first, so blue comes out in front and alpha at the back. Written big-endian it reads ff 4a 90 d2, which is the order the eight hex digits are printed in and the order chapter 14 chose for hashing, so a digest does not depend on the machine.

The window wants a third thing, 4a 90 d2 ff, red first and alpha last. The zero-cost reinterpretation is therefore out twice over: it produces the wrong order here, and it produces a different wrong order on a processor that stores bytes the other way round. One explicit pass over 15,360 pixels is the price of a frame that means the same thing on every machine.

The length check is the other half of the seam. WritePixels is entitled to a slice of one exact size and has its own opinion about what to do with any other, so the buffer states the size it needs in a sentence naming both numbers, and the shell never gets to guess. One more caveat rides along, already met in chapter 14 at png.Encode: the bytes are treated as alpha-premultiplied, and for the fully opaque pixels this scene is made of, premultiplied and straight are the same three numbers.

Now the shell itself, which is those three methods with somewhere to put the result.

▣ Build · stage 3: the package that owns the window
// internal/shell/shell.go
// Package shell is the client's platform layer and the only package in
// the program that imports a windowing library. It opens a window,
// carries one framebuffer to it every frame, and reports which keys are
// down. It draws nothing.
package shell

import (
	"errors"

	"github.com/hajimehoshi/ebiten/v2"

	"theworld/internal/input"
	"theworld/internal/render"
)

// ErrQuit is what an App returns from Step to close the window.
var ErrQuit = errors.New("shell: quit")

// An App is the client as the shell sees it: something that can take
// one step and paint one frame.
type App interface {
	// Step advances the app by one frame, given the keyboard as the
	// shell read it. Returning ErrQuit closes the window; any other
	// error closes it and comes back out of Run.
	Step(in *input.State) error

	// Draw paints the frame. The buffer belongs to the shell and still
	// holds the pixels of the frame before this one.
	Draw(b *render.Buffer)
}

// Config is everything the shell needs to know about the window.
type Config struct {
	Title string // the text in the title bar
	W, H  int    // the framebuffer's size, in our pixels
	Scale int    // how many screen pixels wide one of ours is drawn
}

// game is the three methods the library calls. Nothing else in the
// program implements them, and nothing else needs to know they exist.
type game struct {
	app App
	buf *render.Buffer
	pix []byte
	in  input.State
	err error
}

// Draw runs once per frame the display is ready to show: let the app
// paint our buffer, convert it, hand the bytes over.
func (g *game) Draw(screen *ebiten.Image) {
	g.app.Draw(g.buf)
	if err := g.buf.Pixels(g.pix); err != nil {
		// Draw cannot return an error, so keep it for Update, which can.
		g.err = err
		return
	}
	screen.WritePixels(g.pix)
}

// Layout answers how big our frame is, in our pixels. The window's own
// size in screen pixels is a separate number the library scales to.
func (g *game) Layout(outsideW, outsideH int) (int, int) { return g.buf.W, g.buf.H }

// Run opens the window and does not return until it closes.
func Run(cfg Config, app App) error {
	g := &game{
		app: app,
		buf: render.NewBuffer(cfg.W, cfg.H),
		pix: make([]byte, 4*cfg.W*cfg.H),
	}
	ebiten.SetWindowTitle(cfg.Title)
	ebiten.SetWindowSize(cfg.W*cfg.Scale, cfg.H*cfg.Scale)
	return ebiten.RunGame(g)
}

App is the whole of the contract in two methods, and neither of them mentions a window. A client hands one of those to Run along with four numbers and gets a program; the library's types stop at this file's imports. Run also makes the buffer and the byte slice from the same two numbers, so the length can only be right, and the check inside Pixels protects every caller.

The error path is the inversion showing its teeth in four lines. Draw has no return value, so a failure found there is parked on the struct and reported by Update, which starts by looking for one. A method signature from the library decides what the client can do about a problem.

Whether the bytes survive the trip is a question, not an assumption, so the shell can be asked to measure it: after WritePixels, read the screen image straight back with ReadPixels and compare the two runs of bytes.

// internal/shell/shell.go — a field on Config, two on game, a line in Run, a tail on Draw
// ("fmt" joins the imports.)
type Config struct {
	Title  string
	W, H   int
	Scale  int
	Verify bool // on the first frame, read the screen back and report
}

type game struct {
	// ...as above, plus:
	back    []byte // the first frame read back off the screen, if asked for
	checked bool
}

// ...in Run, once g is built:
	if cfg.Verify {
		g.back = make([]byte, len(g.pix))
	}

// ...at the end of Draw, once WritePixels has been called:
	if g.back == nil || g.checked {
		return
	}
	g.checked = true
	screen.ReadPixels(g.back)
	n, at := 0, -1
	for i, b := range g.back {
		if b != g.pix[i] {
			if n == 0 {
				at = i
			}
			n++
		}
	}
	fmt.Printf("frame 1: sent %d bytes, read back %d, %d differ", len(g.pix), len(g.back), n)
	if n == 0 {
		fmt.Println("; the screen holds the numbers we handed it")
		return
	}
	fmt.Printf("; first at byte %d, sent %d, came back %d\n", at, g.pix[at], g.back[at])
$ go run ./cmd/worldc -verify   (measured on the author's machine)
worldc 0.0.1  160x96 at 5x  arrows move, s snapshots, esc quits
frame 1: sent 61440 bytes, read back 61440, 0 differ; the screen holds the numbers we handed it
window closed after 400 steps, 0 snapshot(s)

Every byte came home unchanged, which settles one thing and leaves another open. It settles that nothing between the slice and the screen image rounds, scales or rewrites the numbers. It cannot settle that the first byte of each group means red, because a round trip through one convention agrees with itself whatever the convention is. For that claim there is the documented order, the test above, and the picture on the glass.

⚠ Worked failure: the order the digits are written in

Here is the conversion loop written the way the color's own name reads. The constant is 0xFF4A90D2, alpha then red then green then blue, and typing the four channels out in that order takes no thought at all:

// internal/render/present.go — the four lines, in the order the digits read
	for _, c := range b.Pix {
		dst[i] = c.A()
		dst[i+1] = c.R()
		dst[i+2] = c.G()
		dst[i+3] = c.B()
		i += 4
	}
$ go test -count=1 -v -run 'TestOneColorThree|TestTheWindowSees' ./internal/render/
=== RUN   TestOneColorThreeOrders
    present_test.go:33: the color      0xFF4A90D2  alpha 255 red 74 green 144 blue 210
    present_test.go:34: in memory      d2 90 4a ff   this machine's byte order
    present_test.go:35: big-endian     ff 4a 90 d2   the order Hash writes and the digits read
    present_test.go:36: Pixels writes  ff 4a 90 d2   red, green, blue, alpha
--- PASS: TestOneColorThreeOrders (0.00s)
=== RUN   TestTheWindowSeesTheFrameWeDrew
    present_test.go:50: the window will not see the frame we drew
            drew e43efe3a1756660ea0a4f2fb3e5493164646059f2c516c7335b7968b9aee0626
            sees 7b87db07d59c079e634f4134a1b8b7e0e9f496cb1b87c24c676d18ebfca2d82b
            15360 of 15360 pixels differ, first at (0,0): ff6e8ca0 arrives as a0ff6e8c
--- FAIL: TestTheWindowSeesTheFrameWeDrew (0.00s)
FAIL
FAIL	theworld/internal/render	0.003s
FAIL

The third and fourth lines of the first test now print the same four bytes, which is the bug in one glance: the loop is writing the big-endian order, and the window is not asking for it. The second test prices the mistake at all 15,360 pixels, and that total is itself the tell. A bug touching literally everything is nearly always a disagreement about a convention and not an error in a calculation. Follow the one pixel the test names. The sky is 0xFF6E8CA0, opaque, red 110, green 140, blue 160. The four bytes now leave as ff 6e 8c a0, and the window reads them in its own order: red 255, green 110, blue 140, alpha 160. Every channel shifted one place along, the alpha byte became the reddest red there is, and the frame stopped being opaque. Repack that and you get a0ff6e8c, the number the test printed.

Two things come out of it. The first is that the label on a []byte is a promise, kept only by the two pieces of code at either end agreeing, and here one end is a library and the other is us. The second is that the test found it in three milliseconds without a display, because it asked the question in terms of client-owned hashes: what will the window make of these bytes, and is it the frame the renderer drew? A window is not needed to answer that, and a person looking at one is not a reliable way to.

◆ Note: everything Ebitengine offered, and why none of it is used

The library that just handed over a window would happily do far more. There is DrawImage for blitting a sprite with rotation, scaling and filtering on the GPU; DrawTriangles for arbitrary textured geometry; Kage, its own shading language, compiled and run per pixel on the graphics card; a vector package for filled, anti-aliased paths; a text package that renders a TrueType font at any size; and a one-line debug print that would put a frame counter in the corner this afternoon. Every one of those is faster and better than the renderer built here.

All of them are declined, and the reason is the book, not the library. A renderer built out of DrawImage teaches you the arguments to DrawImage. Sprites, tile maps, cameras, fonts and the third dimension are the content, and they only exist if the pixels are the client's to compute.

The shell takes a window, a keyboard and a frame clock, and the moment it would draw, it stops. There is a practical dividend as well: a platform layer this thin is a small target to port. The same three methods and one byte conversion are what a browser build would need, and keeping the drawing on the client side of the line keeps that door open instead of pinning the client to one machine's graphics card.

Naming keys before reading them

Ebitengine reports the keyboard through one function, ebiten.IsKeyPressed(ebiten.KeyArrowLeft), which answers exactly one question: is that key down at this instant. A client needs three. Is it held, so the marker keeps moving. Did it go down just now, so one press takes one snapshot instead of forty. Did it come up, which is how a charged attack ends and how a menu closes. The second and third questions are about change, and a single reading cannot answer them. Two readings can.

▣ Build · stage 4: two readings, three questions
// internal/input/input.go
// Package input is the client's own idea of a keyboard. No windowing
// library appears anywhere in it, so it builds and tests wherever Go
// does, including in CI with no display attached.
package input

// Key names something the client can do, never a key on a keyboard.
// Which physical key produces which of these is the platform layer's
// business and is written down in exactly one table.
type Key int

const (
	Left Key = iota
	Right
	Up
	Down
	Snap
	Quit
	NumKeys
)

// State is two readings of the keyboard: the one taken this frame and
// the one before it. Three questions can be answered from that pair,
// and a program that keeps only "is it down" can answer one.
type State struct {
	now, prev [NumKeys]bool
}

// Advance takes a fresh reading, keeping the last one as the past.
func (s *State) Advance(now [NumKeys]bool) {
	s.prev = s.now
	s.now = now
}

// Down reports whether the key is held right now.
func (s *State) Down(k Key) bool { return s.now[k] }

// Pressed reports whether the key went down on this frame: true once
// per hold, however long the hold lasts.
func (s *State) Pressed(k Key) bool { return s.now[k] && !s.prev[k] }

// Released reports whether the key came up on this frame.
func (s *State) Released(k Key) bool { return !s.now[k] && s.prev[k] }
// internal/shell/shell.go — the one table, and the read that fills the state

// keymap is the only place in this program where a physical key is
// named. Every other package says input.Left.
var keymap = [input.NumKeys]ebiten.Key{
	input.Left:  ebiten.KeyArrowLeft,
	input.Right: ebiten.KeyArrowRight,
	input.Up:    ebiten.KeyArrowUp,
	input.Down:  ebiten.KeyArrowDown,
	input.Snap:  ebiten.KeyS,
	input.Quit:  ebiten.KeyEscape,
}

// Update runs once per tick: read the keyboard into our own state, then
// let the app take a step.
func (g *game) Update() error {
	if g.err != nil {
		return g.err
	}
	var now [input.NumKeys]bool
	for k, ek := range keymap {
		now[k] = ebiten.IsKeyPressed(ek)
	}
	g.in.Advance(now)

	if err := g.app.Step(&g.in); err != nil {
		if errors.Is(err, ErrQuit) {
			return ebiten.Termination
		}
		return err
	}
	return nil
}
$ go test -count=1 -v ./internal/input/
=== RUN   TestOneKeyAcrossFiveFrames
    input_test.go:32: frame 1  nothing held        down false pressed false released false
    input_test.go:32: frame 2  the key goes down   down true  pressed true  released false
    input_test.go:32: frame 3  still held          down true  pressed false released false
    input_test.go:32: frame 4  still held          down true  pressed false released false
    input_test.go:32: frame 5  let go              down false pressed false released true
--- PASS: TestOneKeyAcrossFiveFrames (0.00s)
=== RUN   TestKeysDoNotLeakIntoEachOther
    input_test.go:54: one reading later: left released, snap pressed, four other keys untouched
--- PASS: TestKeysDoNotLeakIntoEachOther (0.00s)
PASS
ok  	theworld/internal/input	0.002s

Read the middle column down: down is true for three frames and pressed for exactly one of them, which is the whole point of keeping the previous reading. The test asserts that count, so a refactor that loses the second array fails instead of quietly turning one keypress into sixty snapshots a second.

The table above it is small and load-bearing. input.Left is an action this client understands; ebiten.KeyArrowLeft is a fact about a library. They meet on one line, in one file, and rebinding arrows to WASD is an edit to that line. Nothing else in the program can name a physical key, because nothing else imports anything that has them.

▣ Build · stage 5: a marker that moves, and a frame you can keep
// cmd/worldc/main.go — the client, as the shell sees it
package main

import (
	"flag"
	"fmt"
	"os"

	"theworld/internal/input"
	"theworld/internal/render"
	"theworld/internal/shell"
)

const version = "0.0.1"

// client is all of worldc's own behaviour today: a marker that moves on
// the arrow keys, over the scene chapter 14 fixed in place.
type client struct {
	x, y  int  // the marker, in our pixels
	shot  bool // a snapshot was asked for and has not been taken
	shots int
	steps int
}

// Step is called once per tick, with the keyboard already read into our
// own state.
func (c *client) Step(in *input.State) error {
	c.steps++
	if in.Pressed(input.Quit) {
		return shell.ErrQuit
	}
	if in.Pressed(input.Snap) {
		c.shot = true
	}
	if in.Down(input.Left) {
		c.x--
	}
	if in.Down(input.Right) {
		c.x++
	}
	if in.Down(input.Up) {
		c.y--
	}
	if in.Down(input.Down) {
		c.y++
	}
	return nil
}

// Draw paints one frame into the shell's buffer.
func (c *client) Draw(b *render.Buffer) {
	render.Scene(b)

	// The marker: a hollow square with a dot in it, drawn with the two
	// operations chapter 13 built.
	m := render.Rect{X0: c.x - 4, Y0: c.y - 4, X1: c.x + 5, Y1: c.y + 5}
	b.Line(m.X0, m.Y0, m.X1-1, m.Y0, render.Rock)
	b.Line(m.X0, m.Y1-1, m.X1-1, m.Y1-1, render.Rock)
	b.Line(m.X0, m.Y0, m.X0, m.Y1-1, render.Rock)
	b.Line(m.X1-1, m.Y0, m.X1-1, m.Y1-1, render.Rock)
	b.FillRect(render.Rect{X0: c.x - 1, Y0: c.y - 1, X1: c.x + 2, Y1: c.y + 2}, render.Water)

	if !c.shot {
		return
	}
	c.shot = false
	path := fmt.Sprintf("frame-%02d.png", c.shots)
	if err := b.SavePNG(path); err != nil {
		fmt.Fprintln(os.Stderr, "worldc:", err)
		return
	}
	c.shots++
	fmt.Printf("wrote %s  marker (%d,%d)  sha256 %s\n", path, c.x, c.y, b.Hash())
}

func main() {
	scale := flag.Int("scale", 5, "screen pixels per one of ours")
	verify := flag.Bool("verify", false, "read the first frame back off the screen and report")
	flag.Parse()

	c := &client{x: render.SceneW / 2, y: render.SceneH / 2}
	cfg := shell.Config{
		Title:  "worldc " + version,
		W:      render.SceneW,
		H:      render.SceneH,
		Scale:  *scale,
		Verify: *verify,
	}
	fmt.Printf("worldc %s  %dx%d at %dx  arrows move, s snapshots, esc quits\n",
		version, cfg.W, cfg.H, cfg.Scale)

	if err := shell.Run(cfg, c); err != nil {
		fmt.Fprintln(os.Stderr, "worldc:", err)
		os.Exit(1)
	}
	fmt.Printf("window closed after %d steps, %d snapshot(s)\n", c.steps, c.shots)
}
$ go run ./cmd/worldc   (right arrow held half a second, down arrow a quarter, then s, then escape)
worldc 0.0.1  160x96 at 5x  arrows move, s snapshots, esc quits
wrote frame-00.png  marker (110,63)  sha256 ff9874180c13a0f43656c2e36fa131c706c8545bb90aa0e8fc57fab9bed29cd7
window closed after 578 steps, 1 snapshot(s)

On screen: chapter 14's composition at five times size, grey-blue sky over brown ground with a thin rock horizon, the pond as a flat blue rectangle low on the left, a ridge drawn as two straight lines, the pale block clipped square at the top left, and a small grey box with a blue dot in it that follows the arrow keys. It is the picture the tests have been hashing all volume, finally in front of a person.

The numbers in that transcript check out against each other, which is the useful part. The marker started at the middle of the frame, 80 and 48. Half a second on the right arrow moved it 30 pixels and a quarter second on the down arrow moved it 15, so it landed at 110 and 63, exactly as printed: Step ran 60 times a second, and the 578 steps in the closing line are the ten seconds the window was open. The snapshot went out through chapter 14's SavePNG and its hash through the same Hash the tests assert on, so a frame seen on screen and a frame checked in CI are the same object, and the reason the save happens inside Draw is that this is the only moment the finished frame exists.

The single ebiten import

The border is drawn in one specific place: where the library's vocabulary stops. Two things cross it, and both change type on the way. Pixels leave as a plain []byte in a documented order. Keys arrive as an input.State holding six booleans this client named. No ebiten.Image, no ebiten.Key, no library type of any kind appears in a signature outside internal/shell, and one command says so: grep -rl ebiten internal cmd prints one file.

That single import buys three things. Everything that needs testing ends up on the far side of it, because render and input are ordinary Go packages that need no display, and their tests ran above in five milliseconds; the part that cannot be tested without hardware is a table of six keys and three short methods, small enough to check with your fingers in ten seconds. Replacing the window becomes a package-sized job instead of a rewrite, since a different platform layer has to satisfy the same App interface and produce the same bytes. And the picture cannot drift, because every pixel in it is still decided by code whose output chapter 14 pinned to a hash.

Inversion of control is a cost, not a formality. The client gives up being the outermost thing running. In exchange it gets a window, a keyboard and a frame clock, the same source file serving Linux, macOS and Windows, for one go get.

That bargain is good when the inverted part decides nothing and knows nothing, and it goes bad the moment simulation logic starts living inside Update because that is where the calls happen to arrive. Volume 1 built a world that ticks on its own clock, and it stays that way: the shell asks for pixels, and asking is all it does.

Checkpoint

✓ Checkpoint: what you can now do
  • Implement ebiten.Game's three methods and say what each one is asked for, including why Layout exists and what answering 160 by 96 does to an 800 by 480 window.
  • Name four things a program gives up when a library owns the loop, and show where one of them forced a design decision in Draw.
  • Given the color 0xFF4A90D2, write its four bytes in memory order, in big-endian order, and in the order WritePixels wants, and say why reinterpreting the slice in place is wrong on two counts.
  • Read a hash mismatch where every pixel differs and go looking for a disagreement about a convention instead of an arithmetic bug.
  • Keep two readings of the keyboard and answer held, pressed and released from them, and explain what breaks when a snapshot key uses the wrong one.
  • Point at the single file in the program that imports the platform library and defend every type that crosses its boundary.
⚡ Exercises: try first, then reveal
Exercise 1: change the window without changing the frame. Run the client at -scale 2 and again at -scale 8, pressing s once in each. Predict what the two snapshots will have in common before you compare them.

Everything. The two runs print one pixel hash between them:

$ go run ./cmd/worldc -scale 2
worldc 0.0.1  160x96 at 2x  arrows move, s snapshots, esc quits
wrote frame-00.png  marker (80,48)  sha256 2f16a7db0be64cacb6f606789e11de33e3018d478cc1f41f48fe30770905d051
window closed after 408 steps, 1 snapshot(s)
$ go run ./cmd/worldc -scale 8
worldc 0.0.1  160x96 at 8x  arrows move, s snapshots, esc quits
wrote frame-00.png  marker (80,48)  sha256 2f16a7db0be64cacb6f606789e11de33e3018d478cc1f41f48fe30770905d051
window closed after 408 steps, 1 snapshot(s)

The two PNG files go further than that and come out byte-identical, both hashing to 749c5db2… as files. Layout is what decides how many pixels get drawn, and it answered 160 by 96 both times; the scale only decides how much glass each of those pixels covers. That separation is what makes a snapshot test mean anything, since the frame CI checks is the frame on a 320-pixel window and on a 1280-pixel one.

Exercise 2: take the snapshot on the wrong question. Change Step to use in.Down(input.Snap) instead of in.Pressed, hold s for about a quarter of a second, and count the files before you look.

Fifteen files, every one of them the same picture:

$ go run ./cmd/worldc | tail -2
wrote frame-14.png  marker (80,48)  sha256 2f16a7db0be64cacb6f606789e11de33e3018d478cc1f41f48fe30770905d051
window closed after 474 steps, 15 snapshot(s)
$ ls frame-*.png | wc -l
15

A quarter of a second at sixty steps a second is fifteen steps, and Down was true on all fifteen. The count is a measurement of the tick rate as much as a bug. Anything a person means to happen once per press asks Pressed; anything that should continue while a key is held asks Down, which is why the marker moves on one and the snapshot fires on the other.

Exercise 3: answer Layout honestly and watch it fail. Change Layout to return outsideW, outsideH, so the frame claims to be the size of the window. Work out what will go wrong before running it.

The library believes Layout, so the screen image becomes 800 by 480 while the byte slice is still sized for 160 by 96, and the first WritePixels refuses:

$ go run ./cmd/worldc
worldc 0.0.1  160x96 at 5x  arrows move, s snapshots, esc quits
panic: buffered: len(pix) was 61440 but must be 1536000

goroutine 83 [running]:
github.com/hajimehoshi/ebiten/v2.(*Image).WritePixels(...)
theworld/internal/shell.(*game).Draw(0x30a8df7160e0, 0x30a8df35e300)
	/home/you/theworld/internal/shell/shell.go:95 +0x86
exit status 2

1,536,000 is 4 × 800 × 480, the exact arithmetic Pixels does on its own numbers, run by the library on its own. Two pieces of code sizing one buffer from two different sources is the bug, and the fix is that only Layout gets to answer the question. Note where the panic surfaced: inside Draw, one frame in, from a mistake made in a method that runs once.