Skip to content
nkh.do
Go back

Boom clone devlogs #5: The Grind

We have a database. In a bomberman clone. I want you to sit with that sentence for a moment. The kind of sentence that makes a project manager nod approvingly and a game designer ask “why?” with increasing urgency.

If you’re just joining: #1 was the prototype, #2 was the glow-up, #3 was multiplayer going live, #4 was mounts and consumables. Now the game has a backend. Persistent identity. A levelling system. A currency. A shop. The fullWeight of civilisation, applied to a game where you place bombs next to your brother and laugh.

Let me walk you through what happened, why, and which parts I’m not proud of (most of it, but in a fond way).

The database, and the lie I told it

SQLite via Drizzle ORM. One file on disk. No separate database process, no connection pooling, no DevOps person standing behind me saying “you know PostgreSQL exists, right?” I know. I chose this. SQLite is sufficient because the server runs one process and I am one person with one VPS and approximately zero users who need horizontal scaling. If I ever need PostgreSQL, Drizzle makes the migration a config change, not a rewrite. Future Hoang’s problem. Present Hoang is typing this and feeling smug.

The bigger decision was authentication. I used @colyseus/auth — a module that handles password hashing, JWT lifecycle, and route wiring so I don’t have to write ~200 lines of security-critical boilerplate that I would absolutely get wrong. The module works. The API is… opinionated.

It’s email-centric. The callbacks are called onFindUserByEmail and signInWithEmailAndPassword. We don’t have emails. We have usernames. So I pass the username where the library expects an email. It works perfectly. It’s a semantic lie confined to three lines of callback internals, clearly commented, and technically none of the library’s business what string I choose to identify a human. The module hashes the password, issues the JWT, and doesn’t ask follow-up questions. We are in a trusting relationship.

No email also means no password reset. Lose your password and you’re talking to me directly. I am not prepared for this responsibility.

The login screen. Two fields. We're not running a bank.

One account, one device, no exceptions

Every account gets exactly one active session. Log in on your phone, your laptop gets kicked. Close code 4001 — “Logged in elsewhere” — and you’re staring at the login screen wondering who betrayed you. You betrayed yourself. We all do, eventually.

The implementation: a UUID (sid) stored in the JWT payload and tracked in the accounts table. Every auth check compares the JWT’s sid against what’s in the database. Mismatch? Stale session. Re-login required.

There’s also an in-memory map tracking which room each account is in, so the server can actively kick the old connection via Colyseus instead of waiting for the old device to notice it’s been replaced. The map is lost on server restart, but stale sessions expire naturally via JWT timeout. Worst case after a restart: the old device stays connected until its next action, at which point auth rejects it. Acceptable. Nothing in this game requires split-second session invalidation. Yet. I’m going to regret saying that.

The online player list

A dedicated Colyseus room called presence that every authenticated user joins after login and stays connected to until logout. It tracks who’s online, who’s in a game, and who’s lurking on the home page judging the room list.

This means every logged-in player has two WebSocket connections while in-game — one to the presence room, one to the game room. An idle WebSocket costs virtually nothing. The alternative was merging two presence sources (presence room for idle users, game rooms for active players) and that sounded like a synchronisation problem I didn’t earn.

Game rooms publish events (playerEnteredGame, playerLeftGame, statsUpdated) to a server-level EventEmitter. The presence room subscribes and updates its state. Everything is in-process. No Redis, no pub/sub, no infrastructure I have to explain to anyone. One process, one event bus, one source of truth for who’s online.

Two currencies, two jobs

The game now tracks two things independently: EXP (how much you did) and Gold (what you got for it).

EXP formula, computed per entity at match end:

50 + kills×50 + saves×30 + bombsPlaced×2 + powerupsCollected×10 + outcomeBonus

Where outcomeBonus is 200 for a win, 100 for a draw, 0 for a loss. Even the loser gets 50 EXP for showing up. We are merciful.

Gold formula:

outcomeBonus + goldCollected + kills×50 + saves×10

Where outcomeBonus is 100 for a win, 50 for a draw, 0 for a loss. A loss with no gold pickups and no kills is worth exactly zero Gold. The game has spoken. Try harder.

These are deliberately different formulas. If EXP and Gold used the same math, they’d be redundant — one would always be a linear function of the other and I could’ve just used one number. Instead, EXP measures effort (did you do things?) and Gold measures outcome (did you win? did you hoard?). Two axes, two progression systems, two reasons to keep playing.

