Control panel, world atlas, and the box under both.

califree.net is a private Minecraft server for a handful of friends. It is also a 20,000-line system I specified, built, deployed and now operate on my own: a Flask control panel behind Caddy on an Oracle ARM instance, a Java service that reads Minecraft's own world generator to work out what the terrain holds before anyone walks there, two live maps, and an update pipeline that keeps all of it current without me opening a terminal.

Overworld seed 1244994422874902852 Minecraft 26.2 Ubuntu 24.04 · aarch64 Live at califree.net

20,362
lines across Python, JavaScript, Java, Bash and CSS
107
HTTP endpoints in one Flask application
13
test suites, 400 assertions, no live game required
4
systemd units and 3 cron jobs, self-healing
$0
monthly infrastructure cost, by design

Why I built it instead of buying it

claude/hosting-research-verdict.md
claude/server-info.md

Decision inputsTotal cost of ownership, ARM compatibility, per-server licensing, feature gap analysis

$0 / movs. $96–240 a year for a managed host of the same size

The starting requirement was small: a Minecraft server my friends and I could play on, that I would not have to babysit. The obvious route is a managed host at $8–20 a month, or a free self-hosted panel like Pterodactyl. I researched both and wrote the verdict down before spending anything, because I wanted to be able to defend the choice later.

Pterodactyl is excellent at what it does — multi-tenant game hosting with Docker isolation, a node agent, a billing-adjacent user model. For one server and six players that is a lot of machinery to run and keep patched, and it solves the problem I did not have. Managed hosts solve it for money. Neither of them addresses the thing my players actually ask about.

Because the questions were never "restart the server." They were where is the nearest village, is this a slime chunk, are there spawners near my base, what biome is at these coordinates. Those questions are about the world, not the process — and no control panel answers them, because answering them means knowing both the mathematics of the seed and which terrain actually exists on disk. That gap is the thesis of this project: the administration layer and the world-knowledge layer belong in the same product.

The constraint I chose on purpose

Oracle Cloud's Always Free tier includes an Ampere ARM allowance, so I took a VM.Standard.A1.Flex with 2 OCPU and 12 GB of RAM in us-ashburn-1, Ubuntu 24.04, 150 GB boot volume. Permanently free, which turns a recurring subscription into a one-time domain registration — the only cost the project has.

The price is aarch64. Every piece of the stack needs an ARM build, and anything that quietly expects x86 binary wheels is off the table. That single constraint shaped decisions all the way up: it is a large part of why the Python side of this system depends on Flask and nothing else, with the whole rest of the backend built on the standard library.

How the system is put together

server.py — 5,242 lines
static/index.html — 5,182 lines
47 GET · 60 POST

RuntimeCaddy · gunicorn · Flask 3 · CPython 3.12 · OpenJDK 21 · systemd · cron

DependenciesFlask. Everything else is the Python standard library: hashlib, hmac, socket, struct, gzip, threading, subprocess.

One Flask process serves the whole panel: authentication, server control, accounts and permissions, world management, the map APIs, backups, updates, achievements, the console. It is a single file on purpose — one deployable unit, one restart, no service mesh for six players — and it is organised internally by concern rather than split across packages for the look of it.

Browser vanilla JS · canvas TLS 1.3 Caddy :443 Let's Encrypt, renewed proxy gunicorn 127.0.0.1:8444 · 1w/12t Flask — server.py 107 endpoints · shared caches under lock Minecraft JVM RCON, binary TCP main thread — careful Biomas service HTTP on localhost Java, linked to the jar Region files *.mca, Anvil format parsed in pure Python BlueMap CLI separate JVM cron, tiles to disk

Four data sources with four different failure modes. The design work was mostly in deciding how the panel should behave when each of them is slow, missing or lying.

The fact that shaped the backend

Minecraft services RCON on its main thread — the same thread that runs the game tick. A slow RCON call does not just make the panel slow, it makes the game stutter for everyone online. Once I confirmed that, a whole class of convenient designs became unacceptable, and every RCON path in the panel had to distinguish "the server is not there" from "the server is busy" and never treat the second as the first. Getting that wrong caused the worst bug in the project's history, which I come back to further down.

Operations

Four systemd units: minecraft (inside a screen session, so the console stays attachable for the cases a panel should not try to handle), panel, biomas, and caddy. The biome service is its own unit specifically so it can crash without taking the panel down with it. Three cron jobs: the map render at 09:00 UTC, a panel watchdog every five minutes, a world backup at 08:30.

