Machine Lines

Advent of Code

I’m taking a crack at solving Advent of Code using Raven, a little programming language I’m working on which compiles to WebAssembly. This post is my notebook as I go along, and the code cells are editable, so you can play with the solutions yourself.

Raven’s standard library is, uh, reasonably sparse at the moment. So the solutions will tend to rely on JavaScript interop, or involve rewriting basic functionality, and it’s not all that representative of how I’d like Raven to look. But it might be fun to see some of the nuts and bolts.

Go straight to Day One, Day Two, Day Three, Day Four, Day Five.

Day One

Preamble: We’ll use this load function later, to get inputs for the final answer. You should be able to run each day’s code seperately, but all of them need this function.

fn load(data) {
  url = concat("/posts/advent-2025/input/", data)
  String(await(await(js.fetch(url)).text()))
}

The first puzzle. We’ll start with the demo input.

input = """
  L68
  L30
  R48
  L5
  R60
  L55
  L1
  L99
  R14
  L82
  """
"L68\nL30\nR48\nL5\nR60\nL55\nL1\nL99\nR14\nL82"

Moving the dial right amounts to adding a number to the current position, while moving left subtracts. We can simplify things by replacing left-moves with negative ones, with a quick wrapper for JS’s regex replace.

fn replace(&s, r, t) {
  s = String(js(s).replace(r, t))
}

replace(&input, r`L`, "-")
replace(&input, r`R`, "")

input
"-68\n-30\n48\n-5\n60\n-55\n-1\n-99\n14\n-82"

Split these into lines and parse (again using a JS function), so that we have a list of integers rather than a big string.

fn parseInt(s) { Int64(js.parseInt(s)) }

input = map(js(input).split("\n"), parseInt)
[-68, -30, 48, -5, 60, -55, -1, -99, 14, -82]

Now we need to work out where the dial lands after each move. This is as simple as starting at p=50p = 50 and adding each number in turn; we handle the circularity by taking the modulus pmod100p \mod 100, which wraps eg 114114 back to 1414. (We have to implement mod on top of the wasm primitive rem, to make sure the output is in 0-99 even when pp is negative).

fn mod(a: Int, b: Int) {
  r = rem(a, b)
  if r < 0 { r = r + b }
  return r
}

fn positions(xs) {
  pos = 50
  for x = xs {
    pos = mod(pos + x, 100)
  }
}

positions(input)
[82, 52, 0, 95, 55, 0, 99, 0, 14, 32]

We don’t need to explicitly build a list here, because Raven’s for loop behaves like a list comprehension, returning pos for each iteration.

Finally, we count the number of zeros. Finally something Raven has a built-in function for! (Although we still need to write count).

fn count(xs, f) {
  c = 0
  for x = xs {
    if f(x) { c = c + 1 }
  }
  return c
}

count(positions(input), zero?)
3

6 is the answer to the demo version of the puzzle. Now we just run the whole process on the full version of the data.

fn parseInput(s) {
  replace(&s, r`L`, "-")
  replace(&s, r`R`, "")
  map(js(s).split("\n").filter(js.Boolean), parseInt)
}

input = parseInput(load("01.txt"))
count(positions(input), zero?)
1145

(.filter(js.Boolean) removes empty lines from the file – an empty string casts to boolean false in JS.)

Ok, pretty straightforward! Part two adds a new wrinkle: we have to count the number of times the dial crosses zero, rather than just how many times it lands there. We’ll load the demo data again for this.

input = parseInput("L68\nL30\nR48\nL5\nR60\nL55\nL1\nL99\nR14\nL82")
[-68, -30, 48, -5, 60, -55, -1, -99, 14, -82]

We can look at the dial’s position before we wrap it with mod – if we start at p=50p = 50 and turn rightwards by 200200, we end up at position p=250p = 250. The question now is how many times we have to subtract 100100 again to get back to our 0-99 range, which is given by truncated division div: p÷100=2p \div 100 = 2.

There are two wrinkles. Firstly, for negative pp, the result will be negative (250÷100=2-250 \div 100 = -2) so we have to take the absolute value with abs (in effect, it doesn’t matter which direction we’re going when we cross zero). Secondly, 50÷100=0-50 \div 100 = 0, but if we started at p>0p > 0 and end up with p<=0p <= 0 then we’ve crossed zero, so we have to add one in that case.

fn abs(x) {
  if x < 0 { x = 0 - x }
  return x
}

fn password(codes) {
  pos = 50
  zs = 0
  for x = codes {
    pos2 = pos + x
    zs = zs + abs(div(pos2, 100))
    if ((pos > 0) && (pos2 <= 0)) { zs = zs + 1 }
    pos = mod(pos2, 100)
  }
  return zs
}

password(input)
6

66 is the correct result for the test data, so once again we just run the real thing.

