In this article we will write a terminal–based real–time dungeon crawler. I will to keep it under 150 lines with idiomatic (no code golfing!) Ruby code.

Result game

Main game loop

I want to start with something basic. We need a class that will contain our game state and draw the screen. Let’s create a game.rb with the following content:

class Game
  SLEEP_INTERVAL = 0.2

  def run
    loop do
      draw_screen
      sleep SLEEP_INTERVAL
    end
  end

  private

  def draw_screen
    system "clear"

    puts Time.now
  end
end

Game.new.run

Source is here

Run it (using ruby ./game.rb) and you see that it updates the current time every SLEEP_INTERVAL seconds. Nothing fancy yet!

Rendering the map

Our next goal is to render a level map. Let’s keep the map in the file called map.txt, which will be in the same directory as a game.rb. We need five types of objects:

  • an empty place (s);
  • a start point—a place where player appears (p);
  • a final point—an exit from the level (d);
  • a tree—the cell that could not be entered (t);
  • an enemy—something that kills the player when they appear on the same place (e).

Feel free to design you own map, or use mine:

tsssstsdst
tsssssesst
tstssssset
tstsssssst
tsstssssst
tssssstsst
tstssstsst
tpstsstsst

Here is a class that parses the file and renders trees and empty spaces:

TREE, SPACE = '🌲', "・"

Level = Struct.new(:map, :enemies, :player, :door)

class LevelBuilder
  def initialize(filepath)
    @filepath = filepath
  end

  MAPPING = { 't' => TREE, 's' => SPACE }

  def build
    Level.new.tap do |level|
      level.enemies = []

      level.map = File.readlines(@filepath).map do |line|
        line.sub("\n", "").chars.map do |c|
          MAPPING[c] || SPACE
        end
      end
    end
  end
end

Note that we do not handle start location, final location and enemies yet. Instead, we treat them as empty spaces: since we will have a specific logic tied to them, we will add it later.

Now we need to update our game class to draw (and redraw) the field:

class Game
  SLEEP_INTERVAL = 0.2

  def run
    @level = LevelBuilder.new("./map.txt").build

    loop do
      draw_screen
      sleep SLEEP_INTERVAL
    end
  end

  private

  def draw_screen
    system "clear"

    @level.each do |row|
      row.each do |cell|
        print cell
      end
      puts
    end
  end
end

The only thing it does is just prints whatever we have in in the map field. The only thing that might be unfamiliar to you is system "clear", which just cleans up the terminal.

Source is here

Adding complex objects

Now we need to render player, enemies and door. Let’s extend our Level class definition to support them and decide how we will draw them on the screen:

PLAYER, ENEMY, DOOR, TREE, SPACE = '🧙', '👻', '🚪', '🌲', "・"

Level = Struct.new(:map, :enemies, :player, :door)

After that, let’s define another struct called DynamicObject to represent them:

DynamicObject = Struct.new(:row_idx, :col_idx, :kind)

Now we need to adjust our LevelBuilder to handle new objects. Note that when we find one of those we assign them to separate fields and treat them as empty spaces on the map, because they can change their location:

class LevelBuilder
  def initialize(filepath)
    @filepath = filepath
  end

  MAPPING = { 't' => TREE, 's' => SPACE }

  def build
    Level.new.tap do |level|
      level.enemies = []

      level.map = File.readlines(@filepath).map.with_index do |line, row_idx|
        line.strip.chars.map.with_index do |c, col_idx|
          case c
          when 'e'
            level.enemies << DynamicObject.new(row_idx, col_idx, :enemy)
            SPACE
          when 'p'
            level.player = DynamicObject.new(row_idx, col_idx, :player)
            SPACE
          when 'd'
            level.door = DynamicObject.new(row_idx, col_idx, :door)
            SPACE
          else
            MAPPING[c]
          end
        end
      end
    end
  end
end

Finally, we need to update Game#draw_screen to support our new primitives:

