diff --git a/2022/README.md b/2022/README.md
index d6d1b75bffa14c401092015a271e84fb2dd5567a..7520a7a3f97864ebf46419b6c2fa5f43ac9d399b 100644
--- a/2022/README.md
+++ b/2022/README.md
@@ -543,7 +543,28 @@ The subproblems are
 
 Since I created a `compareTo()` method as part of Part 1, Part 2 is easy.
 
-## Day 14
+## Day 1
+
+- [The problem](https://adventofcode.com/2022/day/14)
+- [The solution](src/main/java/edu/unl/cse/bohn/year2022/Day14.java)
+
+### Part 1
+
+The subproblems are
+- Determine which locations are blocked
+- Determine where a unit of sand moves to
+  - Can it move?
+  - Down, diagonally left, diagonally right?
+- Determine the final status of a unit of sand
+  - Stationary, blocking a location
+  - Falling forever
+
+### Part 2
+
+Same subproblems, except that falling forever isn't an option.
+Instead, we'll have to pretend there's an infinitely-long floor.
+
+## Day 15
 
 (coming soon)
 
diff --git a/2022/src/main/java/edu/unl/cse/bohn/year2022/Day14.java b/2022/src/main/java/edu/unl/cse/bohn/year2022/Day14.java
new file mode 100644
index 0000000000000000000000000000000000000000..d630b76f015076d019f8f67642c08a7d0ac2bb2b
--- /dev/null
+++ b/2022/src/main/java/edu/unl/cse/bohn/year2022/Day14.java
@@ -0,0 +1,178 @@
+package edu.unl.cse.bohn.year2022;
+
+import edu.unl.cse.bohn.Puzzle;
+
+import java.util.*;
+
+@SuppressWarnings("unused")
+public class Day14 extends Puzzle {
+
+    public Day14(boolean isProductionReady) {
+        super(isProductionReady);
+        sampleData = """
+                498,4 -> 498,6 -> 496,6
+                503,4 -> 502,4 -> 502,9 -> 494,9""";
+    }
+
+    public static final int SAND_ORIGIN = 500;
+    public static final boolean verbose = false;
+
+    @Override
+    public long computePart1(List<String> data) {
+        for (String datum : data) {
+            Location.block(datum);
+        }
+        int amountOfRestingSand = 0;
+        Sand sand;
+        do {
+            sand = new Sand();
+            //noinspection StatementWithEmptyBody
+            while (sand.fall() && !sand.isFallingForever()) {
+            }
+            if (sand.isStationary()) amountOfRestingSand++;
+        } while (!sand.isFallingForever());
+        return amountOfRestingSand;
+    }
+
+    @Override
+    public long computePart2(List<String> data) {
+        Location.reset();
+        for (String datum : data) {
+            Location.block(datum);
+        }
+        Location.addFloor();
+        int amountOfRestingSand = 0;
+        Sand sand;
+        do {
+            sand = new Sand();
+            //noinspection StatementWithEmptyBody
+            while (sand.fall()) {
+            }
+            amountOfRestingSand++;
+        } while (!Location.isBlocked(SAND_ORIGIN, 0));
+        return amountOfRestingSand;
+    }
+
+    private record Location(int x, int y) {
+        private static Set<Location> blockedLocations = new HashSet<>();
+        private static int maximumDepth = Integer.MIN_VALUE;
+        private static boolean hasFloor = false;
+
+        public static void block(int x, int y) {
+            blockedLocations.add(new Location(x, y));
+            if (verbose) {
+                System.out.println("Blocking (" + x + "," + y + ")"
+                        + ((y > maximumDepth) ? " ** new maximum depth **" : ""));
+            }
+            if (!hasFloor && y > maximumDepth) {
+                maximumDepth = y;
+            }
+        }
+
+        public static boolean isBlocked(int x, int y) {
+            return (hasFloor && y >= maximumDepth + 2) || blockedLocations.contains(new Location(x, y));
+        }
+
+        public static void block(String structure) {
+            if (structure.contains(" -> ")) {
+                int firstArrowIndex = structure.indexOf(" -> ");
+                int[] firstPoint = Arrays.stream(structure.substring(0, firstArrowIndex).strip().split(","))
+                        .mapToInt(Integer::parseInt).toArray();
+                String restOftheStructure = structure.substring(firstArrowIndex + " -> ".length());
+                int endOfNextPointIndex = restOftheStructure.indexOf(" -> ");
+                endOfNextPointIndex = endOfNextPointIndex == -1 ? restOftheStructure.length() : endOfNextPointIndex;
+                int[] nextPoint = Arrays.stream(restOftheStructure.substring(0, endOfNextPointIndex).strip().split(","))
+                        .mapToInt(Integer::parseInt).toArray();
+                int x = firstPoint[0];
+                int y = firstPoint[1];
+                while (x != nextPoint[0] || y != nextPoint[1]) {
+                    Location.block(x, y);
+                    if (x < nextPoint[0]) x++;
+                    if (x > nextPoint[0]) x--;
+                    if (y < nextPoint[1]) y++;
+                    if (y > nextPoint[1]) y--;
+                }
+                block(restOftheStructure);
+            } else {
+                int[] coordinates = Arrays.stream(structure.strip().split(",")).mapToInt(Integer::parseInt).toArray();
+                block(coordinates[0], coordinates[1]);
+            }
+        }
+
+        public static int getDeepestDepths() {
+            return maximumDepth;
+        }
+
+        public static void reset() {
+            blockedLocations = new HashSet<>();
+            maximumDepth = Integer.MIN_VALUE;
+            hasFloor = false;
+        }
+
+        public static void addFloor() {
+            hasFloor = true;
+        }
+
+        public static void removeFloor() {
+            hasFloor = false;
+        }
+
+        @Override
+        public boolean equals(Object other) {
+            if (this == other) return true;
+            if (!(other instanceof Location location)) return false;
+            return x == location.x && y == location.y;
+        }
+    }
+
+    private static class Sand {
+        private int x, y;
+
+        public Sand() {
+            x = SAND_ORIGIN;
+            y = 0;
+        }
+
+        /**
+         * Causes a grain of sand to move if it can. If (x,y+1) is unblocked, then the sand moves to (x,y+1).
+         * Otherwise, if (x-1,y+1) is unblocked, then the sand moves to (x-1,y+1).
+         * Otherwise, if (x+1,y+1) is unblocked, then the sand moves to (x+1,y+1).
+         * Otherwise, the sand remains stationary.
+         *
+         * @return <code>true</code> if the sand moves, <code>false</code> if the sand remains stationary
+         */
+        public boolean fall() {
+            boolean moved = false;
+            if (!Location.isBlocked(x, y + 1)) {
+                y++;
+                moved = true;
+            } else if (!Location.isBlocked(x - 1, y + 1)) {
+                x--;
+                y++;
+                moved = true;
+            } else if (!Location.isBlocked(x + 1, y + 1)) {
+                x++;
+                y++;
+                moved = true;
+            } else {
+                Location.block(x, y);
+                if (verbose) {
+                    System.out.println("Sand is resting at " + x + "," + y);
+                }
+            }
+            return moved;
+        }
+
+        public boolean isStationary() {
+            return Location.isBlocked(x, y + 1) && Location.isBlocked(x - 1, y + 1) && Location.isBlocked(x + 1, y + 1);
+        }
+
+        @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+        public boolean isFallingForever() {
+            if (verbose && y > Location.getDeepestDepths()) {
+                System.out.println("Sand at " + x + "," + y + " is falling forever");
+            }
+            return y > Location.getDeepestDepths();
+        }
+    }
+}