password(parseInput(load("01.txt")))
6561

Day Two

The puzzle. We have to find numbers that match a pattern, out of a set of ranges.

input = "11-22,95-115,998-1012,1188511880-1188511890,222220-222224,1698522-1698528,446443-446449,38593856-38593862,565653-565659,824824821-824824827,2121212118-2121212124"
"11-22,95-115,998-1012,1188511880-1188511890,222220-222224,1698522-1698528,446443-446449,38593856-38593862,565653-565659,824824821-824824827,2121212118-2121212124"

An “invalid” number is made of repeated digits, eg 55, 6464 or 123123. We can check this by splitting the string in two (using JS, since Raven doesn’t have range indexing yet), and seeing if the first half matches the second.

fn invalid?(id) {
  id = js(string(id))
  mid = div(Int64(id.length), 2)
  start = id.substring(0, mid)
  end = id.substring(mid)
  return start == end
}

show invalid?(6464)
show invalid?(101)
invalid?(6464) = true invalid?(101) = false

(This works on odd-length strings because the “halves” of 12345 are 12 and 345, and they can never be equal.)

Now we just need to loop over the ranges we’re given. We’ll match ranges like 11-22 using a regex, parse to integers as before, then loop over the range and sum up.

fn parseInt(s) { Int64(js.parseInt(s)) }

total = 0
for m = matches(input, r`(\d+)-(\d+)`) {
  [_, a, b] = m
  for id = range(parseInt(a), parseInt(b)) {
    if invalid?(id) { total = total + id }
  }
}

total
1227775554

(for [_, a, b] = ... should really work here. One for the to-do list!)

1227775554 is the right answer for the dummy data, so now the real thing.

fn answer(input) {
  total = 0
  for m = matches(input, r`(\d+)-(\d+)`) {
    [_, a, b] = m
    for id = range(parseInt(a), parseInt(b)) {
      if invalid?(id) { total = total + id }
    }
  }
  return total
}

answer(load("02.txt"))
38158151648

Part two extends the definition of “invalid” to cover any number made up of repeating sequences of digits, like 123123123. The easiest way to check for a more complex pattern like this is with a regex. In this case:

fn invalid?(id) {
  contains?(string(id), r`^(\d+)\1+$`)
}

show invalid?(123)
show invalid?(1231230)
show invalid?(123123123)
invalid?(123) = false invalid?(1231230) = false invalid?(123123123) = true

^ and $ mark the start and end (so we don’t match repeating digits within a longer string). (\d+) matches a group of digits, and \1+ means that original group (\1) repeated at least once (+). (We could have matched the original condition with ^(\d+)\1$, rather than splitting the string.)

Because we’ve updated invalid?, the rest of the answer code is identical – so we run it again for our final answer.

answer(load("02.txt"))
45283684555

Day Three

The puzzle. We need to find the largest two-digit number hidden in a sequence.

input = """
  987654321111111
  811111111111119
  234234234234278
  818181911112111
  """
"987654321111111\n811111111111119\n234234234234278\n818181911112111"

We don’t need to brute force this. The first digit of the answer will always be the largest one on the line (excluding the final digit, which we can’t start with). The second digit will be the largest one that follows.

We need a way to slice arrays, so we can look at parts of the sequence separately.

fn slice(xs, start, end) {
  for i = range(start, end) { xs[i] }
}

slice([6, 8, 7, 9, 10], 2, 4)
[8, 7, 9]

Then we write an argmax function to get the index of the biggest digit in a list.

fn argmax(xs) {
  i = 1
  for j = range(2, length(xs)) {
    if xs[j] > xs[i] { i = j }
  }
  return i
}

xs = [1, 2, 9, 4, 5]
show argmax(xs)
show xs[3]
argmax(xs) = 3 xs[3] = 9

Now we can get the joltage of a digit sequence, by finding the max among all the digits (save the last), and then the max that follows.

fn joltage(digits) {
  a = argmax(slice(digits, 1, length(digits)-1))
  b = argmax(slice(digits, a+1, length(digits)))
  return 10*digits[a] + digits[a+b]
}

joltage([8,1,8,1,8,1,9,1,1,1,1,2,1,1,1])
92

Now it’s just a case of looping over the lines and adding up the joltage (with our good friend parseInt).

fn parseInt(s) { Int64(js.parseInt(s)) }

total = 0
for line = js(input).split("\n") {
  total = total + joltage(map(line, parseInt))
}
total
357

Now the real thing.

input = load("03.txt")
input = js(input).split("\n").filter(js.Boolean)
total = 0
for line = input {
  total = total + joltage(map(line, parseInt))
}
total
17535

Part two extends takes us from finding a two-digit number to a twelve-digit one. It’s easiest to rewrite joltage to work with N digits. The logic is identical: find the largest digit in the list (making sure there’s enough left over), then use rest of the list as candidates for the next digit.

