Advent of Code 2025 - Day 1: Secret Entrance

multicolored illustration

niels jaspers • december 1, 2025

This is an ongoing series of posts about my experiences solving the Advent of Code 2025 puzzles. This year, I'm doing a showdown with my good friend Levi. Since AoC is twelve days long this year, we'll be solving the puzzles in twelve different languages.

Day 1 kicked off this year's Advent of Code in PHP with a deceptively simple puzzle that taught me a painful lesson about testing.

The Problem

You're trying to crack a safe with a circular dial numbered 0-99. You get a list of rotation instructions like L68 (rotate left 68 clicks) or R48 (rotate right 48 clicks). The dial starts at 50.

Part 1 asks: how many times does the dial land on 0 after a rotation?

Part 2 ups the ante: how many times does the dial point at 0, whether during a rotation or at the end of one? Oh, and some rotations are massive, like R1000, which would cross 0 ten times.

Part 1: Straightforward Modular Arithmetic

Part 1 was pretty clean. The dial is circular, so it's just modular arithmetic. Move right, add ticks. Move left, subtract ticks. Wrap around using mod 100. Check if we landed on 0.

My first pass used separate upperBound and lowerBound functions to handle the wrapping:

function upperBound($tot, $ticks) {
    if ($tot + $ticks > 99) {
        return $tot + $ticks - 100;
    }
    return $tot + $ticks;
}

function lowerBound($tot, $ticks) {
    if ($tot - $ticks < 0) {
        return $tot - $ticks + 100;
    }
    return $tot - $ticks;
}

Then I just checked if ($total === 0) after each move. Worked fine.

The optimized version collapsed everything into a tight loop:

foreach ($lines as $line) {
    $ticks = (int)substr($line, 1);
    $ticks = $ticks % 100;

    $total = ($line[0] === 'R')
        ? ($total + $ticks) % 100
        : ($total - $ticks + 100) % 100;

    if ($total === 0) {
        $count++;
    }
}

Nothing fancy. On to Part 2.

Part 2: Where Everything Went Wrong

Part 2 seemed like a natural extension. Instead of just counting when we land on 0, count every time we cross 0 during rotation. Plus handle large inputs like R1000 that cross multiple times.

My approach:

For the right direction, this was easy: if $tot + $ticks > 99, we crossed from 99 back to 0.

For the left direction, I wrote: if $tot - $ticks < 0, we crossed from 0 to 99.

I ran it on the test input. Expected answer: 6. My answer: 6.

I ran it on the real input. Wrong. Too low.

Okay, maybe I'm missing the case where we land exactly on 0? I added checks for $tot - $ticks === 0 and $tot + $ticks === 100.

Test input: 8. That's too high now.

Real input: Also too high.

So the answer is somewhere between my two implementations, and I have no idea why the test case passes with the "wrong" code.

The Debugging Rabbit Hole

I added debug output to trace every move:

Direction: L, Ticks: 5, Total: 0
Passed 0!!
Lower bound: 95

Wait. I'm starting at 0 and going left. Why is that triggering "Passed 0!!"?

Because 0 - 5 = -5, and -5 < 0 is true. But I didn't cross 0, I left from 0. That shouldn't count.

Then I looked at another case:

Direction: L, Ticks: 55, Total: 55
Lower bound: 0

Here I go from 55 to exactly 0. No "Passed 0!!" message. But according to the problem, landing on 0 should count.

And that's when it hit me.

Two Bugs That Cancel Out

My test input gave the correct answer of 6, but not because my code was correct. I had two bugs:

Bug 1 (false positives): When starting at 0 and going left, my < 0 check fires incorrectly. The test data had two of these cases.

Bug 2 (false negatives): When landing exactly on 0 from above (like 55 - 55 = 0), my < 0 check doesn't fire. The test data also had two of these cases.

Two false positives. Two false negatives. They canceled out perfectly on the test data, giving me 6.

On the real dataset with thousands of operations, they didn't cancel out. The bugs compounded differently, giving me wrong answers in both directions depending on which "fix" I applied.

The Actual Fix

The solution was to separate the counting logic from the wrapping logic. They're not the same condition:

function lowerBound($tot, $ticks) {
    global $count;
    $result = $tot - $ticks;
    
    if ($tot > 0 && $result <= 0) {
        $count++;
    }
    
    if ($result < 0) {
        return $result + 100;
    }
    return $result;
}

The key insight is that $tot > 0 excludes the "starting at 0" case, and $result <= 0 catches both crossing through 0 and landing exactly on 0.

Interestingly, the upperBound function didn't need this fix. When you're at 0 and go right, 0 + ticks is never going to trigger > 99 for small ticks, so the false positive case doesn't exist. The asymmetry in the problem naturally made one direction easier than the other.

Final Optimized Solution

foreach ($lines as $line) {
    $ticks = (int)substr($line,1);
    $f = intdiv($ticks,100);
    $count += $f;

    $ticks = $ticks % 100;

    if ($line[0] === 'R') {
        $new = $total + $ticks;
        $count += intdiv($new, 100);  // 1 if crossed, 0 if not
        $total = $new % 100;
    } else {
        $new = $total - $ticks;
        if ($total > 0 && $new <= 0) $count++;
        $total = ($new % 100 + 100) % 100;
    }
}

The right-direction case got even cleaner: intdiv($new, 100) returns 1 if we crossed (value >= 100) and 0 if we didn't. No conditional needed.

Lessons Learned

Test cases can lie to you. A passing test doesn't mean correct code. It means your code produces the expected output for that specific input. If you have bugs that happen to cancel out, you'll never know until you hit data where they don't.

Trace your edge cases manually. I should have walked through "what happens when I start at 0?" before ever running the code. The bug was obvious once I looked at it that way.

Counting and transformation are separate concerns. My original code tried to do both in one conditional, which muddied the logic. Splitting them made the fix obvious.

Not a bad start to AoC 2025. Onward to Day 2.