From e982821c93cbb25b1e21653276f5e33bdab0a7e2 Mon Sep 17 00:00:00 2001
From: Christopher Bohn <bohn@unl.edu>
Date: Sat, 11 Dec 2021 20:29:17 -0600
Subject: [PATCH] Started 2021 Advent of Code

---
 .gitignore                                    | 145 ++++++++
 2020/A201901.py                               |  52 +++
 2020/A201902.py                               |  48 +++
 2020/A201903.py                               | 132 ++++++++
 2020/A201904.py                               |  44 +++
 2020/A201905.py                               |  37 ++
 2020/A201906.py                               |  63 ++++
 2020/A201907.py                               |  76 +++++
 2020/A2019_guidance_computer.py               |  67 ++++
 2020/Day10.py                                 |  90 +++++
 2020/Day10a.py                                |  36 ++
 2020/Day11.py                                 | 233 +++++++++++++
 2020/Day12.py                                 | 113 +++++++
 2020/Day13.py                                 |  60 ++++
 2020/Day13a.py                                |  76 +++++
 2020/Day14.py                                 | 123 +++++++
 2020/Day15.py                                 |  65 ++++
 2020/Day16.py                                 | 165 +++++++++
 2020/Day17.py                                 | 273 +++++++++++++++
 2020/Day18.py                                 | 320 ++++++++++++++++++
 2020/Day18cheat.py                            |  90 +++++
 2020/Day19.py                                 | 107 ++++++
 2020/Day20.py                                 | 184 ++++++++++
 2020/Day20SampleData.txt                      | 107 ++++++
 2020/Day4.py                                  |  95 ++++++
 2020/Day5.py                                  |  49 +++
 2020/Day6.py                                  |  53 +++
 2020/Day7.py                                  | 113 +++++++
 2020/Day8.py                                  |  88 +++++
 2020/Day9.py                                  | 105 ++++++
 2020/DayXX                                    |  30 ++
 2020/ImportData.TEMPLATE                      |  18 +
 2021/pom.xml                                  |  25 ++
 .../java/edu/unl/cse/bohn/ImportData.java     |  91 +++++
 2021/src/main/java/edu/unl/cse/bohn/Main.java |  49 +++
 .../main/java/edu/unl/cse/bohn/Puzzle.java    |  25 ++
 .../java/edu/unl/cse/bohn/year2021/Day1.java  |  56 +++
 2021/src/main/resources/apikeys.TEMPLATE      |   3 +
 README.md                                     |   5 +-
 39 files changed, 3510 insertions(+), 1 deletion(-)
 create mode 100644 .gitignore
 create mode 100644 2020/A201901.py
 create mode 100644 2020/A201902.py
 create mode 100644 2020/A201903.py
 create mode 100644 2020/A201904.py
 create mode 100644 2020/A201905.py
 create mode 100644 2020/A201906.py
 create mode 100644 2020/A201907.py
 create mode 100644 2020/A2019_guidance_computer.py
 create mode 100644 2020/Day10.py
 create mode 100644 2020/Day10a.py
 create mode 100644 2020/Day11.py
 create mode 100644 2020/Day12.py
 create mode 100644 2020/Day13.py
 create mode 100644 2020/Day13a.py
 create mode 100644 2020/Day14.py
 create mode 100644 2020/Day15.py
 create mode 100644 2020/Day16.py
 create mode 100644 2020/Day17.py
 create mode 100644 2020/Day18.py
 create mode 100644 2020/Day18cheat.py
 create mode 100644 2020/Day19.py
 create mode 100644 2020/Day20.py
 create mode 100644 2020/Day20SampleData.txt
 create mode 100644 2020/Day4.py
 create mode 100644 2020/Day5.py
 create mode 100644 2020/Day6.py
 create mode 100644 2020/Day7.py
 create mode 100644 2020/Day8.py
 create mode 100644 2020/Day9.py
 create mode 100644 2020/DayXX
 create mode 100644 2020/ImportData.TEMPLATE
 create mode 100644 2021/pom.xml
 create mode 100644 2021/src/main/java/edu/unl/cse/bohn/ImportData.java
 create mode 100644 2021/src/main/java/edu/unl/cse/bohn/Main.java
 create mode 100644 2021/src/main/java/edu/unl/cse/bohn/Puzzle.java
 create mode 100644 2021/src/main/java/edu/unl/cse/bohn/year2021/Day1.java
 create mode 100644 2021/src/main/resources/apikeys.TEMPLATE

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5e0864d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,145 @@
+# Project-specific
+ImportData.py
+apikeys.json
+
+# Mac file finder metadata
+.DS_Store
+# Windows file metadata
+._*
+# Thumbnail image caches
+Thumbs.db
+ethumbs.db
+# MS Office temporary file
+~*
+# Emacs backup file
+*~
+
+# Common
+[Bb]in/
+[Bb]uild/
+[Oo]bj/
+[Oo]ut/
+[Tt]mp/
+[Xx]86/
+[Ii][Aa]32/
+[Xx]64/
+[Xx]86_64/
+[Xx]86-64/
+[Aa]rm
+[Aa]32
+[Tt]32
+[Aa]64
+[Aa]rch32
+[Aa]rch64
+*.tmp
+*.bak
+*.bk
+*.swp
+
+# Miscellaneous
+*.gcno
+
+# C files
+*.o
+*.out
+
+# Java files
+*.class
+javadoc/
+
+# Maven
+target/
+pom.xml.tag
+pom.xml.releaseBackup
+pom.xml.versionsBackup
+pom.xml.next
+release.properties
+dependency-reduced-pom.xml
+buildNumber.properties
+.mvn/timing.properties
+
+# Python files
+*.pyc
+*.pyo
+__pycache__/
+
+# Swift files
+*.hmap
+*.ipa
+*.dSYM.zip
+*.dSYM
+
+# JetBrains (IntelliJ IDEA, PyCharm, etc) files
+.idea/
+cmake-build-*/
+*.iml
+*.iws
+*.ipr
+venv/
+
+# Eclipse files
+.settings/
+.project
+.classpath
+.buildpath
+.loadpath
+.factorypath
+local.properties
+
+# Visual Studio / VS Code files
+.vs*/
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+ipch/
+*.aps
+*.ncb
+*.opensdf
+*.sdf
+*.cachefile
+*.psess
+*.vsp
+*.vspx
+
+# Netbeans files
+nbproject/private/
+build/
+nbbuild/
+dist/
+nbdist/
+nbactions.xml
+nb-configuration.xml
+
+# Xcode files
+*.xcodeproj/
+xcuserdata/
+.build/
+
diff --git a/2020/A201901.py b/2020/A201901.py
new file mode 100644
index 0000000..3631376
--- /dev/null
+++ b/2020/A201901.py
@@ -0,0 +1,52 @@
+from typing import List
+
+from ImportData import import_data
+
+day: int = 1
+
+sample_data: List[str] = [
+    '100756'
+]
+
+data_structure: type = List[int]
+
+
+def parse_data(data: List[str]) -> data_structure:
+    return list(map(lambda m: int(m), data))
+
+
+def compute_required_fuel(module_mass: int) -> int:
+    return module_mass // 3 - 2
+
+
+def part1(data: data_structure) -> int:
+    return sum(map(lambda m: compute_required_fuel(m), data))
+
+
+def part2(data: data_structure) -> int:
+    next_fuel_demand: int = part1(data)
+    total_fuel_demand: int = 0
+    while next_fuel_demand > 0:
+        total_fuel_demand += next_fuel_demand
+        next_fuel_demand = compute_required_fuel(next_fuel_demand)
+    return total_fuel_demand
+
+
+def adjusted_compute_required_fuel(module_mass: int) -> int:
+    next_fuel_demand: int = compute_required_fuel(module_mass)
+    total_fuel_demand: int = 0
+    while next_fuel_demand > 0:
+        total_fuel_demand += next_fuel_demand
+        next_fuel_demand = compute_required_fuel(next_fuel_demand)
+    return total_fuel_demand
+
+
+def part2a(data:data_structure) -> int:
+    return sum(map(lambda m: adjusted_compute_required_fuel(m), data))
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day, 2019) if production_ready else sample_data
+    print(part1(parse_data(raw_data)))
+    print(part2a(parse_data(raw_data)))
diff --git a/2020/A201902.py b/2020/A201902.py
new file mode 100644
index 0000000..f1ab8cd
--- /dev/null
+++ b/2020/A201902.py
@@ -0,0 +1,48 @@
+from typing import List
+
+from A2019_guidance_computer import compute
+from ImportData import import_data
+
+day: int = 2
+
+sample_data: List[str] = [
+    '1,9,10,3,2,3,11,0,99,30,40,50'
+]
+
+data_structure: type = List[int]
+
+
+def parse_data(data: List[str]) -> data_structure:
+    return list(map(lambda x: int(x), data[0].split(',')))
+
+
+def part1(data: data_structure) -> int:
+    memory: List[int] = data.copy()
+    memory[1] = 12
+    memory[2] = 2
+    memory, _ = compute(memory)
+    return memory[0]
+
+
+def part2(data: data_structure) -> int:
+    running: bool = True
+    noun: int = -1
+    verb: int = -1
+    while running and noun < 99:
+        noun += 1
+        verb = -1
+        while running and verb < 99:
+            verb += 1
+            memory: List[int] = data.copy()
+            memory[1] = noun
+            memory[2] = verb
+            memory, _ = compute(memory, [])
+            running = memory[0] != 19690720
+    return 100 * noun + verb
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day, 2019) if production_ready else sample_data
+    print(part1(parse_data(raw_data)))
+    print(part2(parse_data(raw_data)))
diff --git a/2020/A201903.py b/2020/A201903.py
new file mode 100644
index 0000000..dfdf308
--- /dev/null
+++ b/2020/A201903.py
@@ -0,0 +1,132 @@
+from typing import List, Tuple, Set, Dict
+
+from ImportData import import_data
+
+day: int = 3
+
+sample_data1: List[str] = [
+    'R8,U5,L5,D3',
+    'U7,R6,D4,L4'
+]
+
+sample_data: List[str] = [
+    'R75,D30,R83,U83,L12,D49,R71,U7,L72',
+    'U62,R66,U55,R34,D71,R55,D58,R83'
+]
+
+data_structure: type = Tuple[List[Tuple[str, int]], List[Tuple[str, int]]]
+
+
+def parse_data(data: List[str]) -> data_structure:
+    raw_first_wire: List[str] = data[0].split(',')
+    raw_second_wire: List[str] = data[1].split(',')
+    first_wire: List[Tuple[str, int]] = []
+    second_wire: List[Tuple[str, int]] = []
+    for path in raw_first_wire:
+        first_wire.append((path[0], int(path[1:])))
+    for path in raw_second_wire:
+        second_wire.append((path[0], int(path[1:])))
+    return first_wire, second_wire
+
+
+ordered_pair: type = Tuple[int, int]
+
+
+def trace_path1(initial_position: ordered_pair, direction, distance) -> Tuple[Set[ordered_pair], ordered_pair]:
+    points: Set[ordered_pair] = {initial_position}
+    current_position: ordered_pair = (initial_position[0], initial_position[1])
+    distance_travelled = 0
+    while distance_travelled < distance:
+        distance_travelled += 1
+        if direction == 'U':
+            current_position = (current_position[0], current_position[1] + 1)
+        elif direction == 'D':
+            current_position = (current_position[0], current_position[1] - 1)
+        elif direction == 'R':
+            current_position = (current_position[0] + 1, current_position[1])
+        elif direction == 'L':
+            current_position = (current_position[0] - 1, current_position[1])
+        else:
+            raise RuntimeError(f'Unexpected direction: {direction}')
+        points.add(current_position)
+    return points, current_position
+
+
+def part1(data: data_structure) -> int:
+    current_position: ordered_pair = (0, 0)
+    first_wire_positions: Set[ordered_pair] = {current_position}
+    second_wire_positions: Set[ordered_pair] = {current_position}
+    for path in data[0]:
+        new_positions, current_position = trace_path1(current_position, path[0], path[1])
+        first_wire_positions = first_wire_positions.union(new_positions)
+    current_position = (0, 0)
+    for path in data[1]:
+        new_positions, current_position = trace_path1(current_position, path[0], path[1])
+        second_wire_positions = second_wire_positions.union(new_positions)
+    intersections: List[ordered_pair] = sorted(first_wire_positions.intersection(second_wire_positions),
+                                               key=lambda point: abs(point[0]) + abs(point[1]))
+    return abs(intersections[1][0]) + abs(intersections[1][1])
+
+
+modified_ordered_pair: type = Tuple[int, int, int]
+
+
+def trace_path2(initial_position: modified_ordered_pair, direction: str, distance: int) -> List[modified_ordered_pair]:
+    points: List[modified_ordered_pair] = []  # here we'll assume the initial position is already in the master list
+    current_position: modified_ordered_pair = (initial_position[0], initial_position[1], initial_position[2])
+    distance_traveled = 0
+    while distance_traveled < distance:
+        distance_traveled += 1
+        if direction == 'U':
+            current_position = (current_position[0], current_position[1] + 1, current_position[2] + 1)
+        elif direction == 'D':
+            current_position = (current_position[0], current_position[1] - 1, current_position[2] + 1)
+        elif direction == 'R':
+            current_position = (current_position[0] + 1, current_position[1], current_position[2] + 1)
+        elif direction == 'L':
+            current_position = (current_position[0] - 1, current_position[1], current_position[2] + 1)
+        else:
+            raise RuntimeError(f'Unexpected direction: {direction}')
+        points.append(current_position)
+    return points
+
+
+def part2(data: data_structure) -> int:
+    current_position: modified_ordered_pair = (0, 0, 0)
+    first_wire_positions: List[modified_ordered_pair] = []  # intentionally leaving central port off list
+    second_wire_positions: List[modified_ordered_pair] = []  # (initially)
+    for path in data[0]:
+        new_positions: List[modified_ordered_pair] = trace_path2(current_position, path[0], path[1])
+        current_position = new_positions[-1]
+        first_wire_positions.extend(new_positions)
+    current_position = (0, 0, 0)
+    for path in data[1]:
+        new_positions: List[modified_ordered_pair] = trace_path2(current_position, path[0], path[1])
+        current_position = new_positions[-1]
+        second_wire_positions.extend(new_positions)
+    first_wire_map: Dict[ordered_pair, int] = {}
+    second_wire_map: Dict[ordered_pair, int] = {}
+    for point in first_wire_positions:
+        coordinates: ordered_pair = (point[0], point[1])
+        distance = point[2]
+        if coordinates not in first_wire_map or distance < first_wire_map[coordinates]:
+            first_wire_map[coordinates] = distance
+    for point in second_wire_positions:
+        coordinates: ordered_pair = (point[0], point[1])
+        distance = point[2]
+        if coordinates not in second_wire_map or distance < second_wire_map[coordinates]:
+            second_wire_map[coordinates] = distance
+    intersections: Set[ordered_pair] = set(first_wire_map.keys()).intersection(second_wire_map.keys())
+    minimum_distance: int = 2 ** 30
+    for point in intersections:
+        combined_distance: int = first_wire_map[point] + second_wire_map[point]
+        if combined_distance < minimum_distance:
+            minimum_distance = combined_distance
+    return minimum_distance
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day, 2019) if production_ready else sample_data
+    print(part1(parse_data(raw_data)))
+    print(part2(parse_data(raw_data)))
diff --git a/2020/A201904.py b/2020/A201904.py
new file mode 100644
index 0000000..d88cce5
--- /dev/null
+++ b/2020/A201904.py
@@ -0,0 +1,44 @@
+from typing import List, Tuple, Set
+
+data_structure: type = Tuple[int, int]
+
+
+def parse_data(data: List[str]) -> data_structure:
+    bounds: List[str] = data[0].split('-')
+    return int(bounds[0]), int(bounds[1])
+
+
+def meets_criteria(value: int, bounds: Tuple[int, int], extra_constraint: bool = False) -> bool:
+    satisfies: bool = 100000 <= value <= 999999  # 6-digit
+    satisfies &= bounds[0] <= value <= bounds[1]  # within range
+    digits: List[int] = [10]
+    working_number: int = value
+    has_matching_adjacent_digits: bool = False
+    for i in range(6):
+        digits.append(working_number % 10)
+        working_number = working_number // 10
+        satisfies &= digits[i + 1] <= digits[i]  # non-strictly monotonically increasing digits from left-to-right
+        has_matching_adjacent_digits |= digits[i + 1] == digits[i]
+    satisfies &= has_matching_adjacent_digits  # has at least one pair of identical digits
+    if extra_constraint and satisfies:
+        unique_digits: Set[int] = set(digits)
+        has_pair_of_matching_digits: bool = False
+        for digit in unique_digits:
+            has_pair_of_matching_digits |= digits.count(digit) == 2
+        satisfies &= has_pair_of_matching_digits
+    return satisfies
+
+
+def part1(data: data_structure) -> int:
+    return len([password for password in range(data[0], data[1] + 1) if meets_criteria(password, (data[0], data[1]))])
+
+
+def part2(data: data_structure) -> int:
+    return len([password for password in range(data[0], data[1] + 1)
+                if meets_criteria(password, (data[0], data[1]), True)])
+
+
+if __name__ == '__main__':
+    raw_data = ['178416-676461']
+    print(part1(parse_data(raw_data)))
+    print(part2(parse_data(raw_data)))
diff --git a/2020/A201905.py b/2020/A201905.py
new file mode 100644
index 0000000..82c5453
--- /dev/null
+++ b/2020/A201905.py
@@ -0,0 +1,37 @@
+from typing import List
+
+from A2019_guidance_computer import compute
+from ImportData import import_data
+
+day: int = 5
+
+sample_data: List[str] = [
+
+]
+
+data_structure: type = List[int]
+
+
+def parse_data(data: List[str]) -> data_structure:
+    return list(map(lambda x: int(x), data[0].split(',')))
+
+
+def part1(data: data_structure) -> int:
+    memory: List[int] = data.copy()
+    inputs: List[int] = [1]
+    memory, outputs = compute(memory, inputs)
+    return outputs[-1]
+
+
+def part2(data: data_structure) -> int:
+    memory: List[int] = data.copy()
+    inputs: List[int] = [5]
+    memory, outputs = compute(memory, inputs)
+    return outputs[-1]
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day, 2019) if production_ready else sample_data
+    print(part1(parse_data(raw_data)))
+    print(part2(parse_data(raw_data)))
diff --git a/2020/A201906.py b/2020/A201906.py
new file mode 100644
index 0000000..89b9e32
--- /dev/null
+++ b/2020/A201906.py
@@ -0,0 +1,63 @@
+from typing import List, Dict, Optional, Set
+
+from ImportData import import_data
+
+day: int = 6
+
+sample_data: List[str] = [
+    'COM)B',
+    'B)C',
+    'C)D',
+    'D)E',
+    'E)F',
+    'B)G',
+    'G)H',
+    'D)I',
+    'E)J',
+    'J)K',
+    'K)L',
+]
+
+data_structure: type = Dict[str, Optional[str]]  # object's name, parent object's name
+
+
+def parse_data(data: List[str]) -> data_structure:
+    orbit_map = {'COM': None}
+    for orbit in data:
+        bodies = orbit.split(')')
+        orbit_map[bodies[1]] = bodies[0]
+    return orbit_map
+
+
+def part1(data: data_structure) -> int:
+    orbit_count: int = 0
+    for body in data:
+        parent_body = data[body]
+        while parent_body is not None:
+            orbit_count += 1
+            parent_body = data[parent_body]
+    return orbit_count
+
+
+def part2(data: data_structure) -> int:
+    my_parent_bodies: List[str] = [data['YOU']]
+    santa_parent_bodies: List[str] = [data['SAN']]
+    parent_body = data[my_parent_bodies[0]]
+    while parent_body is not None:
+        my_parent_bodies.append(parent_body)
+        parent_body = data[parent_body]
+    parent_body = data[santa_parent_bodies[0]]
+    while parent_body is not None:
+        santa_parent_bodies.append(parent_body)
+        parent_body = data[parent_body]
+    common_parents: Set[str] = set(my_parent_bodies).intersection(santa_parent_bodies)
+    return min(map(lambda body: my_parent_bodies.index(body) + santa_parent_bodies.index(body), common_parents))
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day, 2019) if production_ready else sample_data
+    print(part1(parse_data(raw_data)))
+    if not production_ready:
+        raw_data.extend(['K)YOU','I)SAN'])
+    print(part2(parse_data(raw_data)))
diff --git a/2020/A201907.py b/2020/A201907.py
new file mode 100644
index 0000000..84fac26
--- /dev/null
+++ b/2020/A201907.py
@@ -0,0 +1,76 @@
+from itertools import permutations
+from typing import List, Tuple, Iterator
+
+from A2019_guidance_computer import compute
+from ImportData import import_data
+
+day: int = 7
+
+sample_data1: List[str] = ['3,15,3,16,1002,16,10,16,1,16,15,15,4,15,99,0,0']
+sample_data2: List[str] = ['3,23,3,24,1002,24,10,24,1002,23,-1,23,101,5,23,23,1,24,23,23,4,23,99,0,0']
+sample_data3: List[str] = [
+    '3,31,3,32,1002,32,10,32,1001,31,-2,31,1007,31,0,33,1002,33,7,33,1,33,31,31,1,32,31,31,4,31,99,0,0,0'
+]
+sample_data4: List[str] = [
+    '3,26,1001,26,-4,26,3,27,1002,27,2,27,1,27,26,27,4,27,1001,28,-1,28,1005,28,6,99,0,0,5'
+]
+sample_data5: List[str] = [
+    '3,52,1001,52,-5,52,3,53,1,52,56,54,1007,54,5,55,1005,55,26,1001,54,-5,54,1105,1,12,1,53,54,53,1008,54,0,55,1001,55,1,55,2,53,55,53,4,53,1001,56,-1,56,1005,56,6,99,0,0,0,0,10'
+]
+
+data_structure: type = List[int]
+
+
+def parse_data(data: List[str]) -> data_structure:
+    return list(map(lambda x: int(x), data[0].split(',')))
+
+
+def part1(data: data_structure) -> int:
+    phase_permutations: Iterator[Tuple[int, ...]] = permutations(range(5))
+    final_output: int = -1
+    # noinspection PyUnusedLocal
+    outputs: List[int] = []
+    for phases in phase_permutations:
+        # print(phases, end='--<')
+        amp_input: int = 0
+        for i in range(5):
+            # print(amp_input, end=',')
+            memory: List[int] = data.copy()
+            _, outputs = compute(memory, [phases[i], amp_input])
+            amp_input = outputs[-1]
+        # print(f'{amp_input}>')
+        final_output = max(final_output, outputs[-1])
+    return final_output
+
+
+def part2(data: data_structure) -> int:
+    phase_permutations: Iterator[Tuple[int, ...]] = permutations(range(5, 10))
+    # phase_permutations = [(9, 8, 7, 6, 5)]
+    final_output: int = -1
+    # noinspection PyUnusedLocal
+    outputs: List[int] = []
+    for phases in phase_permutations:
+        memories = [data.copy() for _ in range(5)]
+        running: bool = True
+        amp_input: int = 0
+        i: int = 0
+        while running:
+            memories[i], outputs = compute(memories[i], [phases[i], amp_input])
+            amp_input = outputs[-1]
+            if i == 4:
+                print(f'{phases}: {amp_input}')
+            if memories[i][-2:] != [2 ** 30, 99] and i == 4:
+                running = False
+            else:
+                i = (i + 1) % 5
+        final_output = max(final_output, outputs[-1])
+    return final_output
+
+
+if __name__ == '__main__':
+    production_ready = False
+    raw_data = import_data(day, 2019) if production_ready else sample_data3
+    print(part1(parse_data(raw_data)))
+    if not production_ready:
+        raw_data = sample_data5
+    print(part2(parse_data(raw_data)))
diff --git a/2020/A2019_guidance_computer.py b/2020/A2019_guidance_computer.py
new file mode 100644
index 0000000..bbe10bc
--- /dev/null
+++ b/2020/A2019_guidance_computer.py
@@ -0,0 +1,67 @@
+from typing import Tuple, List, Optional
+
+maximum_number_of_parameters: int = 3
+
+
+def execute_instruction(instruction_pointer: int, opcode: int, parameters: List[int], inputs: List[int]) \
+        -> Tuple[int, Optional[int], Optional[int]]:  # new instruction pointer, value, output
+    if opcode == 1:  # addition
+        return instruction_pointer + 4, parameters[0] + parameters[1], None
+    elif opcode == 2:  # multiplication
+        return instruction_pointer + 4, parameters[0] * parameters[1], None
+    elif opcode == 3:  # input
+        if len(inputs) > 0:
+            return instruction_pointer + 2, inputs.pop(0), None
+        else:
+            # cue context switch
+            return -1, instruction_pointer, None
+    elif opcode == 4:  # output
+        return instruction_pointer + 2, None, parameters[0]
+    elif opcode == 5:  # jump-if-true
+        new_instruction_pointer: int = parameters[1] if parameters[0] != 0 else instruction_pointer + 3
+        return new_instruction_pointer, None, None
+    elif opcode == 6:  # jump-if-false
+        new_instruction_pointer: int = parameters[1] if parameters[0] == 0 else instruction_pointer + 3
+        return new_instruction_pointer, None, None
+    elif opcode == 7:  # less-than
+        value: int = 1 if parameters[0] < parameters[1] else 0
+        return instruction_pointer + 4, value, None
+    elif opcode == 8:  # equals
+        value: int = 1 if parameters[0] == parameters[1] else 0
+        return instruction_pointer + 4, value, None
+    else:
+        raise RuntimeError(f'Illegal instruction: {opcode} {parameters}')
+
+
+def compute(memory: List[int], inputs: List[int] = ()) -> Tuple[List[int], List[int]]:  # memory, output
+    instruction_pointer: int = 0
+    value: int = 2 ** 30
+    if memory[-2:] == [value, 99]:  # restore from context switch
+        instruction_pointer = memory[-3]
+        memory = memory[:-3]
+    # noinspection PyUnusedLocal
+    output: Optional[int] = None
+    outputs: List[int] = []
+    while memory[instruction_pointer] != 99:
+        opcode: int = memory[instruction_pointer] % 100
+        modes: List[int] = list(map(lambda m: int(m), reversed(str(memory[instruction_pointer] // 100)
+                                                               .zfill(maximum_number_of_parameters))))
+        raw_parameters: List[int] = memory[instruction_pointer + 1: instruction_pointer + maximum_number_of_parameters]
+        parameters: List[int] = [-1 for _ in range(maximum_number_of_parameters - 1)]
+        for i in range(maximum_number_of_parameters - 1):
+            if modes[i] == 0:  # position mode
+                try:
+                    parameters[i] = memory[raw_parameters[i]]
+                except IndexError:  # might happen for instructions with fewer parameters than the maximum
+                    pass
+            if modes[i] == 1:  # immediate mode
+                parameters[i] = raw_parameters[i]
+        instruction_pointer, value, output = execute_instruction(instruction_pointer, opcode, parameters, inputs)
+        if instruction_pointer == -1:  # context switch
+            memory = memory + [value, 2 ** 30, 99]
+        elif value is not None:
+            target: int = memory[instruction_pointer - 1]
+            memory[target] = value
+        if output is not None:
+            outputs.append(output)
+    return memory, outputs
diff --git a/2020/Day10.py b/2020/Day10.py
new file mode 100644
index 0000000..86bc979
--- /dev/null
+++ b/2020/Day10.py
@@ -0,0 +1,90 @@
+from typing import List, Tuple
+
+from ImportData import import_data
+
+day: int = 10
+
+sample_data: List[str] = [
+    '28',
+    '33',
+    '18',
+    '42',
+    '31',
+    '14',
+    '46',
+    '20',
+    '48',
+    '47',
+    '24',
+    '23',
+    '49',
+    '45',
+    '19',
+    '38',
+    '39',
+    '11',
+    '1',
+    '32',
+    '25',
+    '35',
+    '8',
+    '17',
+    '7',
+    '9',
+    '4',
+    '2',
+    '34',
+    '10',
+    '3',
+]
+
+data_structure: type = List[int]
+
+
+def parse_data(data: List[str]) -> data_structure:
+    jolt_list = list(map(lambda j: int(j), data))
+    jolt_list.sort()
+    jolt_list.insert(0, 0)
+    jolt_list.append(jolt_list[-1] + 3)
+    return jolt_list
+
+
+def count_differences(data: List[int]) -> Tuple[int]:
+    differences: List[int] = [0, 0, 0, 0]
+    for i in range(len(data) - 1):
+        difference: int = data[i + 1] - data[i]
+        if difference == 0 or difference > 3:
+            raise ValueError(f'Difference between {data[i]} and {data[i + 1]} is not in-range.')
+        differences[difference] += 1
+    return tuple(differences)
+
+
+def part1(data: data_structure) -> int:
+    differences: Tuple[int] = count_differences(data)
+    return differences[1] * differences[3]
+
+
+def part2(data: data_structure) -> int:
+    extended_data: List[int] = data.copy()
+    too_great = data[-1] + 27  # 3^3 -- clearly too great
+    extended_data.extend([too_great, too_great, too_great])
+    counts: List[int] = [0] * len(data)
+    bsf_queue: List[int] = [0]
+    while len(bsf_queue) > 0:
+        node: int = bsf_queue.pop(0)
+        print(f'popped: {node}({extended_data[node]})\tqueue length: {len(bsf_queue)}', end='')
+        counts[node] += 1
+        for i in range(1, 4):
+            if 0 < extended_data[node + i] - extended_data[node] <= 3:
+                bsf_queue.append(node + i)
+                print(f'\tadding: {node+i}({extended_data[node+i]})', end='')
+        print('',flush=True)
+    return counts[-1]
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day) if production_ready else sample_data
+    parsed_data = parse_data(raw_data)
+    print(part1(parsed_data))
+    print(part2(parsed_data))
diff --git a/2020/Day10a.py b/2020/Day10a.py
new file mode 100644
index 0000000..12be87a
--- /dev/null
+++ b/2020/Day10a.py
@@ -0,0 +1,36 @@
+from typing import List, Tuple
+
+import Day10
+from ImportData import import_data
+
+day: int = Day10.day
+
+sample_data: List[str] = Day10.sample_data
+
+data_structure: type = Day10.data_structure
+
+
+def parse_data(data: List[str]) -> data_structure:
+    return Day10.parse_data(data)
+
+
+def part1(data: data_structure) -> int:
+    return Day10.part1(data)
+
+
+def part2(data: data_structure) -> int:
+    ways_to_get_here: List[int] = [0] * len(data)
+    ways_to_get_here[0] = 1
+    for i in range(1, len(data)):
+        for j in range(1, min(i, 3) + 1):
+            if 0 < data[i] - data[i - j] <= 3:
+                ways_to_get_here[i] += ways_to_get_here[i - j]
+    return ways_to_get_here[-1]
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day) if production_ready else sample_data
+    parsed_data = parse_data(raw_data)
+    print(part1(parsed_data))
+    print(part2(parsed_data))
diff --git a/2020/Day11.py b/2020/Day11.py
new file mode 100644
index 0000000..67edade
--- /dev/null
+++ b/2020/Day11.py
@@ -0,0 +1,233 @@
+from typing import List, Tuple, Callable
+
+from ImportData import import_data
+
+day: int = 11
+
+# noinspection SpellCheckingInspection
+sample_data: List[str] = [
+    'L.LL.LL.LL',
+    'LLLLLLL.LL',
+    'L.L.L..L..',
+    'LLLL.LL.LL',
+    'L.LL.LL.LL',
+    'L.LLLLL.LL',
+    '..L.L.....',
+    'LLLLLLLLLL',
+    'L.LLLLLL.L',
+    'L.LLLLL.LL'
+]
+
+data_structure: type = List[List["Position"]]
+
+
+class Position:
+    def __init__(self, encoding: str):
+        self.is_floor: bool = encoding == '.'
+        self.is_occupied: bool = encoding == '#'
+        self.next_occupied: bool = False
+        self.has_changed_state: bool = True
+
+    def stage_update(self, neighbors: Tuple["Position"], maximum_allowable_neighbors) -> None:
+        if not self.is_occupied and all(not neighbor.is_occupied for neighbor in neighbors):
+            self.next_occupied = True
+        if self.is_occupied and \
+                sum(map(lambda neighbor: 1 if neighbor.is_occupied else 0, neighbors)) >= maximum_allowable_neighbors:
+            self.next_occupied = False
+        if self.is_floor:
+            self.next_occupied = False
+
+    def commit_update(self) -> None:
+        self.has_changed_state = self.is_occupied != self.next_occupied
+        self.is_occupied = self.next_occupied
+
+    def __repr__(self) -> str:
+        if self.is_floor:
+            return '.'
+        return '#' if self.is_occupied else 'L'
+
+
+def parse_data(data: List[str]) -> data_structure:
+    seating_chart: List[List[Position]] = []
+    for line in data:
+        row: List[Position] = list(map(lambda c: Position(c), line))
+        seating_chart.append(row)
+    return seating_chart
+
+
+def check_for_changes(positions: List[List[Position]]) -> bool:
+    changes_exist: bool = False
+    for row in positions:
+        changes_exist |= any(seat.has_changed_state for seat in row)
+    return changes_exist
+
+
+def count_occupied_seats(positions: List[List[Position]]) -> int:
+    occupied_seats: int = 0
+    for row in positions:
+        occupied_seats += sum(map(lambda seat: 1 if seat.is_occupied else 0, row))
+    return occupied_seats
+
+
+def display_seating_chart(positions: List[List[Position]]) -> None:
+    for row in positions:
+        for seat in row:
+            print(seat, end='')
+        print()
+    print()
+
+
+def process_day11(data: data_structure,
+                  neighbor_function: Callable[[data_structure, int, int], Tuple[Position]],
+                  maximum_allowable_neighbors) -> int:
+    # display_seating_chart(data)
+    changes_exist = check_for_changes(data)
+    while changes_exist:
+        for y in range(len(data)):
+            for x in range(len(data[0])):
+                seat = data[y][x]
+                neighbors: Tuple[Position] = neighbor_function(data, y, x)
+                seat.stage_update(neighbors, maximum_allowable_neighbors)
+        for row in data:
+            for seat in row:
+                seat.commit_update()
+        # display_seating_chart(data)
+        changes_exist = check_for_changes(data)
+    return count_occupied_seats(data)
+
+
+def find_part1_neighbors(data: List[List[Position]], y: int, x: int) -> Tuple[Position]:
+    minimum_x: int = 0
+    minimum_y: int = 0
+    maximum_y = len(data) - 1
+    maximum_x = len(data[0]) - 1
+    neighbors: List[Position] = []
+    if x > minimum_x:
+        neighbors.append(data[y][x - 1])
+    if x < maximum_x:
+        neighbors.append(data[y][x + 1])
+    if y > minimum_y:
+        neighbors.append(data[y - 1][x])
+    if y < maximum_y:
+        neighbors.append(data[y + 1][x])
+    if x > minimum_x and y > minimum_y:
+        neighbors.append(data[y - 1][x - 1])
+    if x > minimum_x and y < maximum_y:
+        neighbors.append(data[y + 1][x - 1])
+    if x < maximum_x and y > minimum_y:
+        neighbors.append(data[y - 1][x + 1])
+    if x < maximum_x and y < maximum_y:
+        neighbors.append(data[y + 1][x + 1])
+    return tuple(neighbors)
+
+
+def part1(data: data_structure) -> int:
+    return process_day11(data, find_part1_neighbors, 4)
+
+
+def find_part2_neighbors(data: List[List[Position]], y: int, x: int) -> Tuple[Position]:
+    neighbors: List[Position] = []
+    # right
+    i: int = y
+    j: int = x + 1
+    still_looking: bool = j < len(data[i])
+    while still_looking:
+        if data[i][j].is_floor:
+            j += 1
+            still_looking = j < len(data[i])
+        else:
+            neighbors.append(data[i][j])
+            still_looking = False
+    # left
+    i = y
+    j = x - 1
+    still_looking = j >= 0
+    while still_looking:
+        if data[i][j].is_floor:
+            j -= 1
+            still_looking = j >= 0
+        else:
+            neighbors.append(data[i][j])
+            still_looking = False
+    # down
+    i = y + 1
+    j = x
+    still_looking = i < len(data)
+    while still_looking:
+        if data[i][j].is_floor:
+            i += 1
+            still_looking = i < len(data)
+        else:
+            neighbors.append(data[i][j])
+            still_looking = False
+    # up
+    i = y - 1
+    j = x
+    still_looking = i >= 0
+    while still_looking:
+        if data[i][j].is_floor:
+            i -= 1
+            still_looking = i >= 0
+        else:
+            neighbors.append(data[i][j])
+            still_looking = False
+    # upper-left
+    i = y - 1
+    j = x - 1
+    still_looking = i >= 0 and j >= 0
+    while still_looking:
+        if data[i][j].is_floor:
+            i -= 1
+            j -= 1
+            still_looking = i >= 0 and j >= 0
+        else:
+            neighbors.append(data[i][j])
+            still_looking = False
+    # upper-right
+    i = y - 1
+    j = x + 1
+    still_looking = i >= 0 and j < len(data[i])
+    while still_looking:
+        if data[i][j].is_floor:
+            i -= 1
+            j += 1
+            still_looking = i >= 0 and j < len(data[i])
+        else:
+            neighbors.append(data[i][j])
+            still_looking = False
+    # lower-right
+    i = y + 1
+    j = x + 1
+    still_looking = i < len(data) and j < len(data[i])
+    while still_looking:
+        if data[i][j].is_floor:
+            i += 1
+            j += 1
+            still_looking = i < len(data) and j < len(data[i])
+        else:
+            neighbors.append(data[i][j])
+            still_looking = False
+    # lower-left
+    i = y + 1
+    j = x - 1
+    still_looking = i < len(data) and j >= 0
+    while still_looking:
+        if data[i][j].is_floor:
+            i += 1
+            j -= 1
+            still_looking = i < len(data) and j >= 0
+        else:
+            neighbors.append(data[i][j])
+            still_looking = False
+    return tuple(neighbors)
+
+
+def part2(data: data_structure) -> int:
+    return process_day11(data, find_part2_neighbors, 5)
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day) if production_ready else sample_data
+    print(part1(parse_data(raw_data)))
+    print(part2(parse_data(raw_data)))
diff --git a/2020/Day12.py b/2020/Day12.py
new file mode 100644
index 0000000..547f71a
--- /dev/null
+++ b/2020/Day12.py
@@ -0,0 +1,113 @@
+from typing import List
+
+from ImportData import import_data
+
+day: int = 12
+
+sample_data: List[str] = [
+    'F10',
+    'N3',
+    'F7',
+    'R90',
+    'F11',
+]
+
+data_structure: type = List[str]
+
+
+def parse_data(data: List[str]) -> data_structure:
+    return data
+
+
+def modify_instruction(instruction: str, direction: int) -> str:
+    modified_instruction: str = instruction
+    if modified_instruction[0] == 'F':
+        if direction == 0:
+            modified_instruction = 'N' + modified_instruction[1:]
+        elif direction == 90:
+            modified_instruction = 'E' + modified_instruction[1:]
+        elif direction == 180:
+            modified_instruction = 'S' + modified_instruction[1:]
+        elif direction == 270:
+            modified_instruction = 'W' + modified_instruction[1:]
+        else:
+            raise ValueError(f'Unexpected direction: {direction} for instruction {instruction}')
+    if modified_instruction[0] == 'L':
+        rotation: int = -int(modified_instruction[1:])
+        modified_instruction = 'R' + str(rotation)
+    if modified_instruction[0] == 'S':
+        distance: int = -int(modified_instruction[1:])
+        modified_instruction = 'N' + str(distance)
+    if modified_instruction[0] == 'W':
+        distance: int = -int(modified_instruction[1:])
+        modified_instruction = 'E' + str(distance)
+    return modified_instruction
+
+
+def part1(data: data_structure) -> int:
+    direction: int = 90
+    latitude: int = 0
+    longitude: int = 0
+    for instruction in data:
+        reduced_instruction = modify_instruction(instruction, direction)
+        command: str = reduced_instruction[0]
+        value: int = int(reduced_instruction[1:])
+        if command == 'R':
+            direction = (direction + value) % 360
+        elif command == 'N':
+            latitude += value
+        elif command == 'E':
+            longitude += value
+        else:
+            raise RuntimeError(f'Unexpected command: {instruction} reduced to {reduced_instruction}'
+                               f' with command {command} and value {value}')
+    return abs(longitude) + abs(latitude)
+
+
+def part2(data: data_structure) -> int:
+    latitude: int = 0
+    longitude: int = 0
+    waypoint_north_offset: int = 1
+    waypoint_east_offset: int = 10
+    for instruction in data:
+        command: str = instruction[0]
+        value: int = int(instruction[1:])
+        if command == 'F':
+            latitude += waypoint_north_offset * value
+            longitude += waypoint_east_offset * value
+        elif command == 'N':
+            waypoint_north_offset += value
+        elif command == 'S':
+            waypoint_north_offset -= value
+        elif command == 'E':
+            waypoint_east_offset += value
+        elif command == 'W':
+            waypoint_east_offset -= value
+        elif command == 'R':
+            if value == 90:
+                waypoint_north_offset, waypoint_east_offset = -waypoint_east_offset, waypoint_north_offset
+            elif value == 180:
+                waypoint_north_offset, waypoint_east_offset = -waypoint_north_offset, -waypoint_east_offset
+            elif value == 270:
+                waypoint_north_offset, waypoint_east_offset = waypoint_east_offset, -waypoint_north_offset
+            else:
+                raise ValueError(f'Unexpected direction: {value} for instruction {instruction}')
+        elif command == 'L':
+            if value == 90:
+                waypoint_north_offset, waypoint_east_offset = waypoint_east_offset, -waypoint_north_offset
+            elif value == 180:
+                waypoint_north_offset, waypoint_east_offset = -waypoint_north_offset, -waypoint_east_offset
+            elif value == 270:
+                waypoint_north_offset, waypoint_east_offset = -waypoint_east_offset, waypoint_north_offset
+            else:
+                raise ValueError(f'Unexpected direction: {value} for instruction {instruction}')
+        else:
+            raise RuntimeError(f'Unexpected command: {instruction} with command {command} and value {value}')
+    return abs(longitude) + abs(latitude)
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day) if production_ready else sample_data
+    print(part1(parse_data(raw_data)))
+    print(part2(parse_data(raw_data)))
diff --git a/2020/Day13.py b/2020/Day13.py
new file mode 100644
index 0000000..2c1b0cf
--- /dev/null
+++ b/2020/Day13.py
@@ -0,0 +1,60 @@
+from typing import List, Set, Tuple, Union
+
+from ImportData import import_data
+
+day: int = 13
+
+sample_data: List[str] = [
+    '939',
+    '7,13,x,x,59,x,31,19'
+]
+
+data_structure: type = Tuple[int, List[Union[int, str]]]
+
+
+def parse_data(data: List[str]) -> data_structure:  # It sounds like they're setting up something for 'x', so
+    ready_time: int = int(data[0])  # we're not going to do all the data re-structuring here
+    busses: List[Union[int, str]] = []
+    for bus in data[1].split(','):
+        busses.append(int(bus) if bus.isnumeric() else bus)
+    return ready_time, busses
+
+
+def part1(data: data_structure) -> int:
+    ready_time: int = data[0]
+    busses: Set[Union[int, str]] = set(data[1])
+    if 'x' in busses:
+        busses.remove('x')
+    first_bus: int = -1
+    delay: int = max(busses) + 1
+    for bus in busses:
+        this_delay: int = bus - (ready_time % bus)
+        if this_delay < delay:
+            delay = this_delay
+            first_bus = bus
+    return first_bus * delay
+
+
+def fits_constraint(time: int, busses: List[Union[int, str]]) -> bool:
+    valid: bool = True
+    for i in range(len(busses)):
+        if busses[i] != 'x':
+            valid &= (time + i) % busses[i] == 0
+    return valid
+
+
+def part2(data: data_structure) -> int:
+    busses: List[Union[int, str]] = data[1]
+    multiplier: int = 1
+    time: int = busses[0] * multiplier
+    while not fits_constraint(time, busses):
+        multiplier += 1
+        time: int = busses[0] * multiplier
+    return time
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day) if production_ready else sample_data
+    print(part1(parse_data(raw_data)))
+    print(part2(parse_data(raw_data)))
diff --git a/2020/Day13a.py b/2020/Day13a.py
new file mode 100644
index 0000000..3ebf1fb
--- /dev/null
+++ b/2020/Day13a.py
@@ -0,0 +1,76 @@
+from typing import List, Set, Tuple, Union
+
+from ImportData import import_data
+from Day13 import day, sample_data, data_structure, parse_data, part1, fits_constraint
+
+
+def find_initial_time(period0: Union[int, str], displacement0: int,
+                      period1: Union[int, str], displacement1: int) -> Tuple[int, int]:
+    if period0 == period1 == 'x':
+        mutual_period = 0
+        time = 0
+    elif period0 == 'x':
+        assert displacement1 >= 0
+        mutual_period = period1
+        time = period1 - displacement1
+        while time <= 0:
+            time += period1
+    elif period1 == 'x':
+        assert displacement0 >= 0
+        mutual_period = period0
+        time = period0 - displacement0
+        while time <= 0:
+            time += period0
+    elif period0 == 0:
+        assert displacement1 <= 0
+        mutual_period = period1
+        time = abs(displacement1)
+    elif period1 == 0:
+        assert displacement0 <= 0
+        mutual_period = period0
+        time = abs(displacement0)
+    else:
+        mutual_period = period0 if period0 == period1 else period0 * period1  # mutually prime
+        time = -displacement0 if period0 > period1 else -displacement1
+        period = max(period0, period1)
+        still_looking: bool = True
+        while still_looking:
+            time += period
+            satisfies_0 = (time + displacement0) % period0 == 0
+            satisfies_1 = (time + displacement1) % period1 == 0
+            still_looking = time < 0 or not (satisfies_0 and satisfies_1)
+    return mutual_period, round(time)
+
+
+def divide_and_conquer(busses: List[Union[int, str]], offset: int) -> Tuple[int, int]:
+    if len(busses) == 1:
+        print(f'\t\t\tConquering position {offset}')
+        period, displacement = find_initial_time(busses[0], offset, busses[0], offset)
+    elif len(busses) == 2:
+        print(f'\t\t\tConquering positions {offset}-{offset + 1}')
+        period, displacement = find_initial_time(busses[0], offset, busses[1], offset + 1)
+    else:
+        print(f'Solving positions {offset}..{offset + len(busses) - 1}')
+        halfway = len(busses) // 2
+        print(f'\tDividing positions {offset}..{offset + halfway - 1}')
+        period0, displacement0 = divide_and_conquer(busses[0:halfway], offset)
+        print(f'\tDividing positions {offset + halfway}..{offset + len(busses) - 1}')
+        period1, displacement1 = divide_and_conquer(busses[halfway:], offset + halfway)
+        print(f'\t\tConquering positions {offset}..{offset + len(busses) - 1}')
+        if len(busses) == 72:
+            print(f'\t\t\t\tperiod0 = {period0}, displacement0 = {-displacement0}, '
+                  f'period1 = {period1}, displacement1 = {-displacement1}')
+        period, displacement = find_initial_time(period0, -displacement0, period1, -displacement1)
+    return period, displacement
+
+
+def part2(data: data_structure) -> int:
+    _, time = divide_and_conquer(data[1], 0)
+    return time
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day) if production_ready else sample_data
+    print(part1(parse_data(raw_data)))
+    print(part2(parse_data(raw_data)))
diff --git a/2020/Day14.py b/2020/Day14.py
new file mode 100644
index 0000000..182d10b
--- /dev/null
+++ b/2020/Day14.py
@@ -0,0 +1,123 @@
+from typing import List, Union, Dict, Set
+
+from ImportData import import_data
+
+day: int = 14
+
+sample_data1: List[str] = [
+    'mask = XXXXXXXXXXXXXXXXXXXXXXXXXXXXX1XXXX0X',
+    'mem[8] = 11',
+    'mem[7] = 101',
+    'mem[8] = 0'
+]
+
+sample_data2: List[str] = [
+    'mask = 000000000000000000000000000000X1001X',
+    'mem[42] = 100',
+    'mask = 00000000000000000000000000000000X0XX',
+    'mem[26] = 1',
+]
+
+data_structure: type = List[Dict[str, Union[str, int]]]
+
+
+def parse_data(data: List[str]) -> data_structure:
+    instructions: List[Dict[str, Union[str, int]]] = []
+    # noinspection PyUnusedLocal
+    instruction: Dict[str, Union[str, int]] = {}
+    for line in data:
+        halves: List[str] = line.split(' = ')
+        if halves[0][0:3] == 'mas':
+            instruction = {'opcode': 'mask', 'mask': halves[1]}
+        elif halves[0][0:3] == 'mem':
+            instruction = {'opcode': 'mem', 'address': (int(halves[0][4:-1])), 'value': int(halves[1])}
+        else:
+            instruction = {'opcode': 'nop'}
+        instructions.append(instruction)
+    return instructions
+
+
+def apply_weird_mask(value: int, mask: str) -> int:
+    new_value: int = value
+    word_size: int = len(mask)  # should be 36
+    for i in range(word_size):
+        if mask[-i - 1] == '0':
+            new_value = new_value & ~(1 << i)
+        if mask[-i - 1] == '1':
+            new_value = new_value | (1 << i)
+    return new_value
+
+
+def run_program1(data: data_structure) -> Dict[int, int]:
+    mask: str = 'X' * 36
+    memory: Dict[int, int] = {}  # not using a List to avoid index errors
+    for line in data:
+        if line['opcode'] == 'mask':
+            mask = line['mask']
+        elif line['opcode'] == 'mem':
+            memory[line['address']] = apply_weird_mask(line['value'], mask)
+        else:
+            raise RuntimeError(f'Unexpected instruction: {line}')
+    return memory
+
+
+def part1(data: data_structure) -> int:
+    memory = run_program1(data)
+    values_sum: int = 0
+    for address in memory:
+        values_sum += memory[address]
+    return values_sum
+
+
+def get_matching_addresses(address: int, mask: str) -> Set[int]:
+    wildcard_addresses: Set[str] = set()
+    new_mask: str = ''
+    word_size: int = len(mask)  # should be 36
+    new_address: str = f'{address:036b}'
+    for i in range(word_size):
+        if mask[i] == '0':
+            new_mask = new_mask + new_address[i]
+        else:
+            new_mask = new_mask + mask[i]
+    wildcard_addresses.add(new_mask)
+    for i in range(word_size):
+        new_wildcard_addresses: Set[str] = set()
+        for wildcard_address in wildcard_addresses:
+            if wildcard_address[i] == 'X':
+                new_wildcard_addresses.add(wildcard_address[:i] + '0' + wildcard_address[i+1:])
+                new_wildcard_addresses.add(wildcard_address[:i] + '1' + wildcard_address[i+1:])
+            else:
+                new_wildcard_addresses.add(wildcard_address)
+        wildcard_addresses = new_wildcard_addresses
+    addresses: Set[int] = set(map(lambda a: int(a,2), wildcard_addresses))
+    return addresses
+
+
+def run_program2(data: data_structure) -> Dict[int, int]:
+    word_size = 36
+    mask: str = '0' * word_size
+    memory: Dict[int, int] = {}
+    for line in data:
+        if line['opcode'] == 'mask':
+            mask = line['mask']
+        elif line['opcode'] == 'mem':
+            matching_addresses: Set[int] = get_matching_addresses(line['address'], mask)
+            for address in matching_addresses:
+                memory[address] = line['value']
+        else:
+            raise RuntimeError(f'Unexpected instruction: {line}')
+    return memory
+
+
+def part2(data: data_structure) -> int:
+    memory = run_program2(data)
+    values_sum: int = 0
+    for address in memory:
+        values_sum += memory[address]
+    return values_sum
+
+
+if __name__ == '__main__':
+    production_ready = True
+    print(part1(parse_data(import_data(day) if production_ready else sample_data1)))
+    print(part2(parse_data(import_data(day) if production_ready else sample_data2)))
diff --git a/2020/Day15.py b/2020/Day15.py
new file mode 100644
index 0000000..6ef2df2
--- /dev/null
+++ b/2020/Day15.py
@@ -0,0 +1,65 @@
+from typing import List, Dict
+
+day: int = 15
+
+sample_data: List[List[int]] = [
+    [0, 3, 6],  # 436
+    [1, 3, 2],  # 1
+    [2, 1, 3],  # 10
+    [1, 2, 3],  # 27
+    [2, 3, 1],  # 78
+    [3, 2, 1],  # 438
+    [3, 1, 2]  # 1836
+]
+
+expected_result1: List[int] = [436, 1, 10, 27, 78, 438, 1836]
+expected_result2: List[int] = [175594, 2578, 3544142, 261214, 6895259, 18, 362]
+
+live_data: List[int] = [10, 16, 6, 0, 1, 17]
+
+data_structure: type = List[int]
+
+
+def simple_memory_game(data: data_structure, target_entry: int) -> int:
+    utterances: List[int] = data.copy()
+    utterances.insert(0, -1)
+    while len(utterances) <= target_entry:
+        last_utterance: int = utterances[-1]
+        current_timestamp: int = max(index for index, value in enumerate(utterances) if value == last_utterance)
+        previous_timestamp: int = \
+            max(index for index, value in enumerate(utterances[:-1]) if value == last_utterance) \
+                if utterances.index(last_utterance) < current_timestamp \
+                else current_timestamp
+        next_utterance: int = current_timestamp - previous_timestamp
+        utterances.append(next_utterance)
+    return utterances[target_entry]
+
+
+def memory_game(data: data_structure, target_entry: int) -> int:
+    timestamps: Dict[int, int] = {}     # utterance, timestamp
+    timestamp: int = 0
+    for utterance in data:
+        timestamp += 1
+        timestamps[utterance] = timestamp
+    utterance: int = 0
+    last_utterance: int = data[-1]
+    while timestamp < target_entry:
+        timestamp += 1
+        previous_timestamp: int = timestamps[utterance] if utterance in timestamps else timestamp
+        timestamps[utterance] = timestamp
+        last_utterance = utterance
+        utterance = timestamp - previous_timestamp
+    return last_utterance
+
+
+if __name__ == '__main__':
+    production_ready = True
+    if production_ready:
+        print(memory_game(live_data, 2020))
+        print(memory_game(live_data, 30000000))
+    else:
+        for i in range(len(sample_data)):
+            print(sample_data[i])
+            print(f'\tSimple Part 1: expected {expected_result1[i]}, got {simple_memory_game(sample_data[i], 2020)}')
+            print(f'\tBetter Part 1: expected {expected_result1[i]}, got {memory_game(sample_data[i], 2020)}')
+            print(f'\tBetter Part 2: expected {expected_result2[i]}, got {memory_game(sample_data[i], 30000000)}')
diff --git a/2020/Day16.py b/2020/Day16.py
new file mode 100644
index 0000000..9098ae8
--- /dev/null
+++ b/2020/Day16.py
@@ -0,0 +1,165 @@
+from typing import List, Dict, Tuple, Set
+
+from ImportData import import_data
+
+day: int = 16
+
+sample_data1: List[str] = [
+    'class: 1-3 or 5-7',
+    'row: 6-11 or 33-44',
+    'seat: 13-40 or 45-50',
+    '',
+    'your ticket:',
+    '7,1,14',
+    '',
+    'nearby tickets:',
+    '7,3,47',
+    '40,4,50',
+    '55,2,20',
+    '38,6,12'
+]
+
+sample_data2: List[str] = [
+    'class: 0-1 or 4-19',
+    'row: 0-5 or 8-19',
+    'seat: 0-13 or 16-19',
+    '',
+    'your ticket:',
+    '11,12,13',
+    '',
+    'nearby tickets:',
+    '3,9,18',
+    '15,1,5',
+    '5,14,9',
+]
+
+
+class Ticket:
+    fields: Dict[str, List[Tuple[int, int]]] = {}  # field name, [(lower_bound, upper_bound)]
+
+    def __init__(self, numbers: List[int]):
+        self.numbers = numbers
+
+    @classmethod
+    def add_field(cls, field_data: str) -> None:
+        field_name, range_data = field_data.split(': ')
+        value_ranges: List[str] = range_data.split(' or ')
+        ranges: List[Tuple[int, int]] = []
+        for value_range in value_ranges:
+            lower_bound, upper_bound = value_range.split('-')
+            ranges.append((int(lower_bound), int(upper_bound)))
+        cls.fields[field_name] = ranges
+
+    def is_valid_number(self, number: int, field: str) -> bool:
+        number_is_valid: bool = False
+        if field not in self.fields:
+            return False
+        for lower_bound, upper_bound in self.fields[field]:
+            if lower_bound <= number <= upper_bound:
+                number_is_valid = True
+        return number_is_valid
+
+    def is_valid(self) -> bool:
+        # ticket_is_valid: bool = False
+        # for number in self.numbers:
+        #     for field in self.fields:
+        #         if self.is_valid_number(number, field):
+        #             ticket_is_valid = True
+        # return ticket_is_valid
+        return self.ticket_error_rate() == 0
+
+    def ticket_error_rate(self) -> int:
+        error_rate: int = 0
+        for number in self.numbers:
+            number_is_valid: bool = False
+            for field in self.fields:
+                if self.is_valid_number(number, field):
+                    number_is_valid = True
+            if not number_is_valid:
+                error_rate += number
+        return error_rate
+
+    @staticmethod
+    def error_rate(tickets: List["Ticket"]) -> int:
+        total_error_rate: int = 0
+        for ticket in tickets:
+            total_error_rate += ticket.ticket_error_rate()
+        return total_error_rate
+
+    @classmethod
+    def deduce_fields(cls, tickets: List["Ticket"]) -> Dict[str, int]:  # field name, numbers index
+        valid_tickets: List[Ticket] = []
+        for ticket in tickets:
+            if ticket.is_valid():
+                valid_tickets.append(ticket)
+        assert Ticket.error_rate(valid_tickets) == 0
+        # we now have a list of valid tickets
+        field_names: Set[str] = set(cls.fields.keys())
+        indices: Set[int] = set(range(len(tickets[0].numbers)))
+        deduction: Dict[str, Set[int]] = {}
+        for field in field_names:
+            deduction[field] = indices.copy()
+        # we now have a deduction dictionary in which each field maps to all of the possible number indices
+        # for ticket in valid_tickets:
+        for ticket in tickets:
+            if ticket.is_valid():
+                for index in indices:
+                    for field in field_names:
+                        if index in deduction[field] and not ticket.is_valid_number(ticket.numbers[index], field):
+                            deduction[field].remove(index)
+        # some fields in the deductions dictionary should now map to a singleton set
+        # we'll use those to further reduce the non-singleton sets
+        conclusion: Dict[str, int] = {}
+        while len(deduction) > 0:
+            singletons: Set[str] = {field for field in deduction.keys() if len(deduction[field]) == 1}
+            if len(singletons) == 0:
+                raise RuntimeError('Ran out of singletons!')
+            field: str = singletons.pop()
+            index: int = deduction.pop(field).pop()
+            conclusion[field] = index
+            for field in deduction:
+                if index in deduction[field]:
+                    deduction[field].remove(index)
+                assert len(deduction[field]) > 0
+        return conclusion
+
+
+data_structure: type = List[Ticket]
+
+
+# noinspection PyUnusedLocal
+def parse_data(data: List[str]) -> data_structure:
+    tickets: List[Ticket] = []
+    delimiter: int = data.index('')
+    for field_data in data[:delimiter]:
+        Ticket.add_field(field_data)
+    ticket_numbers: List[int] = list(map(lambda n: int(n), data[delimiter + 2].split(',')))
+    tickets.append(Ticket(ticket_numbers))  # your ticket
+    delimiter += 3
+    for ticket_data in data[delimiter + 2:]:  # nearby tickets
+        ticket_numbers = list(map(lambda n: int(n), ticket_data.split(',')))
+        tickets.append(Ticket(ticket_numbers))
+    return tickets
+
+
+# noinspection PyUnusedLocal
+def part1(data: data_structure) -> int:
+    return Ticket.error_rate(data)
+
+
+# noinspection PyUnusedLocal
+def part2(data: data_structure) -> int:
+    my_ticket: Ticket = data[0]
+    field_mapping: Dict[str, int] = Ticket.deduce_fields(data[1:])
+    departure_fields = {field for field in my_ticket.fields if field.startswith('departure')}
+    product: int = 1
+    for field in departure_fields:
+        index: int = field_mapping[field]
+        product *= my_ticket.numbers[index]
+    return product
+
+
+if __name__ == '__main__':
+    production_ready = True
+    print(part1(parse_data(import_data(day) if production_ready else sample_data1)))
+    print(part2(parse_data(import_data(day) if production_ready else sample_data2)))
diff --git a/2020/Day17.py b/2020/Day17.py
new file mode 100644
index 0000000..e275869
--- /dev/null
+++ b/2020/Day17.py
@@ -0,0 +1,273 @@
+from typing import List, Set, FrozenSet, Tuple
+
+from ImportData import import_data
+
+day: int = 17
+
+sample_data: List[str] = [
+    '.#.',
+    '..#',
+    '###'
+]
+
+data_structure: type = FrozenSet["Cube"]
+
+
+class Cube:
+    cubes: Set["Cube"] = set()
+
+    def __init__(self, x: int, y: int, z: int, active: bool = False):
+        self.w = None
+        self.x = x
+        self.y = y
+        self.z = z
+        self.active = active
+        self.next_state = active
+
+    @classmethod
+    def initialize(cls, starter_data: List[str]) -> FrozenSet["Cube"]:
+        cls.cubes = set()
+        z: int = 0
+        for y in range(len(starter_data)):
+            for x in range(len(starter_data[y])):
+                active: bool = starter_data[y][x] == '#'
+                cls.cubes.add(Cube(x, 2 - y, z, active))
+        return frozenset(cls.cubes)
+
+    @classmethod
+    def get_cube(cls, x: int, y: int, z: int, w: int = 0) -> "Cube":
+        matches: Set["Cube"] = {cube for cube in cls.cubes if cube.x == x and cube.y == y and cube.z == z}
+        if len(matches) == 0:
+            cube: "Cube" = Cube(x, y, z)
+            cls.cubes.add(cube)
+            return cube
+        elif len(matches) == 1:
+            return matches.pop()
+        else:
+            raise RuntimeError(f'Found {len(matches)} cubes with coordinates ({x}, {y}, {z}).')
+
+    @classmethod
+    def absent(cls, x: int, y: int, z: int, w: int = 0) -> bool:
+        matches: Set["Cube"] = {cube for cube in cls.cubes if cube.x == x and cube.y == y and cube.z == z}
+        return len(matches) == 0
+
+    @classmethod
+    def get_bounds(cls) -> Tuple[int, ...]:
+        minimum_x: int = min({cube.x for cube in cls.cubes})
+        maximum_x: int = max({cube.x for cube in cls.cubes})
+        minimum_y: int = min({cube.y for cube in cls.cubes})
+        maximum_y: int = max({cube.y for cube in cls.cubes})
+        minimum_z: int = min({cube.z for cube in cls.cubes})
+        maximum_z: int = max({cube.z for cube in cls.cubes})
+        return maximum_x, maximum_y, maximum_z, minimum_x, minimum_y, minimum_z
+
+    @classmethod
+    def expand_space(cls) -> FrozenSet["Cube"]:
+        maximum_x, maximum_y, maximum_z, minimum_x, minimum_y, minimum_z = cls.get_bounds()
+        for x in range(minimum_x - 1, maximum_x + 2):
+            for y in range(minimum_y - 1, maximum_y + 2):
+                for z in range(minimum_z - 1, maximum_z + 2):
+                    if cls.absent(x, y, z):
+                        cls.cubes.add(Cube(x, y, z))
+        return frozenset(cls.cubes)
+
+    def number_of_active_neighbors(self) -> int:
+        active_neighbors: Set["Cube"] = {cube for cube in self.cubes if
+                                         self.x - 1 <= cube.x <= self.x + 1 and
+                                         self.y - 1 <= cube.y <= self.y + 1 and
+                                         self.z - 1 <= cube.z <= self.z + 1 and
+                                         cube.active}
+        if self in active_neighbors:
+            active_neighbors.remove(self)
+        return len(active_neighbors)
+
+    def activate(self) -> None:
+        self.next_state = True
+
+    def deactivate(self) -> None:
+        self.next_state = False
+
+    def prep_change(self) -> None:
+        active_neighbors: int = self.number_of_active_neighbors()
+        if self.active:
+            self.next_state = 2 <= active_neighbors <= 3
+        else:
+            self.next_state = active_neighbors == 3
+
+    @classmethod
+    def commit_changes(cls) -> FrozenSet["Cube"]:
+        for cube in cls.cubes:
+            cube.active = cube.next_state
+        return frozenset(cls.cubes)
+
+    @classmethod
+    def print_cubes(cls) -> None:
+        maximum_x, maximum_y, maximum_z, minimum_x, minimum_y, minimum_z = cls.get_bounds()
+        for z in range(minimum_z, maximum_z + 1):
+            print(f'z={z}')
+            for y in range(maximum_y, minimum_y - 1, -1):
+                for x in range(minimum_x, maximum_x + 1):
+                    print('#' if cls.get_cube(x, y, z).active else '.', end='')
+                print()
+            print()
+
+    def __repr__(self) -> str:
+        return f'({self.x},{self.y},{self.z}): {self.active}'
+
+
+class Hypercube(Cube):
+    def __init__(self, x: int, y: int, z: int, w: int, active: bool = False):
+        super().__init__(x, y, z, active)
+        self.w = w
+
+    @classmethod
+    def initialize(cls, starter_data: List[str]) -> FrozenSet[Cube]:
+        cls.cubes = set()
+        z: int = 0
+        w: int = 0
+        for y in range(len(starter_data)):
+            for x in range(len(starter_data[y])):
+                active: bool = starter_data[y][x] == '#'
+                cls.cubes.add(Hypercube(x, 2 - y, z, w, active))
+        return frozenset(cls.cubes)
+
+    @classmethod
+    def get_cube(cls, x: int, y: int, z: int, w: int = 0) -> Cube:
+        matches: Set[Cube] = {cube for cube in cls.cubes if cube.x == x and cube.y == y and cube.z == z and cube.w == w}
+        if len(matches) == 0:
+            cube: "Hypercube" = Hypercube(x, y, z, w)
+            cls.cubes.add(cube)
+            return cube
+        elif len(matches) == 1:
+            return matches.pop()
+        else:
+            raise RuntimeError(f'Found {len(matches)} cubes with coordinates ({x}, {y}, {z}).')
+
+    @classmethod
+    def absent(cls, x: int, y: int, z: int, w: int = 0) -> bool:
+        matches: Set["Cube"] = {cube for cube in cls.cubes if
+                                cube.x == x and cube.y == y and cube.z == z and cube.w == w}
+        return len(matches) == 0
+
+    @classmethod
+    def get_bounds(cls) -> Tuple[int, ...]:
+        maximum_x, maximum_y, maximum_z, minimum_x, minimum_y, minimum_z = super(Hypercube, cls).get_bounds()
+        minimum_w: int = min({cube.w for cube in cls.cubes})
+        maximum_w: int = max({cube.w for cube in cls.cubes})
+        return maximum_x, maximum_y, maximum_z, minimum_x, minimum_y, minimum_z, minimum_w, maximum_w
+
+    @classmethod
+    def expand_space(cls) -> FrozenSet["Cube"]:
+        maximum_x, maximum_y, maximum_z, minimum_x, minimum_y, minimum_z, minimum_w, maximum_w = cls.get_bounds()
+        for x in range(minimum_x - 1, maximum_x + 2):
+            for y in range(minimum_y - 1, maximum_y + 2):
+                for z in range(minimum_z - 1, maximum_z + 2):
+                    for w in range(minimum_w - 1, maximum_w + 2):
+                        if cls.absent(x, y, z, w):
+                            cls.cubes.add(Hypercube(x, y, z, w))
+        return frozenset(cls.cubes)
+
+    def number_of_active_neighbors(self) -> int:
+        active_neighbors: Set["Cube"] = {cube for cube in self.cubes if
+                                         self.x - 1 <= cube.x <= self.x + 1 and
+                                         self.y - 1 <= cube.y <= self.y + 1 and
+                                         self.z - 1 <= cube.z <= self.z + 1 and
+                                         self.w - 1 <= cube.w <= self.w + 1 and
+                                         cube.active}
+        if self in active_neighbors:
+            active_neighbors.remove(self)
+        return len(active_neighbors)
+
+    @classmethod
+    def print_cubes(cls) -> None:
+        maximum_x, maximum_y, maximum_z, minimum_x, minimum_y, minimum_z, minimum_w, maximum_w = cls.get_bounds()
+        for w in range(minimum_w, maximum_w + 1):
+            for z in range(minimum_z, maximum_z + 1):
+                print(f'z={z}, w={w}')
+                for y in range(maximum_y, minimum_y - 1, -1):
+                    print(f'{str(abs(y)).rjust(3)} ', end='')
+                    for x in range(minimum_x, maximum_x + 1):
+                        print('#' if cls.get_cube(x, y, z, w).active else '.', end='')
+                    print()
+                print('    ', end='')
+                for x in range(minimum_x, maximum_x + 1):
+                    print(abs(x) // 10, end='')
+                print()
+                print('    ', end='')
+                for x in range(minimum_x, maximum_x + 1):
+                    print(abs(x) % 10, end='')
+                print()
+                print()
+            print()
+
+    def __repr__(self) -> str:
+        return f'({self.x},{self.y},{self.z},{self.w}): {self.active}'
+
+    @classmethod
+    def remove_inactive_hyperplane(cls, dimension: str, value: int):
+        trimming: bool = False
+        hyperplane: Set[Cube] = {cube for cube in cls.cubes if getattr(cube, dimension) == value and cube.active}
+        if len(hyperplane) == 0:
+            cls.cubes = cls.cubes - {cube for cube in cls.cubes if getattr(cube, dimension) == value}
+            trimming = True
+        return trimming
+
+    @classmethod
+    def trim_space(cls) -> None:
+        trimming: bool = True
+        while trimming:
+            trimming = False
+            maximum_x, maximum_y, maximum_z, minimum_x, minimum_y, minimum_z, minimum_w, maximum_w = cls.get_bounds()
+            trimming |= cls.remove_inactive_hyperplane('x', minimum_x)
+            trimming |= cls.remove_inactive_hyperplane('x', maximum_x)
+            trimming |= cls.remove_inactive_hyperplane('y', minimum_y)
+            trimming |= cls.remove_inactive_hyperplane('y', maximum_y)
+            trimming |= cls.remove_inactive_hyperplane('z', minimum_z)
+            trimming |= cls.remove_inactive_hyperplane('z', maximum_z)
+            trimming |= cls.remove_inactive_hyperplane('w', minimum_w)
+            trimming |= cls.remove_inactive_hyperplane('w', maximum_w)
+
+
+def parse_data1(data: List[str]) -> data_structure:
+    return Cube.initialize(data)
+
+
+def parse_data2(data: List[str]) -> data_structure:
+    return Hypercube.initialize(data)
+
+
+def part1(data: data_structure) -> int:
+    cubes: FrozenSet[Cube] = data
+    for cycle in range(6):
+        # print(f'Cycle: {cycle}')
+        cubes = Cube.expand_space()
+        # Cube.print_cubes()
+        for cube in cubes:
+            cube.prep_change()
+        cubes = Cube.commit_changes()
+    # print('Final:')
+    # Cube.print_cubes()
+    return len({cube for cube in cubes if cube.active})
+
+
+def part2(data: data_structure) -> int:
+    cubes: FrozenSet[Cube] = data
+    for cycle in range(6):
+        print(f'Cycle: {cycle}')
+        Hypercube.trim_space()
+        # Hypercube.print_cubes()
+        cubes = Hypercube.expand_space()
+        for cube in cubes:
+            cube.prep_change()
+        cubes = Hypercube.commit_changes()
+    print('Final:')
+    # Hypercube.trim_space()
+    # Hypercube.print_cubes()
+    return len({cube for cube in cubes if cube.active})
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day) if production_ready else sample_data
+    print(part1(parse_data1(raw_data)))
+    print(part2(parse_data2(raw_data)))
diff --git a/2020/Day18.py b/2020/Day18.py
new file mode 100644
index 0000000..6e0acb7
--- /dev/null
+++ b/2020/Day18.py
@@ -0,0 +1,320 @@
+from typing import List, Union, Optional
+
+from ImportData import import_data
+
+day: int = 18
+
+sample_data: List[str] = [
+    '1 + 2 * 3 + 4 * 5 + 6',
+    '1 + (2 * 3) + (4 * (5 + 6))',
+    '2 * 3 + (4 * 5)',
+    '5 + (8 * 3 + 9 + 3 * 4 * 3)',
+    '5 * 9 * (7 * 3 * 3 + 9 * 3 + (8 + 6 * 4))',
+    '((2 + 4 * 9) * (6 + 9 * 8 + 6) + 6) + 2 + 4 * 2'
+]
+
+data_structure: type = List["Expression"]
+
+
+# noinspection DuplicatedCode
+class Expression:
+    def __init__(self, left: Union[int, "Expression"], operator: Optional[str] = None,
+                 right: Optional[Union[int, "Expression"]] = None):
+        self.left: Union[int, "Expression"] = left
+        self.operator: Optional[str] = operator
+        self.right: Optional[Union[int, "Expression"]] = right
+
+    @classmethod
+    def parse1(cls, expression: str) -> Optional["Expression"]:
+        tokens: List[str] = expression.split()
+        if len(tokens) == 0:
+            return None
+        if len(tokens) == 1:
+            # the only token should be a number
+            return Expression(int(tokens[0]))
+        if tokens[-1].isnumeric():
+            right: int = int(tokens[-1])
+            # the second-to-last token should be '+' or '*'
+            operator: str = tokens[-2]
+            offset: int = len(tokens[-1]) + 1 + len(tokens[-2]) + 1
+            # by golly, there'd better be a left-expression
+            left: str = expression[:-offset]
+            return Expression(Expression.parse1(left), operator, right)
+        if tokens[-1][-1] == ')':
+            parentheses_count: int = 1
+            offset: int = 1
+            while parentheses_count > 0:
+                if expression[-offset - 1] == ')':
+                    parentheses_count += 1
+                if expression[-offset - 1] == '(':
+                    parentheses_count -= 1
+                offset += 1
+            right_expression: str = expression[-offset + 1:-1]
+            offset += 2
+            operator: Optional[str] = expression[-offset] if offset < len(expression) else None
+            offset += 1
+            left_expression: str = expression[:-offset] if offset < len(expression) else ''
+            if left_expression == '':
+                left_expression, right_expression = right_expression, left_expression
+            return Expression(Expression.parse1(left_expression), operator, Expression.parse1(right_expression))
+        raise RuntimeError(f'Uninterpretable expression: {expression}')
+
+    @staticmethod
+    def is_balanced(token: str) -> bool:
+        left_parentheses: int = token.count('(')
+        right_parentheses: int = token.count(')')
+        return left_parentheses == right_parentheses
+
+    @classmethod
+    def parse2(cls, expression: str) -> Optional["Expression"]:
+        # if expression[0] == '(' and expression[-1] == ')' and expression[1:-1].count('(') == 0:
+        #     return cls.parse2(expression[1:-1])
+        tokens: List[str] = expression.split(' * ')
+        if len(tokens) <= 1:
+            # only numbers, parentheses and addition
+            return cls.parse1(expression)
+        if cls.is_balanced(tokens[0]):
+            left_expression: str = tokens[0]
+            operator: str = '*'
+            right_expression: str = expression[len(left_expression) + 3:]
+            return Expression(Expression.parse2(left_expression), operator, Expression.parse2(right_expression))
+        else:
+            left_parenthesis: int = -1
+            right_parenthesis: int = -1
+            parentheses_count: int = 0
+            offset: int = 0
+            while right_parenthesis < 0:
+                if expression[offset] == '(':
+                    parentheses_count += 1
+                    if left_parenthesis < 0:
+                        left_parenthesis = offset
+                if expression[offset] == ')':
+                    parentheses_count -= 1
+                    if parentheses_count == 0:
+                        right_parenthesis = offset
+                offset += 1
+            left_expression: str = ''
+            operator: Optional[str] = None
+            right_expression: str = ''
+            if left_parenthesis == 0:
+                left_expression = expression[left_parenthesis + 1: right_parenthesis]
+                offset += 1
+                if offset < len(expression):
+                    operator = expression[offset]
+                offset += 2
+                if offset < len(expression):
+                    right_expression = expression[offset:]
+                # compensate for order of operations
+                offset = 1
+                while operator == '+' and offset < len(right_expression):
+                    if right_expression[offset] == '*':
+                        if cls.is_balanced(right_expression[offset + 1:].strip()):
+                            left_expression = '(' + left_expression + ') + ' + right_expression[:offset].strip()
+                            operator = right_expression[offset]
+                            right_expression = right_expression[offset + 1:].strip()
+                    offset += 1
+            else:
+                right_expression = expression[left_parenthesis:]
+                offset = left_parenthesis - 2
+                if offset > 0:
+                    operator = expression[offset]
+                offset -= 1
+                if offset > 0:
+                    left_expression = expression[:offset]
+                # compensate for order of operations
+                offset = len(left_expression) - 2
+                while operator == '+' and offset > 0:
+                    if left_expression[offset] == '*':
+                        if cls.is_balanced(left_expression[:offset].strip()):
+                            right_expression = left_expression[offset + 1:].strip() + ' + (' + right_expression + ')'
+                            operator = left_expression[offset]
+                            left_expression = left_expression[:offset].strip()
+                    offset -= 1
+            return Expression(Expression.parse2(left_expression), operator, Expression.parse2(right_expression))
+
+    @staticmethod
+    def trim_unwanted_parentheses(expression: str) -> str:
+        if expression == '' or expression[0] != '(':
+            return expression
+        trimmed_expression: str = expression[1:-1]
+        safe_to_trim: bool = True
+        number_of_parentheses: int = 0
+        for character in trimmed_expression:
+            if character == '(':
+                number_of_parentheses += 1
+            if character == ')':
+                number_of_parentheses -= 1
+            if number_of_parentheses < 0:
+                safe_to_trim = False
+        return trimmed_expression if safe_to_trim else expression
+
+    @classmethod
+    def parse3(cls, expression: str) -> Optional["Expression"]:
+        if '(' not in expression:
+            tokens: List[str] = expression.split(' * ')
+            if len(tokens) <= 1:
+                # only numbers, parentheses and addition
+                return cls.parse1(expression)
+            left_expression: str = tokens[0]
+            operator: str = '*'
+            right_expression: str = expression[len(left_expression) + 3:]
+            return Expression(Expression.parse2(left_expression), operator, Expression.parse2(right_expression))
+        number_of_parentheses: int = 0
+        left_expression: str = ''
+        operator: str = ''
+        right_expression: str = expression
+        building: bool = True
+        while building:
+            if right_expression == '':
+                building = False
+            else:
+                character: str = right_expression[0]
+                right_expression = right_expression[1:]
+                if character == '(':
+                    number_of_parentheses += 1
+                    left_expression = left_expression + character
+                elif character == ')':
+                    number_of_parentheses -= 1
+                    left_expression = left_expression + character
+                elif character == '*' and number_of_parentheses == 0:
+                    operator = '*'
+                    left_expression = left_expression.strip()
+                    right_expression = right_expression.strip()
+                    building = False
+                elif character == '+' and number_of_parentheses == 0 and right_expression.strip()[0] == '(':
+                    operator = '+'
+                    left_expression = left_expression.strip()
+                    right_expression = right_expression.strip()
+                    building = False
+                elif character == '+' and number_of_parentheses == 0 and '*' not in right_expression:
+                    operator = '+'
+                    left_expression = left_expression.strip()
+                    right_expression = right_expression.strip()
+                    building = False
+                else:
+                    left_expression = left_expression + character
+        left_expression = cls.trim_unwanted_parentheses(left_expression)
+        right_expression = cls.trim_unwanted_parentheses(right_expression)
+        return Expression(Expression.parse3(left_expression), operator, Expression.parse3(right_expression))
+
+    @classmethod
+    def parse4(cls, expression: str) -> Optional["Expression"]:
+        expression = expression.strip()
+        if '(' not in expression:
+            tokens: List[str] = expression.split(' * ')
+            if len(tokens) <= 1:
+                # only numbers, parentheses and addition
+                return cls.parse1(expression)
+            left_expression: str = tokens[0]
+            operator: str = '*'
+            right_expression: str = expression[len(left_expression) + 3:]
+            return Expression(Expression.parse2(left_expression), operator, Expression.parse2(right_expression))
+        left_parenthesis: int = -1
+        right_parenthesis: int = -1
+        number_of_parentheses: int = 0
+        offset: int = 0
+        left_expression: str = ''
+        left_operator: str = ''
+        right_operator: str = ''
+        right_expression: str = ''
+        while right_parenthesis < 0:
+            if expression[offset] == '(':
+                number_of_parentheses += 1
+                if left_parenthesis < 0:
+                    left_parenthesis = offset
+            if expression[offset] == ')':
+                number_of_parentheses -= 1
+                if number_of_parentheses == 0:
+                    right_parenthesis = offset
+            offset += 1
+        if left_parenthesis > 0:
+            left_operator = expression[left_parenthesis - 2]
+            left_expression = expression[:left_parenthesis - 3]
+        if right_parenthesis < len(expression) - 1:
+            right_operator = expression[right_parenthesis + 2]
+            right_expression = expression[right_parenthesis + 4:]
+        parenthetical_expression: str = expression[left_parenthesis + 1:right_parenthesis]
+        middle: Expression = cls.parse4(parenthetical_expression)
+        if left_operator == '*':
+            left: Expression = cls.parse4(left_expression)
+            middle = Expression(left, left_operator, middle)
+        elif left_operator == '+':
+            if '*' in left_expression:
+                # there are no parentheses in left_expression
+                offset = -1
+                while left_expression[offset] != '*':
+                    offset -= 1
+                left: Expression = cls.parse4(left_expression[:offset - 1])
+                middle = Expression(cls.parse4(left_expression[offset + 2:]), left_operator, middle)
+                middle = Expression(left, '*', middle)
+            else:
+                left: Expression = cls.parse4(left_expression)
+                middle = Expression(left, left_operator, middle)
+        if right_operator == '*':
+            right: Expression = cls.parse4(right_expression)
+            middle = Expression(middle, right_operator, right)
+        elif right_operator == '+':
+            if '*' in right_expression:
+                # if the multiplication occurs within another parenthetical expression, we can ignore it for now
+                offset = 0
+                number_of_parentheses = 0
+                multiplication: int = -1
+                while multiplication < 0 and offset < len(right_expression):
+                    if right_expression[offset] == '(':
+                        number_of_parentheses += 1
+                    if right_expression[offset] == ')':
+                        number_of_parentheses -= 1
+                    if right_expression[offset] == '*' and number_of_parentheses == 0:
+                        multiplication = offset
+                    offset += 1
+                if multiplication < 0:
+                    right: Expression = cls.parse4(right_expression)
+                    middle = Expression(middle, right_operator, right)
+                else:
+                    right: Expression = cls.parse4(right_expression[offset + 1:])
+                    middle = Expression(middle, right_operator, cls.parse4(right_expression[:offset - 1]))
+                    middle = Expression(middle, '*', right)
+            else:
+                right: Expression = cls.parse4(right_expression)
+                middle = Expression(middle, right_operator, right)
+        return middle
+
+    def evaluate(self) -> int:
+        if self.operator is None and self.right is None:
+            if type(self.left) is int:
+                return self.left
+            else:
+                return self.left.evaluate()
+        if self.operator is not None and self.right is not None:
+            evaluated_left: int = self.left if type(self.left) is int else self.left.evaluate()
+            evaluated_right: int = self.right if type(self.right) is int else self.right.evaluate()
+            if self.operator == '+':
+                return evaluated_left + evaluated_right
+            elif self.operator == '*':
+                return evaluated_left * evaluated_right
+            else:
+                raise RuntimeError(f'Unexpected operator: {self.operator} for {self}')
+        raise RuntimeError(f'Unexpected expression: {self}')
+
+    def __repr__(self) -> str:
+        return f'<{self.left}>' if self.operator is None and self.right is None \
+            else f'<{self.left} {self.operator} {self.right}>'
+
+
+def parse_data(data: List[str]) -> data_structure:
+    return [Expression.parse1(expression) for expression in data]
+
+
+def part1(data: data_structure) -> int:
+    return sum([expression.evaluate() for expression in data])
+
+
+def part2(data: data_structure) -> int:
+    return sum([expression.evaluate() for expression in data])
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day) if production_ready else sample_data
+    print(part1([Expression.parse1(expression) for expression in raw_data]))
+    print(part2([Expression.parse4(expression) for expression in raw_data]))
diff --git a/2020/Day18cheat.py b/2020/Day18cheat.py
new file mode 100644
index 0000000..fd16383
--- /dev/null
+++ b/2020/Day18cheat.py
@@ -0,0 +1,90 @@
+from ImportData import import_data
+
+
+# noinspection PyShadowingNames
+def evaluate(expression, operators, operations):
+    operation_stack = list()
+    number_stack = list()
+
+    expression_list = expression[:-1].split(" ") if expression[-1] == '\n' else expression.split(" ")
+
+    for string in expression_list:
+        if string in operators.keys():
+            while len(operation_stack) > 0 and operators[string] <= operators[operation_stack[-1]]:
+                operation = operation_stack.pop()
+                num1 = number_stack.pop()
+                num2 = number_stack.pop()
+
+                result = operations[operation](num1, num2)
+                number_stack.append(result)
+
+            operation_stack.append(string)
+        elif string[0] == '(':
+            while string[0] == '(':
+                operation_stack.append('(')
+                string = string[1:]
+            number_stack.append(int(string))
+
+        elif string[-1] == ')':
+            number_stack.append(int(string[:string.index(')')]))
+
+            while string[-1] == ')':
+                string = string[:-1]
+                operation = ')'
+
+                while operation != '(':
+                    operation = operation_stack.pop()
+
+                    if operation == '(':
+                        break
+
+                    num1 = number_stack.pop()
+                    num2 = number_stack.pop()
+
+                    result = operations[operation](num1, num2)
+                    number_stack.append(result)
+        else:
+            number_stack.append(int(string))
+
+    while len(operation_stack) > 0:
+        operation = operation_stack.pop()
+        num1 = number_stack.pop()
+        num2 = number_stack.pop()
+
+        result = operations[operation](num1, num2)
+
+        number_stack.append(result)
+
+    return number_stack[0]
+
+
+operations = {
+    '+': lambda num1, num2: num1 + num2,
+    '*': lambda num1, num2: num1 * num2
+}
+
+# For part 1
+operators_part_1 = {
+    '+': 1,
+    '*': 1,
+    '(': 0
+}
+
+# For part 2
+operators_part_2 = {
+    '+': 2,
+    '*': 1,
+    '(': 0
+}
+
+if __name__ == '__main__':
+    expressions = import_data(18)
+
+    sum_part_1 = 0
+    sum_part_2 = 0
+    for expression in expressions:
+        sum_part_1 += evaluate(expression, operators_part_1, operations)
+        sum_part_2 += evaluate(expression, operators_part_2, operations)
+
+    print("Part 1 sum {}".format(sum_part_1))
+    print("Part 2 sum {}".format(sum_part_2))
diff --git a/2020/Day19.py b/2020/Day19.py
new file mode 100644
index 0000000..a9c72ad
--- /dev/null
+++ b/2020/Day19.py
@@ -0,0 +1,107 @@
+from typing import List, Tuple, Set, Union, Dict
+
+from ImportData import import_data
+
+day: int = 19
+
+# noinspection SpellCheckingInspection
+sample_data: List[str] = [
+    '0: 4 1 5',
+    '1: 2 3 | 3 2',
+    '2: 4 4 | 5 5',
+    '3: 4 5 | 5 4',
+    '4: "a"',
+    '5: "b"',
+    '',
+    'ababbb',
+    'bababa',
+    'abbbab',
+    'aaabbb',
+    'aaaabbb'
+]
+
+data_structure: type = Tuple[List[Union[str, Set[Tuple[int, ...]]]], List[str]]
+
+
+# rules: List[Union[str, Set[List[int]]]]
+#   As a string, a simple rule (e.g., 'a')
+#   As a Set[List[int]], one or more sequences of rule numbers
+# messages: List[str]
+#   Self-explanatory
+
+
+def parse_data(data: List[str]) -> data_structure:
+    delimiter: int = data.index('')
+    numbered_rules_list: List[str] = data[:delimiter]
+    messages: List[str] = data[delimiter + 1:]
+    rules_only_list: List[str] = [''] * len(numbered_rules_list)
+    for numbered_rule in numbered_rules_list:
+        number, rule = numbered_rule.split(': ')
+        rules_only_list[int(number)] = rule
+    rules: List[Union[str, Set[Tuple[int, ...]]]] = []
+    for rule in rules_only_list:
+        if rule[0] == '"':
+            rules.append(rule[1])
+        else:
+            options: List[str] = rule.split(' | ')
+            rule_set: Set[Tuple[int, ...]] = set()
+            for option in options:
+                rule_set.add(tuple([int(x) for x in option.split()]))
+            rules.append(rule_set)
+    return rules, messages
+
+
+known_matches: Dict[str, Set[int]] = {}
+
+
+def find_partial_match(message: str, rules: List[Union[str, Set[Tuple[int, ...]]]],
+                       rule_number: int) -> Tuple[str, str]:
+    prefix: str = message
+    suffix: str = ''
+    found_a_partial_match: bool = False
+    while prefix != '' and not found_a_partial_match:
+        if matches(prefix, rules, rule_number):
+            found_a_partial_match = True
+        else:
+            suffix = prefix[-1] + suffix
+            prefix = prefix[:-1]
+    return prefix, suffix
+
+
+def matches(message: str, rules: List[Union[str, Set[Tuple[int, ...]]]], rule_number: int) -> bool:
+    if type(rules[rule_number]) == str:
+        return message == rules[rule_number]
+    if message in known_matches and rule_number in known_matches[message]:
+        return True
+    found_a_match: bool = False
+    for rule in rules[rule_number]:
+        prefix: str = ''
+        suffix: str = message
+        for sub_rule in rule:
+            partial_prefix, suffix = find_partial_match(suffix, rules, sub_rule)
+            prefix = prefix + partial_prefix
+        if prefix == message and suffix == '':
+            found_a_match = True
+        if found_a_match:
+            if message in known_matches:
+                known_matches[message].add(rule_number)
+            else:
+                known_matches[message] = {rule_number}
+    return found_a_match
+
+
+def part1(data: data_structure) -> int:
+    rules: List[Union[str, Set[List[int]]]] = data[0]
+    messages: List[str] = data[1]
+    return sum(map(lambda message: 1 if matches(message, rules, 0) else 0, messages))
+
+
+def part2(data: data_structure) -> int:
+    return -1
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day) if production_ready else sample_data
+    print(part1(parse_data(raw_data)))
+    print(part2(parse_data(raw_data)))
diff --git a/2020/Day20.py b/2020/Day20.py
new file mode 100644
index 0000000..70d87a1
--- /dev/null
+++ b/2020/Day20.py
@@ -0,0 +1,184 @@
+from functools import reduce
+from typing import List, Union, Optional
+
+from ImportData import import_data, read_file
+
+day: int = 20
+
+data_structure: type = List["Tile"]
+
+
+class Tile:
+    def __init__(self, data: List[str]):
+        self.number = int(data[0][5:-1])
+        self.pixels = data[1:]
+        # each border either links to the neighboring tile or holds a copy of the border string
+        self.borders: List[Union["Tile", str]] = [
+            self.pixels[0],                             # top border
+            self.pixels[-1],                            # bottom border
+            ''.join(row[0] for row in self.pixels),     # left border
+            ''.join(row[-1] for row in self.pixels)     # right border
+        ]
+        self.right: Optional["Tile"] = None
+        self.down: Optional["Tile"] = None
+        self.number_of_neighbors = 0
+
+    def attach(self, other: "Tile") -> bool:
+        attached: bool = False
+        # print(f'Matching {self.number}...')
+        for i in range(len(self.borders)):
+            if type(self.borders[i]) == str and not attached:
+                # print(f'\t...maybe to {other.number}', end='')
+                attached = attached or self._try_to_attach(i, other)
+        return attached
+
+    def _try_to_attach(self, my_border_index: int, other: "Tile") -> bool:
+        attached: bool = False
+        border: str = self.borders[my_border_index]
+        reversed_border: str = border[::-1]
+        your_border_index: int = 0
+        while your_border_index < 4 and not attached and type(other.borders[your_border_index] == str):
+            if border == other.borders[your_border_index]:
+                attached = True
+                self.borders[my_border_index] = other
+                self.number_of_neighbors += 1
+                other.borders[your_border_index] = self
+                other.number_of_neighbors += 1
+                # print(f'\tMatched {self.number} to {other.number}')
+            elif reversed_border == other.borders[your_border_index]:
+                attached = True
+                self.borders[my_border_index] = other
+                self.number_of_neighbors += 1
+                other.borders[your_border_index] = self
+                other.number_of_neighbors += 1
+                # print(f'\tMatched {self.number} to {other.number}')
+            # else:
+            # print('\tno', end='')
+            your_border_index += 1
+        print()
+        return attached
+
+    def flip_diagonal(self) -> None:
+        new_pixels: List[str] = [''] * len(self.pixels[0])
+        for index in range(len(new_pixels)):
+            new_pixels[index] = ''.join(row[index] for row in self.pixels)
+        self.pixels = new_pixels
+
+    def flip_vertical(self) -> None:
+        new_pixels: List[str] = [''] * len(self.pixels[0])
+        for index in range(len(new_pixels)):
+            new_pixels[index] = self.pixels[index][::-1]
+        self.pixels = new_pixels
+
+    def flip_horizontal(self) -> None:
+        new_pixels: List[str] = [''] * len(self.pixels[0])
+        height = len(new_pixels)
+        for index in range(height):
+            new_pixels[index] = self.pixels[height - index - 1]
+        self.pixels = new_pixels
+
+    def rotate_clockwise(self)-> None:
+        self.flip_diagonal()
+        self.flip_vertical()
+
+    def rotate_counterclockwise(self) -> None:
+        self.flip_diagonal()
+        self.flip_horizontal()
+
+    def __repr__(self) -> str:
+        return f'{self.number}: '  # \
+        # f'{self.borders[0].number if type(self.borders[0]) != str else None} ' \
+        # f'{self.borders[1].number if type(self.borders[0]) != str else None} ' \
+        # f'{self.borders[2].number if type(self.borders[0]) != str else None} ' \
+        # f'{self.borders[3].number if type(self.borders[0]) != str else None}'
+
+
+def parse_data(data: List[str]) -> data_structure:
+    tiles: List[Tile] = []
+    for tile_index in range(0, len(data), 12):
+        tiles.append(Tile(data[tile_index:tile_index + 11]))
+    return tiles
+
+
+def part1(tiles: data_structure) -> int:
+    for tile in tiles:
+        if tile.number_of_neighbors < 4:
+            for other_tile in tiles:
+                if other_tile != tile and other_tile.number_of_neighbors < 4:
+                    tile.attach(other_tile)
+    assert [tile.number for tile in tiles if tile.number_of_neighbors == 0] == []
+    assert [tile.number for tile in tiles if tile.number_of_neighbors == 1] == []
+    assert len([tile.number for tile in tiles if tile.number_of_neighbors == 2]) == 4
+    corner_numbers: List[int] = [tile.number for tile in tiles if tile.number_of_neighbors == 2]
+    product: int = reduce(lambda i, j: i * j, corner_numbers)
+    return product
+
+
+def form_composite(tiles: data_structure) -> List[str]:
+    # First, grab a corner
+    upper_left: Tile = [tile for tile in tiles if tile.number_of_neighbors == 2][0]
+    # Assign orientation
+    neighbors: List[Tile] = [tile for tile in upper_left.borders if type(tile) == Tile]
+    upper_left.down = neighbors[0]
+    upper_left.right = neighbors[1]
+    # rotate & flip tiles
+    tile: Tile = upper_left
+    below_tile: Optional[Tile] = upper_left.down
+    above_tile: Optional[Tile] = None
+    next_row_left: Optional[Tile] = below_tile
+    right_tile: Optional[Tile] = tile.right
+    # noinspection PyUnusedLocal
+    next_right_tile: Optional[Tile] = None
+    index: int = right_tile.borders.index(tile)
+    if index == 2:      # left
+        next_right_tile = right_tile.borders[3] if type(right_tile.borders[3]) == Tile else None
+    elif index == 3:    # right
+        next_right_tile = right_tile.borders[2] if type(right_tile.borders[3]) == Tile else None
+        right_tile.flip_vertical()
+    elif index == 0:    # above
+        next_right_tile = right_tile.borders[1] if type(right_tile.borders[3]) == Tile else None
+        right_tile.rotate_counterclockwise()
+    elif index == 1:    # below
+        next_right_tile = right_tile.borders[0] if type(right_tile.borders[3]) == Tile else None
+        right_tile.rotate_clockwise()
+    else:
+        raise IndexError('Weird geometry is at play.')
+    
+
+    # # build the composite
+    composite: List[str] = []
+
+    # neighbors: List[int] = [upper_left.borders.index(tile) for tile in upper_left.borders if type(tile) == Tile]
+    # down: int = neighbors[0]
+    # right: int = neighbors[1]
+    # # build the composite
+    # composite: List[str] = []
+    # left_tile: Union[Tile, str] = upper_left
+    # while type(left_tile == Tile):
+    #     for i in range(1, len(left_tile.pixels) - 1):
+    #         tile: Union[Tile, str] = left_tile
+    #         line: str = ''
+    #         while type(tile) == Tile:
+    #             line = line + tile.pixels[i][1:len(tile.pixels[i]) - 1]
+    #             tile = tile.borders[right]
+    #         composite.append(line)
+    #         print(line)
+    #     left_tile = left_tile.borders[down]
+    return composite
+
+
+def part2(tiles: data_structure) -> int:
+    for tile in tiles:
+        if tile.number_of_neighbors < 4:
+            for other_tile in tiles:
+                if other_tile != tile and other_tile.number_of_neighbors < 4:
+                    tile.attach(other_tile)
+    composite = form_composite(tiles)
+    return -1
+
+
+if __name__ == '__main__':
+    production_ready = False
+    raw_data = import_data(day) if production_ready else read_file('Day20SampleData.txt')
+    print(part1(parse_data(raw_data)))
+    print(part2(parse_data(raw_data)))
diff --git a/2020/Day20SampleData.txt b/2020/Day20SampleData.txt
new file mode 100644
index 0000000..b07aa4b
--- /dev/null
+++ b/2020/Day20SampleData.txt
@@ -0,0 +1,107 @@
+Tile 2311:
+..##.#..#.
+##..#.....
+#...##..#.
+####.#...#
+##.##.###.
+##...#.###
+.#.#.#..##
+..#....#..
+###...#.#.
+..###..###
+
+Tile 1951:
+#.##...##.
+#.####...#
+.....#..##
+#...######
+.##.#....#
+.###.#####
+###.##.##.
+.###....#.
+..#.#..#.#
+#...##.#..
+
+Tile 1171:
+####...##.
+#..##.#..#
+##.#..#.#.
+.###.####.
+..###.####
+.##....##.
+.#...####.
+#.##.####.
+####..#...
+.....##...
+
+Tile 1427:
+###.##.#..
+.#..#.##..
+.#.##.#..#
+#.#.#.##.#
+....#...##
+...##..##.
+...#.#####
+.#.####.#.
+..#..###.#
+..##.#..#.
+
+Tile 1489:
+##.#.#....
+..##...#..
+.##..##...
+..#...#...
+#####...#.
+#..#.#.#.#
+...#.#.#..
+##.#...##.
+..##.##.##
+###.##.#..
+
+Tile 2473:
+#....####.
+#..#.##...
+#.##..#...
+######.#.#
+.#...#.#.#
+.#########
+.###.#..#.
+########.#
+##...##.#.
+..###.#.#.
+
+Tile 2971:
+..#.#....#
+#...###...
+#.#.###...
+##.##..#..
+.#####..##
+.#..####.#
+#..#.#..#.
+..####.###
+..#.#.###.
+...#.#.#.#
+
+Tile 2729:
+...#.#.#.#
+####.#....
+..#.#.....
+....#..#.#
+.##..##.#.
+.#.####...
+####.#.#..
+##.####...
+##..#.##..
+#.##...##.
+
+Tile 3079:
+#.#.#####.
+.#..######
+..#.......
+######....
+####.#..#.
+.#...#.##.
+#.#####.##
+..#.###...
+..#.......
+..#.###...
diff --git a/2020/Day4.py b/2020/Day4.py
new file mode 100644
index 0000000..1e5f2ef
--- /dev/null
+++ b/2020/Day4.py
@@ -0,0 +1,95 @@
+import string
+from typing import List, Dict
+
+from ImportData import import_data
+
+
+def parse_documents(lines: List[str]) -> List[Dict[str, str]]:
+    records: List[Dict[str, str]] = []
+    record = {'byr': '', 'iyr': '', 'eyr': '', 'hgt': '', 'hcl': '', 'ecl': '', 'pid': '', 'cid': ''}
+    for line in lines:
+        if line == '':
+            records.append(record)
+            record = {'byr': '', 'iyr': '', 'eyr': '', 'hgt': '', 'hcl': '', 'ecl': '', 'pid': '', 'cid': ''}
+        else:
+            for field in line.split():
+                pair = field.split(':')
+                record[pair[0]] = pair[1]
+    records.append(record)
+    return records
+
+
+def part1(records: List[Dict[str, str]]) -> int:
+    valid_records = 0
+    for record in records:
+        invalid_fields = 0
+        for field in record:
+            if record[field] == '':
+                invalid_fields += 1
+        if invalid_fields == 0:
+            valid_records += 1
+        elif invalid_fields == 1 and record['cid'] == '':
+            valid_records += 1
+        else:
+            pass
+    return valid_records
+
+
+def validate_year(year: str, lower_bound: int, upper_bound: int) -> bool:
+    if year == '':
+        return False
+    if not year.isnumeric():
+        return False
+    return lower_bound <= int(year) <= upper_bound
+
+
+def validate_height(height: str) -> bool:
+    if len(height) < 4:
+        return False
+    if not height[:-2].isnumeric():
+        return False
+    magnitude = int(height[:-2])
+    if height[-2:] == 'cm':
+        return 150 <= magnitude <= 193
+    elif height[-2:] == 'in':
+        return 59 <= magnitude <= 76
+    else:
+        return False
+
+
+def validate_hair_color(hair_color: str) -> bool:
+    if len(hair_color) != 7:
+        return False
+    if hair_color[0] != '#':
+        return False
+    return all(color in string.hexdigits for color in hair_color[1:])
+
+
+def part2(records: List[Dict[str, str]]) -> int:
+    valid_records = 0
+    for record in records:
+        is_valid = True
+        birth_year = record['byr']
+        issue_year = record['iyr']
+        expire_year = record['eyr']
+        height = record['hgt']
+        hair_color = record['hcl']
+        eye_color = record['ecl']
+        passport_id = record['pid']
+        is_valid &= validate_year(birth_year, 1920, 2002)
+        is_valid &= validate_year(issue_year, 2010, 2020)
+        is_valid &= validate_year(expire_year, 2020, 2030)
+        is_valid &= validate_height(height)
+        is_valid &= validate_hair_color(hair_color)
+        is_valid &= eye_color in {'amb', 'blu', 'brn', 'gry', 'grn', 'hzl', 'oth'}
+        is_valid &= len(passport_id) == 9 and passport_id.isnumeric()
+        if is_valid:
+            valid_records += 1
+    return valid_records
+
+
+if __name__ == '__main__':
+    data = import_data(4)
+    documents = parse_documents(data)
+    print(part1(documents))
+    print(part2(documents))
diff --git a/2020/Day5.py b/2020/Day5.py
new file mode 100644
index 0000000..12b466b
--- /dev/null
+++ b/2020/Day5.py
@@ -0,0 +1,49 @@
+from typing import List
+
+from ImportData import import_data
+
+
+def decode_seat(seat: str) -> int:
+    weight = 1
+    value = 0
+    for character in reversed(seat):
+        if character in {'F', 'L'}:
+            value += 0
+        elif character in {'B', 'R'}:
+            value += weight
+        else:
+            raise ValueError(f'{character} is not a valid encoding')
+        weight *= 2
+    return value
+
+
+def part1(seats: List[str]) -> int:
+    maximum_seat_number = -1
+    for seat in seats:
+        seat_number = decode_seat(seat)
+        maximum_seat_number = seat_number if seat_number > maximum_seat_number else maximum_seat_number
+    return maximum_seat_number
+
+
+def part2(seats: List[str]) -> int:
+    missing_seats: List[int] = []
+    filled_seats: List[int] = list(map(decode_seat, seats))
+    my_seat: List[int] = []
+    for seat in range(max(filled_seats)):
+        if seat not in filled_seats:
+            missing_seats.append(seat)
+    print(missing_seats)
+    for seat in missing_seats:
+        if seat - 1 in filled_seats and seat + 1 in filled_seats:
+            my_seat.append(seat)
+    if len(my_seat) != 1:
+        print(my_seat)
+        return -1
+    else:
+        return my_seat[0]
+
+
+if __name__ == '__main__':
+    data = import_data(5)
+    print(part1(data))
+    print(part2(data))
diff --git a/2020/Day6.py b/2020/Day6.py
new file mode 100644
index 0000000..3886d0e
--- /dev/null
+++ b/2020/Day6.py
@@ -0,0 +1,53 @@
+import string
+from typing import List, Dict
+
+from ImportData import import_data
+
+
+def parse_answers(lines: List[str]) -> List[Dict[str, int]]:
+    answer_counts: List[Dict[str, int]] = []
+    answer_count = {'group_size': 0}
+    for letter in string.ascii_lowercase:
+        answer_count[letter] = 0
+    for line in lines:
+        if line == '':
+            answer_counts.append(answer_count)
+            answer_count = {'group_size': 0}
+            for letter in string.ascii_lowercase:
+                answer_count[letter] = 0
+        else:
+            answer_count['group_size'] += 1
+            for letter in line:
+                answer_count[letter] += 1
+    answer_counts.append(answer_count)
+    return answer_counts
+
+
+def part1(responses: List[Dict[str, int]]) -> int:
+    total = 0
+    for response in responses:
+        count = 0
+        for letter in string.ascii_lowercase:
+            if response[letter] > 0:
+                count += 1
+        total += count
+    return total
+
+
+def part2(responses: List[Dict[str, int]]) -> int:
+    total = 0
+    for response in responses:
+        count = 0
+        for letter in string.ascii_lowercase:
+            if response[letter] == response['group_size']:
+                count += 1
+        total += count
+    return total
+
+
+if __name__ == '__main__':
+    data = import_data(6)
+    # data = ['abc', '', 'a', 'b', 'c', '', 'ab', 'ac', '', 'a', 'a', 'a', 'a', '', 'b']
+    answers = parse_answers(data)
+    print(part1(answers))
+    print(part2(answers))
diff --git a/2020/Day7.py b/2020/Day7.py
new file mode 100644
index 0000000..95c607c
--- /dev/null
+++ b/2020/Day7.py
@@ -0,0 +1,113 @@
+from typing import Dict, List, Set
+
+from ImportData import import_data
+
+day = 7
+
+# sample_data = [
+#     'light red bags contain 1 bright white bag, 2 muted yellow bags.',
+#     'dark orange bags contain 3 bright white bags, 4 muted yellow bags.',
+#     'bright white bags contain 1 shiny gold bag.',
+#     'muted yellow bags contain 2 shiny gold bags, 9 faded blue bags.',
+#     'shiny gold bags contain 1 dark olive bag, 2 vibrant plum bags.',
+#     'dark olive bags contain 3 faded blue bags, 4 dotted black bags.',
+#     'vibrant plum bags contain 5 faded blue bags, 6 dotted black bags.',
+#     'faded blue bags contain no other bags.',
+#     'dotted black bags contain no other bags.'
+# ]
+
+sample_data = [
+    'shiny gold bags contain 2 dark red bags.',
+    'dark red bags contain 2 dark orange bags.',
+    'dark orange bags contain 2 dark yellow bags.',
+    'dark yellow bags contain 2 dark green bags.',
+    'dark green bags contain 2 dark blue bags.',
+    'dark blue bags contain 2 dark violet bags.',
+    'dark violet bags contain no other bags.'
+]
+
+
+def parse_data(data: List[str]) -> Dict[str, Dict[str, int]]:
+    """
+    Turns the raw input into a usable data structure
+    :param data: raw input
+    :return: outer_color : (inner_color : quantity)
+    """
+    bags: Dict[str, Dict[str, int]] = {}
+    for line in data:
+        relationship = line.split(' bags contain ')
+        outer_color = relationship[0]
+        contents = relationship[1]
+        if outer_color in bags:
+            raise RuntimeError(f'Encountered a second rule for {outer_color}.')
+        bags[outer_color] = {}
+        if contents != 'no other bags.':
+            for content in contents.split(' bag'):
+                if len(content) > 2:  # end of the rule, really should only be '.' or 's.'
+                    if content[0].isnumeric():  # first inner bag
+                        inner_bag = content
+                    elif content[1].isnumeric():
+                        raise RuntimeError(f'This shouldn\'t be possible: {content}.')
+                    elif content[2].isnumeric():  # previous quantity was 1
+                        inner_bag = content[2:]
+                    elif content[3].isnumeric():  # previous quantity was greater than 1
+                        inner_bag = content[3:]
+                    else:
+                        raise RuntimeError(f'You still aren\'t parsing it correctly: {content}')
+                    # my data appears to be single-digit quantities; that simplifies matters
+                    quantity = int(inner_bag[0])
+                    inner_color = inner_bag[2:]
+                    if inner_color in bags[outer_color]:
+                        raise RuntimeError(f'Encountered a second quantity for {inner_color}.')
+                    if inner_color[0].isnumeric():
+                        raise RuntimeError(
+                            f'The assumption of single-digit quantities is unfounded for {outer_color}:{inner_color}')
+                    bags[outer_color][inner_color] = quantity
+    return bags
+
+
+def reverse_map(data: Dict[str, Dict[str, int]]) -> Dict[str, Set[str]]:
+    reverse_mapped_data: Dict[str, Set[str]] = {}
+    for outer_color in data:
+        for inner_color in data[outer_color]:
+            if inner_color not in reverse_mapped_data:
+                reverse_mapped_data[inner_color] = set()
+            reverse_mapped_data[inner_color].add(outer_color)
+    return reverse_mapped_data
+
+
+def part1(data: Dict[str, Dict[str, int]]) -> int:
+    wrappers: Dict[str, Set[str]] = reverse_map(data)
+    possible_containers: Set[str] = set()
+    colors_to_check: Set[str] = set()
+    color: str = 'shiny gold'
+    if color in wrappers:
+        colors_to_check.update(wrappers[color] - possible_containers)
+        possible_containers.update(wrappers[color])
+    while len(colors_to_check) > 0:
+        color = colors_to_check.pop()
+        if color in wrappers:
+            colors_to_check.update(wrappers[color] - possible_containers)
+            possible_containers.update(wrappers[color])
+    return len(possible_containers)
+
+
+def count_bags(colors_to_check: Dict[str, int], bag_mapping: Dict[str, Dict[str, int]]) -> int:
+    count = 1
+    for color in colors_to_check:
+        next_level: Dict[str, int] = bag_mapping[color]
+        multiplier: int = colors_to_check[color]
+        count += multiplier * count_bags(next_level, bag_mapping)
+    return count
+
+
+def part2(data: Dict[str, Dict[str, int]]) -> int:
+    return count_bags({'shiny gold': 1}, data) - 2
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day) if production_ready else sample_data
+    parsed_data = parse_data(raw_data)
+    print(part1(parsed_data))
+    print(part2(parsed_data))
diff --git a/2020/Day8.py b/2020/Day8.py
new file mode 100644
index 0000000..ccae3cd
--- /dev/null
+++ b/2020/Day8.py
@@ -0,0 +1,88 @@
+from typing import List, Set, Tuple
+
+from ImportData import import_data
+
+day = 8
+
+sample_data = [
+    'nop +0',
+    'acc +1',
+    'jmp +4',
+    'acc +3',
+    'jmp -3',
+    'acc -99',
+    'acc +1',
+    'jmp -4',
+    'acc +6'
+]
+
+
+def parse_data(data: List[str]) -> List[str]:
+    return data
+
+
+def parse_instruction(instruction: str) -> Tuple[str, int]:
+    fields: List[str] = instruction.split()
+    opcode: str = fields[0]
+    operand: int = int(fields[1])
+    return opcode, operand
+
+
+def execute_instruction(opcode: str, operand: int, program_counter: int, accumulator: int) -> Tuple[int, int]:
+    if opcode == 'nop':
+        program_counter += 1
+    elif opcode == 'acc':
+        accumulator += operand
+        program_counter += 1
+    elif opcode == 'jmp':
+        program_counter += operand
+    else:
+        raise RuntimeError(f'Illegal opcode: {opcode}')
+    return program_counter, accumulator
+
+
+def part1(program: List[str]) -> int:
+    accumulator: int = 0
+    program_counter: int = 0
+    history: Set[int] = set()
+    while program_counter not in history:
+        opcode, operand = parse_instruction(program[program_counter])
+        history.add(program_counter)
+        program_counter, accumulator = execute_instruction(opcode, operand, program_counter, accumulator)
+    return accumulator
+
+
+def mutate_opcode(opcode: str) -> str:
+    if opcode == 'nop':
+        return 'jmp'
+    if opcode == 'jmp':
+        return 'nop'
+    return opcode
+
+
+def part2(program: List[str]) -> int:
+    accumulator: int = 0
+    mutation_position: int = -1
+    successful: bool = False
+    while not successful:
+        accumulator = 0
+        program_counter: int = 0
+        history: Set[int] = set()
+        mutation_position += 1
+        while program_counter != len(program) and program_counter not in history:
+            opcode, operand = parse_instruction(program[program_counter])
+            history.add(program_counter)
+            if program_counter == mutation_position:
+                opcode = mutate_opcode(opcode)
+            program_counter, accumulator = execute_instruction(opcode, operand, program_counter, accumulator)
+        if program_counter == len(program):
+            successful = True
+    return accumulator
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day) if production_ready else sample_data
+    parsed_data = parse_data(raw_data)
+    print(part1(parsed_data))
+    print(part2(parsed_data))
diff --git a/2020/Day9.py b/2020/Day9.py
new file mode 100644
index 0000000..7bd9b66
--- /dev/null
+++ b/2020/Day9.py
@@ -0,0 +1,105 @@
+from typing import List, Tuple, Optional
+
+from ImportData import import_data
+
+day: int = 9
+
+sample_data: List[str] = [
+    '35',
+    '20',
+    '15',
+    '25',
+    '47',
+    '40',
+    '62',
+    '55',
+    '65',
+    '95',
+    '102',
+    '117',
+    '150',
+    '182',
+    '127',
+    '219',
+    '299',
+    '277',
+    '309',
+    '576'
+]
+
+data_structure: type = List[int]
+
+
+def parse_data(data: List[str]) -> data_structure:
+    return list(map(lambda s: int(s), data))
+
+
+def validity_check(value: int, preamble: List[int]) -> Tuple[Optional[int], Optional[int]]:
+    preamble_size: int = len(preamble)
+    i: int = 0
+    witness: List[Optional[int]] = [None, None]
+    while i < preamble_size - 1:
+        j: int = i + 1
+        while j < preamble_size:
+            if preamble[i] + preamble[j] == value:
+                witness = [preamble[i], preamble[j]]
+                i = preamble_size + 1
+                j = preamble_size + 1
+            else:
+                j += 1
+        i += 1
+    return witness[0], witness[1]
+
+
+def part1(data: data_structure, preamble_size: int) -> int:
+    preamble: List[int] = data[:preamble_size]
+    working_queue: List[int] = data[preamble_size:]
+    found_invalid_number: bool = False
+    while not found_invalid_number:
+        witness = validity_check(working_queue[0], preamble)
+        if witness[0] is None:
+            found_invalid_number = True
+        else:
+            preamble.pop(0)
+            preamble.append(working_queue.pop(0))
+    return working_queue[0]
+
+
+def find_range(number: int, data: List[int]) -> Tuple[int, int]:
+    data_length = len(data)
+    left: int = 0
+    # noinspection PyUnusedLocal
+    right: int = 0
+    weakness_sum: int = 0
+    while left < data_length - 1 and weakness_sum != number:
+        right = left + 1
+        weakness_sum = data[left]
+        while right < data_length and weakness_sum < number:
+            weakness_sum += data[right]
+            if weakness_sum != number:
+                right += 1
+        if weakness_sum != number:
+            left += 1
+    return left, right
+
+
+def part2(number: int, data: data_structure) -> int:
+    left, right = find_range(number, data)
+    least: int = data[left]
+    greatest: int = data[left]
+    for number in data[left:right + 1]:
+        if number < least:
+            least = number
+        if number > greatest:
+            greatest = number
+    return least + greatest
+
+
+if __name__ == '__main__':
+    production_ready = True
+    raw_data = import_data(day) if production_ready else sample_data
+    preamble_length: int = 25 if production_ready else 5
+    parsed_data = parse_data(raw_data)
+    invalid_number = part1(parsed_data, preamble_length)
+    print(invalid_number)
+    print(part2(invalid_number, parsed_data))
diff --git a/2020/DayXX b/2020/DayXX
new file mode 100644
index 0000000..9740ec2
--- /dev/null
+++ b/2020/DayXX
@@ -0,0 +1,30 @@
+from typing import List
+
+from ImportData import import_data
+
+day: int = X
+
+sample_data: List[str] = [
+
+]
+
+data_structure: type = Y
+
+
+def parse_data(data: List[str]) -> data_structure:
+    return None
+
+
+def part1(data: data_structure) -> int:
+    return -1
+
+
+def part2(data: data_structure) -> int:
+    return -1
+
+
+if __name__ == '__main__':
+    production_ready = False
+    raw_data = import_data(day) if production_ready else sample_data
+    print(part1(parse_data(raw_data)))
+    print(part2(parse_data(raw_data)))
diff --git a/2020/ImportData.TEMPLATE b/2020/ImportData.TEMPLATE
new file mode 100644
index 0000000..dec59dc
--- /dev/null
+++ b/2020/ImportData.TEMPLATE
@@ -0,0 +1,18 @@
+from typing import List
+
+import requests
+
+# noinspection SpellCheckingInspection
+session_token = ''
+
+
+def import_data(aoc_day: int, year: int = 2020) -> List[str]:
+    return requests.get(url=f"https://adventofcode.com/{year}/day/{aoc_day}/input",
+                        headers={'Cookie': f'session={session_token}'}).text.split("\n")[:-1]
+
+
+def read_file(filename: str) -> List[str]:
+    with open(filename) as file:
+        data = file.readlines()
+    line: str = ''
+    return [line.strip() for line in data]
diff --git a/2021/pom.xml b/2021/pom.xml
new file mode 100644
index 0000000..22efcff
--- /dev/null
+++ b/2021/pom.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>edu.unl.cse.bohn</groupId>
+    <artifactId>AdventOfCoding</artifactId>
+    <version>1.0-SNAPSHOT</version>
+
+    <properties>
+        <maven.compiler.source>17</maven.compiler.source>
+        <maven.compiler.target>17</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.googlecode.json-simple</groupId>
+            <artifactId>json-simple</artifactId>
+            <version>1.1.1</version>
+        </dependency>
+    </dependencies>
+
+</project>
\ No newline at end of file
diff --git a/2021/src/main/java/edu/unl/cse/bohn/ImportData.java b/2021/src/main/java/edu/unl/cse/bohn/ImportData.java
new file mode 100644
index 0000000..e472acd
--- /dev/null
+++ b/2021/src/main/java/edu/unl/cse/bohn/ImportData.java
@@ -0,0 +1,91 @@
+package edu.unl.cse.bohn;
+
+import org.json.simple.JSONObject;
+import org.json.simple.parser.JSONParser;
+import org.json.simple.parser.ParseException;
+
+import java.io.BufferedReader;
+import java.io.FileNotFoundException;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLConnection;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Objects;
+
+public class ImportData {
+
+    public static final String FILENAME = "apikeys.json";
+
+    protected final String protocol;
+    protected final String host;
+    protected final String path;
+    protected final String apiKey;
+
+    @SuppressWarnings("unused")
+    public static List<String> readFile(String filename) throws IOException {
+        List<String> data = new LinkedList<>();
+        BufferedReader bufferedReader;
+        bufferedReader = new BufferedReader(new FileReader(filename));
+        while (bufferedReader.ready()) {
+            data.add(bufferedReader.readLine());
+        }
+        return data;
+    }
+
+    public ImportData(String apiKeyName, int year, int day) {
+        String apiKey;
+        protocol = "https";
+        host = "adventofcode.com";
+        path = "/" + year + "/day/" + day + "/input";
+        try {
+            apiKey = getApiKey(apiKeyName);
+        } catch (IOException ioException) {
+            System.err.println("Could not retrieve API key: " + ioException.getMessage());
+            apiKey = null;
+        }
+        this.apiKey = apiKey;
+    }
+
+    protected String getApiKey(String apiKeyName) throws IOException {
+        JSONObject apiKeyJson;
+        try (InputStreamReader inputStreamReader = new InputStreamReader(
+                Objects.requireNonNull(ImportData.class.getClassLoader().getResourceAsStream(FILENAME)))) {
+            apiKeyJson = (JSONObject)new JSONParser().parse(inputStreamReader);
+        } catch (NullPointerException nullPointerException) {
+            FileNotFoundException newException = new FileNotFoundException("File " + FILENAME + " not found.");
+            newException.initCause(nullPointerException);
+            throw newException;
+        } catch (ParseException parseException) {
+            throw new IOException("Error while parsing file " + FILENAME + ".", parseException);
+        }
+        if (!apiKeyJson.containsKey(apiKeyName)) {
+            System.err.println("WARNING! Could not locate API key named " + apiKeyName + " in file " + FILENAME + ".");
+        }
+        String apiKey = apiKeyJson.get(apiKeyName).toString();
+        if (apiKey.equals("")) {
+            System.err.println("WARNING! API key named " + apiKeyName + " in file " + FILENAME + " is blank.");
+        }
+        return apiKey;
+    }
+
+    public List<String> importData() throws IOException {
+        List<String> data = new LinkedList<>();
+        BufferedReader bufferedReader;
+        try {
+            URLConnection connection = new URI(protocol, host, path, null).toURL().openConnection();
+            connection.setRequestProperty("Cookie", apiKey);
+            bufferedReader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
+        } catch (URISyntaxException | MalformedURLException originalException) {
+            throw new IOException("Could not retrieve usable data from " + host + ".", originalException);
+        }
+        while (bufferedReader.ready()) {
+            data.add(bufferedReader.readLine());
+        }
+        return data;
+    }
+}
diff --git a/2021/src/main/java/edu/unl/cse/bohn/Main.java b/2021/src/main/java/edu/unl/cse/bohn/Main.java
new file mode 100644
index 0000000..f2d082f
--- /dev/null
+++ b/2021/src/main/java/edu/unl/cse/bohn/Main.java
@@ -0,0 +1,49 @@
+package edu.unl.cse.bohn;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.Calendar;
+import java.util.Scanner;
+
+public class Main {
+    public static final String defaultApiKey = "aoc";
+
+    private static String getUserInput(String prompt, String defaultValue, Scanner scanner) {
+        System.out.print(prompt + "[" + defaultValue + "]: ");
+        String userInput = scanner.nextLine();
+        return userInput.equals("") ? defaultValue : userInput;
+    }
+
+    public static void main(String... arguments) {
+        Scanner scanner = new Scanner(System.in);
+        String apiKey = getUserInput("Enter API key", defaultApiKey, scanner);
+        Calendar calendar = Calendar.getInstance();
+        int year = Integer.parseInt(getUserInput("Enter puzzle year",
+                Integer.toString(calendar.get(Calendar.YEAR)), scanner));
+        int day = Integer.parseInt(getUserInput("Enter puzzle day",
+                Integer.toString(calendar.get(Calendar.DAY_OF_MONTH)), scanner));
+        scanner.close();
+        ImportData dataSource = new ImportData(apiKey, year, day);
+        String className = Main.class.getPackageName() + ".year" + year + ".Day" + day;
+        Puzzle puzzle = null;
+        try {
+            puzzle = (Puzzle)Class.forName(className).getConstructors()[0]
+                    .newInstance();
+        } catch (ClassNotFoundException classNotFoundException) {
+            System.err.println("Could not find class " + className);
+            System.exit(1);
+        } catch (InstantiationException ignored) {
+            System.err.println(className + " is an abstract class.");
+            System.exit(1);
+        } catch (IllegalAccessException ignored) {
+            System.err.println("The requested constructor for " + className + " is inaccessible.");
+            System.exit(1);
+        } catch (InvocationTargetException invocationTargetException) {
+            System.err.println("The constructor for " + className + " threw an exception.");
+            System.err.println(invocationTargetException.getMessage());
+            Throwable originalException = invocationTargetException.getCause();
+            System.err.println("Caused by: " + originalException);
+            System.exit(1);
+        }
+        puzzle.solvePuzzle(dataSource);
+    }
+}
diff --git a/2021/src/main/java/edu/unl/cse/bohn/Puzzle.java b/2021/src/main/java/edu/unl/cse/bohn/Puzzle.java
new file mode 100644
index 0000000..ef977db
--- /dev/null
+++ b/2021/src/main/java/edu/unl/cse/bohn/Puzzle.java
@@ -0,0 +1,25 @@
+package edu.unl.cse.bohn;
+
+import java.io.IOException;
+import java.util.List;
+
+public abstract class Puzzle {
+    protected Integer day = null;
+    protected String sampleData = "";
+    protected boolean isProductionReady = false;
+
+    public abstract int computePart1(List<String> data);
+    public abstract int computePart2(List<String> data);
+
+    public void solvePuzzle(ImportData dataSource) {
+        List<String> data = null;
+        try {
+            data = isProductionReady ? dataSource.importData() : List.of(sampleData.split(System.lineSeparator()));
+        } catch (IOException ioException) {
+            System.err.println("Could not retrieve data: " + ioException);
+            System.exit(1);
+        }
+        System.out.println("Part 1: " + computePart1(data));
+        System.out.println("Part 2: " + computePart2(data));
+    }
+}
diff --git a/2021/src/main/java/edu/unl/cse/bohn/year2021/Day1.java b/2021/src/main/java/edu/unl/cse/bohn/year2021/Day1.java
new file mode 100644
index 0000000..2ad7cd2
--- /dev/null
+++ b/2021/src/main/java/edu/unl/cse/bohn/year2021/Day1.java
@@ -0,0 +1,56 @@
+package edu.unl.cse.bohn.year2021;
+
+import edu.unl.cse.bohn.Puzzle;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@SuppressWarnings("unused")
+public class Day1 extends Puzzle {
+    List<Integer> depths;
+
+    public Day1() {
+        day = 1;
+        sampleData = """
+                199
+                200
+                208
+                210
+                200
+                207
+                240
+                269
+                260
+                263""";
+        isProductionReady = true;
+    }
+
+    private int countIncreases(List<Integer> values) {
+        Integer lastValue = null;
+        int increases = 0;
+        for (int value : values) {
+            if ((lastValue != null) && (value > lastValue)) {
+                increases++;
+            }
+            lastValue = value;
+        }
+        return increases;
+    }
+
+    @Override
+    public int computePart1(List<String> data) {
+        depths = data.stream().map(Integer::parseInt).collect(Collectors.toList());
+        return countIncreases(depths);
+    }
+
+    @Override
+    public int computePart2(List<String> data) {
+        int numberOfWindows = depths.size() - 2;
+        List<Integer> slidingWindows = new ArrayList<>(numberOfWindows);
+        for (int i = 0; i < numberOfWindows; i++) {
+            slidingWindows.add(depths.get(i) + depths.get(i + 1) + depths.get(i + 2));
+        }
+        return countIncreases(slidingWindows);
+    }
+}
diff --git a/2021/src/main/resources/apikeys.TEMPLATE b/2021/src/main/resources/apikeys.TEMPLATE
new file mode 100644
index 0000000..13923fa
--- /dev/null
+++ b/2021/src/main/resources/apikeys.TEMPLATE
@@ -0,0 +1,3 @@
+{
+    "aoc": ""
+}
diff --git a/README.md b/README.md
index 5344c27..4c081cc 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,5 @@
-# Advent of Coding
+# 2021 Advent of Coding Solutions
 
+## Day 1
+
+A nice little warmup exercise in which we iterate over a list.
-- 
GitLab