def draw_screen
  system "clear"

  @level.map.each_with_index do |row, row_idx|
    row.each_with_index do |cell, col_idx|
      if @level.player.row_idx == row_idx && @level.player.col_idx == col_idx
        print PLAYER
      elsif @level.door.row_idx == row_idx && @level.door.col_idx == col_idx
        print DOOR
      elsif @level.enemies.find { |enemy| enemy.row_idx == row_idx && enemy.col_idx == col_idx }
        print ENEMY
      else
        print cell
      end
    end
    puts "\n"
  end
end

You can find the whole snippet here

How to read user input

Our next goal is to find a way to control the character. We want to be able to move it up, down, left and right. Let’s start with the code that checks if any key is pressed right now.

class Game
  # ...

  private

  def get_pressed_key
    begin
      system('stty raw -echo')
      (STDIN.read_nonblock(4).ord rescue nil)
    ensure
      system('stty -raw echo')
    end
  end
end

This looks like a bit cryptic, so we will examine it line by line:

  • stty raw -echo turns off echoing of input keystrokes;
  • STDIN.read_nonblock(4) reads 4 bytes from user input, and .ord returns the integer representation of the entered character (e.g., 'w'.ord == 19);
  • stty -raw echo turns echoing back on.

Let’s call this method inside the game loop and use the value to update player’s position:

class Game
  # ...

  def run
    @level = LevelBuilder.new("./map.txt").build

    loop do
      draw_screen

      new_player_position = DynamicObject.new(@level.player.row_idx, @level.player.col_idx, :player)
      new_player_position.move(get_pressed_key)

      @level.player = new_player_position

      sleep SLEEP_INTERVAL
    end
  end

  # ...
end

Finally, we have to implement DynamicObject#move. Let’s use WASD keys to control movements:

UP, DOWN, RIGHT, LEFT = 119, 115, 100, 97

DynamicObject = Struct.new(:row_idx, :col_idx, :kind) do
  def move(dir)
    case dir
    when RIGHT then self.col_idx += 1
    when LEFT then self.col_idx -= 1
    when UP then self.row_idx -= 1
    when DOWN then self.row_idx += 1
    end
  end
end

Source is here

Try it out! You can see that player position is updated on the screen.

If game feels a bit slow to you—try to play with SLEEP_INTERVAL value.

Collision handling

If you ran the previous example, you might have noticed that player can step on trees, ghosts, door and even leave the screen. The reason is that we update the position regardless of what is placed under it. Let’s fix that!

First of all, we need to add a method that will check new position and tell us what will happen if we move object there. Note that we will make this method generic: it will be able to check if passed object collides with any of objects we pass. We will use it to handle other collisions later.

There can be multiple outcomes:

  • :out_of_border is returned when object attempts to leave the border of the level;
  • :tree, :ghost, :door or :player is returned when object bumps into the corresponding object.

This is how Game#run looks like:

class Game
  # ...

  def run
    @level = LevelBuilder.new("./map.txt").build

    loop do
      draw_screen

      new_player_position = DynamicObject.new(@level.player.row_idx, @level.player.col_idx, :player)
      new_player_position.move(get_pressed_key)

      case check_collision(new_player_position.row_idx, new_player_position.col_idx, @level.enemies + [@level.door])
      when :door
        puts "🎉 Level passed 🎉"
        break
      when :enemy
        puts "☠️ You died ☠️"
        break
      when nil
        @level.player = new_player_position
      end

      sleep SLEEP_INTERVAL
    end
  end

  # ...
end

Now we should implement Game#check_collision:

class Game
  # ...

  private

  def check_collision(row_idx, col_idx, objects)
    return :out_of_border if row_idx < 0 || row_idx >= @level.map.length || col_idx < 0 || col_idx >= @level.map[0].length
    return :tree if @level.map[row_idx][col_idx] == TREE
    objects.find { _1.row_idx == row_idx && _1.col_idx == col_idx }&.kind
  end
  # ...
end

Source is here

