Sitemap

Can Jev play Space Invaders?

Going back to my Space Invaders game, and letting Jev have a turn

--

Press enter or click to view image in full size

In 2018 I wrote Create a simple cross-platform desktop game with Go. I took a terminal Space Invaders game I had made earlier and turned it into a desktop app. Go drew each frame as an image, a local web server served it, and a tiny library called webview put it in a window. Fetch the images quickly enough and you had a game. It was a fun little project to fiddle around.

Eight years later, I opened the project again. It didn’t build.

This is one of the less charming forms of software nostalgia. The aliens had patiently waited for me, but the dependencies had moved on. I decided to get it running again, tidy up the game, and then hand the controls to Jev, TypeSafe’s decision model.

Getting the aliens back on screen was the easy part. Getting Jev to stop driving into their bombs took rather longer.

Getting the aliens back

The old project had no go.mod, and its webview and audio dependencies needed replacing. I added a module targeting Go 1.26, moved to webview_go and gopxl/beep/v2, and kept gift for image processing. jQuery and the unused Bootstrap dependency could retire.

The most satisfying removal was the web server. In the original app, the page polled /frame for images and sent key presses to /key. There was even a cache workaround for the old Windows browser engine, which otherwise liked the first frame so much it kept showing it.

The current webview package has Bind, which exposes a Go function to JavaScript. The page can ask Go for a frame directly, without an HTTP server in between. This is the frame binding from the new app.

w.Bind("frame", func(seq uint64) map[string]any {
s, src := game.CurrentFrame()
if s == seq {
return map[string]any{"seq": s, "src": ""}
}
return map[string]any{"seq": s, "src": src}
})

Each frame has a sequence number. If the page already has the latest one, Go returns an empty image string. Otherwise it returns the new image. A small JavaScript loop calls this through requestAnimationFrame, while two more bindings pass key-down and key-up events to Go.

The browser side is small enough to show almost in full. The frame function below is the Go function bound above, exposed as an asynchronous JavaScript call.

