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.
$ 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.
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.
// 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.
// 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.
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.
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.
// 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.
// 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
- Implement
ebiten.Game's three methods and say what each one is asked for, including whyLayoutexists 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 orderWritePixelswants, 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.
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.