Try to run the game and see that now we handle collisions properly: there’s no way to cross the border, step on the tree and game ends when you bump into the ghost or door.

Adding fancy AI to our ghosts

Our ghosts are not dangerous at all, because they can’t move, let’s address that! First of all, we need to change our main loop to trigger movement. Moreover, we should check the collisions after that to make sure that ghost did not bump into the player:

class Game
  # ...

  def run
    @level = LevelBuilder.new("./map.txt").build

    loop do
      move_enemies
      draw_screen

      case check_collision(@level.player.row_idx, @level.player.col_idx, @level.enemies)
      when :enemy
        puts "☠️ You died ☠️"
        break
      end

      new_player_position = DynamicObject.new(@level.player.row_idx, @level.player.col_idx, :player)
      new_player_position.move(get_pressed_key)

      case check_collision(new_player_position.row_idx, new_player_position.col_idx, @level.enemies + [@level.door])
      when :door
        puts "🎉 Level passed 🎉"
        break
      when :enemy
        puts "☠️ You died ☠️"
        break
      when nil
        @level.player = new_player_position
      end

      sleep SLEEP_INTERVAL
    end
  end

  # ...
end

Now we can implement the smart AI–powered algorithm that makes decision on enemy movement:

class Game
  # ...

  def move_enemies
    @level.enemies.each_with_index do |enemy, idx|
      next if rand(1) > 0.8

      new_enemy = DynamicObject.new(enemy.row_idx, enemy.col_idx, :enemy)
      new_enemy.move([RIGHT, LEFT, UP, DOWN].sample)
      @level.enemies[idx] = new_enemy if check_collision(new_enemy.row_idx, new_enemy.col_idx, [@level.door]).nil?
    end
  end

  # ...

Source is here

Extracting the rendering logic

I have a feeling that our Game class becomes some kind of a God object: it handles user input, screen rendering and movement logic. Let’s try to extract all rendering–related code to the separate class:

class Screen
  def render_level(level)
    system "clear"

    level.map.each_with_index do |row, row_idx|
      row.each_with_index do |cell, col_idx|
        if level.player.row_idx == row_idx && level.player.col_idx == col_idx
          print PLAYER
        elsif level.door.row_idx == row_idx && level.door.col_idx == col_idx
          print DOOR
        elsif level.enemies.find { |enemy| enemy.row_idx == row_idx && enemy.col_idx == col_idx }
          print ENEMY
        else
          print cell
        end
      end
      puts "\n"
    end
  end

  def render_death_message = puts "☠️ You died ☠️"
  def render_level_passed_message = puts "🎉 Level passed 🎉"
end

With this change we can easily replace terminal with a web page or even print game state to the paper!

Now we can change the main loop Game class and drop #draw_screen method:

class Game
  # ...

  def run
    screen = Screen.new

    @level = LevelBuilder.new("./map.txt").build

    loop do
      move_enemies
      screen.render_level(@level)

      case check_collision(@level.player.row_idx, @level.player.col_idx, @level.enemies)
      when :enemy
        screen.render_death_message
        break
      end

      new_player_position = DynamicObject.new(@level.player.row_idx, @level.player.col_idx, :player)
      new_player_position.move(get_pressed_key)

      case check_collision(new_player_position.row_idx, new_player_position.col_idx, @level.enemies + [@level.door])
      when :door
        screen.render_level_passed_message
        break
      when :enemy
        screen.render_death_message
        break
      when nil
        @level.player = new_player_position
      end

      sleep SLEEP_INTERVAL
    end
  end

  # ...
end

Final code is here


Let’s take a break here: we built more or less playable game in 138 lines. I have some more ideas for you to practice:

  • try to add fireballs that are created when player presses SPACE and which should eliminate ghosts;
  • try to add more static objects (like houses 🏠) to the level—you will probably need to refactor LevelBuilder#build to some kind of DSL;
  • add a way to have more than one level (probably you will need to turn Game class into the state machine);
  • build up level generator to make the game endless.