Drill Run

How itwas built

We're 2 students with an intense university timetable. We worked on this Kotlin game with no engine, a Spring Boot server, and this website. This particuler page is written for engineers.

What the game is

You play as a mole on an 8×8 grid. Travelling as far as you can, you'll encounter various ores, fruits and monsters. Mining all the stone and ores will enable you to progress to the next level. Use the fruits to your advantage, get better pickaxes and if you travel far enough, you might find treasure!

Level 8 in progress: the mole's headlamp lights part of the grid, a snake
                  and a crystal bug sit near lava, and eight hearts are showing.

Score

Score is a weighted sum of what you carry, plus a level bonus:

stone + (iron × 3) + (gold × 5) + (diamond × 8) + ((level − 1) × 10)

It's computed on the device and submitted as a finished number. The server stores it and never recalculates it — a decision covered in the server section.

Materials

Every terrain has a hardness (def) and every pickaxe a power (pow). You can mine a tile when pow ≥ def.

Dirt 1 · Gold 1
Both breakable from the first pickaxe. Gold being as soft as dirt is deliberate and happens to be metallurgically true.
Stone 2
Not an ore anywhere outside this game, but it's most of what stands between you and everything else, and it counts toward levelling.
Iron 4 · Diamond 5
Gated behind the stone and iron pickaxes respectively.
Hardstone 6 · Chest 6
Diamond pickaxe only. Chests are worth far more than anything else and only appear once you're holding one.

Pickaxe power runs 3, 4, 5, 6 against hardness 1 to 6, so each upgrade opens exactly one new material. Reach runs 0, 1, 2, 3 tiles, which is a separate axis and the more interesting one — see pathfinding.

Architecture

The game is Kotlin and Jetpack Compose with no typical game engine. We, however, made a GameEngine entity that processess what happens in-game. The GameEngine and Compose communicate through a single read-only object produced once per frame.

Touch input tap to row, col Frame tick dt seconds GameEngine command queue owns Player across levels WorldState tiles · entities player · particles calls the services GameSnapshot immutable, one per frame Compose canvas draws only update(dt) · requests toSnapshot() adds score, upgrade costs
Input and time enter into the GameEngine. WorldState builds the snapshot; GameEngine annotates it with score and upgrade costs on the way out. The renderer only ever sees the snapshot.

Why a snapshot

Compose recomposes whenever an observed state changes. The renderer, with direct access to WorldState, would be reading a mutable object that is running and updating the simulation, whilst recomposition would be triggered by every individual field write.

Instead, WorldState.toSnapshot() builds a GameSnapshot - flat lists of render structs with positions already interpolated and textures already resolved. The composable always holds one snapshot reference and swaps it a new one. Simulation types never cross into the UI layer, so the renderer cannot accidentally mutate the world, and the simulation can be tested without Compose in the loop.

Services rather than one god object

Whilst technically, our game isn't complicated enough to hold all of these operaitons in a single file, we decided to create the split for readability's sake. The rules are split into stateless companion objects, each taking the grid and returning a result: GridService generates terrain, HazardService places lava and quicksand, TravelService does pathfinding, EntityService builds enemy routes, UpgradeService handles pickaxes.

WorldState holds the mutable data and orchestrates them. GameEngine sits above it, owning the player across levels and translating queued commands into world requests. The layering means a level transition is world = createWorld() and nothing else needs to know.

The game loop

The biggest challenge, is that movement needs to look smooth at whatever rate the phone is drawing. The rules, simultaneously, have to behave identically on a slow phone and a fast one. Those two goals pull apart, and the standard fix is to run the simulation in fixed-size steps while drawing in between them.

Accumulators

Every moving thing carries an accumulator. Each frame it gains the elapsed time, and while it holds at least one step's worth, one step is taken and that much is subtracted:

player.accumulator += dt
while (player.accumulator >= PLAYER_STEP_TIME) {
    val next = player.path.removeFirstOrNull()
    ...
    player.accumulator -= PLAYER_STEP_TIME
}

A dropped frame produces two steps rather than one longer one, so a lag spike never lets an enemy pass through a wall. Entities carry their own accumulators with their own step times, which is how the difficulty setting changes enemy speed without touching anything else — it multiplies their step time by 1.4 on easy and 0.8 on hard.

Interpolation

Positions are grid coordinates, so a mole moving one tile would jump without help. toSnapshot lerps between the previous and current tile using the accumulator as the fraction:

x = lerp(previous.col, current.col, accumulator / PLAYER_STEP_TIME)

The simulation stays discrete and only the rendering is continuous, which keeps collision logic to integer comparisons.

What we'd change

The loop is driven by a LaunchedEffect containing while (true) { ...; delay(16) }. That's a fixed sleep, not a frame sync, so the real interval is 16ms plus however long the update and snapshot took. withFrameNanos would give the actual vsync timestamp and remove the drift.