Deployment is a commit. Pushing to the repository triggers a GitHub Actions workflow that rsyncs five paths to the instance over SSH and restarts the panel service. There is no manual file copy anywhere in the loop, which matters more for a one-person project than a large one — I am the only person who would catch a mistake, and I am also the one making it.

Reading the game to predict the world

scripts/Biomas.java — 488 lines
scripts/estructuras.py
scripts/recon-worldgen.py
nbt.py

TechniquesBytecode reading, compiling against a third-party jar, deterministic PRNG reimplementation, binary format parsing, content-addressed cache keys

±8192the sampling window whose biome hash becomes the cache key

This is the part of the project I would most want to talk through in an interview. It answers one question — what is out there? — from two directions that disagree with each other, and reconciling them honestly turned out to be the hard part.

What the seed says

Minecraft generates terrain deterministically from a 64-bit seed, so in principle every village, ruined portal, ocean monument and slime chunk in the world can be computed without loading any of it. In practice the placement rules are not documented anywhere I was willing to trust: community wikis lag releases, disagree with each other, and are confidently wrong in exactly the places that matter.

So I read the server jar. Minecraft 26.2 ships unobfuscated, which made the implementation the source of truth. From it I confirmed rather than assumed:

  • structure placement is a grid-candidate test, then a frequency roll, then an exclusion-zone check — with four distinct FrequencyReductionMethod reducers that behave differently and are easy to conflate;
  • the slime-chunk formula, including a 32-bit integer overflow in the original Java. A direct port to Python is silently wrong, because Python integers do not wrap. This was a real bug in my code, found by comparing against the game rather than against a wiki;
  • ForceLoadCommand's cap of 256 chunks per call — a literal sipush 256 in the bytecode — and that forced tickets sit at level 15;
  • that Blender and BlendingData only engage for chunks explicitly marked as needing it, which is what let me reason correctly about seams when trimming a world for a version migration.
The method, stated plainly: when the documentation is unreliable and the cost of being wrong is silently wrong data rather than a crash, read the implementation. Nothing in the panel tells a player where a village is on the authority of a wiki page.

What the disk says

The seed cannot tell you everything. A spawner a player already destroyed is gone; chests get looted; people build things. For anything already generated, the disk is authoritative — so nbt.py parses the Anvil region format directly: the 4 KB header of 1,024 location entries (a three-byte sector offset plus a one-byte length), the 4 KB timestamp table behind it, then zlib-compressed NBT per chunk. The Status string is what separates a half-built proto-chunk from a minecraft:full one, and reading it wrong means reporting structures that do not exist yet.

The map draws both kinds of knowledge in the same canvas, and is explicit about which is which. That distinction is the whole reason a feature got deleted later.

The biome service, and a cache key I am proud of

Biomes are not something you can shortcut. Rather than reimplement multi-noise biome sampling, I wrote Biomas.java: 488 lines that compile against the Minecraft server jar and hold the game's real MultiNoiseBiomeSource, NoiseBasedChunkGenerator and RandomState, exposed over com.sun.net.httpserver with a fixed thread pool. It is the game's own generator, answering over HTTP on localhost. The panel gets exact biomes and I get zero drift from the real world.

Biome tiles are expensive, so they are cached — but the cache key is not the version number. It is seed + generator fingerprint, where the fingerprint hashes biome names sampled on a fixed lattice from −8192 to +8192 in steps of 512 at surface height:

# no version table to keep up to date: the key changes
# the moment world generation does
clave = "%d-%s" % (semilla, huella())   # huella() hashes the lattice

If Mojang changes terrain generation in an update, the fingerprint changes and every stale tile is invalidated automatically. No version allowlist, nothing for me to maintain, no possibility of serving a pre-update map as current. Structure tables use the same idea against (jar path, mtime, size). Designing cache keys so that being wrong is impossible rather than unlikely is a habit I picked up from this system paying me back for it.

Two maps of the same place

scripts/render-mapa.sh
scripts/parche-bluemap.sh
scripts/setup-bluemap.sh
static/index.html — canvas layer

RenderingBlueMap CLI (WebGL tiles) · HTML canvas 2D with hit-testing, pan, zoom and layered pixel-art markers

Players want two different things from a map: to fly around what has been built, and to plan a trip to something nobody has seen yet. Those are different products, so there are two maps.

The 3D map

