Backend

MineAlpha

A Minecraft server list and community portal I founded and built on a Node.js backend. This case study covers its core, a vote system that counts every vote at once and keeps delivering it to the game server even when that server is down.

Client
Personal project
Year
2023
Role
Founder & Developer
MineAlpha cover

MineAlpha is a Minecraft server list and community portal I founded and built on a Node.js backend. Server owners list their server, players vote for it once a day, and each vote does two things: it ranks the server, and it rewards the player inside the game. The second part is the hard one. The reward is delivered by notifying the game server directly, over a protocol called Votifier, and the game server is not always there to answer. It can be restarting, lagging, or simply offline at the exact second someone votes.

So the whole vote system is built around one rule: a vote is counted the moment it is cast, and delivery is a separate job that keeps trying until it lands. This case study walks through that machinery, and through the reason it has kept running for years with very little maintenance.

30K+ Monthly visitors
3+ Years live, minimal upkeep

Count first, deliver later

Casting a vote is two steps, deliberately split. The first records the vote. The second hands the delivery to a queue.

// Record the vote, then hand delivery to the queue. Two separate jobs.
async userVote(userId: number, idServer: number) {
	await this.userService.setVote(userId, idServer);
	await this.scheduleVote(userId, idServer, 0, false);
}

The record is what the ranking reads, so it has to be reliable, and a single synchronous write is about as reliable as it gets.

// The count itself: one synchronous write, and this is what the ranking reads.
async setVote(userId: number, serverId: number) {
	return this.userRepository
		.createQueryBuilder()
		.update()
		.set({
			serverVoted: serverId,
			voteTime: () => "CURRENT_TIMESTAMP",
		})
		.where({ id: userId })
		.execute();
}
Build note 01

Counting must not wait on delivery

The ranking is the product. If I delivered the reward inline and the game server was down, I would either block the vote behind a network timeout or lose the count to an exception. Splitting the two means the count is never at the mercy of a server I do not control, and delivery is free to fail and retry as much as it needs to without anyone noticing.

A queue with a fallback

The delivery job goes onto a queue. When Redis is up the vote is pushed there; when it is not, it falls back to an in-memory list in the same process.

// Queue on Redis when it is up, fall back to an in-memory list when it is not.
async scheduleVote(
	user: number,
	server: number,
	attempt: number,
	nextCycle: boolean
) {
	const vote: VoteInterface = {
		user,
		server,
		attempt,
		cycle: nextCycle ? this.cycle + 1 : this.cycle,
		date: new Date().toISOString(),
	};

	if (this.redisService.isReady()) {
		await this.redisService.rPush("votesList", vote);
	} else {
		this.votes.push(vote);
	}
}

That fallback is not a quirk of the queue, it is the whole Redis layer. Every operation checks the connection first and quietly does nothing if it is down, so Redis going away degrades the system instead of crashing it.

// Every Redis op checks the connection first and no-ops if it is down.
public async rPush(key: string, value: unknown) {
	if (!this.isReady()) return;
	await this.getClient().rPush(key, value ? JSON.stringify(value) : "");
}
Build note 02

Redis is an optimisation, not a dependency

I wanted the vote queue to survive Redis being unreachable, because a queue that takes the whole site down when its broker hiccups is worse than no queue at all. Redis makes the queue durable and shared across restarts; the in-memory fallback makes sure a bad Redis day is a degraded day, not an outage.

Draining the queue

A scheduled task drains the queue. It runs every fifteen seconds, takes up to thirty votes per pass, and is wrapped in a mutex so two passes can never overlap and double-send.

// Every 15 seconds: drain up to 30 pending votes in one exclusive pass.
@Interval(15 * 1000)
async executeVotes() {
	if (this.votifierLock.isLocked()) return;

	await this.votifierLock.runExclusive(async () => {
		let remaining = 30;
		while (remaining-- > 0 && !this.votesService.voteLock.isBusy()) {
			if (!(await this.votesService.executeVote())) break;
			await sleep(50);
		}
	});
}

The mutex has a sixty-second timeout, set when the task is constructed, so even a pass that wedges releases on its own rather than freezing delivery forever.

// A 60s-timeout mutex: one drain at a time, and a stuck drain frees itself.
this.votifierLock = withTimeout(new Mutex(), 1000 * 60);

Retrying failed deliveries

Each vote in the queue carries an attempt counter and a cycle number. Delivering it is best effort: the count is applied once, on the first attempt, and then the reward is sent. If the send fails, the vote is rescheduled with one more attempt and bumped to the next cycle.