One piece of enemy state also accumulates raw dt inside a loop that has already consumed a step's worth of time, which makes the snake charge marginally faster on a slow frame. It survived because it was the first version of that timing that behaved correctly in play — several earlier attempts didn't — and we stopped rather than risk regressing it.

Pathfinding

Tapping a tile has to answer one question: can the mole get there, and by which route? Rock blocks movement until it's mined, so the answer changes constantly.

Breadth-first search, and why not A*

The grid is unweighted — stepping onto any passable tile costs the same as any other. On an unweighted graph, the first time BFS reaches a node it has reached it by a shortest path, so no priority queue is needed. A* would add a heuristic, a cost function and an ordered frontier to solve a problem with at most 64 nodes, where the entire search completes in microseconds. It would be complexity bought with nothing.

The implementation is the standard one: a queue, a visited grid, and a prev grid recording which tile each was reached from. On arrival, walking prev backwards and reversing gives the route.

Passability is per-pickaxe

A tile is steppable if it's empty or a hazard. The destination is a special case: it's allowed into the frontier even when solid, because that's the tile you intend to mine — and it's rejected on arrival if pickaxe.pow < terrain.def. So the same routine answers "can I walk there" and "can I mine that" depending on what you tapped.

On easy, lava is excluded from the frontier entirely, so the game will not route you through something that costs a heart. On normal and hard it's walkable and the decision is yours.

Reach is a trim on the path

Pickaxe reach isn't a separate mechanic — it removes the last n entries from the returned path. A diamond pickaxe drops three, so the mole stops three tiles short and mines from there. One line, and it means reach automatically respects walls: you can only mine at distance if a route to within that distance exists.

It also produced our nastiest crash, covered in what went wrong.

Level generation

Every level is generated fresh. The generator has to make boards that get harder, stay varied, and — critically — can always be finished.

Ore distribution, and a mistake in it

Each tile rolls through a chain of independent random checks, with rarer ores unlocking at set levels and their odds rising slowly with depth:

if (random() < dirtChance)      DIRT
else if (random() < stoneChance) STONE
else if (random() < ironChance)  IRON
else if (random() < goldChance)  GOLD
else if (random() < diamondChance) DIAMOND
else STONE

This does not do what the constants suggest. Each check only runs if every earlier one failed, so the real probability of iron is (1−dirt) × (1−stone) × iron, not ironChance. And the final else is stone, so stone silently absorbs all the leftover probability mass.

It produces boards that play well, which is why it survived. But the tuning constants don't mean what they appear to, and any future adjustment has to reason about the whole chain rather than one number. The correct form is a single roll against cumulative weights.

Hazard clustering

Lava and quicksand are placed after terrain, onto dirt and empty tiles only, with the spawn corner excluded. Placement seeds a tile then tries to grow into shuffled neighbours, so hazards form small patches rather than pepper — a scattered single-tile hazard is trivially walked around and adds nothing.

The solvability invariant

The generator originally enforced a local property: every ore your pickaxe can break must have at least one neighbour it can also break. If it didn't, one neighbour was demoted to dirt.

Local adjacency does not add up to global reachability. Nothing checked that a route existed from the spawn corner, so a well-connected pocket of ore completely walled off by harder rock satisfies the invariant and is still unreachable. It's the kind of bug that's rare enough to survive a lot of playtesting and obvious in hindsight.

The fix reuses the pathfinder: flood-fill from the spawn corner over tiles the current pickaxe can pass or break, and confirm every countable ore is inside the reachable set. No new algorithm — the same BFS that already answers "can the mole get there," asked once per generated board.

Enemies

Three enemies share the board with you. All of them are deterministic: their routes are decided when the level is generated and never react to what you do. You can learn any of them by watching.

Fixed routes, walked back and forth

Each enemy holds a list of tiles and an index that advances by direction, flipping sign at either end. So one path definition gives a patrol that runs forward, reverses and repeats, with no branching logic. Routes are built by a random walk of length set by level, rejecting any step that would immediately backtrack — otherwise short paths degenerate into a two-tile shuffle.

The three

Ghost
Two separate short routes with two anchor points. It walks one, vanishes, and reappears on the other — the same path list holding two segments rather than any teleport machinery.
Crystal bug
Its route is seeded on an ore tile, so it patrols around something you want. It ignores the top rows so the opening moves of a level aren't immediately contested.
Snake
The only one that reacts. It charges in place, then paths directly at your current position and runs the route, then recharges. It ignores terrain — it burrows — so walls don't save you, but the charge-up is long enough to leave.

Enemy count grows with level, and that's the real difficulty curve. Each one individually is readable; five of them on 64 tiles is a spatial problem rather than a reaction one.

Deterministic on purpose

Chasing AI would make every death feel like the game cheated. Fixed routes mean a death is always something you could have seen — you tapped without checking, or you misjudged the timing. It also makes the whole system debuggable: a route can be logged once at generation and replayed.

The server

Accounts are optional, but if you make one the server has to hold your login, keep your scores, prove your email is real, and be able to erase all of it on request. That last part is a legal requirement, not a nice-to-have.

