diff --git a/2024/src/aoc/days/day21.py b/2024/src/aoc/days/day21.py
new file mode 100644
index 0000000..4f1f5b6
--- /dev/null
+++ b/2024/src/aoc/days/day21.py
@@ -0,0 +1,132 @@
+import functools
+
+from . import SeparateRunner
+
+NUMPAD = {
+ "A": (3, 2),
+ "0": (3, 1),
+ "1": (2, 0),
+ "2": (2, 1),
+ "3": (2, 2),
+ "4": (1, 0),
+ "5": (1, 1),
+ "6": (1, 2),
+ "7": (0, 0),
+ "8": (0, 1),
+ "9": (0, 2),
+}
+
+DIRPAD = {
+ "A": (0, 2),
+ "^": (0, 1),
+ "<": (1, 0),
+ "v": (1, 1),
+ ">": (1, 2),
+}
+
+
+@functools.cache
+def shortest_numpad(from_: str, to: str) -> list[str]:
+ inverse = set(NUMPAD.values())
+ ay, ax = NUMPAD[from_]
+ by, bx = NUMPAD[to]
+
+ dx, dy = bx - ax, by - ay
+
+ sx = "<" if dx < 0 else ">"
+ sy = "^" if dy < 0 else "v"
+
+ if dx > 0 and (by, ax) in inverse or (ay, bx) not in inverse:
+ return abs(dy) * sy + abs(dx) * sx + "A"
+ else:
+ return abs(dx) * sx + abs(dy) * sy + "A"
+
+
+@functools.cache
+def shortest_dirpad(from_: str, to: str) -> str:
+ inverse = set(DIRPAD.values())
+ ay, ax = DIRPAD[from_]
+ by, bx = DIRPAD[to]
+
+ dx, dy = bx - ax, by - ay
+ sx = "<" if dx < 0 else ">"
+ sy = "^" if dy < 0 else "v"
+
+ if dx > 0 and (by, ax) in inverse or (ay, bx) not in inverse:
+ return abs(dy) * sy + abs(dx) * sx + "A"
+ else:
+ return abs(dx) * sx + abs(dy) * sy + "A"
+
+
+def encode_shortest_numpad(code: str) -> str:
+ pos = "A"
+
+ res = ""
+
+ for c in code:
+ res += shortest_numpad(pos, c)
+ # print(c, res)
+ pos = c
+
+ return res
+
+
+def encode_shortest_dirpad(code: str) -> str:
+ pos = "A"
+
+ res = ""
+
+ for c in code:
+ if pos != c:
+ res += shortest_dirpad(pos, c)
+ else:
+ res += "A"
+ pos = c
+
+ return res
+
+
+def decode(code: str, pad: dict[str, tuple[int, int]]) -> str:
+ result = ""
+ inverse = {v: k for k, v in pad.items()}
+
+ y, x = pad["A"]
+
+ for i, c in enumerate(code):
+ match c:
+ case "A":
+ result += inverse[y, x]
+ case "^":
+ y -= 1
+ case "v":
+ y += 1
+ case "<":
+ x -= 1
+ case ">":
+ x += 1
+
+ if (y, x) not in inverse:
+ raise ValueError(
+ f"""Moved off the board {x, y}, after processing {c}.
+ Path so far: {result} (from {code[:i]})"""
+ )
+
+ return result
+
+
+class DayRunner(SeparateRunner):
+ @classmethod
+ def part1(cls, input: str) -> int:
+ result = 0
+ for code in input.strip().split("\n"):
+ numpad = encode_shortest_numpad(code)
+ robot1 = encode_shortest_dirpad(numpad)
+ robot2 = encode_shortest_dirpad(robot1)
+
+ result += int(code[:-1]) * len(robot2)
+
+ return result
+
+ @classmethod
+ def part2(cls, input: str) -> int:
+ pass
diff --git a/2024/tests/samples/21.txt b/2024/tests/samples/21.txt
new file mode 100644
index 0000000..4cf0c29
--- /dev/null
+++ b/2024/tests/samples/21.txt
@@ -0,0 +1,5 @@
+029A
+980A
+179A
+456A
+379A
diff --git a/2024/tests/test_day21.py b/2024/tests/test_day21.py
new file mode 100644
index 0000000..eaddd02
--- /dev/null
+++ b/2024/tests/test_day21.py
@@ -0,0 +1,51 @@
+import pytest
+
+from aoc.days.day21 import (
+ DayRunner,
+ encode_shortest_dirpad,
+ encode_shortest_numpad,
+)
+
+from . import get_data
+
+
+def test_encode_shortest_numpad() -> None:
+ assert encode_shortest_numpad("029A") in (
+ "^^AvvvA",
+ "^AvvvA",
+ "AvvvA",
+ )
+
+
+def test_encode_shortest_dirpad() -> None:
+ numpad_encoded = encode_shortest_numpad("029A")
+ assert len(encode_shortest_dirpad(numpad_encoded)) == len(
+ "v<>^AAvA<^AA>A^A"
+ )
+
+
+@pytest.mark.parametrize(
+ "code,answer",
+ [
+ (
+ "029A",
+ ">^AvAA<^A>A>^AvA^A^A^A>AAvA^AA>^AAAvA<^A>A",
+ ),
+ ("980A", ">^AAAvA^A>^AvAA<^A>AA>^AAAvA<^A>A^AA"),
+ (
+ "179A",
+ ">^A>^AAvAA<^A>A>^AAvA^A^AAAA>^AAAvA<^A>A",
+ ),
+ ("456A", ">^AA>^AAvAA<^A>A^AA^AAA>^AAvA<^A>A"),
+ ("379A", ">^AvA^A>^AAvA<^A>AAvA^A^AAAA>^AAAvA<^A>A"),
+ ],
+)
+def test_encode_shortest_dirpad_twice(code: str, answer: str) -> None:
+ numpad_encoded = encode_shortest_numpad(code)
+ robot1 = encode_shortest_dirpad(numpad_encoded)
+ robot2 = encode_shortest_dirpad(robot1)
+ assert len(robot2) == len(answer)
+
+
+def test_sample_part1() -> None:
+ assert DayRunner.part1(get_data(21)) == 126384