Levels are derived from total EXP using a curve (200 + level × 100 cumulative). Cap is 150. Your level determines your medal — a medieval-themed rank badge. 15 tiers, from Peasant at the bottom to Myth at the top. The game now has opinions about you, and the opinion starts at “Peasant.” We all start somewhere.

Earning gold, or: the sack of coins that changed everything

Six gold powerup types, scattered across maps like everything else:

TypeValue
Copper coin5
Silver coin10
Gold coin20
Copper sack50
Silver sack100
Gold sack200

Two base sprites — a coin and a sack — tinted copper, silver, or gold. Six variants from two assets. The game dev’s oldest trick: recolour and ship.

Gold powerups bank immediately on collection. You can’t lose them on death. They don’t enter your entity inventory. They don’t drop when you die. The moment you touch a gold sack, that value is yours forever. Like direct deposit, but with more explosions.

Bots collect gold powerups too. The gold is wasted on them — they don’t have accounts, bank accounts, or dreams. But it creates a natural denial pressure: if you let the bot grab that gold sack, that’s 200 Gold you’ll never see. You didn’t need it. You wanted it. There’s a difference.

Spending gold, or: the part where it gets dangerous

The shop. Two tabs. Everything costs Gold. No refunds.

The shop is a dedicated page with two tabs: Consumables and Cosmetics. Everything costs Gold. No real money, no premium currency, no battle pass. Just Gold, earned by playing, spent on things that help you play. The ouroboros of game economies.

Consumables — the items from devlog #4 (holy water, shield, arrow, winged greaves, spicy kelp). Purchased in the shop, stored in your locker, equipped into a 6-slot loadout before matches. Stock is deducted only when you actually use the item mid-match. Didn’t use your shield? It’s still there next game. Match-ended mid-shield? Still there. The server batches all stock deductions at match end in a single database transaction alongside stat persistence. Atomic. No double-spending. No losing items to a crash. New accounts get a starter pack — one of each essential — so you’re not walking into your first match with nothing but hope.

Cosmetics — visual-only overrides. Four equip slots: background (profile decoration), wing (back accessory on your character), bomb skin (what your bombs look like), movement effect (trail behind you when you run). Angel wings. Golden bombs. A shadow trail at high speed. Items like the shadow trail, which was free in devlog #4, are now a purchase. Existing players lost their trail. I am not apologizing. Capitalism.

The one rule, written in stone: cosmetics never affect gameplay. Your golden bomb looks expensive. It still has the same blast range, the same fuse, the same everything. The diamond bomb skin is just a diamond bomb skin. If a cosmetic ever touches a gameplay number, something has gone deeply wrong and I have betrayed every principle this project stands for. Which is mostly “make explosions fun,” but still.

Your locker has a slot cap (default 12). Expandable with Gold. If your locker is full, purchases are blocked. No selling, no discarding. Choose wisely, or buy more slots, which is also spending Gold, which is also the economy working as intended.

Profiles: the receipts

A player profile. Stats, medals, match history. The numbers don't lie, but they don't flatter either.

Every player has a public profile. Medal, level, EXP, lifetime stats (wins, losses, draws, kills, saves, deaths, bombs placed, powerups collected), and match history. Click any name — in the lobby, in the player list, in the match summary — and you can see exactly how they’re doing.

Stats use a dual approach: running counters on the account row for fast reads, and append-only match history rows as the source of truth. If the counters ever drift — and they shouldn’t, but “shouldn’t” is doing a lot of work in that sentence — they can be recomputed from history. Every match stores a record per human player: who they were, what class they picked, what team, whether they won, and all the per-match numbers. Bots don’t get records. Bots don’t have feelings. Bots don’t check their win rate at 2am and wonder where it all went wrong.

What’s next

Last time I said we’d give people names and bank accounts. Done. Check. Shipped. Next time: the thing I said I’d build last time. Or something else entirely. The scope creep is the journey.

Until then, the database exists, the Peasants are grinding, and somewhere a player is staring at a gold sack worth 200 Gold while a bot walks directly toward it with no respect for human ambition. See you in #6.


Share this post on:

Previous Post
12.8 Pixels of Jank: Smoothing Movement in a Server-Authoritative Game
Next Post
Boom clone devlogs #4: Ride the Pig