A remake of Battle City — the tank game from the NES — built on Godot. You defend the eagle at the bottom of the field, chew through brick, grab power-ups, and keep the enemy away from your base.
Status: playable on desktop and in the browser, two players over the network. Core, rendering, input, a 35-level campaign, NES-chip-style sound, splash screen, menu, pause, stats, high score, gamepad, builds for three platforms and the web. Two-player co-op: a room by code through our own server, or a direct connection on the local network; a dropped connection is survivable — the match catches up from the journal.
The room server also serves the game itself, packs into a Docker image and deploys with a single command. Next up: mobile platforms and the stores.
Not a game "inspired by" it, but those exact rules: 35 levels, four kinds of enemy tank, six power-ups (helmet, clock, shovel, star, grenade, tank), brick, steel, water, forest and ice, star upgrades that reset on death, two-player co-op.
The fidelity bar is recognizable, without frame-by-frame obsession: someone who played the original should sit down and say "yes, that's it". There is no frame-level comparison against an emulator; every tunable number lives in one place and is dialed in by feel.
All content is our own: sprites and sound are generated by scripts from text descriptions, and the level layouts are ours. No Namco/Bandai assets or maps are used.
| What | State |
|---|---|
| Windows, macOS, Linux | builds, plays |
| Browser | builds, plays |
| Co-op: quick game, room by code, local network | works |
| Room server and game hosting in one image | works |
| Public server on the internet | needs a machine with an address — the code is there |
| Android, iOS | subproject 2 |
| App Store, Google Play | subproject 4 |
The main architectural decision is a deterministic core without a single node.
All game logic lives in game/core/ as plain GDScript classes and knows nothing about
the screen, the keyboard or the network. The simulation steps in fixed ticks of 1/60
of a second and takes exactly five bits per player as input: up, down, left, right,
fire. All arithmetic is integer (coordinates are in 1/16 of a pixel), and randomness
comes from our own seeded generator.
That buys three things:
- The rules are testable without graphics. "A bullet does not pass through steel", "the shovel gives brick back after twenty seconds", "the grenade does not touch an enemy that is still blinking" — ordinary unit tests, run in seconds in headless mode.
- Network co-op reduces to exchanging button presses. Both devices compute the same thing, so tens of bytes per second travel over the wire, not the state of the world.
- Touch controls change nothing in the logic. They simply become another source of the same five bits.
The presentation layer reads state and draws it; it holds no state of its own. The core does not play sounds — it accumulates events, and the renderer decides how to show them.
game/ the Godot project: the whole game
core/ simulation and campaign: rules without nodes and without the engine
presentation/ rendering, sound, HUD, effects
ui/ screens and the root switcher
net/ connectivity: a room through the server, local network, rollback
platform/ keyboard, gamepad, file reading, high score, server address
levels/ 35 layouts in a text format
assets/ atlases, sounds, icon, splash — all generated from tools/
tests/ GUT tests
server/ room server and game hosting — in Go, knows nothing about the game
deploy/ Prometheus scrape and alert rules, an Alloy example, the Grafana dashboard
tools/ content generators, test runner, builds
docs/ design and plans
All content — sprites, font, sounds, level layouts, icon and splash — is described as
text in tools/ and assembled by scripts. What lives in the repository is a
description, not a binary of unknown origin: the diff shows what changed, editing does
not require an editor, and generation is reproducible.
You need Godot 4.7.2.
export GODOT=/Applications/Godot.app/Contents/MacOS/Godot
"$GODOT" . # open the project in the editor
"$GODOT" --path . --headless --quit # check that the project importsTests — together with the architectural boundary checks and the atlas verification:
./tools/test.shThe first run on a machine downloads the server's Go modules, checked against
server/go.sum; after that they come from the module cache.
Sprites are rebuilt from their text sources:
cd tools && python3 gen_sprites.pyFrom the project directory (the godot symlink comes with brew install --cask godot):
| Command | What it does |
|---|---|
godot |
Run the game |
godot -e |
Open the project in the Godot editor |
godot --path <path to project> |
Run from any other folder |
A built application no longer needs Godot: open "build/macos/BASE 13.app" after
./tools/build.sh, or drag it into Applications.
| Movement | Fire | |
|---|---|---|
| Player 1 | Arrows | Space |
| Player 2 | W A S D | Tab |
The menu has a NETWORK entry — the two-player game. Ways to meet:
| Entry | What it does |
|---|---|
QUICK GAME |
Pairs you with the first person who pressed the same thing. No code, no address |
CREATE ROOM |
Opens a room on the server and shows a six-character code |
JOIN ROOM |
Asks for a code and seats you in that room |
LAN HOST |
Waits for a partner directly, showing your address on the local network |
LAN JOIN |
Asks for the partner's address |
QUICK GAME is for playing with a stranger, CREATE ROOM with someone specific:
you read the code out loud, and no passer-by will land in that room.
A room works from anywhere: both sides dial out, so there is no port to forward and no need to be on the same network. All it takes is a running server — see "Room server" below.
The game does not wait for the partner's keys. It guesses them — whatever the partner held last — and when the real ones arrive and differ, it steps back a few ticks and computes them again within one frame. Your own tank answers two ticks after the press. The game stands only when the partner has been silent for longer than 200 ms: a hidden tab, a dropped link, or a path too long for guessing to cover.
If the game stutters, look at the [net] lines — they appear every five seconds in the
terminal, and in the browser in the developer console. L puts the same figures on
screen:
[net] 300 ticks in 5000 ms (norm 5000), speed 100%, stops 0 (longest 0 ms), frozen 0 (longest 0 ms), rollbacks 8 (deepest 8), resim 3 ms, skips 1, lead 9 against 6
stops— times the game stood past those 200 ms for a moment, up to 150 ms: a path a little too long for guessing. These are the waits the server hears.frozen— stands longer than that: the partner's hidden tab, a dropped link. Shown here, not sent to the server as waits.rollbacksanddeepest— how often a guess was wrong and how far back it had to go. On a path of about 110 ms to the server expect around eight ticks at the deepest.resim— what stepping back cost this machine over the five seconds.skipsandlead— a side that got ahead of its partner lets one tick in twenty go until the two are level. When the partner is back from a stand, the lead the stand left is let go at once instead, before the picture moves again.
frozen means the partner went quiet; stops that keep coming mean the network. A speed
noticeably below a hundred with neither and a large resim means the machine cannot keep
up.
A direct connection (LAN HOST / LAN JOIN) does without the server, but on macOS 15
and newer the system asks for local network permission on the first connection. Without
it the game will not find the partner, even though the port is open and the machines are
on the same Wi-Fi. The permission is granted in System Settings → Privacy & Security →
Local Network, and it is needed on both machines. Through a room there is no such
fuss at all.
Esc pauses; while paused, Q exits to the menu and F toggles fullscreen. The same
legend is shown at the bottom of the menu. A gamepad works alongside the keyboard: the
d-pad and stick steer, the bottom or right button fires, Start pauses.
At startup the window is fitted to the monitor at an integer scale of 256×240: there is never any fractional stretching, which would give pixels different sizes. On a fourteen-inch laptop the window fits triple scale, and fullscreen fits quadruple.
A separate Go program. Its one outside dependency is Prometheus's own client library, for the metrics; the WebSocket layer is still written by hand. It builds into one file that you only have to copy onto a machine and run. It knows nothing about tanks: it sees a room code, the participants and the order of packets, and what those bytes mean is the game's business. So the same server will do for the next game too.
cd server && go run . # on this machine, port 27014
cd server && go build -o relay . # one file you can carry away
./relay -addr=:27014To check that it is alive: curl localhost:27014/health.
The port is deliberately not 8080: that one is taken on almost every work machine, and the client would silently have gone to somebody else's service.
By default the client looks for the server on its own machine. Another address is set with a flag:
godot -- --relay=ws://your-server:27014/wsIn the browser the address is derived from the page's address (https → wss), so it
is enough to serve the static files and the room server from one host. A page parameter
works for testing too, on a page opened from your own machine:
?relay=ws://localhost:27014/ws. A page served from anywhere else ignores it — otherwise
a link to the real site could send the game to any server the link names.
A room lives as long as somebody is in it, plus five minutes for a dropped player to come back. There are no accounts yet: whoever knows the code gets in.
The same server also serves the game for the browser — with the -static flag:
./relay -static ./web # game on / , socket on /ws , state on /healthOne port for everything is not about thrift: the browser derives the socket address from the page's address, so from a single origin there is nothing to configure in the client at all.
./tools/image.sh # web build, image, check that it comes up
docker run -p 27014:27014 base13:latestInside is scratch: the binary and the game's files, about eighty megabytes with the
gzipped twins below, no system
and no shell. Godot is deliberately kept out of the image — that would be a second
binding to the engine version, living apart from project.godot; so docker build
requires a ready build/web and fails with an explanation if it is missing.
The engine alone is forty megabytes, and ten in gzip. The image carries a gzipped twin of each large file, made once at build time, and the server hands it to any browser that accepts gzip. The proxy in front need not compress anything.
The image does no TLS: it listens on plain HTTP, and the certificate and the proxy in
front of it are the machine owner's business. But without HTTPS the game will not
start in the browser: Godot requires a secure origin, and only HTTPS and localhost
qualify. Opening the build at http://192.168.x.x:27014/ from a second computer will
not work — you get "Secure Context — Check web server configuration".
Settings come from environment variables; a flag overrides a variable, in case you need to bring up a second copy alongside:
| Variable | What it sets |
|---|---|
PORT |
The port (the common convention among hosting platforms) |
ADDR |
The whole address, if you need to bind to one interface |
STATIC_DIR |
The folder with the game's files; empty means relay only |
MAX_ROOMS |
How many rooms at once, 250 by default. A room at its caps is about 4 MB, so 250 is about a gigabyte in the worst case; past it newcomers see SERVER IS BUSY |
METRICS_ADDR |
Where Prometheus reads the server's figures, for example :27015. Empty, the default, means no metrics listener at all. Its port must differ from the public one — compared as a number, so 027014 is still 27014 — or the server refuses to start |
METRICS_TOKEN |
A bearer token the metrics listener demands, at least 16 bytes. Environment only: a flag would show in ps |
On SIGTERM the server stops accepting and says goodbye to the players with a close
frame instead of cutting the connection: otherwise the client spends half a minute
knocking at a room that is already gone. A restart ends matches anyway — the journal
lives in memory.
How much it takes: the measurement BASE13_LOAD=1 go test -run TestLoadManyPairs ./server shows two hundred pairs (four hundred people) with no losses and nothing late.
The limit is memory for the journals: a full match takes 4 MB, so a gigabyte is about
two hundred and fifty simultaneous long matches.
You cannot scale out with a second instance: a pair must land on the same process, because the journal it catches up from lives next to it. Details are in the design doc.
The image speaks plain HTTP. Whatever terminates TLS in front of it has to do three things, and each one fails visibly when it is missing:
- Pass the WebSocket upgrade through on
/ws. Without it the page loads and the game starts, but every attempt to connect ends inNO CONNECTION. - Not buffer the response. A buffering proxy holds packets back and turns the match
into a slideshow while every
[net]line still reads 100%. - Allow a connection that is quiet for a while. A person waiting for a partner sends nothing for minutes. The server pings every twenty seconds and every client answers — a browser by itself — so the link is never silent longer than that; a proxy timeout under twenty seconds still cuts it.
Caddy does all three by itself:
game.example.com {
reverse_proxy 127.0.0.1:27014
}
Nginx needs saying:
location / {
proxy_pass http://127.0.0.1:27014;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_buffering off;
proxy_read_timeout 3600s;
}One location for everything: the page, /ws and /health live on one origin on
purpose — that is what leaves the client with nothing to configure.
Limits per address belong to the proxy too: behind it the server sees every connection
come from the proxy's own address, so it can cap the total but cannot tell one person from
a thousand. With nginx that is limit_conn in a server block.
Publish the container's port on the loopback only — -p 127.0.0.1:27014:27014. Only the
proxy needs to reach it; published on every interface it answers from the outside over
plain http, where the browser build refuses to start and nobody can tell why.
The server can show Prometheus and Grafana what stdout only hints at: connections and rooms against their limits, refusals and how connections ended, how many people sit down to play and how long they wait for a partner, and how the game actually runs for them — the round trip to each player, the server's own delay, and the pace players' games report. With the alert rules below, a full server or a desync reaches a person instead of a log.
It is off unless asked for. The figures go out on a port of their own, never the public
one: the proxy in front forwards every path, so /metrics on the public port would be on
the internet. There it is a 404, and tools/image.sh checks that it stays one.
openssl rand -hex 32 > metrics-token # once; readable only by whoever runs the container
printf 'METRICS_TOKEN=%s\n' "$(cat metrics-token)" > metrics.env
docker run -d --name base13 --restart unless-stopped \
-p 127.0.0.1:27014:27014 \
-p 127.0.0.1:27015:27015 -e METRICS_ADDR=:27015 \
--env-file metrics.env \
ghcr.io/proshik/base13:<version>127.0.0.1 in the second publish matters as much as in the first: without it the figures
answer from outside. If Prometheus or Alloy runs in a container of its own, a docker network
shared with base13 is better still — it reaches base13:27015 with nothing published at
all. A binary without Docker listens on the loopback only: METRICS_ADDR=127.0.0.1:27015.
Given an address beyond the machine and no token, it says so at startup.
The token is needed even with the loopback publish. -p 127.0.0.1:… only limits where the
port is published on the host; any other container on the same docker bridge reaches the
container's address directly, published or not. With METRICS_TOKEN set, a scrape without
Authorization: Bearer <token> gets a 401. The token goes in through the environment, not a
flag, because flags show in ps, and through --env-file, not -e METRICS_TOKEN=…, so it
does not stay in the shell's history.
The image has no EXPOSE for the metrics port on purpose: docker run -P publishes every
exposed port on every interface.
Ready-made examples live in deploy/:
| File | What it is |
|---|---|
deploy/prometheus/scrape.yml |
A Prometheus configuration with the relay job and the token read from a file |
deploy/alloy/relay.alloy |
The same scrape through Grafana Alloy, sent on to Grafana Cloud or any remote-write store |
deploy/prometheus/alerts.yml |
Alert rules: down, restarting, near the limits, adding lag, starved of CPU, desyncs |
deploy/grafana/relay.json |
The dashboard: Now, Product, Lag and performance, Health, Resources |
The job must be called relay: every rule and panel selects job="relay". Alloy does not
evaluate rules — with Alloy, load alerts.yml into the store's ruler. The dashboard imports
through Dashboards → New → Import and asks which Prometheus to read. server/deploy_test.go
checks that every figure these files name is one the server really exposes, so a renamed
metric fails the tests instead of leaving a panel quietly empty.
Point an uptime monitor at /health, not at /. A request for the page counts as a page
load, and a monitor that checks every minute adds 1440 page loads a day that no player made.
Events, and what players' games say about themselves. A seating, a pairing, a refusal, a relayed packet, a window of ticks reported by a player's side — each is added to a count or a histogram and forgotten. The server keeps no list of who did what.
It never records an address, a room code, a seed, a game name, or any text a client sent as
it was sent. Every label comes from a closed set the server defines: a platform it does not
know becomes other, a version that is not three numbers becomes other, and only the ten
most common versions among players seated right now get a series of their own. A test seats
players in a room with a known code and seed and checks that neither appears in the scrape.
It does not count people. There is no identifier to count by, deliberately: "players" is who is seated right now, a seating is an event, and one person who plays three matches is three seatings. How many different people played is not something this server can say.
Figures from players' games — pace, waits, input delay, frame rate, desyncs, and the platform and version in the hello — are self-reported, and a client can forge them. The server reads a report into a fixed shape, clamps every figure to what a real window can be, takes at most one every four seconds per connection over time, and never relays, journals or logs it. That bounds what a forger can do; it does not stop a handful of fake clients from bending a distribution. The round trip is measured by the server's own pings, but a client can make its own figure worse by answering late.
The metrics carry no room codes; the logs do. Every line about a room names its code and game
(room K7QX2M (private, game tanks): player 2 joined), so stdout deserves the same care as the
machine itself.
A match that stutters has one of three causes, and the Lag and performance row tells them
apart with the same rule as the [net] lines:
- Speed below 95% with waits — the game stood waiting for the partner's input: the network. Look at the round trip for that platform, and at the worst gap.
- Speed below 95% without waits — nothing was waited for: the player's machine, or a frozen stand. The frame rate by platform shows a machine that cannot keep up; the worst gap shows a stand.
- Round trip high, worst gap low — a slow but steady path. Guessing the partner's keys absorbs it for as long as the round trip fits in 200 ms: the speed holds and nothing waits. Past that the game stands, and the stands show as waits.
- Worst gap high, round trip normal — jitter: Wi-Fi or a mobile link. A burst shorter than 200 ms is guessed through; a longer one stands the game.
- Input delay above two — a player still on a build older than
0.6.0, which raised its delay from five to sixteen instead of guessing. Both lines under two mean everyone is on the current build. - Forward delay high — the server itself. Check the Go scheduler latency and CPU first.
The machine share includes frozen stands. A window in which the game stood still — a
partner's hidden tab, a relay drop and return, the player's own hidden tab — is reported like
any other, and it reads as low speed with no waits, which is the machine verdict. So before
blaming players' machines, look at relay_packet_gap_worst_seconds: a stand shows there as a
gap of seconds, and a slow machine does not. The same windows pull down the speed and frame
rate panels.
Two things bend that rule. Players on 0.6.0 sent such stands as waits, so their frozen
windows read as network. And on any build a window with a partner's stand can still carry
a few one-frame stops right after it, while the two sides fall back into step, which also
tips it to network.
What else the figures do not say:
cutdisconnects include players whose socket failed on a write (a broken pipe, a reset), not only players evicted for falling behind.- A code room's wait for a partner includes the time the code took to reach them. Time played together and abandoned waits are observed when the empty room is swept, five to six minutes late, and rooms still open at a restart are never observed.
- The forward delay misses writes that hit the five-second write limit; those become
disconnects or evictions. Right after an eviction a burst of slow forwards is normal.
relay_packets_totalcounts packets read from players, not packets delivered. - The worst gap includes whole pauses in the middle of a connection — menus, the STATS screen, a hidden tab. Read its buckets, not its mean.
- Godot answers a ping once a frame, so desktop and native round trips include up to about 16 ms of the game loop; a browser answers at once.
- The engine download time is a lower bound: socket and proxy buffers hide the rest, and behind
nginx with
proxy_buffering onit measures only the hop to the proxy. HEAD and 206 count as successful downloads; a 304 on the gzipped twin counts asidentity. - iPadOS Safari sends a Macintosh user agent, so iPads count as
web, notweb_ios. - Versions are live players only, the ten most common plus
other; while real traffic is low, a few fake clients can rotate fake versions through the ten. - A development build reports version
0.0.0:project.godotcarries that placeholder, and onlytools/stamp_version.shat release time stamps the real number. So a localgodotrun and the web buildtools/image.shmakes both show up as0.0.0in the version label, not asotherand not as the repository's own version. - Single-player and LAN games never talk to the server, so they are not measured at all.
The server comes up on one of them, and both clients go to it.
On laptop A:
ipconfig getifaddr en0 # your address, for example 192.168.31.234
cd server && go run . -addr=:27014
godot # in another window; the server is local, nothing to findOn laptop B:
curl http://192.168.31.234:27014/health # first make sure the server is visible
godot -- --relay=ws://192.168.31.234:27014/wsThen: A picks CREATE ROOM and reads out the code, B picks JOIN ROOM and enters it.
If curl does not answer, the game is not the problem: either the machines are on
different networks, or -addr=:27014 was replaced with 127.0.0.1. If curl answers
but the game says NO CONNECTION with an address on screen, macOS did not let the
application onto the local network; the permission is in System Settings → Privacy &
Security → Local Network.
None of this fuss will be there once the server moves to a real machine on the internet: then both sides dial out, and the local network has nothing to do with it.
./tools/build.shPuts builds for Windows, macOS and Linux into build/. You need the Godot 4.7.2 export
templates — installed through Editor → Manage Export Templates, or by unpacking the
official .tpz into ~/Library/Application Support/Godot/export_templates/.
The presets live in export_presets.cfg and are kept in the repository: without them
the build is not reproducible. They hold no secrets for desktop; when Android arrives,
the keystore passwords will live in environment variables, not in this file.
The macOS build is signed "to itself" but not notarized: Apple has not checked it, and the system will say so. This is how you open it — System Settings → Privacy & Security → scroll down to the message about the blocked application → "Open Anyway". Once per application. Right-click → "Open" no longer works for this case on macOS 15 and newer. Notarization requires a paid Apple account — that is subproject 4.
The web build lands in build/web/ and works from any static host — threads are off, so
the host needs no special headers:
cd build/web && python3 -m http.server 8137Open http://localhost:8137/. The address with ?selftest runs the reference scenario
and prints the world hash on screen: it must match the desktop one, otherwise
cross-platform co-op is impossible. The same on desktop — godot -- --selftest.
Every push to any branch runs ./tools/test.sh — the same run as locally: core
isolation, server neutrality, atlas and sound verification against the manifests, the Go
tests and all the GUT tests.
The image is not built on push. Publishing from every branch would fill up the registry, and the package budget is zero with a hard stop: run out of space and a real release will not go out either.
Cut by hand from master: Actions tab → "release" workflow → Run workflow. Two fields:
the version number without v, and what exactly to release.
| What | What happens | Why separate |
|---|---|---|
server |
Web build, image into the registry, image checked by running it | Ships often; makes no desktop exports |
desktop |
Builds for Windows, macOS, Linux, a vX.Y.Z tag, a release with attachments |
Three exports with texture rebuilds — slow and expensive |
both |
Both of the above |
Desktop builds are expensive and the backend has to be updated often — hence the split. Only the desktop release places a git tag: that is what people download, while a server version may go out more than once.
By hand rather than automatically, because a release is something you hand to someone,
and a human should decide when it is ready. A run from anything but master, or a
repeat of a version already taken, is rejected by the workflow itself.
docker pull ghcr.io/proshik/base13:0.1.0
docker run -p 27014:27014 ghcr.io/proshik/base13:0.1.0| Image tag | When |
|---|---|
X.Y.Z |
a server release with that number |
latest |
the last one released |
sha-<commit> |
always — it shows exactly what is running on the server |
To build the same thing by hand:
just --list # every task in the project
just build # all four platforms
just build Web # for the browser only
just build Windows Linux macOS
just image # web build and imagejust is an index, not a dependency. The task bodies live in tools/*.sh, they are
called directly without it, and CI calls exactly those:
./tools/build.sh Web
./tools/image.shGodot and the export templates live in the run's cache: the templates weigh a gigabyte, and downloading them every time takes longer than everything else put together.
- Design — game rules, world model, level format, testing strategy
- Core implementation plan — 17 tasks, TDD
- Playable-game design — rendering, input, campaign
- Part B1 plan — 12 tasks, TDD
- Part B2 design — sound, screens, high score, builds
- Part B2 plan — 12 tasks, TDD
- Co-op design — lockstep, input delay, hash comparison; superseded for network play by the rollback design below
- Rooms and relay design — server, room codes, journal and reconnect
- Quick game and image design — matchmaking, static hosting, Docker
- Deployment plan — the image on a public machine behind a proxy; next up, not yet done
- Network lag plan —
the adaptive input delay of
0.5.0, since replaced by rollback - Metrics plan —
Prometheus figures, alerts and a dashboard for the room server; shipped in
0.5.0 - Rollback design —
guessing the partner's keys and stepping back instead of waiting; shipped in
0.6.0 - Rollback plan — 9 tasks, TDD
- Homebrew cask plan — installation on macOS in one command; after the deployment
- CLAUDE.md — invariants that must not be broken
An independent implementation. Battle City is a trademark and a copyrighted work of Namco/Bandai Namco Entertainment; this project is not affiliated with them and is not endorsed by them. The graphics, sound and level layouts were created from scratch.