It's Spring Boot with PostgreSQL, and it serves both the game's JSON API and this website from one application.

Game Interceptor Server GET /user/scores + access token 401 expired POST /user/access new access token retry original request
Token refresh happens below the game code. Nothing in the UI knows a token expired.

Two token types

A short-lived access token is sent with every request. A long-lived refresh token is stored server-side with revoked and expired flags, and exchanges for a new access token. An OkHttp interceptor in the game catches a 401, performs the exchange and replays the original request, so no call site handles expiry.

Server-side storage is what makes revocation possible. Signed tokens alone can't be withdrawn before they expire; a row in a table can be flagged. Every refresh token is revoked on password change and on account deletion, which is what actually removes someone who had access.

One-time codes

Email verification and password reset both issue six-digit codes. One table holds them with a purpose column, so the hashing, expiry, attempt counting and rate limiting exist once rather than twice — with per-purpose policy where it differs. Reset is stricter than verification: a stolen verification code confirms an address, a stolen reset code takes the account.

Codes are stored hashed with the same encoder as passwords. A six-digit code is a credential for as long as it lives, and a database read shouldn't be an account takeover. Attempts are counted before the comparison, so a crash mid-check can't buy a free guess.

Deletion, and not leaking who exists

Deletion starts on this site with an email address and is confirmed by a one-time link, because someone who has walked away from an account usually can't remember the password — that's often why they're leaving.

The form answers identically whether or not the address is registered. Rate-limit rejections are swallowed for the same reason. Any difference in response turns a public form into a way of testing whether an address has an account here.

Scores are trusted, deliberately

The game computes the final score and submits it. The server stores it without recalculating, which means a modified client can submit whatever it likes.

Validating it properly means either replaying the whole run server-side or shipping the scoring rules to both sides and keeping them in sync — real work for a free game with a leaderboard of friends. We took the risk knowingly. The mitigation is that the terms allow removing fabricated scores, and the leaderboard is per-difficulty and small enough to eyeball. If the game ever had a reason to be cheated, the fix is submitting the run's material counts and level and computing the score server-side from the same formula.

Two security chains, one application

The JSON API is stateless with CSRF disabled and JWT authentication. The website is session-based with CSRF on. These are incompatible configurations, so they're two separate ordered filter chains matched by URL rather than one chain trying to serve both.

What went wrong

Three bugs worth writing down, because each one hid for a different reason.

The level that couldn't be completed

Levels end when the count of remaining mineable ore reaches zero. That count was computed in the world's constructor. Chest generation ran afterwards, overwriting a 3×3 region with hardstone.

Any ore inside that region was destroyed but still counted, and hardstone isn't countable, so nothing could ever decrement it. The count could not reach zero and the level could not be finished.

It only triggered with a diamond pickaxe, which is deep enough into a run that it took a long time to see. The fix is recomputing the count after generating the chest. The lesson is that a constructor default is evaluated at construction, and anything mutating the object afterwards owns the consequences.

A loop that iterated values as indices

The solvability repair collected four neighbour states into a list — 1 diggable, 0 not, -1 off-grid — then looped to find one to demote:

for (index in validTiles) {        // iterates VALUES, not positions
    if (validTiles[index] == 0) {  // validTiles[-1] throws

Kotlin's for (x in list) gives elements, not indices, and it compiles fine when the elements happen to be integers. It needed validTiles.indices.

It hid behind a second bug. A neighbouring function returned coordinates as (col, row) while everything else used (row, col), so the check was validating a transposed grid — and on a square board that looks like it works. Correcting the transposition immediately exposed the crash, because the check finally started examining tiles that needed repairing. Two bugs, each concealing the other.

A trim applied to the wrong kind of movement

Pickaxe reach works by removing the last n tiles from a path. Being hit also produces a path — a two-tile knockback. The trim didn't distinguish between them.

With a diamond pickaxe, three entries were removed from a three-entry knockback path, leaving nothing. The knockback moved the mole zero tiles, and invincibility ends the moment a path empties, so the player was still standing on the enemy and lost a heart every tick until dead. It read as instant death on contact.

It also crashed elsewhere: with reach 3 and an adjacent target, the path is two entries and the third removal calls removeAt(-1). Both had gone unnoticed because the arithmetic only breaks at the highest pickaxe tier, and by then most adjacent tiles are already empty and take a different code path.

Who built what

Drill Run is made by Grumpy Studio - go to Privacy Policy, there is more there about us. We made most decisions together; the split below is who wrote which part.

Michal Gromann
Spring Boot server and API, game loop, player/entity mechanics, UI structure, this website.
Konrad Markiewicz
Tutorial, SFX & music pipeline, hazard mechanics, UI visual design, and most of the art.

The art is AI-generated. Neither of us are artists, i.e. we are programmers at a university and with limited time, we chose to spend it on the game and the server instead of learning to animate and draw.