fn joltage(digits, n) {
  j = 0
  while n > 0 {
    i = argmax(slice(digits, 1, length(digits) - (n - 1)))
    j = (10*j) + digits[i]
    digits = slice(digits, i+1, length(digits))
    n = n - 1
  }
  return j
}

show joltage([8,1,8,1,8,1,9,1,1,1,1,2,1,1,1], 2)
show joltage([8,1,8,1,8,1,9,1,1,1,1,2,1,1,1], 12)
joltage([8, 1, 8, 1, 8, 1, 9, 1, 1, 1, 1, 2, 1, 1, 1], 2) = 92 joltage([8, 1, 8, 1, 8, 1, 9, 1, 1, 1, 1, 2, 1, 1, 1], 12) = 888911112111

And the real thing:

total = 0
for line = input {
  total = total + joltage(map(line, parseInt), 12)
}
show total
total = 173577199527257

Day Four

The puzzle. We need to count the rolls of paper (@) which are surrounded by less than four other rolls in a grid.

input = """
  ..@@.@@@@.
  @@@.@.@.@@
  @@@@@.@.@@
  @.@@@@..@.
  @@.@@@@.@@
  .@@@@@@@.@
  .@.@.@.@@@
  @.@@@.@@@@
  .@@@@@@@@.
  @.@.@@@.@.
  """

fn lines(s: String) {
  ls = js(s).split("\n").filter(js.Boolean)
  map(ls, String)
}

input = lines(input)
["..@@.@@@@.", "@@@.@.@.@@", "@@@@@.@.@@", "@.@@@@..@.", "@@.@@@@.@@", ".@@@@@@@.@", ".@.@.@.@@@", "@.@@@.@@@@", ".@@@@@@@@.", "@.@.@@@.@."]

Here’s code for a 2D slice.

fn slice(xs, is, js) {
  for i = is {
    for j = js { xs[i][j] }
  }
}

slice(input, range(2, 4), range(5, 7))
[[c"@", c".", c"@"], [c"@", c".", c"@"], [c"@", c"@", c"."]]

Which lets us get the box around a given cell.

fn max(x, y) { if x >= y { x } else { y } }
fn min(x, y) { if x <= y { x } else { y } }

fn neighbours(i, N) { range(max(1, i-1), min(N, i+1)) }

N = length(input)
M = length(input[1])
box = slice(input, neighbours(2, N), neighbours(2, M))
[[c".", c".", c"@"], [c"@", c"@", c"@"], [c"@", c"@", c"@"]]

Then we can count the amount of paper in the neighbourhood.

fn flatMap(xss, f) {
  ys = []
  for xs = xss {
    for x = xs { append(&ys, f(x)) }
  }
  return ys
}

fn flatMap(xss) { flatMap(xss, identity) }

fn count(xs, f) {
  c = 0
  for x = xs {
    if f(x) { c = c + 1 }
  }
  return c
}

fn paper?(ch: Char) { ch == c"@" }

count(flatMap(box), paper?)-1
6

We subtract 1 so that we’re only counting the neighbours, not the paper in the middle of the box. Now we can loop over the coordinates in the grid:

fn accessible(input) {
  N = length(input)
  M = length(input[1])
  total = 0
  for i = range(1, N) {
    for j = range(1, M) {
      if not(paper?(input[i][j])) { continue }
      box = slice(input, neighbours(i, N), neighbours(j, M))
      if (count(flatMap(box), paper?)-1) < 4 { total = total + 1 }
    }
  }
  return total
}

accessible(input)
13

The real deal:

accessible(lines(load("04.txt")))
1346

Part two has us removing rolls of paper, which makes more rolls accessible, and so we repeat until we can go no further. The code to do that is structurally similar to accessible, but we build the output as we go.

@extend, fn js(ch: Char) { js(string(ch)) } # A conversion we need

fn remove(input) {
  N = length(input)
  M = length(input[1])
  output = []
  total = 0
  for i = range(1, N) {
    row = []
    for j = range(1, M) {
      box = slice(input, neighbours(i, N), neighbours(j, M))
      if paper?(input[i][j]) {
        if count(flatMap(box), paper?) < 5 {
          total = total + 1
        } else {
          append(&row, c"@")
          continue
        }
      }
      append(&row, c".")
    }
    append(&output, String(js(row).join("")))
  }
  return [output, total]
}

[output, total] = remove(input)
println(js(output).join("\n"))
total
.......@.. .@@.@.@.@@ @@@@@...@@ @.@@@@..@. .@.@@@@.@. .@@@@@@@.@ .@.@.@.@@@ ..@@@.@@@@ .@@@@@@@@. ....@@@... 13

We can remove in a loop, until there’s nothing to remove.

