On Day 17 we need to write Tetris simulator. It is pretty cool task. It took me back to my highschool years when I wanted to be a game developer and Tetris was the first game I wrote as practice.
The one in the puzzle is a bit different. We have 5 different blocks:
####
.#.
###
.#.
..#
..#
###
#
#
#
#
##
##
And we also have the input file that looks like this:
>>><<><>><<<>><>>><<<>>><<<><<<>><>><<>>
Those are the moves that will be applied to falling blocks in each cycle. So in each cycle current block is moving left or right one unit (if it can) and then one unit down (again if it can). We start from the first block on the list and when it settles down on the bottom we move to the next. If we ran out of blocks the list starts from the beginning. The same goes for left/right movements. When we ran out of moves we start from the beginning.
Now the actual task is to simulate 2022 blocks dropping and then output the height of tower of blocks.
I just implemented the actual Tetris simulator. Here is my code:
data = File.read("17.txt", chomp: true).split("")
ROCKS = [
  [
    [true, true, true, true]
  ],
  [
    [false, true, false],
    [true, true, true],
    [false, true, false]
  ],
  [
    [true, true, true],
    [false, false, true],
    [false, false, true]
  ],
  [
    [true],
    [true],
    [true],
    [true]
  ],
  [
    [true, true],
    [true, true]
  ]
]
class Rock
  def initialize(index, x, y)
    @index = index
    @x = x
    @y = y
    rock = ROCKS[index]
    points = []
    rock.each_with_index do |row, y|
      row.each_with_index do |elem, x|
        if elem
          points.push([x, y])
        end
      end
    end
    @points = points
  end
  def height
    ROCKS[@index].size
  end
  def width
    ROCKS[@index][0].size
  end
  def points
    @points.map do |point|
      [@x + point[0], @y + point[1]]
    end
  end
  def push_left
    @x -= 1
  end
  def push_right
    @x += 1
  end
  def push_down
    @y -= 1
  end
  def offseted_points(offset_x, offset_y)
    @points.map do |point|
      [@x + point[0] + offset_x, @y + point[1] + offset_y]
    end
  end
end
class Map
  def initialize
    @width = 7
    @height = 0
    @map = []
    @height.times do |y|
      @width.times do |x|
        @map[y] = Array.new(@width, ".")
      end
    end
  end
  def height
    @height
  end
  def set(x, y, value)
    @map[y][x] = value
  end
  def extend_height(rows)
    rows.times do
      @map.push(Array.new(@width, "."))
      @height += 1
    end
  end
  def render
    if @current_rock
      @current_rock.points.each do |(x, y)|
        set(x, y, "@")
      end
    end
    @height.times do |y|
      row = "#{(@height - y - 1).to_s.rjust(4)} |"
      @width.times do |x|
        row += @map[@height - y - 1][x]
      end
      row += "|"
      puts row
    end
    row = "     +"
    (@width).times do
      row += "-"
    end
    row += "+"
    puts row
    if @current_rock
      @current_rock.points.each do |(x, y)|
        set(x, y, ".")
      end
    end
  end
  def top_full_row
    (@height - 5).downto(0) do |y|
      row = Array.new(@width, false)
      @map[y].each_with_index { |elem, x| row[x] = true if elem == "#" }
      @map[y + 1].each_with_index { |elem, x| row[x] = true if elem == "#" }
      @map[y + 2].each_with_index { |elem, x| row[x] = true if elem == "#" }
      @map[y + 3].each_with_index { |elem, x| row[x] = true if elem == "#" }
      if row.all? { |elem| elem }
        return y + 1
      end
    end
    return nil
  end
  def collide?(points)
    points.any? do |(x, y)|
      x < 0 || x > (@width - 1) || y < 0 || y > (@height - 1) || @map[y][x] == "#"
    end
  end
  def add_rock(index)
    rock_height = ROCKS[index].size
    required_height = top_rock + 3 + rock_height
    if required_height > @height
      extend_height(required_height - @height)
    end
    @current_rock = Rock.new(index, 2, top_rock + 3)
  end
  def push_left
    return unless @current_rock
    offseted_points = @current_rock.offseted_points(-1, 0)
    if !collide?(offseted_points)
      @current_rock.push_left
    end
  end
  def push_right
    return unless @current_rock
    offseted_points = @current_rock.offseted_points(1, 0)
    if !collide?(offseted_points)
      @current_rock.push_right
    end
  end
  def push_down
    return unless @current_rock
    offseted_points = @current_rock.offseted_points(0, -1)
    if collide?(offseted_points)
      @current_rock.points.each do |(x, y)|
        set(x, y, "#")
      end
      @current_rock = nil
      return false
    else
      @current_rock.push_down
      return true
    end
  end
  def top_rock
    (@height - 1).downto(0) do |y|
      if @map[y].any? { |elem| elem == "#" }
        return y + 1
      end
    end
    return 0
  end
  def result
    top_rock
  end
