Day 4/2025 - Paper Rolls and Forklifts

Day four of AoC 2025 (available at https://adventofcode.com/2025/day/4) puts us in a warehouse with a grid of paper rolls. We're playing a forklift operator, figuring out which rolls we can access and remove.

A roll is accessible if it has fewer than four neighboring rolls in the eight adjacent positions. Empty spaces are marked with ., rolls with #.

Parsing the Input

The input is a straightforward character grid:

type Cell = "." | "#";
type Matrix = Cell[][];

const processInput = (day: number): Matrix => {
    const lines = readInputLines(day);
    const cells = lines.map(line => 
        line.split("") as Cell[]
    );
    return cells;
};

Always test your input parsing before diving into the algorithm. I can't count how many times I've debugged a "broken" algorithm only to find the input was malformed.

Part One: Counting Accessible Rolls

For each roll, we count its neighbors in all eight directions. If fewer than four neighbors are rolls, that roll is accessible.

const countNeighbors = (matrix: Matrix, row: number, col: number): number => {
    const topRow = matrix[row - 1];
    const bottomRow = matrix[row + 1];
    const currentRow = matrix[row];
    
    const topLeft = topRow?.[col - 1] === "#" ? 1 : 0;
    const top = topRow?.[col] === "#" ? 1 : 0;
    const topRight = topRow?.[col + 1] === "#" ? 1 : 0;
    const left = currentRow?.[col - 1] === "#" ? 1 : 0;
    const right = currentRow?.[col + 1] === "#" ? 1 : 0;
    const bottomLeft = bottomRow?.[col - 1] === "#" ? 1 : 0;
    const bottom = bottomRow?.[col] === "#" ? 1 : 0;
    const bottomRight = bottomRow?.[col + 1] === "#" ? 1 : 0;
    
    return topLeft + top + topRight + left + right + bottomLeft + bottom + bottomRight;
};

JavaScript's optional chaining (?.) saves us here. Accessing matrix[-1] returns undefined, and undefined?.[0] is also undefined, which is not "#", so we get 0. No boundary checks needed.

The Mutation Bug

My first attempt modified the input matrix directly, marking accessible rolls with X. This broke everything — once you mark a roll as X, its neighbors suddenly have fewer roll neighbors, cascading incorrectly through the grid.

The fix: always work on a copy.

const copy = matrix.map(row => [...row]);

This creates an independent copy where modifications don't affect the original. I went against my instincts of never mutating input and paid for it with debugging time.

Part Two: Iterative Removal

Part two asks: keep removing accessible rolls until no more can be removed. How many total rolls do we remove?

This is where a graph-based solution would shine — model each roll as a node, edges to neighbors, and update connectivity as rolls are removed. Very efficient, very elegant.

But brute force works too:

type RemovalResult = {
    matrix: Matrix;
    removed: number;
};

const removeLayer = (matrix: Matrix): RemovalResult => {
    const copy = matrix.map(row => [...row]);
    let count = 0;
    
    for (let row = 0; row < matrix.length; row++) {
        for (let col = 0; col < matrix[row].length; col++) {
            if (matrix[row][col] !== "#") continue;
            
            const neighbors = countNeighbors(matrix, row, col);
            if (neighbors < 4) {
                copy[row][col] = ".";
                count++;
            }
        }
    }
    
    return { matrix: copy, removed: count };
};

const partTwo = (input: Matrix, debug: boolean) => {
    let matrix = input;
    let total = 0;
    let passCount = 0;
    
    while (true) {
        const { matrix: newMatrix, removed } = removeLayer(matrix);
        if (removed === 0) break;
        
        total += removed;
        matrix = newMatrix;
        passCount++;
    }
    
    return total;
};

We repeatedly call removeLayer until it returns 0 — a stable configuration where all remaining rolls have four or more neighbors and can't be accessed.

Performance

The brute force solution runs in 77 milliseconds over 45 passes on a 140×140 grid. Could a graph solution be faster? Absolutely. Is it worth implementing? For this input size, no.

My rule of thumb: optimize when you need to, not when you can. The graph approach would add complexity for negligible real-world benefit here.

Takeaways

  1. Don't mutate your input: Creating a copy before modification prevents cascading bugs. The few bytes of memory are worth the sanity.

  2. Optional chaining for boundary conditions: JavaScript's ?. operator elegantly handles out-of-bounds array access, returning undefined instead of throwing.

  3. Brute force has limits, but they're often far away: 45 passes over 19,600 cells in 77ms. Sometimes the "naive" solution is plenty fast.

  4. Test your input parsing first: Before writing any algorithm, verify you're reading the data correctly. It saves hours of debugging the wrong thing.

The full solution is available in the repository.