BlueMap renders the real world into WebGL tiles from a separate JVM on a nightly cron. I patched its web application so the 3D view speaks the same biome vocabulary as the panel, and added a control the stock tool does not have: repaint the entire map from scratch, with a choice between starting now or overnight, a live progress bar, and an honest warning that playing during a full render will be laggy.

BlueMap has no progress API for this. I found the log format by reading a string-concatenation constant inside the CLI jar — \1: \1%\1, which prints as description: N.NNN% (ETA: …) — and then wrote a test that fails if that format ever changes. The alternative was a progress bar that silently freezes after an upstream release and looks like a hang.

Repaint is the one button in this system that runs a recursive delete. The script refuses to delete anything unless the resolved path ends in /maps, and its test mounts a fake ~/bluemap seeded with decoys — the BlueMap web app, its assets, the jar itself — then asserts exactly what survived. The most dangerous line in a codebase should have the most paranoid test, not the most comments.

The 2D map

The second map is the one that did not exist before: a Chunkbase-equivalent computed from our actual seed, drawn on a canvas inside the panel. Layered icons for villages, ruins, slime chunks and each spawner type; pan and zoom; hit-testing against marker positions so hovering names a structure and lights it up; the layer strip persisting its state across reloads and tab switches because that is what people expect of a map they use every day.

All of it is plain JavaScript and a 2D context. No framework, no map library, no build step — which was a deliberate call for a page that has to stay responsive on an ARM instance sharing 12 GB with a Minecraft server and two JVMs.

Accounts, and a bug worth keeping

server.py — auth layer
claude/cuentas-y-seguridad.md
claude/fusion-cuentas.md

Securityhashlib.scrypt with per-user salt · HMAC-signed session cookies · secrets.compare_digest · roles plus per-user permission flags · two-factor authentication

Passwords are hashed with scrypt from the standard library, with a per-user salt. Sessions are HMAC-signed cookies verified in constant time, which keeps the single gunicorn worker stateless — no session store to lose on restart, no shared state to get wrong across twelve threads. On top of that: roles, per-user permission flags for finer-grained access, two-factor authentication, and a forced password change on first login.

The bug

The forced password change was enforced in the browser. The panel showed a wall the user could not click past, and the API behind it did not care. Anyone who opened developer tools, or simply knew the endpoint names, walked straight around a security control I had written and believed in.

I found it, moved the check server-side onto every authenticated request, and kept the story because it is the most useful kind of mistake: I knew the principle perfectly well in the abstract and still shipped the violation in my own code. The browser is not a trust boundary — and a security rule that lives only in the UI is not a rule, it is a suggestion with a nice modal.

Two smaller decisions with teeth

  • Account merging. Players made duplicate accounts, so there is a flow that folds two identities into one while keeping permissions, history and links intact. Identity merges are unglamorous and easy to get subtly wrong, which is exactly why I wrote it rather than asking people to start over.
  • Never hand-write a UUID. Whitelisting goes through the game's own whitelist add over RCON instead of editing the JSON file directly, because a UUID derived from a username is the offline-mode UUID and is wrong for a real account. A wrong UUID produces a lockout with no error message pointing at the cause — the kind of bug that eats an evening.

Running without a terminal

scripts/actualizar.py
scripts/api-jar.py
scripts/mundos.py
claude/actualizacion-automatica.md

ReliabilitySHA-1 verification against Mojang's manifest · pre-swap backup · map freeze · automatic rollback · five-minute watchdog

6 hversion check interval; no human in the path from release to live

The requirement I set for myself was that someone who does not use a terminal should be able to run this server. That rules out "SSH in and swap the jar" as an answer to anything routine.

So updates are automatic. Every six hours the panel checks Mojang's version manifest. On a new release it backs up the world, downloads the jar, verifies its SHA-1 against the manifest, stops the server, swaps it in, restarts, restarts the biome service so the structure tables reload against the new version, freezes the map while the swap is in flight, and rolls back if the server does not come back up. A watchdog restarts the panel if it stops answering. Backups run nightly before the render. Worlds are uploaded, inspected and switched from the browser.

Why I stopped trusting the word "automatic"

The updater worked. The panel, meanwhile, kept the structure tables it had read out of the previous jar in memory — so after every update the world map tab kept confidently answering with the old version's generation rules until somebody happened to restart the panel for an unrelated reason. Nothing crashed. Nothing logged an error. The data was just quietly wrong.