// First attempt counts the vote; a failed delivery retries, up to four times.
if (vote.attempt === 0) {
	await this.updateServerListVotes(vote.server, serverList.votes + 1);
	await this.userService.fetchDiscordInfo(user);
}

if (!user.voteAnonymously) {
	const success = await this.executeVotifier(user, serverList.server);
	if (!success && vote.attempt < 4) {
		await this.scheduleVote(vote.user, vote.server, vote.attempt + 1, true);
	}
}

The cycle is what stops one dead server from starving everyone else. A retry scheduled for the next cycle waits behind every vote still pending in the current one, so a server that is down gets its retries spread out instead of hammered, while live servers keep getting their votes on time. After four retries the delivery is abandoned. The count, made on the first attempt, stays.

Build note 03

Bound the retries, keep the count

An unbounded retry against a server that is gone is just a slow leak. Four attempts across four cycles is enough to ride out a restart or a brief outage, which is what almost every failure actually is. If the server never comes back, the vote still counted, and the only thing lost is a reward notification to a server nobody can reach anyway.

The diagram below traces a single vote through that whole path: where it is counted, where it is stored, how the scheduler picks it back up, and what happens when delivery fails. The game server drops and recovers at random as the scheduler checks it, a roughly one in three chance of going offline and one in two of coming back, so deliveries mostly land while some fail, retry, and now and then give up. Take Redis down and the queue falls back to memory, while the count stays put either way.

Vote delivery path · MineAlpha
0counted
0delivered
0gave up
deliver count now enqueue drain send yes no no retry · +1 attempt, next cycle Player votes userVote() scheduleVote() setVote → DB the count, kept Redis · votesList external queue Scheduler every 15s · ≤30 Votifier send server online reward sent give up still counted delivered? attempt < 4? 0

Follow one vote down the path. It is counted at once, then queued, drained, and sent. The game server drops and recovers at random on each check, a 30% chance of going offline and a 50% chance of coming back, so deliveries mostly land while some fail, retry, and occasionally give up, all while the count stays put. Take Redis down to see the queue fall back to memory.

The Votifier protocol

Delivery itself is the Votifier v2 protocol. The backend opens a TCP socket to the server’s Votifier port and sends a small payload: who voted, when, and which service sent it.

// The Votifier v2 payload; the Mojang UUID rides along only when there is one.
const voteOptions = {
	username: javaNickname,
	...(javaUUID && { uuid: javaUUID }),
	address: "127.0.0.1",
	timestamp,
	serviceName: process.env.VOTIFIER_SERVICE,
	timeout: 15000,
};

The payload is signed with a per-server token using HMAC-SHA256, so the game server can trust that the vote really came from MineAlpha and not from anyone who found its address.

// Sign the payload with the server's token so it can trust the vote's origin.
const signature = crypto
	.createHmac("sha256", options.token)
	.update(voteAsJson)
	.digest("base64");

Who the vote is for depends on the account. A premium, online-mode account is linked to its real Mojang UUID, so the vote carries it; an offline-mode account has no Mojang identity, so it votes by name alone:

// Premium accounts carry a Mojang UUID; offline accounts vote by name alone.
if (user.minecraftJavaPremium instanceof MinecraftUuidEntity) {
	javaNickname = user.minecraftJavaPremium.username ?? "";
	javaUUID = user.minecraftJavaPremium.uuid;
} else if (user.minecraftJavaSP) {
	javaNickname = user.minecraftJavaSP;
}

That second branch is what supports offline-mode servers. They run their own independent UUID scheme, deriving a player’s UUID from their name rather than from Mojang, so sending the name and leaving the Mojang UUID out of the payload is exactly what lets such a server match the vote to the right player. Bedrock servers get a configurable name prefix for the same reason: the vote has to arrive in the form the receiving server expects.

The daily reset

Votes reset every night at midnight. The reset does not just zero the counters: it first copies today’s votes into a set of yesterday columns, then clears the live ones.

// Midnight: archive today's votes into "yesterday", then zero the live ones.
public async resetUserVotes() {
	await this.userService.saveVotesToYesterday();
	await this.userService.resetUsersVotes();
}

Those yesterday columns are a small safety net. A delivery retried just after midnight still has a timestamp to send, and the day’s totals can be rebuilt from them if the process restarts at the wrong moment, so the boundary between two days never costs a vote. The reset runs under the same lock the drain loop checks before each vote, so it never lands in the middle of a delivery.

The result

The pieces add up to a vote that behaves predictably: counted the instant it is cast, delivered as soon as the game server can be reached, retried through a brief outage, and carried safely across the nightly reset. Separating the count from the delivery is what makes that possible, and protecting it is what the rest of the system is built for.