Advent of Code 2025; Day 8: Playground

Connecting junction boxes to form electrical circuits with their nearest neighbours.

Part 1: Connecting Junction Boxes

I’ve stumbled into an underground playground where some industrious elves have suspended electrical junction boxes across the space. Their grand plan is to connect these boxes with strings of lights, and naturally they need some help figuring out the optimal wiring.

Each junction box has a 3D position (X, Y, Z coordinates), and the elves want to connect pairs that are as close together as possible. When boxes connect, they form circuits. The task is to process the 1,000 shortest connections and then multiply the sizes of the three largest circuits that are created.

Data Structure

I started by creating a simple Vec3 class. I could pull in a library for this, but for Advent of Code I prefer keeping things minimal where possible - vectors are straightforward enough that rolling your own helps reinforce the fundamentals:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Vec3:
    def __init__(self, x: int, y: int, z: int) -> None:
        self.x = x
        self.y = y
        self.z = z

    def sub(self, other: Vec3) -> Vec3:
        return Vec3(self.x - other.x, self.y - other.y, self.z - other.z)

    def mul(self, other: Vec3) -> Vec3:
        return Vec3(self.x * other.x, self.y * other.y, self.z * other.z)

    def magsqr(self) -> int:
        return abs(sum(self.mul(self)))

Using magnitude squared rather than actual magnitude avoids the square root calculation - since we only care about relative distances for sorting, this works fine.

I then also chucked together a JunctionBox class which simply represents and index and Vec3 position. The index represents the junction box in the original input so that I can uniquely identify it and refer back to it.

Finding Closest Connections

This is a nearest nieghbor situation, I’m going to start simple and see how far I get. Simply look at each box, and compare it to every other box (I said keep it simple!) to find the distance between, using the magsqr function on my Vec3 this gives a nice scalar easily sortable result:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def connect_junction_boxes(jboxes: list[JunctionBox]) -> list[tuple[int, int, int]]:
    boxes = [(i, j.pos) for i, j in enumerate(jboxes)]
    pairs = []
    for i, a in boxes:
        closest = None
        for j, b in boxes:
            if j != i:
                d = a.sub(b).magsqr()
                # check if this box is closer than the last
                if closest is None or d < closest[2]:
                    closest = (i, j, d)  
        # Add a to the list with it's closest box
        pairs.append(closest)
    return sorted(pairs, key=lambda x: x[2])

I track which box is closest, and the result is a list of all the junction boxes with their nearest other box.

Error: This is actually incorrect - more on that later!

Tracing Circuits

With a list of connection pairings, I needed to work out which boxes end up in which circuits. Now I’m not sure if there’s a standard known algorithm for this (there more than likely is) but half the fun of AoC is the voyage of discovery so I’m doing my best to feel it out on my own.

Thinking of it logically, I need to go through each pair and create a list of circuits. If one of the items in the pair is in an existing circuit, append it’s partner to the same circuit. There’s a few cases around that to flesh out though.

The logic breaks down like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
  Processing connections in order of distance:

  Connection (A, B):
  ┌─────────────────────────────────────────────────────────────┐
  │  Case 1: Neither A nor B in any circuit                     │
  │          → Create new circuit {A, B}                        │
  │                                                             │
  │  Case 2: A in circuit, B not (or vice versa)                │
  │          → Add the unconnected box to existing circuit      │
  │                                                             │
  │  Case 3: Both in different circuits                         │
  │          → Merge the two circuits into one                  │
  │                                                             │
  │  Case 4: Both already in same circuit                       │
  │          → No change (already connected)                    │
  └─────────────────────────────────────────────────────────────┘

Here’s a visual example of circuit formation:

1
2
3
4
5
6
  Initial boxes:     After connection (0,1):    After connection (2,3):
      0   1              {0, 1}                    {0, 1}  {2, 3}
      2   3              2   3

  After connection (1,2) - merges circuits:
      {0, 1, 2, 3}

The implementation uses sets to ensure box IDs remain unique within each circuit:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def trace_circuits(connections: list[tuple[int, int, int]]) -> list[set[int]]:
    circuits: list[set[int]] = []

    # finds which circuit contains this ID
    def find(a: int) -> int | None:
        for i, c in enumerate(circuits):
            if a in c:
                return i

    for a, b, _ in connections:
        circuit_a = find(a)
        circuit_b = find(b)

        if circuit_a is None and circuit_b is None:
            circuits.append({a, b})
        elif circuit_a is None and circuit_b is not None:
            circuits[circuit_b].add(a)
        elif circuit_b is None and circuit_a is not None:
            circuits[circuit_a].add(b)
        elif circuit_a is not None and circuit_b is not None and circuit_a != circuit_b:
            circuits[circuit_b].update(circuits[circuit_a])
            circuits.remove(circuits[circuit_a])

    return circuits

The merge case was the most complex here, it’s ultimately just merge one set with the other but then I also need to remove the set that was merged in as it’s no longer an independent circuit.

Putting it all together:

python
1
2
3
4
5
def part1(input_data: str) -> int:
    jboxes = load_junction_box_coordinates(input_data)
    connections = connect_junction_boxes(jboxes)[:2000]
    circuits, _ = trace_circuits(connections)
    return prod(map(len, sorted(circuits, key=len, reverse=True)[:3]))

Tada! Take the top 3 sorted by circuit length, multiply together (using math.prod) and we have… the wrong answer!?

What went wrong here then? I mentioned earlier that I actually made an error - I’m only now figuring that out.

I can see in the example given that there are 11 circuits with several single node circuits - wait… how can any be single when by default they are all paired to at least their nearest box? There’s a fundamental detail that I misinterpreted - I need to go through each connection in order - regardless of how many connections a single box may make to other boxes. That way there are a lot more connections in the list, the example goes from 20 to 190 total connections!

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def connect_junction_boxes(jboxes: list[JunctionBox]) -> list[tuple[int, int, int]]:
    boxes = [(i, j.pos) for i, j in enumerate(jboxes)]
    pairs = []
    # from each box (a), find distance to each other box (b)
    for i, a in boxes:
        for j, b in boxes:
            if j != i:
                d = a.sub(b).magsqr()
                pairs.append((i, j, d))

    # return  collection of pairs including their distance (sorted)
    return sorted(pairs, key=lambda x: x[2])

I hit another snag: my distance calculation includes both (A -> B) and (B -> A) with identical distances, effectively doubling my connection list. This meant when I look at the first 1,000 connections, I was only really looking at the first 500 twice! My quick hack was to take the top 2,000 instead of 1,000 - the circuit tracing naturally deduplicates anyway since connecting already-linked boxes does nothing.

Part 2: The Final Connection

Part 2 asks for the last connection that joins everything into a single circuit. Once all boxes are connected, we need to multiply the X coordinates of the two boxes in that final connection.

I modified the circuit tracing to track whenever a connection actually changes something (creates a new circuit, adds a box, or merges circuits) and return the last such connection:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def trace_circuits(connections: list[tuple[int, int, int]]) -> tuple[list[set[int]], tuple[int, int]]:
    circuits: list[set[int]] = []
    last: tuple[int, int] = (0, 0)

    def find(a: int) -> int | None:
        for i, c in enumerate(circuits):
            if a in c:
                return i

    for a, b, _ in connections:
        circuit_a = find(a)
        circuit_b = find(b)

        if circuit_a is None and circuit_b is None:
            circuits.append({a, b})
            last = (a, b)
        elif circuit_a is None and circuit_b is not None:
            circuits[circuit_b].add(a)
            last = (a, b)
        elif circuit_b is None and circuit_a is not None:
            circuits[circuit_a].add(b)
            last = (a, b)
        elif circuit_a is not None and circuit_b is not None and circuit_a != circuit_b:
            circuits[circuit_b].update(circuits[circuit_a])
            circuits.remove(circuits[circuit_a])
            last = (a, b)

    return (circuits, last)

I’ll admit I wasted time here being overly cautious. I wasn’t sure if removing the 2,000 connection limit would cause the algorithm to run forever, so I tried a binary search approach - starting at 10,000 connections, noting I got one circuit, then manually tweaking downward until I settled on 7,717 iterations. However, this didn’t seem to give the correct answer.

In the end, I just removed the limit entirely and it took a few seoncds to complete with the correct result. A classic case of overthinking it when the straightforward approach would have worked fine from the start!

python
1
2
3
4
5
def part2(input_data: str) -> int:
    jboxes = load_junction_box_coordinates(input_data)
    connections = connect_junction_boxes(jboxes)
    _, (a, b) = trace_circuits(connections)
    return jboxes[a].pos.x * jboxes[b].pos.x

Improvements

Looking back on it, a cleaner solution to fix the duplicate connection issue would be to only consider pairs where j > i. If you think of all pairwise distances as a matrix, you get something like this:

j=0j=1j=2j=3
i=0-d01d02d03
i=1d10-d12d13
i=2d20d21-d23
i=3d30d31d32-

The diagonal is meaningless (distance from a box to itself), and the lower-left half is a mirror of the upper-right: d01 == d10, d02 == d20, etc. Distance is symmetric - it doesn’t matter which direction you measure from.

By only considering pairs where j > i, we’re taking just the upper triangle:

j=0j=1j=2j=3
i=0d01d02d03
i=1d12d13
i=2d23
i=3

This halves the number of pairs we compute and avoids duplicates entirely:

python
1
2
3
4
5
for i, a in boxes:
    for j, b in boxes:
        if j > i:  # Only upper triangle
            d = a.sub(b).magsqr()
            pairs.append((i, j, d))

As always, full code is available at github.com/lordmoocow/aoc25.


This one had a few false starts - the nearest neighbour mistake cost me some time, and I overcomplicated part 2 unnecessarily. The core circuit-tracing algorithm was satisfying to work out though. What I ended up with is what I now realised is essentially a simplified Union-Find structure. 4 days to go.