fn removeAll(input) {
  total = 0
  while true {
    [input, removed] = remove(input)
    if removed == 0 { break }
    total = total + removed
  }
  return [input, total]
}

[output, total] = removeAll(input)
println(js(output).join("\n"))
total
.......... .......... .......... ....@@.... ...@@@@... ...@@@@@.. ...@.@.@@. ...@@.@@@. ...@@@@@.. ....@@@... 43

And the real deal:

removeAll(lines(load("04.txt")))[2]
8493

This would all be a lot nicer if we had a matrix type!

Day Five

The puzzle. Given a set of ranges (the first list), we need to check which IDs (the second list) are included.

input = """
  3-5
  10-14
  16-20
  12-18

  1
  5
  8
  11
  17
  32
  """
"3-5\n10-14\n16-20\n12-18\n\n1\n5\n8\n11\n17\n32"
fn parseInt(s) { Int64(js.parseInt(s)) }

fn split(string, by) {
  map(js(string).split(by).filter(js.Boolean), String)
}

fn parse(input) {
  [ranges, ids] = split(input, "\n\n")
  ids = map(split(ids, "\n"), parseInt)
  ranges = (for line = split(ranges, "\n") {
    map(split(line, "-"), parseInt)
  })
  return [ranges, ids]
}

[ranges, ids] = parse(input)
show ranges
show ids
ranges = [[3, 5], [10, 14], [16, 20], [12, 18]] ids = [1, 5, 8, 11, 17, 32]

There are cleverer methods, but for now it’s entirely reasonable to check every ID against every range, which gets us our answer.

fn fresh?(ranges, id) {
  for range = ranges {
    [a, b] = range
    if (a <= id) && (id <= b) { return true }
  }
  return false
}

fn countFresh(ranges, ids) {
  total = 0
  for id = ids {
    if fresh?(ranges, id) { total = total + 1 }
  }
  return total
}

show countFresh(parse(input)...)
show countFresh(parse(load("05.txt"))...)
countFresh(parse(input)...) = 3 countFresh(parse(load("05.txt"))...) = 638

Part two makes things trickier – we now have to count how many valid IDs there are in total, and brute force is no longer an option. (There are up to 562,817,005,870,729 valid IDs in my input file, which would take a week to check even if each ID only takes a nanosecond.)

So we instead need to add up the length of all the ranges. That should be simple, but the problem is double counting: in our original input, ranges 10-14 (length 5) and 12-18 (length 7) only contribute 9 total IDs (not 12), because IDs 12, 13 and 14 are covered by both.

We can address this by filtering out all the ranges that overlap the one we’re interested in.

fn disjoint?([a1, a2], [b1, b2]) {
  (b2 < a1) || (b1 > a2)
}

fn overlapping(rs, range) {
  no = []
  yes = [range]
  for r = rs {
    if disjoint?(r, range) {
      append(&no, r)
    } else {
      append(&yes, r)
    }
  }
  return [no, yes]
}

[no, yes] = overlapping([[3, 5], [10, 14], [16, 20]], [12, 18])
show no
show yes
no = [[3, 5]] yes = [[12, 18], [10, 14], [16, 20]]

Then we merge the overlapping ranges into one.

fn merge(rs) {
  [a, b] = rs[1]
  for r = rs {
    if r[1] < a { a = r[1] }
    if r[2] > b { b = r[2] }
  }
  return [a, b]
}

merge(yes)
[10, 20]

We can use this to consolidate all ranges in the set. We are essentially just transfering the ranges from one list to another, but we filter out overlapping ranges from the output list and merge as we go.

fn consolidate(ranges) {
  rs = []
  for r = ranges {
    [rs, os] = overlapping(rs, r)
    append(&rs, merge(os))
  }
  return rs
}

consolidate(parse(input)[1])
[[3, 5], [10, 20]]

That significantly simplifies our original set of ranges, and finally we can count them up!

fn count(ranges) {
  ranges = consolidate(ranges)
  total = 0
  for r = ranges {
    [a, b] = r
    total = total + (b - a) + 1
  }
  return total
}

show count(parse(input)[1])
show count(parse(load("05.txt"))[1])
count(parse(input)[1]) = 14 count(parse(load("05.txt"))[1]) = 352946349407338

Raven’s lack of built-in data structures, or library functions like sort, is starting to feel limiting – though that perhaps inspires more creative solutions, too. We’ll see how much further we can get.

That’s all for now!

Citation
@misc{innes2025,
  title = {{Advent of Code}},
  url = {https://mikeinnes.io/posts/advent-2025/},
  author = {Innes, Michael John},
  year = {2025},
  month = {December},
  note = {Accessed: }
}

I write regularly about things like this my newsletter, which you can get by sponsoring me. Or, sign up for the free updates: