diff --git a/2023/ruby/day_10.rb b/2023/ruby/day_10.rb
new file mode 100644
index 0000000..50c25c3
--- /dev/null
+++ b/2023/ruby/day_10.rb
@@ -0,0 +1,104 @@
+require "forwardable"
+
+DIR_DELTAS = {
+ N: [-1, 0],
+ W: [ 0, -1],
+ E: [ 0, 1],
+ S: [ 1, 0],
+}
+TILE_NEIGHBOR_DELTAS = {
+ ?| => "NS",
+ ?- => "EW",
+ ?L => "NE",
+ ?J => "NW",
+ ?7 => "SW",
+ ?F => "SE",
+ ?S => "NEWS",
+}.transform_values {|dirs| dirs.chars.map { DIR_DELTAS.fetch(_1.to_sym) }}
+
+Tiles = Data.define(:tiles) do
+ extend Forwardable
+ def_delegator :tiles, :key
+ def_delegator :tiles, :fetch
+ def_delegator :tiles, :size
+
+ def neighbors(coord)
+ TILE_NEIGHBOR_DELTAS.fetch(self.fetch(coord))
+ .map {|delta| coord.zip(delta).map { _1 + _2 }}
+ .select { tiles.has_key?(_1) }
+ end
+
+ def to_s
+ coords = tiles.keys
+ y_range, x_range = *extents
+ y_range.map {|y|
+ x_range.map {|x| tiles.fetch([y,x], ?.) }.join
+ }.join("\n")
+ end
+
+ def extents
+ y_range = Range.new(*tiles.keys.map(&:first).minmax)
+ x_range = Range.new(*tiles.keys.map(&:last).minmax)
+ [y_range, x_range]
+ end
+end
+
+tiles = ARGF.readlines(chomp: true).each.with_index.inject({}) {|tiles, (row, y)|
+ tiles.merge(
+ row.chars.each.with_index
+ .reject {|tile,_| tile == ?. }
+ .to_h {|tile,x| [[y,x], tile] }
+ )
+}
+tiles = Tiles.new(tiles)
+
+start = tiles.key(?S)
+seed = tiles.neighbors(start)
+ .find {|candidate|
+ tiles.neighbors(candidate).any? { tiles.fetch(_1) == ?S }
+ }
+
+loop_ = [seed]
+until loop_.last == start
+ cur = loop_.last
+ loop_ << tiles.neighbors(cur)
+ .reject { _1 == loop_[-2] }
+ .first
+end
+
+# fix the start tile
+start_dirs = [seed, loop_[-2]].map {|coord| start.zip(coord).map { _2 - _1 }}
+tile = TILE_NEIGHBOR_DELTAS.find { _2.sort == start_dirs.sort }[0]
+tiles.tiles[start] = tile
+
+loop_ = Tiles.new(loop_.to_h { [_1, tiles.fetch(_1)] })
+# puts loop_
+
+# part one
+p loop_.size / 2
+
+# part two
+y_range, x_range = *loop_.extents
+p y_range.map {|y|
+ x_range.chunk_while {|x_a, x_b|
+ a = loop_.fetch([y, x_a], nil)
+ b = loop_.fetch([y, x_b], nil)
+
+ (a.nil? && b.nil?) || (a && TILE_NEIGHBOR_DELTAS.fetch(a).include?([0,1]))
+ }.select {|chunk|
+ # keep empty tiles
+ end_tiles = chunk.values_at(0, -1).map {|x| loop_.fetch([y,x], nil) }
+ next true if end_tiles.all?(&:nil?)
+
+ # only keep pipes that cross the horizontal axis
+ deltas = end_tiles.flat_map {|tile| TILE_NEIGHBOR_DELTAS.fetch(tile) }
+ [[-1,0], [1,0]].all? {|dy| deltas.include?(dy) }
+ }.inject([false, 0]) {|(enclosed, count), chunk|
+ start_tile = loop_.fetch([y,chunk[0]], nil)
+ if start_tile.nil?
+ [enclosed, count + (enclosed ? chunk.size : 0)]
+ else
+ [!enclosed, count]
+ end
+ }
+}.sum(&:last)