Advent of Code 2025 - Day 7, in Kotlin - Laboratories
Kotlin solutions to parts 1 and 2 of Advent of Code 2025, Day 7: 'Laboratories'
I enjoyed this puzzle! Fair warning - I know what happens in Part 2 and used the code for that to solve both parts today. So if you’re here looking for hints on part 1 and not part 2, maybe find another resource or accept that there are spoilers here today.
When I first wrote this, I had slightly different solutions for parts 1 and 2, only to realize that I could probably solve this whole thing with one grid traversal function. Even better, we don’t have to manipulate the grid in any way, only maintain counts outside of the input grid.
If you’d rather just view the code, my GitHub Repository is here.
Puzzle Input
Because we don’t manipulate our input today, we’ll leave it as-is in a List<String>, make it a class property, and call it grid.
The only thing we need from the grid is where the starting point is. To get that (and for future use) we’ll write a findAll() extension function on our List<String> grid. This loops through all of the cells of the grid and returns any Point2D coordinates of matching elements. Because we only have one start, we’ll take the first() (and only) S we can find.
class Day07(private val grid: List<String>) {
private val start: Point2D = grid.findAll('S').first()
private fun List<String>.findAll(target: Char): List<Point2D> =
flatMapIndexed { y, row ->
row.mapIndexed { x, c ->
if (c == target) Point2D(x, y) else null
}.filterNotNull()
}
}
⭐ Day 7, Part 1
The puzzle text can be found here.
Before we get into strategy, let’s write some helper functions for our grid. Maybe at some point we’ll move these to our Extensions.kt file but for now, we can leave them here (local to Day07). These will both be implemented as operators.
First, let’s implement the contains operator, which will let us determine if a given Point2D is a valid spot on the grid.
// In Day07
private operator fun List<String>.contains(point: Point2D): Boolean =
point.x in this.first().indices && point.y in this.indices
Next, the get operator which returns either the Char at the given Point2D, or null if the given point is out of bounds. This uses the contains operator we just wrote.
// In Day07
private operator fun List<String>.get(point: Point2D): Char? =
if (this.contains(point)) this[point.y][point.x] else null
Strategy
Here’s the point where the spoilers come up. Part 2 is going to ask us to count all of the paths through the grid that the stream could possibly take. It occurred to me that if we know that, we can also answer part 1 with that data. So let’s write one function that calculates all of the paths through the grid and use that.
We’ll implement this countAllPaths function as an extension function on our List<String> grid. It will return a single Map<Point2D, Long> where the key represents a point in the grid and the value represents how many times the stream passed through this point.
One thing to note before we go over this is that our Point2D class has constants for NORTH, EAST, WEST, and SOUTH, which we will use to find relative points to the one we have (such as when we split, or when the stream flows Down/South).
This solution uses an iterative process via a sequence generator, but could have been implemented recursively. I suspect the recursive function may have had less code, but I found this easier to reason about personally, so I kept it this way.
// In Day07
private fun List<String>.countAllPaths(): Map<Point2D, Long> =
generateSequence(setOf(start) to mutableMapOf(start to 1L)) { (streams, pathCounts) ->
val liveStreams = streams.flatMap { stream ->
val streamFlowsTo = stream + Point2D.SOUTH
when {
grid[streamFlowsTo] == '^' ->
listOf(streamFlowsTo + Point2D.EAST, streamFlowsTo + Point2D.WEST)
else ->
listOf(streamFlowsTo)
}
.filter { it in this }
.onEach {
pathCounts[it] =
pathCounts.getOrDefault(stream, 0L) +
pathCounts.getOrDefault(it, 0)
}
}.toSet()
if (liveStreams.isNotEmpty()) liveStreams to pathCounts
else null
}.last().second
I know that’s maybe a bit much, so let’s go over it. We’ll be generating a sequence of states using generateSequence. A state is comprised of all the points that a stream exists in, and how many times each spot on the grid has had a stream pass through it. Over time the number of streams will grow until they all fall off the bottom of the grid, at which point the sequence will stop generating new states.
Our sequence generator has a Pair of things in its initial state - a Set<Point2> representing the streams we know about (initially set with the start), and a MutableMap<Point2D, Long> which we will use to count paths. The key to that map is a place in the grid, and the value is the total number of streams that have passed through that point. It’s mutable because we’ll edit it in place and return it at the very end. Otherwise, we’d have to write some map merging logic (which isn’t hard, it’s just more code we don’t strictly need here).
The first order of business is to calculate liveStreams, the new Set<Point2D> representing the active streams in the grid. We do this by looking at each stream we know about and calculating the position one down from it (SOUTH), calling this streamFlowsTo. Next, we examine the grid at streamFlowsTo and do some work to calculate our state changes. If the streamFlowsTo run into a splitter, we create two more Point2D objects to add to the liveStreams, one to the Left/West and one to the Right/East. Otherwise, the stream has flowed down and not run into anything we we add streamFlowsTo to our liveStreams. Because this logic can generate streams that don’t fit on the grid, we’ll filter those invalid points out.
Once we have all of the new streams that exist after this turn, we want to add the counts to our pathCounts map. The count of any one spot is the existing count, plus the count of the stream that entered this new spot. This lets streams run over one another, always adding to the count. Picture each stream as carrying along a count with them. We start with 1, and if streams merge, so do their counts.
If we end up with an empty liveStreams set, the streams have all fallen off the bottom of the grid and we return null to sequenceGenerator, indicating the sequence has ended. Otherwise, we pair up the liveStreams and pathCounts, which sequenceGenerator will use for the next round.
At the end, we only care about the last() value of the sequence, and only the second part of the state, the pathCounts. This represents the total number of streams each spot on the grid has had flow through it.
Now we can call this and solve part 1…
// In Day07
fun solvePart1(): Int {
val timelines = grid.countAllPaths()
return grid.findAll('^').count { spot ->
spot + Point2D.NORTH in timelines
}
}
We store the total count of all the paths in timelines. Next we use findAll to find all of the splitters and count how many of the spots just up from them have any count at all in the timelines map. This indicates that some stream passed through that point before being split. Return this count for our answer.
Star earned! Onward!
⭐ Day 7, Part 2
The puzzle text can be found here.
Since we did all the hard work in part 1, we can get our answer for part 2 without much more work.
// In Day07
fun solvePart2(): Long =
grid.countAllPaths()
.filter { it.key.y == grid.lastIndex }
.values
.sum()
We call countAllPaths, find all of the spots that are on the bottom row of the grid, and add up the values for our answer.
Star earned! See you tomorrow!
Further Reading
- Index of All Solutions - All posts and solutions for 2025, in Kotlin.
- My Github repo - Solutions and tests for each day.
- Solution - Full code for day 7
- Advent of Code - Come join in and do these challenges yourself!