end
rock_index = 0
move_index = 0
x = 2
y = 1
rocks = x * 20 + y * 15
map = Map.new
2022.times do
  map.add_rock(rock_index)
  while true
    if data[move_index] == "<"
      map.push_left
    elsif data[move_index] == ">"
      map.push_right
    end
    move_index = (move_index + 1) % data.size
    break unless map.push_down
  end
  rock_index = (rock_index + 1) % ROCKS.size
end
puts map.result
And here is how the bottom of my tower looks like for test input:
 100 |....##.|
  99 |....##.|
  98 |....#..|
  97 |..#.#..|
  96 |..#.#..|
  95 |#####..|
  94 |..###..|
  93 |...#...|
  92 |..####.|
  91 |..###..|
  90 |..###..|
  89 |..####.|
  88 |....###|
  87 |.#...#.|
  86 |.#####.|
  85 |.#..#..|
  84 |.#..#..|
  83 |.##.##.|
  82 |.##.##.|
  81 |..####.|
  80 |.###...|
  79 |..#....|
  78 |.####..|
  77 |....##.|
  76 |....##.|
  75 |....#..|
  74 |..#.#..|
  73 |..#.#..|
  72 |#####..|
  71 |..###..|
  70 |...#...|
  69 |..####.|
  68 |..###..|
  67 |..###..|
  66 |..####.|
  65 |....###|
  64 |.#...#.|
  63 |.#####.|
  62 |.#..#..|
  61 |.#..#..|
  60 |.##.##.|
  59 |.##.##.|
  58 |..####.|
  57 |.###...|
  56 |..#....|
  55 |.####..|
  54 |....##.|
  53 |....##.|
  52 |....#..|
  51 |..#.#..|
  50 |..#.#..|
  49 |#####..|
  48 |..###..|
  47 |...#...|
  46 |..####.|
  45 |..###..|
  44 |..###..|
  43 |..####.|
  42 |....###|
  41 |.#...#.|
  40 |.#####.|
  39 |.#..#..|
  38 |.#..#..|
  37 |.##.##.|
  36 |.##.##.|
  35 |..####.|
  34 |.###...|
  33 |..#....|
  32 |.####..|
  31 |....##.|
  30 |....##.|
  29 |....#..|
  28 |..#.#..|
  27 |..#.#..|
  26 |#####..|
  25 |..###..|
  24 |...#...|
  23 |..####.|
  22 |..###..|
  21 |..###..|
  20 |..####.|
  19 |....###|
  18 |.#...#.|
  17 |.#####.|
  16 |.#..#..|
  15 |.#..#..|
  14 |.##.##.|
  13 |.##.##.|
  12 |..####.|
  11 |.###...|
  10 |..#....|
   9 |.####..|
   8 |....##.|
   7 |....##.|
   6 |....#..|
   5 |..#.#..|
   4 |..#.#..|
   3 |#####..|
   2 |..###..|
   1 |...#...|
   0 |..####.|
     +-------+
Part B raises difficulty significantly. We need to output the height of the tower after dropping 1000000000000 blocks!
Simulating this will take a lot of time, so we need to find another way. If you look at the actual at the game board I pasted above you can see that certain configurations at certain intervals. You can see the same blocks at height 31, 54, 77 and 100. The different is the same and it is 23 blocks.
So it must be the same for the actual input. We need to implement some code to detect this pattern. We can start with some additional method for our map:
def top16
    numbers = []
    (top_rock - 1).downto([top_rock - 16, 0].max) do |y|
      number = []
      @width.times do |x|
        number.push(@map[y][x] == "." ? 0 : 1)
      end
      number = number.join
      numbers.push(number.to_i(2))
    end
    numbers
  end
This method will return top 16 lines and convert it to numbers. Empty space is considered as 0 and block is considered as 1. We can then convert it to decimal number. So in the end we will have an array of 16 integers.
Then we can change our simulator to look for patterns:
def tetris(blocks)
  rock_index = 0
  move_index = 0
  x = 2
  y = 1
  rocks = x * 20 + y * 15
  map = Map.new
  cache = {}
  heights = {}
  blocks.times do |i|
    map.add_rock(rock_index)
    current_top_rock = map.top_rock
    while true
      if @data[move_index] == "<"
        map.push_left
      elsif @data[move_index] == ">"
        map.push_right
      end
      move_index = (move_index + 1) % @data.size
      break unless map.push_down
    end
    cache[map.top16] ||= []
    cache[map.top16].push([i + 1, map.top_rock])
    rock_index = (rock_index + 1) % ROCKS.size
  end
  indexes = cache[cache.keys.last]
  diff = indexes[1][0] - indexes[0][0]
  multiplier = indexes[1][1] - indexes[0][1]
  cache.each do |top, indexes|
    next if indexes.size == 1
    heights[indexes[0][0]] = indexes[0][1]
  end
  [map.result, diff, multiplier, heights]
end
So now after each block drops we are adding the block number and current height to our cache. Key to our cache is this array of top 16 blocks converted to integers. So each key of our cache should tell us after which block we got the same configuration and what was it’s height.
This top 16 block can look like this [30, 52, 52, 60, 40, 60, 40, 120, 19, 19, 18, 18, 30, 56, 16, 30] and example cached values like this [[1996, 3044], [3726, 5691], [5456, 8338], [7186, 10985], [8916, 13632]]. First number is block number and second one is the height. If we subtract the same values from adjacent indexes we will get this [[1730, 2647], [1730, 2647], [1730, 2647], [1730, 2647]]. This means that after we see all possible combinations they start to repeat every 1730 blocks. And the height increases by 2647 blocks.
The below part of code is also caching the heights of the first appearances of certain patterns.
cache.each do |top, indexes|
  next if indexes.size == 1
  heights[indexes[0][0]] = indexes[0][1]
end
Now having all those above information we can figure out what will be the height after one billion blocks:
def predict_height(blocks, diff, multiplier, heights)
  modulo = blocks % diff
  modulo += diff unless heights[modulo]
  base = heights[modulo]
  base + ((blocks - modulo) / diff) * multiplier
end
height, diff, multiplier, heights = tetris(10_000)
puts predict_height(1_000_000_000_000, diff, multiplier, heights)
Here is the full code:
@data = File.read("17.txt").strip.split("")
ROCKS = [
  [
    [true, true, true, true]
  ],
  [
    [false, true, false],
    [true, true, true],
    [false, true, false]
  ],
  [
    [true, true, true],
    [false, false, true],
    [false, false, true]
  ],
  [
    [true],
    [true],
    [true],
    [true]
  ],
  [
    [true, true],
    [true, true]
  ]
]
MAX = 100
class Rock
  attr_reader :index
  def initialize(index, x, y)
    @index = index
    @x = x
    @y = y
    rock = ROCKS[index]
    points = []
    rock.each_with_index do |row, y|
      row.each_with_index do |elem, x|
        if elem
          points.push([x, y])
        end
      end
    end
    @points = points
  end
  def height
    ROCKS[@index].size
  end
  def width
    ROCKS[@index][0].size
  end
  def points
    @points.map do |point|
      [@x + point[0], @y + point[1]]
    end
  end
  def push_left
    @x -= 1
  end
  def push_right
    @x += 1
  end
  def push_down
    @y -= 1
  end
  def offseted_points(offset_x, offset_y)
    @points.map do |point|
      [@x + point[0] + offset_x, @y + point[1] + offset_y]
    end
  end
