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!
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.
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.
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.