← work ·
TrueTick
Minecraft server hosting built on an anti-overselling invariant — capacity admission enforced in code, wake-on-join with scale-to-zero, live TPS/MSPT behind a staleness gate, and metered billing by default, so an empty server costs nothing to keep.
go · grpc · docker · postgres · next.js · live ↗
TrueTick is a Minecraft server hosting platform I designed, built, and run in production across two regions. It is the first thing on this site that is a business rather than a system: a Go control plane, a Next.js panel, metered billing, and paying users on the other side of it.
It exists because game hosting has a structural problem. The two things that make the business profitable are the two things that make it bad. The first is overselling CPU — packing more guaranteed cores onto a box than the box has, betting that not everyone plays at once. The second is charging a flat monthly fee for servers that sit empty most of the month. Most visible complaints about game hosting, from lag spikes at peak hours to billing that quietly renews, are downstream of one of those two.
The interesting part is that both are solvable, and solving them is cheaper, not more expensive — if you are willing to build the platform around that fact instead of bolting it on.
The Invariant
The product promise is "we do not lie about resources." I treat that as a system invariant rather than a marketing line: every architectural decision gets checked against it, and a decision that violates it is rejected even when it would be convenient. Nothing below is a feature I chose independently. Each one is what that constraint forces.
Capacity Is Checked, Not Promised
If you promise a server two guaranteed cores, you have to actually hold two cores for it. That means capacity is a number the code enforces, not a number sales estimates.
The check does not live where you would first put it. Creating a server reserves nothing — it only picks which node the server will live on. The guarantee is enforced on wake. A hibernated server holds no resources, and the moment someone joins it wants its full allocation back; that is the only point where the answer can be honest. Gate on creation instead and a box sitting comfortably inside its limits while half its servers sleep will over-commit the instant they all wake at once.
// Capacity admission: refuse to wake a server whose guaranteed cpu/ram
// would over-commit the node. Held under capMu and the server is marked
// Starting inside the lock, so concurrent admissions serialize and the
// just-admitted server is counted before the next caller checks.
c.capMu.Lock()
_, ramMB := c.store.RunningAllocationForNode(c.cfg.NodeID)
newCPUs := c.cfg.CPUTiers.CPUsForRAM(b.RamMB, c.cfg.CpusPerServer)
if ramMB+b.RamMB > c.cfg.Cap.RAMMB || c.usedCPUs()+newCPUs > c.cpuBudget() {
c.capMu.Unlock()
return ErrNodeAtCapacity
}
c.store.SetState(id, StateStarting) // count this server as active before releasing
c.capMu.Unlock()cpuBudget() stands in for the real ceiling, which splits into separate budgets this write-up does not get into. Everything else is the shipped code.
The lock ordering matters more than it looks. Two players joining two sleeping servers at the same second will both read capacity, both find room, and both start — unless the admitted server is written into the accounting before the mutex is released. That single line is the difference between an invariant and a good intention.
When the node genuinely has no room, the honest answer is a refusal delivered to the player as a disconnect message, rather than a connection that hangs until it times out. Several refusal reasons carry their own wording — a spend cap reached, a start still cooling down. Overselling is the industry's way of never having to send any of them.
Wake-on-Join and Scale-to-Zero
The second half of the model: a server that nobody is playing on should not be running, and should not be billed. That only works if waking it is invisible enough that players never learn to dread it.
A proxy owns the Minecraft port. Every connection begins with a handshake packet, which carries the hostname the client dialed — enough to route without ever needing the backend to be awake.
// Handshake holds the parsed fields of a Minecraft handshake packet.
type Handshake struct {
ProtocolVersion int
ServerAddress string
ServerPort uint16
NextState int // 1 = status, 2 = login
// AddressExtra is the NUL-delimited tail some non-vanilla clients
// (Forge/FML, legacy forwarding) append; "" = vanilla-shaped.
AddressExtra string
}NextState is what makes the whole thing viable. A value of 1 is a status ping — the server list refreshing in the background, which must never wake anything, or a player idling on their multiplayer screen would keep a box hot for free. A value of 2 is an actual login, and only that starts a container. Parsing the address is also less trivial than it looks: Forge and legacy proxy forwarding append NUL-delimited payloads to the hostname, so the routable name is everything before the first \x00 and the tail has to be preserved rather than discarded.
From there it is a state machine — HIBERNATED → STARTING → RUNNING → STOPPING — with the connection held open through a readiness probe, and a separate idle monitor that hibernates servers once they have been empty long enough. Player counts come from the live server rather than from connection tracking, because a proxy's view of who is connected drifts from the game's view, and the game's view is the one that decides whether someone is actually playing. I wrote up the whole wake path — the protocol parsing, the limbo server that holds the joining player, and the race it took a while to see — in a separate post.
Honest Metrics or None
The panel shows live TPS and MSPT — the numbers that say whether the server is actually keeping up, polled over RCON. Publishing them is the point: a host that oversells cannot show you these, because they would show it.
Which makes the failure mode the interesting case. When a server is sleeping, or the poller cannot reach it, there is a last known sample sitting in memory. Rendering it is the tempting choice; it keeps the dashboard looking alive. It is also exactly the lie the product exists to not tell — a number presented as current that is not.
// Latest returns the most recent sample only if it is fresh (within staleness).
// A stale sample is reported as absent so a sleeping/failing server never shows
// a fabricated-live TPS.
func (c *Collector) Latest(id string) (Sample, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
s, ok := c.latest[id]
if !ok || c.now().Sub(s.At) > c.staleness {
return Sample{}, false
}
return s, true
}The gate returns absence, and the panel renders an honest empty state. The same rule holds elsewhere: a disk probe that fails leaves the field at zero and the UI renders an em dash, rather than failing the request or inventing a plausible figure.
Billing That Follows the Invariant
If servers sleep, charging a flat monthly fee for them stops making sense — it would bill for the hibernation the platform just worked to provide. So the default is metered: a ledger denominated in micro-dollars, ticking once per minute, charging by RAM-time for servers that are actually running. A flat plan still exists for servers that genuinely need to stay up around the clock, billed on its own daily path rather than through the meter. The point was never to abolish flat pricing, only to stop charging it to people whose servers are empty.
// cost returns the µ$ cost for ramMB running for one interval.
// (ramMB/1024) GB * (sec/3600) hr * rateMicrosPerGBHour, with the division
// done once at the end so the per-tick rate isn't truncated to an integer.
func (m *Meter) cost(ramMB int) int64 {
sec := int64(m.cfg.Interval / time.Second)
return int64(ramMB) * sec * m.cfg.RateMicrosPerGBHour / (1024 * 3600)
}Order of operations is load-bearing. Converting the rate to µ$ per second up front truncates the fraction and silently under-charges by roughly ten percent at realistic prices — a rounding decision that would quietly become the business model. Keeping the rate at GB-hour granularity and dividing once at the end keeps the arithmetic exact. Each tick charges exactly one interval; there is no proration inside the metered path. Top-ups are idempotent, because a payment webhook will eventually be delivered twice.
One more consequence of the invariant: when a data-plane node is unreachable, its servers are skipped entirely — no charge and no enforcement. Uptime that cannot be verified is not uptime that can be billed.
What Honesty Costs
The engineering above is the easy part. The expensive part is that the invariant applies to everything the project says, not just what it does.
Every number in the panel, on the landing page, and in the docs has to trace back to code, a config value, or a measurement. When the data does not exist, the answer is an empty state rather than a placeholder that looks like data. A summary from a search tool, a secondhand restatement, or a sentence already sitting in the project's own docs does not count as a source — only something opened and read directly does.
That rule was written after a page on the site carried a claim for fifteen days that nobody had checked against a primary source. It was wrong. Nothing in the system caught it, because it was not a code defect — it was a sentence that sounded true and had been restated enough times that it read as verified.
Building a product whose entire differentiator is not lying turns out to be less about the architecture than about the discipline around it. The state machine, the admission lock, the staleness gate — those hold. Prose does not hold on its own.