async function loop() {
try {
var f = await frame(seq);
seq = f.seq;
if (f.src !== '') img.src = f.src;
} catch (e) {}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

The returned image is a PNG data URI, so the page can assign it straight to an image element. The game loop produces frames independently of the page asking for them. If the page asks twice before the next frame is ready, the sequence number saves it receiving the same image twice. There is no need to make the game simulation run at the browser’s refresh rate.

Go still draws the pictures and the webview still shows them, but the plumbing no longer needs its own web server. With that gone, I turned to another small inconvenience left over from 2018.

I embedded the images, sounds and HTML with //go:embed. The old version looked for assets next to the executable, which was awkward when go run put that executable in a temporary directory. Now the assets travel inside the binary, and this works from a checkout with Go and the native build dependencies installed.

go run ./cmd/invaders

The binary still uses the operating system’s webview and audio facilities, but there is no separate asset folder to lose. Sound playback got a similar cleanup. The speaker is initialised once and the three WAV files are decoded into memory at startup, rather than reopened and decoded whenever something shoots or explodes.

There are only three effects to worry about, for shooting, an alien being killed, and an explosion. Once decoded, each sits in a buffer that can supply a fresh audio stream whenever it is needed. If the audio device cannot be initialised, the game carries on silently. The invasion should not have to wait for my speakers.

While I was in there

With the pictures and sounds working again, I could finally play. This was when I discovered that my younger self had left me a few things to do.

The cannon could drive off the screen, destroyed aliens still influenced when the formation turned around, and bombs kept falling forever after disappearing off the bottom. Clear every alien and you were rewarded with an empty screen that carried on being empty. Apparently I had not planned for anyone to be good at it.

There were less visible problems too, including unsynchronised state shared between the game loop and the old web handlers, and a collision check that used one sprite’s dimensions for both objects. That explained a few suspicious hits. For once, blaming the game was justified.

Once I had fixed those, adding lives and another wave seemed reasonable. Then it seemed a shame not to have shields. You can probably see how this went.

Press enter or click to view image in full size
The renovated game running in its desktop window, with the full playfield visible. Three lives, four shields, and quite a lot of aliens to negotiate with.

The cannon now moves smoothly while a key is held, with the game loop targeting 50 updates a second. There are three lives, a score, a saved high score, and proper title and game-over screens. The aliens speed up as their numbers fall, and clearing a wave starts another one, lower and faster.

That change to movement makes a noticeable difference. The old cannon moved a fixed distance for each key event, so holding an arrow depended on the operating system’s key-repeat behaviour. Now a key-down means “start moving” and a key-up means “stop”. The game checks that held state on each update. You get continuous movement rather than a cannon that appears to be negotiating each step with the keyboard.

I added four destructible shields, bombs dropped by the lowest alien in each column, and a mystery UFO worth 50 to 300 points. You can pause with p, which is an advantage the aliens have graciously agreed to give you.

The code also outgrew its original two-file arrangement. The game rules and drawing now live in internal/game, embedded assets in internal/assets, and window setup in cmd/invaders. Separating the simulation from the window means I can test the rules without opening the app. A rendering test can also produce screenshots using the game's own drawing code. The full-window screenshot above was captured from the running app.

By then the renovation had produced a pleasant little game and a convenient way to give another player access to its state. That brought me back to Jev.

Giving Jev the controls

Jev is what TypeSafe calls a System One model. You give it a state and typed questions, and it returns structured decisions rather than a written response. A Choice question selects from options you supply, a Noul question returns a probability for a yes/no proposition, and a Score question rates something against a rubric. Questions can share a state in one request while being evaluated independently.

For Space Invaders, Choice and Noul covered the two decisions I needed. Where should the cannon go, and should it shoot? It seemed a manageable first day at work.

In this integration, Jev receives text descriptions, not screenshots. Go reads the game state and turns it into words. Jev chooses an action, and Go carries it out. I didn’t train or fine-tune a model for the game.

There’s no Go SDK for TypeSafe, but the API is a single endpoint, so the client is short. A request carries a state, a model name and a map of named questions. The answers come back under the same names.

type Question struct {
Type string `json:"type"` // "choice", "noul" or "score"
Instructions any `json:"instructions"` // plain text, or JSON with named parts
Criteria any `json:"criteria,omitempty"`
}

type Answer struct {
Choice string `json:"choice"` // Choice: the option picked
Probabilities map[string]float64 `json:"probabilities"` // Choice: every option's probability
Confidence float64 `json:"confidence"`
Noul float64 `json:"noul"` // Noul: probability of yes
}

answers, err := client.Ask(ctx, state, questions)

Ask marshals the state and questions, adds "model": "jev-latest", and posts them to https://api.typesafe.ai/v1/systemone with the API key as a bearer token. If the service says it's rate limited or overloaded, it waits half a second and tries once more. Everything interesting happens in what goes into state and questions.

This gives it a different view from the human player. I have to look at the screen and judge whether a bomb is heading towards me. The autopilot gets a snapshot containing the cannon, live aliens, bombs, remaining shields and the UFO, along with the formation’s movement timing. Go can work with those exact positions before writing a description for Jev. So this is an experiment in choosing actions from prepared information, rather than learning to recognise Space Invaders from its pixels.

The game itself knows nothing about TypeSafe. It exposes an Autopilot interface with a snapshot of the game, input controls and events such as a cannon hit or a shot ending. The Jev implementation lives in a separate package and is wired in only when I build with the jev tag.

The interface is deliberately small.

type Autopilot interface {
Input(v View, in Input) Input
Observe(v View)
Event(ev Event, fromPilot bool)
HUD() (top, bottom string)
}

Before each simulation step, Input can supply movement and firing controls. Afterwards, Observe receives a fresh snapshot. Event reports what happened, and HUD supplies the status text drawn in the screen corners. The snapshot contains copies of the relevant state, so the background work can read it without reaching into objects the game loop is changing.

That boundary also keeps the API out of the game loop. The aliens continue moving while a request is in flight. Pausing the universe until Jev replied would certainly improve its chances, but it would make for a rather different game.

go build -tags jev -o invaders ./cmd/invaders
TYPESAFE_API_KEY=... ./invaders -jev

A normal build leaves the Jev implementation out. In a Jev build, pressing j switches the autopilot on or off, so I can take over whenever I think I can do better. Whether I can is a separate question, conveniently absent from the logs.

Left, right, or straight into a bomb

With the controls connected, I needed to decide what to tell Jev. I started with the sort of running commentary you might give someone learning the game. Go described the danger here, to the left and to the right, along with the nearest alien and whether the laser was ready. A typical description looked like this.

{
"cannon_position": "away from the walls",
"danger_here": "a bomb is falling onto this spot and will hit very soon",
"danger_left": "clear: no bomb is falling onto this spot",
"danger_right": "a bomb is falling onto this spot but is still high up",
"line_of_fire": "an alien",
"laser": "ready to fire",
"nearest_alien": "an alien is a little to your left"
}

Jev chose left, right or stay, and answered whether to fire. A background goroutine sent requests one after another, while the game carried on using the latest answer.

Those were two questions in the same request, shown here slightly trimmed. The names in backticks refer to fields in the state above, which is how TypeSafe lets a question point at a particular piece of it.

questions := map[string]typesafe.Question{
"move": {
Type: "choice",
Instructions: "Which way should the cannon move right now? Staying alive comes first: never stay in, or move into, a spot where a bomb will hit very soon. When you are safe, move toward `nearest_alien`.",
Criteria: map[string]string{
"left": "Move left. Choose this when `danger_here` says a bomb will hit very soon and `danger_left` is clear, or when you are safe and `nearest_alien` is to your left.",
"right": "Move right. ...", // the mirror image of left
"stay": "Stay where you are. Choose this when `danger_here` is clear and `nearest_alien` is directly above you, or when moving either way would lead into a bomb that will hit very soon.",
},
},
"fire": {
Type: "noul",
Instructions: "Should the cannon fire its laser right now?",
Criteria: map[string]string{
"true": "`laser` is ready to fire and `line_of_fire` is an alien or the mystery UFO.",
"false": "`laser` is not ready, or `line_of_fire` is nothing or your own shield.",
},
},
}

The Choice answer names one option, with a probability for each. The Noul answer is a probability that the cannon should fire, and the code fires above 0.5.

The first danger descriptions covered the cannon’s current position and strips 30 pixels to either side. “Very soon” meant a bomb was less than 0.7 seconds from impact. Those phrases were translations of calculations done in Go. Jev didn’t have to subtract coordinates or convert pixels into seconds, but it did have to choose from the picture those phrases painted. Anything outside that picture might as well not exist.

Before letting it loose, I tried ten hand-built situations against the API. Jev got nine right. In the tenth, it moved towards an alien on the left even though a bomb was about to land there.

My instruction allowed a left move when the cannon was safe and the nearest alien was to the left. I had meant that the move should be safe, but what I had written only said the cannon was safe where it currently stood. Jev had followed my instructions into a bomb. This was awkward for my plans to blame Jev.

I rewrote the condition to require danger_left to be clear before moving left. The same ten scenarios then passed.

// before
"left": "Move left. Choose this when `danger_here` says a bomb will hit very soon and `danger_left` is clear, or when you are safe and `nearest_alien` is to your left.",

// after
"left": "Move left. Choose this only when `danger_left` is clear, and either `danger_here` says a bomb will hit very soon or `nearest_alien` is to your left. Never choose this when `danger_left` says a bomb will hit very soon or is the wall.",

The distinction was small enough to miss in prose but large enough to drive a cannon through. “You are safe, so move towards the alien” and “move towards the alien only if the way is clear” allow different actions. The test was useful because I could inspect the exact words behind the mistake instead of vaguely asking the model to be more careful.

Ten out of ten looked encouraging. The aliens were about to provide a larger sample.

Good advice, delivered late

To find out how those tidy test scenarios translated into actual play, I added a decision log. It recorded what Jev had been told, what it chose and what happened afterwards. Each movement decision was followed for one second, and each shot was tracked until it hit something, missed or was cancelled. The autopilot could also start another game by itself, which made it very efficient at collecting evidence against my first design.

In the first session, covering 683 decisions over roughly three minutes, half the completed shots hit a target. The cannon was lost about every six seconds. Twenty-nine shots hit its own shields.

The log made the shooting failures particularly puzzling. Jev was saying “fire” when the description said an alien was in the line of fire. It was choosing sensibly from the information I had supplied, so I looked at what happened between sending that information and pulling the trigger.

Requests in these sessions generally took about 250–450 ms. At around 300 ms, the cannon could travel 45 pixels while Jev was answering. By the time “fire” arrived, it described a shot from somewhere the cannon had already left. Good advice, wrong address.

The movement descriptions had a similar problem. My left and right danger zones covered only 30 pixels. The cannon could drive beyond that neighbourhood before the next answer arrived, meeting a bomb Jev had never been told about.

At 50 updates a second, a 300 ms request spans about 15 game updates. During those updates, the cannon moves, bombs fall and the aliens continue across the screen. “Shoot now” therefore needs a fairly precise definition of “now”. Mine had accidentally meant “a little while ago, when this would have been a good idea”.

So I checked the line of fire again before applying a fire decision, widened the danger zones to 50 pixels, and warned about approaching bombs earlier. Shots into our own shields fell from 29 to one in the next run. Over a longer, 15-minute session, shot accuracy settled at 61%.

Better. Still a lot of ammunition going into space, even by the standards of the game.

Asking a better question

Wider danger zones helped, but they left the basic arrangement unchanged. I asked Jev which direction to move, then let the cannon keep going until another answer arrived. I was giving it a steering wheel with a delay and wondering why it kept having accidents.

There was a way to give it less driving to do. This was my game. I knew how fast the cannon moved, how fast bombs fell, and how the alien formation stepped across the screen. Go could calculate possible journeys before Jev chose one.

So I changed the question from “left, right or stay?” to “which spot should you go to?”

The code now lists roughly 20 destinations across the screen. For each one, it simulates the cannon continuing towards its current destination during the estimated API delay, then travelling to the candidate destination. It checks that journey against the visible bombs and looks a little beyond arrival. Separately, it predicts whether a shot would meet an alien or the UFO, allowing for their movement while the laser travels upwards.

Jev gets options like this.

Move a long way to the left. Safety: safe: no falling bomb will hit you on the way there or for a while after you arrive. Shooting: an alien will be in your line of fire when you get there.

The options describe safe, risky and deadly trips. Jev picks one, and the code steers towards that destination, stopping when it reaches it or gets within a pixel.

Get Sau Sheong’s stories in your inbox

Join Medium for free to get updates from this writer.

Most of the information has moved out of the state and into the options themselves. The state now holds only what the fire question needs.

{
"laser": "ready to fire",
"line_of_fire": "a shot fired now would hit an alien"
}

Each candidate spot becomes one option of the Choice question, keyed by a name the code can look up afterwards.

options := make(map[string]string, len(o.Spots))
for _, s := range o.Spots {
options[s.Key] = s.Text // "spot_03": "Move a long way to the left. Safety: safe: ..."
}

questions := map[string]typesafe.Question{
"spot": {
Type: "choice",
Instructions: map[string]any{"role": role, "question": "Pick the spot the cannon should go to next, following `priorities` in order.", "priorities": priorities},
Criteria: options,
},
"fire": fireQuestion,
}

Reading the answers is equally plain. The chosen key maps back to a spot the code already knows the position of.

answers, err := p.client.Ask(ctx, o.State, o.questions())
pick, fire := answers["spot"], answers["fire"]
for _, s := range o.Spots {
if s.Key == pick.Choice {
chosen = s // the code steers towards chosen.X
}
}
p.fire = fire.Noul >= 0.5 && o.State.Laser == laserReady

Checking the journey matters as much as checking the destination. A clear spot on the other side of a falling bomb is not much use if the cannon has to cross the bomb’s path at exactly the wrong moment. The forecast advances the cannon and bombs one game tick at a time and checks whether their rectangles overlap.

A collision before the expected answer arrives is labelled deadly because that candidate decision would come too late. A collision during the trip is also deadly. If the trip is clear but a bomb threatens the cannon soon after arrival, the spot is risky. Otherwise it is safe within the simulated window, which normally includes another 0.8 seconds after arrival, with an overall four-second cap.

The delay estimate is updated from measured response times using a moving average. It doesn’t assume the network will always take exactly 300 ms. That estimate determines how long the simulated cannon follows its existing instruction before switching to the proposed one.

“Safe” here means safe within the forecast. It cannot account for bombs that haven’t been dropped yet, and its assumptions about shields and the formation can become stale as the game changes. The simulation is useful precisely because this is a small, predictable world, but it still has a limited view of the future.

Shooting needs its own forecast. An alien above the cannon now may have moved sideways by the time the laser reaches its row. The code steps the formation forwards, including its reversal at the screen edge, and checks where the rising beam would meet it. Shields matter too, since an excellent shot at an alien is less excellent when it has to pass through your own defences first.

The fire question remains separate from the destination choice. Heading towards a promising firing position doesn’t mean the cannon should shoot immediately. Jev is also given the laser’s availability and the predicted result of firing when the answer takes effect. The two answers share a request, but serve different purposes.

With this design, shot accuracy rose from 61% to 96%, and survival after decisions made in safe situations rose to 92%. Escaping danger was still a problem, though. That figure actually fell from 76% to 72% in the recorded runs.

The cannon had become a much better shot without becoming a particularly good escape artist.

Please stop changing your mind

The next log helped explain why. Jev had selected an unsafe destination while a safe one was available only three times in 644 decisions. The destinations looked sensible. The trouble was getting the cannon to finish a journey.

When several spots looked equally good, Jev kept choosing a different one. The cannon zig-zagged, abandoning the route used in an earlier forecast. It had the air of someone trying to choose the shortest supermarket queue, except the queues were dropping bombs.

Confidence was often around 0.4 in these cases. TypeSafe’s confidence documentation explains that a low value can mean no option is a clear winner. It isn’t a direct probability that the cannon will survive.

Meanwhile, new bombs could appear just after a snapshot was taken. With requests sent one at a time, a bomb might wait nearly two request durations before an answer accounting for it arrived. That became painful as the aliens descended.

The delay needed attention again, so I made the requests overlap, starting one at most every 100 ms with a limit of three in flight. An answer based on an older observation than one already applied is discarded. This reduces the wait for a fresh observation, although it doesn’t make an individual request faster and can use more API calls.

For example, suppose a bomb appears just after one request leaves. With sequential requests, the autopilot waits for that answer before sending a description that includes the new bomb, then waits again for the next answer. Overlapping requests can get that new description on its way while the first request is still being processed.

The answers need not return in the order they were sent. Each observation therefore has a sequence number. If an answer about observation 42 arrives after one about observation 43 has already been accepted, 42 is ignored. Otherwise an unusually slow request could make the cannon change its mind based on an older world, which it was already doing quite enough of without help from the network.

To discourage the zig-zagging, I added an explicit “keep heading to your current destination” option. I also added an “exposed” warning for positions beneath low aliens. Those spots might be clear now, but a newly dropped bomb could arrive before the autopilot had time to react and dodge.

These preferences needed an order. Simply telling Jev to prefer staying put had produced a cannon that declined to chase an alien. I had asked it to be less indecisive and accidentally encouraged early retirement.

The revised instructions put survival first, then a chance to shoot. Jev should keep its current destination if it met both priorities, and avoid exposed positions unless they offered a shooting opportunity. If no safe spot offered a target, it could stay where it was, provided that was safe too.

The instructions accept structure, so the priorities went in as a numbered list rather than one long sentence.

"priorities": []string{
"1. Survive: pick a spot described as safe if there is one. If no spot is safe, pick a risky spot rather than a deadly one.",
"2. Attack: among safe spots, pick one where an alien or the mystery UFO will be in your line of fire, even if it is a long move.",
"3. Commit: if you are already heading to a spot that satisfies 1 and 2, keep heading there.",
"4. Avoid exposed spots unless they satisfy 2.",
"5. If no safe spot has an alien or the mystery UFO in your line of fire, stay where you are if that is safe.",
},

The code still checks a firing answer before using it. If a laser is already in flight, it cannot launch another. If a fresh forecast says the shot would hit a different kind of target from the one described to Jev, the shot is dropped. The model’s answer is permission to try an action, with the current game state deciding whether that action still makes sense.

How well did it play?

After those changes, the cannon spent less time reversing and more time surviving. Across the development sessions, the figures looked like this.

I kept the game’s score separate from the measurements used to debug the autopilot. The score tells me how much damage it has done. The decision log helps explain why it stayed alive or failed. An escaped movement decision means the cannon was classified as under threat and wasn’t hit during the following second of active play. It doesn’t mean that decision alone caused the escape, since newer decisions may have taken over during that second.

The screen shows a running dodge and hit tally. Resolved decisions go into jev-actions.jsonl, with the supplied state, chosen destination, confidence, firing probability and outcomes. A separate jev-summary.txt keeps the aggregate figures. This is considerably more paperwork than I normally require from someone playing Space Invaders, but it made the failures useful.

Press enter or click to view image in full size

The wider-danger-zone version ran for 15 minutes. The final design was tested in shorter sessions of a few minutes each. Its shot accuracy was slightly below the first destination-based version, but the recorded survival figures were better. In the final sample, Jev chose an unsafe spot while a safe one was available once in 1,052 decisions, and selected no spots labelled exposed.

I wouldn’t read too much into a few percentage points. Bomb drops are random, the sessions differed in length, and the changing danger descriptions mean “under threat” didn’t mean quite the same thing in every version. A single cannon hit also marks all pending decisions from the preceding second as killed, so several decisions can share one unfortunate ending. Shot accuracy counts completed shots, excluding cancellations.

The figures helped me improve the autopilot, but they measure the whole arrangement, including my forecasts and shot checks. With Go already doing so much of the work, a controller written entirely in code would be a useful comparison. I haven’t tested that here. This began as a way to let Jev play an old game, and I’m not quite ready to turn the aliens into a benchmark committee.

The highest score recorded in these sessions was 1,680, achieved by the earlier left/right version during its longer run. This compared to my measly powers when my highest score is 230. So the final design gets the better survival figures, while its predecessor keeps the high score. I can’t quite award it every trophy.

Letting Jev play

If you’d like to watch it yourself, you’ll need Go, the native webview dependencies for your platform and a TypeSafe API key. The autopilot only exists in builds made with the jev tag, so a normal build won't respond to the flag or the key described here.

git clone https://github.com/sausheong/invadersapp
cd invadersapp
go build -tags jev -o invaders ./cmd/invaders

The game looks for the key in three places. It first checks the TYPESAFE_API_KEY environment variable, then a .env file in the current directory containing a TYPESAFE_API_KEY= line, and finally a file called typesafe-api-key in the game's data directory. On macOS that directory is ~/Library/Application Support/invaders, next to the saved high score.

With the key in place, start the game with the autopilot switched on.

./invaders -jev

There’s nothing else to press. After two seconds on the title screen, the autopilot starts a game by itself, and it does the same after every game over. Left alone, it will keep playing until you close the window, which is either a triumph of automation or a very efficient way to watch aliens win.

You can also start without the flag and press j whenever you fancy a rest. Pressing j again takes the controls back. If the key can't be found, the top-right corner says so instead of quietly doing nothing.

While Jev is playing, the top-right corner shows its latest move as an arrow, its confidence in the chosen spot and how long the request took. The bottom-right corner keeps the running dodge and hit tally. The summary file is rewritten every five seconds, so you can check the figures in jev-summary.txt without stopping the game. When the window closes, including after Ctrl+C in the terminal, the final summary is printed as well.

There’s also a macOS app bundle, built with the same tag.

scripts/build-macOS -tags jev

An app opened from Finder can’t be given the -jev flag or see a .env file in your project folder, so save the key in the data directory and press j once the window opens.

One practical note: the autopilot makes several requests a second while a game is running, and it keeps starting new games unattended. Each request is small, but if you leave it running overnight, you are paying for a very dedicated Space Invaders habit.

A slower evening

I thought the story ended there. Then, in the evening as I was fiddling around with Jev and Invaders, Jev started playing badly again, dying every few seconds with the same code that had been doing well that afternoon.

The decision log pointed at the clock rather than the choices. Decisions were arriving about twice a second instead of eight or nine times. Several times, every destination on offer was marked deadly, with a bomb due to hit before any answer could take effect. Jev was left with nothing sensible to choose from.

Timing the API showed why. Requests that had taken 230–300 ms in the afternoon now took about 520 ms, and a tiny one-question request was no faster. Connecting to the service took a few milliseconds, so the time was being spent on TypeSafe’s side. Their models page does warn that capacity is adjusting to heavy demand. Answers also seemed to arrive in batches roughly every half-second, so sending more requests at once didn’t produce fresher decisions. Under those conditions, the autopilot could see the game only about twice a second. Bombs fall 200 pixels a second.

Everything I’d built so far assumed each answer would arrive in time to deal with the next surprise. At half a second, a bomb dropped close to the cannon lands before any decision about it can take effect. If I couldn’t get answers sooner, I could at least ask the question before I needed the answer.

So every request now carries a third question alongside the destination and the fire decision. If a new bomb suddenly appeared right above you, too close to wait for another decision, which way would you run? Go describes each side, including the nearest safe spot, any wall in the way and any known bombs falling there. Jev picks a direction. The answer is a contingency plan, held in reserve.

"escape": {
Type: "choice",
Instructions: map[string]string{
"role": role,
"question": "Plan ahead: if a new bomb suddenly appears right above you, too close to wait for another decision, which way should you run?",
},
Criteria: map[string]string{
// written fresh for every request, for example:
"left": "Run left. The nearest safe spot is a short dash to the left, and no known bomb is falling there.",
"right": "Run right. The right wall is right beside you, so there is no room to run this way.",
},
},

On every game update, the code checks whether the cannon’s current course runs into a bomb before a fresh answer could possibly arrive. If it does, the cannon carries out the escape Jev already chose, dashing to the nearest safe spot in that direction without waiting for the network. The next answer then takes over as usual. TypeSafe’s documentation describes this as asking speculative questions, and letting code use whichever answers apply. I had read that page early on without realising how useful it would be to a laser cannon.

// A bomb the current plan runs into before a fresh decision could
// arrive: carry out the escape Jev planned in advance.
if !p.escaping && p.escapeDir != 0 {
f := newForecast(v)
if f.hitSoon(x, p.dest, int(p.lag.Load())+escapeMargin) {
if ex, _ := f.escapeSpot(x, p.escapeDir); ex >= 0 {
p.dest, p.escaping = ex, true
}
}
}

The estimated delay decides how far ahead to look. The direction came from Jev, in a request that finished before the bomb even appeared.

I ran the previous version and the new one back to back that evening, about four minutes each, under the same slow responses.

Press enter or click to view image in full size

The new version lost the cannon three times in almost four minutes, where the previous one lost it about every five seconds. It wasn’t a pure improvement, though. It fired far fewer shots, and hits fell from about 27 a minute to 11. Part of that is because the previous version kept dying and starting new games with a full formation directly overhead, but the new one was also more cautious. A cannon that keeps running away is a cannon that isn’t shooting.

The other lesson was that the autopilot’s performance was never just a property of the model. The same code played very differently depending on how quickly the service answered. When the answers can’t come faster, the best move is to ask for them earlier.

Eight years later

Looking back, I spent much more time fixing the arrangements around Jev than correcting its choices. The model stayed the same throughout. What changed was the information I gave it, the questions I asked, and how the game used its answers.

The first lesson was about words. Jev did exactly what my sentences said, which isn’t always what I meant. “When you are safe” sent the cannon into a bomb, because I had meant safe after the move. “Prefer staying put” produced a cannon that stopped chasing aliens, because I hadn’t said which preference came first. Both were fixed by writing the condition precisely and putting the priorities in order. The decision log made that possible. I could read the exact words behind each mistake instead of guessing.

The second lesson was about time, and it kept coming back. Every answer arrives some while after the question, and the game doesn’t wait. At around 300 ms, the cannon drifted 45 pixels before a “fire” arrived, so good advice landed at the wrong address. On the slower evening, answers took about half a second and seemed to arrive in batches. Jev could effectively see the game only twice a second, and there were moments when every destination on offer was already deadly. The same code, playing the same game, went from losing the cannon every 19 seconds to every 5, largely because the service was slower.

Response speed turned out to be something I could design around rather than just suffer. The forecasts describe the future rather than the present, simulating the measured delay so each option describes the moment the answer will actually take effect. The code checks a decision before acting on it, dropping a shot if the line of fire has changed since Jev saw it. That alone took shots into our own shields from 29 to one. Overlapping requests, with sequence numbers to throw away stale answers, keep decisions fresher, at least while the service isn’t batching them. The “keep heading there” option gave the cannon a reason to finish its journeys.

The biggest single improvement was asking before I needed the answer. The planned escape took the cannon from losing a life every 5 seconds to every 76 on that slow evening, because the answer was already waiting when the bomb appeared. If you can’t make the answers arrive sooner, you can still change when you ask the questions.

The third lesson is less flattering to the premise of the experiment. By the end, Go was doing most of the heavy lifting. It simulated every bomb, predicted where the formation would be, worked out what each shot would hit, and labelled every destination safe, risky or deadly. Once all that is written down, many of Jev’s decisions have fairly obvious answers. Firing is close to a rule: if the laser is ready and an alien will be in the line of fire, shoot. Choosing a destination could be a scoring function that puts safety first, then a target, then distance. The escape could simply take the side with the nearest safe spot. A plain algorithm would make those choices without a network round trip, which, as the slower evening showed, is where most of the trouble came from.

That doesn’t make the exercise pointless. Space Invaders is a small, fully visible, predictable world, which is exactly where hand-written rules shine. Jev earns its keep when the situation is harder to reduce to numbers, and the interesting part here was learning how to shape the questions and the timing around a model. Whether a few dozen lines of Go could beat Jev at its own game is a story for another day.

By the end, Jev was competent enough to be fun to watch, though the aliens still got their share of victories.

The code is at github.com/sausheong/invadersapp, including the build instructions and optional Jev integration. You can play it yourself, or supply a TypeSafe API key and let Jev have a go.

Thank yous

Thanks to Serge Zaitsev for the original webview package, and the maintainers of webview_go and gopxl/beep for keeping the pieces moving. Thanks also to the TypeSafe team for Jev. The sound effects come from Classics United.

--

--