Two defects, not one. The cache had no invalidation tied to the jar, and jar selection sorted by filename, where "26.2" sorts after "26.10" and a text sort picks the older release. Both are fixed — the cache is keyed on (path, mtime, size), selection is by modification time — and both are now covered by a test that drops a newer jar on disk underneath the running process and asserts that the tables reload with no restart and no special call.

A manual step hidden inside something advertised as automatic is the worst kind of manual step. Nobody knows it exists until the data has been wrong for weeks and nothing has complained.

The 432 lines I deleted

git history
claude/generadores-y-dungeons.md
server.py — comment at the removal site

432 → 1lines of working code, replaced by one honest sentence in the interface

I built a feature called "scan this area for spawners." Select a rectangle of unexplored terrain, and the panel would force-load those chunks through RCON, wait for generation, read what appeared, and unload them. It took 432 lines and it worked.

It also made the game hitch for everyone online, because force-loading runs through the main thread. It permanently grew the world on disk, for terrain nobody had chosen to visit. And when a request timed out, the panel declared the server dead partway through and abandoned its own cleanup — leaving chunks force-loaded forever, still ticking, invisible, with no record that it had happened.

I fixed the timeout handling properly: separate "no connection" from "busy", never abort on a slow reply, and record each batch before issuing it so cleanup survives a crash. Then I wrote out the pros and cons of the feature in full, looked at what it was actually for, and deleted all 432 lines.

What replaced it

A sentence. When the spawner layer is on, the map now says that it shows spawners only in terrain players have already visited; that this is deliberate, because those positions are real and current rather than estimated; and that to see spawners somewhere, go there and wait for the nightly update.

Players lost a button and gained a guarantee: nothing on that map is a guess. I left a comment at the deletion site explaining the reasoning and pointing at the git history and the design note, so the next person to have this idea — me, in six months — finds the argument instead of the absence.

Knowing that a feature works is not the same as knowing it should exist. The judgement I most want to be hired for is the second one, and it is the one that only shows up in what a codebase does not contain.

Measuring before optimising

scripts/medir-panel.py
scripts/panel_falso.py
claude/panel-rendimiento-y-movimiento.md

MethodInstrumented handler timings and request counts against the real page, driven by Playwright over a fake backend

−80%network requests per session, 258 → 51

The frontend had grown to five thousand lines and it felt like it. Instead of guessing, I wrote the measurement first: a harness that drives the real page against a fake panel, times individual handlers and counts every request. Then I optimised against the numbers and re-ran it.

MeasuredBeforeAfterChange
Mouse-move handler0.117 ms0.004 ms−96%
Layer-strip rebuild0.950 ms0.015 ms−98%
Tab switch1.085 ms0.270 ms−75%
Requests per session25851−80%

What actually moved the numbers: throttling pointer handling to requestAnimationFrame with a cached bounding rectangle invalidated on scroll and resize; rebuilding the map's layer strip only when its shape changes rather than on every state update; document.hidden guards so a backgrounded tab stops polling entirely; one clock tick driving every timer in the page instead of five independent intervals. And finding an infinite onerror retry loop that was firing 273 requests at a single missing PNG, fixed by nulling the handler in the three places that needed it.

Motion, as a system rather than a pile of effects

In the same pass I gave the panel a motion vocabulary: four tokens — 130 ms for responding to a press, 220 ms for hover and state changes, 360 ms for large pieces entering, one shared easing curve — plus a global prefers-reduced-motion block. The rule I wrote down is that nothing lasts longer than ~350 ms and nothing travels more than a few pixels, because the point is for the interface to answer, not to perform. An animation you notice is an animation in the way.

Map icons scale on hover, which required real hit-testing against marker positions in canvas coordinates. That surfaced another bug: there was no pointerleave handler, so an icon the cursor left by exiting the canvas stayed enlarged forever. The test caught it. I did not.

Testing what can't be reproduced

scripts/panel_falso.py
scripts/probar-*.py — 13 suites
400 assertions

Doubles builtFake panel serving the real frontend · fake server jars · fake ~/bluemap tree with decoy files · a fake java that records its arguments · region files with real Anvil headers

This system's dependencies are a running Minecraft server, a JVM biome service holding Mojang's generator, gigabytes of region files, and a 3D renderer. None of that stands up inside a test run, and "it depends on too much to test" is not an answer I was willing to accept.

So I built the doubles. panel_falso.py serves the real frontend and answers the real API shapes, driven by Playwright against a real browser, so the user interface is tested as it actually ships rather than through a mock of itself. Around it: fake jars with controllable timestamps, a fake BlueMap directory tree salted with files that must survive, a fake java binary that records the arguments it was invoked with so I can assert the render is forced correctly without rendering anything.

