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
claude/hosting-research-verdict.md
claude/server-info.md
Decision inputsTotal cost of ownership, ARM compatibility, per-server licensing, feature gap analysis
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.
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.
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.
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.
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.
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.
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
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.
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:
FrequencyReductionMethod
reducers that behave differently and are easy to conflate;ForceLoadCommand's cap of 256 chunks per call — a literal
sipush 256 in the bytecode — and that forced tickets sit at level 15;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 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.
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.
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.
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.
/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 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.
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 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.
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.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
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.
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.
git history
claude/generadores-y-dungeons.md
server.py — comment at the removal site
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.
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.
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
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.
| Measured | Before | After | Change |
|---|---|---|---|
| Mouse-move handler | 0.117 ms | 0.004 ms | −96% |
| Layer-strip rebuild | 0.950 ms | 0.015 ms | −98% |
| Tab switch | 1.085 ms | 0.270 ms | −75% |
| Requests per session | 258 | 51 | −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.
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.
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:
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.pointerleave, both
described above.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.
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.
Oracle Cloud provisioning, Ubuntu on aarch64, systemd unit design, cron scheduling, Caddy with automatic TLS, firewall and port policy, watchdogs, backup and rollback.
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.
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.
Binary file format parsing (Anvil, NBT), reading JVM bytecode to recover algorithms, reimplementing deterministic PRNG mathematics including integer-overflow behaviour, content-addressed cache keys.
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.
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.
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.
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