end
class Map
  def initialize
    @width = 7
    @height = 0
    @shrinked = 0
    @map = []
    @height.times do |y|
      @width.times do |x|
        @map[y] = Array.new(@width, ".")
      end
    end
  end
  def height
    @height
  end
  def set(x, y, value)
    @map[y][x] = value
  end
  def extend_height(rows)
    rows.times do
      @map.push(Array.new(@width, "."))
      @height += 1
    end
  end
  def render
    if @current_rock
      @current_rock.points.each do |(x, y)|
        set(x, y, "@")
      end
    end
    @height.times do |y|
      row = "#{(@height - y - 1).to_s.rjust(4)} |"
      @width.times do |x|
        row += @map[@height - y - 1][x]
      end
      row += "|"
      puts row
    end
    row = "     +"
    (@width).times do
      row += "-"
    end
    row += "+"
    puts row
    if @current_rock
      @current_rock.points.each do |(x, y)|
        set(x, y, ".")
      end
    end
  end
  def top16
    numbers = []
    (top_rock - 1).downto([top_rock - 16, 0].max) do |y|
      number = []
      @width.times do |x|
        number.push(@map[y][x] == "." ? 0 : 1)
      end
      number = number.join
      numbers.push(number.to_i(2))
    end
    numbers
  end
  def collide?(points)
    points.any? do |(x, y)|
      x < 0 || x > (@width - 1) || y < 0 || y > (@height - 1) || @map[y][x] == "#"
    end
  end
  def add_rock(index)
    rock_height = ROCKS[index].size
    required_height = top_rock + 3 + rock_height
    if required_height > @height
      extend_height(required_height - @height)
    end
    @current_rock = Rock.new(index, 2, top_rock + 3)
  end
  def push_left
    return unless @current_rock
    offseted_points = @current_rock.offseted_points(-1, 0)
    if !collide?(offseted_points)
      @current_rock.push_left
    end
  end
  def push_right
    return unless @current_rock
    offseted_points = @current_rock.offseted_points(1, 0)
    if !collide?(offseted_points)
      @current_rock.push_right
    end
  end
  def push_down
    return unless @current_rock
    offseted_points = @current_rock.offseted_points(0, -1)
    if collide?(offseted_points)
      @current_rock.points.each do |(x, y)|
        set(x, y, "#")
      end
      @current_rock = nil
      return false
    else
      @current_rock.push_down
      return true
    end
  end
  def top_rock
    (@height - 1).downto(0) do |y|
      if @map[y].any? { |elem| elem == "#" }
        return y + 1
      end
    end
    return 0
  end
  def result
    top_rock
  end
end
def tetris(blocks)
  rock_index = 0
  move_index = 0
  x = 2
  y = 1
  rocks = x * 20 + y * 15
  map = Map.new
  cache = {}
  heights = {}
  blocks.times do |i|
    map.add_rock(rock_index)
    current_top_rock = map.top_rock
    while true
      if @data[move_index] == "<"
        map.push_left
      elsif @data[move_index] == ">"
        map.push_right
      end
      move_index = (move_index + 1) % @data.size
      break unless map.push_down
    end
    cache[map.top16] ||= []
    cache[map.top16].push([i + 1, map.top_rock])
    rock_index = (rock_index + 1) % ROCKS.size
  end
  indexes = cache[cache.keys.last]
  diff = indexes[1][0] - indexes[0][0]
  multiplier = indexes[1][1] - indexes[0][1]
  cache.each do |top, indexes|
    next if indexes.size == 1
    heights[indexes[0][0]] = indexes[0][1]
  end
  [map.result, diff, multiplier, heights]
end
def predict_height(blocks, diff, multiplier, heights)
  modulo = blocks % diff
  modulo += diff unless heights[modulo]
  base = heights[modulo]
  base + ((blocks - modulo) / diff) * multiplier
end
height, diff, multiplier, heights = tetris(10_000)
puts predict_height(1_000_000_000_000, diff, multiplier, heights)