Every suite's docstring opens with why this test exists, and the answer is almost always a specific bug. Real ones these caught:

  • An accept=".zip,.tar,.gz,.tgz" attribute on the upload input, which made macOS grey out the Open button for a perfectly valid world archive whose name did not match — with no message explaining why, because the browser does not give one. The backend never cared: it sniffs contents with zipfile.is_zipfile and tarfile.open. The panel was refusing at the door what the server would have happily accepted. Fixed by dropping the attribute and doing magic-number detection in the browser instead.
  • A chunked-upload loop that would re-send forever if the server under-reported the bytes it had received. Found because my own harness under-counted — the bug in the test exposed a real bug in the client. Fixed with a no-progress guard.
  • Jar selection by filename, and the missing pointerleave, both described above.
The harness is roughly 8,000 lines of Python across 24 scripts — comparable to the panel itself. For a system whose real dependencies cannot be summoned on demand, the test doubles are part of the architecture, not scaffolding around it.

If it were a product

Market read, written for myself

ComparablesPterodactyl and its forks (free, self-hosted, multi-tenant) · Multicraft (licensed per server) · managed hosts at $8–20/month · Chunkbase (seed tools, browser-only)

I did the honest version of this analysis rather than the flattering one, because the answer changes what the project is for.

As a control panel, this is not competitive and should not try to be. Pterodactyl owns self-hosted multi-tenant game hosting, it is free, and the moat is a decade of edge cases across dozens of games. What I have is a single-tenant appliance: superb for the server it was built for, and missing the container isolation, node federation, resource accounting and billing hooks that anyone hosting for other people needs on day one.

The defensible piece is the other layer — world intelligence tied to a live server. Nobody joins administration to the seed's mathematics and to what is actually on disk, and the reason is instructive: doing it means tracking Mojang's world generation forever. That maintenance burden is exactly why the best-known seed tool lags new Minecraft releases by weeks, which is precisely when players most want it.

Which is also where the interesting business answer sits. My cache-key design makes staleness self-announcing instead of silent, and the biome service links against the real jar rather than reimplementing it, so a version bump is mostly a rebuild rather than a research project. That is a genuine structural advantage over the reimplement-and-chase approach. It would still need to be productised — multi-tenancy, per-seed isolation, a cost model for CPU-heavy tile rendering — and I would want a real read on willingness to pay before writing any of it. For now the conclusion I acted on was: keep it an appliance, and let the engineering be the portfolio.

What I take from it

One person, one system, from an empty Oracle console to a service my friends use every day and I do not think about. Every layer is mine, which means every failure was also mine to find — and the parts of this write-up I would most want asked about are the bugs, the deletion and the tests, not the feature list.

Cloud & Linux operations

Oracle Cloud provisioning, Ubuntu on aarch64, systemd unit design, cron scheduling, Caddy with automatic TLS, firewall and port policy, watchdogs, backup and rollback.

Backend engineering

Python, Flask, 107-endpoint HTTP API design, threading with explicit locks, a binary TCP protocol client, subprocess orchestration, cache-invalidation design, standard-library-first dependency discipline.

Frontend engineering

Vanilla JavaScript at scale, canvas 2D rendering with hit-testing, pan and zoom, performance profiling and measured optimisation, a motion token system, reduced-motion and keyboard accessibility.

Data & reverse engineering

Binary file format parsing (Anvil, NBT), reading JVM bytecode to recover algorithms, reimplementing deterministic PRNG mathematics including integer-overflow behaviour, content-addressed cache keys.

JVM

Java service compiled against a third-party jar, embedded HTTP server with a thread pool, running Minecraft's own registries and world generator out of process.

Release engineering & testing

GitHub Actions CI/CD to a live host, zero-touch dependency upgrades with checksum verification, Playwright browser testing, and test-double architecture for dependencies that cannot be reproduced.

Security

Password storage with scrypt, HMAC session integrity and constant-time comparison, role and permission modelling, two-factor authentication, server-side authorisation — learned properly, by getting it wrong first.

The business half

Build-versus-buy with total cost of ownership, competitive and moat analysis, scope decisions defended in writing, and the discipline to delete working code when it serves the user badly.

Juan Barrera B.S. Computer Science & Business · M.S. Information Systems and Data Analytics The server: califree.net More work: juanjbarreraj.com