A tiny Minecraft: Java Edition server written in Go with no dependencies outside the standard library. It is a limbo: a place a player can be sent to and left waiting, holding the connection open without a game running behind it. Put it behind a proxy as the fallback for when a real server is down or restarting, or run it on its own as somewhere to idle.
What a joined player gets:
- The void, or a world. Without a world the player floats in an empty
dimension. Point
WORLDat a saved world and the chunks around its spawn are served instead, prebuilt once at startup for every protocol version. - A server list entry. Pings are answered with the description from
MOTD, the online count, and the client's own version whenever it is one the server speaks. - Other players. Everyone in the limbo is visible to everyone else, with skins, in the player list and in the world.
- A connection that stays up. Keep alives go out every fifteen seconds and packets above 256 bytes are compressed, as a full server would.
The server speaks every protocol version from 1.14 through 26.3 (protocols 477 to 777). Every packet is implemented once, at the latest version, and carried to older clients through per-version transformers.
With Go 1.24 or newer:
go run .Or with the published image:
docker run --rm -p 25565:25565 ghcr.io/shonz1/go-void-limbo:latestEither way the server listens on :25565, encrypts connections, checks every
login with Mojang, and puts players in creative mode in the void. Everything
below is optional.
Every setting is read from the environment. An unset or unreadable value falls back to the default rather than stopping the server, and the fallback is logged.
| Variable | Default | Meaning |
|---|---|---|
ADDRESS |
:25565 |
Where to listen, as anything net.Listen takes: :25565, 127.0.0.1:25599, 0.0.0.0:25565. |
MOTD |
A void limbo |
What a server list ping describes the server as. |
GAME_MODE |
creative |
The mode joining players are put in: survival, creative, adventure or spectator, case aside. See Game mode and the void. |
WORLD |
(unset) | Directory of a saved world to show, holding a level.dat and a region folder. Unset, worlds/Lobby under the working directory is used if it exists, and the void otherwise. |
ENCRYPTION |
true |
Whether to encrypt connections and check logins with Mojang. Anything strconv.ParseBool accepts. See Encryption. |
FORWARDING_SECRET |
(unset) | The secret a modern (Velocity-style) proxy signs forwarded logins with. Setting it is what turns modern forwarding on. See Behind a proxy. |
LOG_LEVEL |
INFO |
DEBUG, INFO, WARN or ERROR. Packet traffic is logged at DEBUG. |
There is one command line flag:
| Flag | Meaning |
|---|---|
-forwarding-secret |
The same secret as FORWARDING_SECRET, for an operator who would rather not leave it in the environment. The flag wins when both are set. |
By default every login is settled the way a vanilla online-mode server settles it: the connection is encrypted with a key generated at startup, and the account is checked with Mojang's session server. Only a player with a real account can join, and only under their own name.
With ENCRYPTION=false nothing is encrypted and nothing is checked: a login is
taken on the word of whoever is on the connection. That is the right setting
behind a proxy that has already authenticated the player, and the wrong one
for a port anyone on the internet can reach. The server warns at startup when
it is off.
A limbo is usually a backend of a proxy, which authenticates the player itself and forwards who they are. Both forwarding schemes are supported:
- Modern forwarding (Velocity, and BungeeCord forks that implement it).
Set
FORWARDING_SECRETto the secret from the proxy's configuration. The server then asks every login for a payload signed with it, and a signed login is taken from the proxy without being checked with Mojang here. Encryption can stay on: it only governs logins nobody signed. - BungeeCord forwarding (
ip_forward: true). The proxy writes the account into the handshake in plain text, so there is nothing to sign and nothing to check. It is read only whenENCRYPTION=falseand no forwarding secret is set, because on an encrypted server those fields would be the one way to skip the check.
In either case the port should be one only the proxy can reach. A limbo that holds a forwarding secret but has encryption off will still let in a connection that answers the forwarding request with nothing, under whatever name it asked for.
A world is a normal Java Edition save: the server reads its level.dat for
the spawn and the region files around it, and sends the chunks within nine
chunks of spawn. The rest of the world is never loaded. Loading happens once,
at startup, for every protocol version, so the cost of a world is paid before
the first player connects: every chunk is translated, encoded and compressed
then, and held compressed, so a join sends bytes that are already on hand and
a world costs a few megabytes to keep.
If WORLD names a directory that cannot be loaded, the server stops rather
than starting empty: an operator who pointed at a world wants that world or
the reason there is none.
Creative is the default because a limbo is a place to wait, and creative lets a player fly around while doing it. One thing to know when choosing another: a joining client waits up to thirty seconds for the chunk it stands in to render before it gives up on the loading screen, and only a spectator skips that wait. On a server with no world, any mode but spectator leaves a joining player on the loading screen for those thirty seconds.
The image is a static binary on a distroless root, running as a non-root
user, and is published for linux/amd64 and linux/arm64:
ghcr.io/shonz1/go-void-limbo:latest
ghcr.io/shonz1/go-void-limbo:sha-<commit>
Every merge to main publishes a new latest alongside a tag for the commit
it was built from. Settings are passed as environment variables, and a world
is mounted in:
docker run --rm -p 25565:25565 \
-e MOTD="Waiting for the lobby" \
-e GAME_MODE=spectator \
-e FORWARDING_SECRET="$(cat forwarding.secret)" \
-v "$PWD/worlds/Lobby:/data/worlds/Lobby:ro" \
ghcr.io/shonz1/go-void-limbo:latestThe working directory inside the image is /data, so a world mounted at
/data/worlds/Lobby is found without setting WORLD. A world mounted anywhere
else needs WORLD pointed at it. The flag form of the secret works too, as
the container's command:
docker run --rm -p 25565:25565 ghcr.io/shonz1/go-void-limbo:latest -forwarding-secret "$SECRET"To build the image yourself:
docker build -t go-void-limbo .The ordinary suite is unit tests only and runs in seconds:
go test ./...The end-to-end suite launches a real Minecraft client of every supported version in a container and has it join the limbo. It needs Docker and downloads a large client image on first use, so it sits behind a build tag:
go test -tags e2e -timeout 60m ./e2eEach version takes well under a minute, most of it the client's own wait for
a spawn chunk, and the versions are spread over a pool of client containers
that run at once. E2E_CLIENTS sets the pool's size and defaults to two; each
client is a full Minecraft client that wants a few gigabytes of memory, so
raise it as far as the machine running Docker allows. E2E_SHARD, written as
index/count with the index counted from zero, keeps a run to its share of the
versions, which is how several machines split the suite: E2E_SHARD=0/8 is the
first of eight.
A load generator under cmd/loadtest connects a
crowd of simulated clients and reports what serving them costs.
- Every pull request is built, vetted, formatted-checked and tested under the race detector, the Docker image is built without being pushed, and the end-to-end suite has the real client of every version join the limbo, the versions dealt out over a matrix of hosted runners.
- Every push to
mainbuilds the multi-platform image and publishes it to the GitHub Container Registry.
| Package | Holds |
|---|---|
config |
Reading the settings above from the environment and command line. |
server |
Accepting connections, the shared registries, the status a ping is answered from. |
client |
One connection: its phases, encryption, compression, keep alives, and the packet loop. |
handlers |
What is done with each serverbound packet. |
packets |
Every packet, clientbound and serverbound, implemented at the latest protocol version. |
transformers |
How a packet at the latest version is carried down to each older one, and back up. |
protocol |
The packet id tables per version and phase. |
gamedata |
The registries and tags the configuration phase sends, generated from the client's own data. |
auth |
Mojang session checks, the server key, and both forwarding schemes. |
world, anvil |
Reading a saved world and turning its chunks into packets. |
nbt, streams, types |
The wire formats everything above is written in. |