6
\$\begingroup\$

(EDIT: I incorporated a lot of the feedback from the answers, and posted a follow-up question here: Codingame: Great Escape bot in Ruby - Follow-up)

I started working on some code challenges at CodinGame to learn Ruby, and this is my first big Ruby project. It's a bot for their Great Escape contest (here is a sample gameplay video).

Screenshot of a 3-player game of the Great Escape

Each player starts at one side of the board and must reach the opposite side. Each turn, a player can either move along the grid, or try to block their opponents by placing a wall somewhere. It is illegal to completely cut an opponent off from their goal side.

My bot maintains the distance to the goal and next step along the shortest path for each grid cell for each player and follows that. In addition, it tries to block the opponent who is closest to victory by placing walls across their shortest path.

Communication goes through console gets / puts. Each turn, my bot receives player and wall coordinates and outputs a single command to either move or place a new wall.

As I said, this is my first big Ruby project, so all comments on coding style, code organisation, naming conventions, performance, etc., are welcome. The only constraints are that the contest system requires everything to be in one file and has a limited set of libraries (I'm not actually sure which ones).

#!/usr/bin/env ruby
STDOUT.sync = true # DO NOT REMOVE
require 'set'
class BinaryHeap
 def initialize
 @elements = []
 end
 def size
 @elements.size
 end
 def empty?
 @elements.empty?
 end
 def clear
 @elements.clear
 self
 end
 def push(element)
 @elements << element
 bubble_up(@elements.size - 1)
 self
 end
 alias :<< :push
 def pop
 return nil if empty?
 root = @elements[0]
 @elements[0] = @elements[@elements.size - 1]
 @elements.pop
 trickle_down(0)
 root
 end
 def peek
 return nil if empty?
 @elements[0]
 end
 def inspect
 "<#{self.class}: size=#{size}, peek=#{peek || "nil"}>"
 end
 private
 def bubble_up(i)
 p = parent(i)
 while (i > 0 && comp(i, p) < 0) do
 swap(i, p)
 i, p = p, parent(p)
 end
 end
 def trickle_down(i)
 begin
 j, r, l = -1, right_child(i), left_child(i)
 if (r < @elements.size && comp(r, i) < 0)
 j = comp(l, r) < 0 ? l : r
 elsif (l < @elements.size && comp(l, i) < 0)
 j = l;
 end
 swap(i, j) unless j < 0
 i = j
 end while i >= 0
 end
 def left_child(i)
 2 * i + 1
 end
 def right_child(i)
 2 * i + 2
 end
 def parent(i)
 (i - 1) / 2
 end
 def swap(i, j)
 @elements[i], @elements[j] = @elements[j], @elements[i]
 end
 def comp(i, j)
 @elements[i] <=> @elements[j]
 end
end
Player = Struct.new(:id, :row, :col, :walls_left)
Wall = Struct.new(:row, :col, :dir)
Node = Struct.new(:row, :col, :left, :right, :up, :down, :dists, :successors) do
 def to_s
 "(#{row}, #{col})"
 end
 def inspect
 "(#{row}, #{col})"
 end
 def neighbours
 [left, right, up, down].compact
 end
 def successorString(id)
 case successors[id]
 when nil then "nil"
 when left then "LEFT"
 when right then "RIGHT"
 when up then "UP"
 when down then "DOWN"
 end
 end
end
NodeWrapper = Struct.new(:node, :player_id) do
 def <=> (other)
 node.dists[player_id] <=> other.node.dists[player_id]
 end
end
# w: width of the board
# h: height of the board
# player_count: number of players (2 or 3)
# my_id: id of my player (0 = 1st player, 1 = 2nd player, ...)
$w, $h, @player_count, @my_id = gets.split(" ").collect {|x| x.to_i}
#init grid
@grid = Array.new($h){Array.new($w){Node.new()}}
for row in 0..$h-1 do
 for col in 0..$w-1 do
 @grid[row][col].row = row
 @grid[row][col].col = col
 @grid[row][col].left = @grid[row][col - 1] unless col == 0
 @grid[row][col].right = @grid[row][col + 1] unless col == $w - 1
 @grid[row][col].up = @grid[row - 1][col] unless row == 0
 @grid[row][col].down = @grid[row + 1][col] unless row == $h - 1
 @grid[row][col].dists = [$w - col - 1, col, $h - row - 1]
 @grid[row][col].successors = [@grid[row][col].right, @grid[row][col].left, @grid[row][col].down]
 end
end
@walls = []
@players = []
# Breaks the connections in the grid between these cells
# Returns the list of invalidated cells
def breakConnection(row1, col1, row2, col2)
 invalid = [];
 if row1 == row2
 r = row1
 c1 = (col1 < col2 ? col1 : col2)
 c2 = (col1 < col2 ? col2 : col1)
 if @grid[r][c1].right
 @grid[r][c1].right = nil
 @grid[r][c2].left = nil
 for id in 0..@player_count-1 do
 if @grid[r][c1].successors[id] == @grid[r][c2]
 invalid += invalidateCell(@grid[r][c1], id)
 end
 if @grid[r][c2].successors[id] == @grid[r][c1]
 invalid += invalidateCell(@grid[r][c2], id)
 end
 end
 end
 else
 c = col1
 r1 = (row1 < row2 ? row1 : row2)
 r2 = (row1 < row2 ? row2 : row1)
 if @grid[r1][c].down
 @grid[r1][c].down = nil
 @grid[r2][c].up = nil
 for id in 0..@player_count-1 do
 if @grid[r1][c].successors[id] == @grid[r2][c]
 invalid += invalidateCell(@grid[r1][c], id)
 end
 if @grid[r2][c].successors[id] == @grid[r1][c]
 invalid += invalidateCell(@grid[r2][c], id)
 end
 end
 end
 end
 return invalid
end
# Breaks the connections in the grid crossing this wall
# Returns the list of invalidated cells
def addWall(wall)
 invalid = []
 if wall.dir == "V"
 # Wall starts at the top left corner of (row, col) and continues down for 2 cells
 invalid += breakConnection(wall.row, wall.col - 1, wall.row, wall.col)
 invalid += breakConnection(wall.row + 1, wall.col - 1, wall.row + 1, wall.col)
 else # H
 # Wall starts at the top left corner of (row, col) and continues right for 2 cells
 invalid += breakConnection(wall.row - 1, wall.col, wall.row, wall.col)
 invalid += breakConnection(wall.row - 1, wall.col + 1, wall.row, wall.col + 1)
 end
 return invalid
end
# Invalidates the given node and all nodes whose shortest path go through it
# Returns the list of invalidated cells
def invalidateCell(node, player_id)
 STDERR.puts "Invalidating #{node.to_s} for player #{player_id}"
 node.successors[player_id] = nil
 # Check if we can reroute
 node.neighbours.each do |n| 
 if n.dists[player_id] == node.dists[player_id] - 1
 node.successors[player_id] = n
 STDERR.puts "Rerouting successful"
 return []
 end
 end
 STDERR.puts "No rerouting possible"
 # No rerouting possible. Invalidate this and predecessors.
 node.dists[player_id] = nil
 invalid = [[node, player_id]]
 node.neighbours.each do |n|
 invalid += invalidateCell(n, player_id) if n.successors[player_id] == node
 end
 STDERR.puts "Finished invalidating #{node.to_s} for player #{player_id}"
 STDERR.puts invalid.to_s
 return invalid
end
# Updates the shortest paths of the given cells
def computeNewPaths(invalid)
 playerCells = Array.new(@player_count){[]}
 invalid.each do |node, id|
 playerCells[id] << node
 end
 for id in 0..@player_count-1 do
 computeNewPathsForPlayer(playerCells[id], id)
 end
end
# Updates the shortest paths of the given cells
def computeNewPathsForPlayer(invalid, player_id)
 frontier = BinaryHeap.new()
 # Add all non-invalidated neighbours to our frontier
 invalid.each do |node|
 node.neighbours.each do |neighbour|
 frontier << NodeWrapper.new(neighbour, player_id) if neighbour.dists[player_id]
 end
 end
 # Expand the closest frontier node until they're out
 until frontier.empty? do
 node = frontier.pop.node
 node.neighbours.each do |neighbour|
 if neighbour.dists[player_id].nil? || neighbour.dists[player_id] > node.dists[player_id] + 1
 neighbour.dists[player_id] = node.dists[player_id] + 1
 neighbour.successors[player_id] = node
 frontier << NodeWrapper.new(neighbour, player_id)
 end
 end
 end
end
def block(opponent)
 STDERR.puts "Trying to block #{opponent}"
 # Try to block each move of the upcoming shortest path
 blocked = false
 node = @grid[opponent.row][opponent.col]
 succ = node.successors[opponent.id]
 until (blocked || succ.nil?) do
 blocked = blockConnection(node, succ)
 node = succ
 succ = node.successors[opponent.id]
 end
 return blocked
end
def blockConnection(node, succ)
 STDERR.puts "Trying to block the connection between #{node} and #{succ}"
 if node.row == succ.row
 dir = "V"
 col = [node.col, succ.col].max
 return tryWall(node.row, col, dir) || tryWall(node.row - 1, col, dir)
 else
 dir = "H"
 row = [node.row, succ.row].max
 return tryWall(row, node.col, dir) || tryWall(row, node.col - 1, dir)
 end
end
def tryWall(row, col, dir)
 if isValidWall(row, col, dir)
 puts "#{col} #{row} #{dir}"
 return true
 else
 return false
 end
end
def isValidWall(row, col, dir)
 # Within the grid
 return false if row < 0 || row > $h-1
 return false if col < 0 || col > $w-1
 return false if dir == "V" && row == $h-1
 return false if dir == "H" && col == $w-1
 # Does not intersect existing walls
 @walls.each do |wall|
 if wall.dir == dir
 return false if dir == "V" && col == wall.col && (row - wall.row).abs < 2
 return false if dir == "H" && row == wall.row && (col - wall.col).abs < 2
 else
 return false if dir == "V" && col == wall.col + 1 && row == wall.row - 1
 return false if dir == "H" && col == wall.col - 1 && row == wall.row + 1
 end
 end
 # Does not cut off a player's last path
 (0..@player_count-1).each do |player_id|
 return false unless verifyDestinationStillReachableWithWall(row, col, dir, player_id)
 end
 return true
end
def verifyDestinationStillReachableWithWall(row, col, dir, player_id)
 return explore(@players[player_id].row, @players[player_id].col, player_id, row, col, dir, Set.new())
end
# DFS with preference towards the destination side
def explore(row, col, player_id, wall_row, wall_col, wall_dir, marked)
 node = @grid[row][col]
 return true if node.dists[player_id] == 0
 marked << node
 neighbours =
 case player_id
 when 0
 [node.right, node.up, node.down, node.left]
 when 1
 [node.left, node.up, node.down, node.right]
 when 2
 [node.down, node.left, node.right, node.up]
 end
 neighbours.compact.each do |n|
 unless (wallBlocks(wall_row, wall_col, wall_dir, node, n) || marked.include?(n))
 return true if explore(n.row, n.col, player_id, wall_row, wall_col, wall_dir, marked)
 end
 end
 return false
end
def wallBlocks(wall_row, wall_col, wall_dir, node, neighbour)
 if node.row == neighbour.row
 wall_dir == "V" && wall_col == [node.col, neighbour.col].max && (wall_row == node.row || wall_row == node.row - 1)
 else
 wall_dir == "H" && wall_row == [node.row, neighbour.row].max && (wall_col == node.col || wall_col == node.col - 1)
 end
end
# game loop
loop do
 @players = []
 @player_count.times do |id|
 # x: x-coordinate of the player
 # y: y-coordinate of the player
 # walls_left: number of walls available for the player
 col, row, walls_left = gets.split(" ").collect {|x| x.to_i}
 @players << Player.new(id, row, col, walls_left)
 end
 invalid = []
 @walls = []
 wall_count = gets.to_i # number of walls on the board
 wall_count.times do
 # wall_x: x-coordinate of the wall
 # wall_y: y-coordinate of the wall
 # wall_orientation: wall orientation ('H' or 'V')
 wall_x, wall_y, wall_orientation = gets.split(" ")
 wall = Wall.new(wall_y.to_i, wall_x.to_i, wall_orientation)
 @walls << wall
 invalid += addWall(wall)
 end
 computeNewPaths(invalid) unless invalid.empty?
 me = @players[@my_id]
 myDist = @grid[me.row][me.col].dists[@my_id]
 tookAction = false
 if me.walls_left > 0
 opponents = (0..@player_count-1).to_a - [@my_id]
 opponents.map! { |id| @players[id] }
 opponents.select! { |o| o.row > -1 }
 opponents.shuffle! if opponents.length >= 2
 opponents.sort_by! { |o| @grid[o.row][o.col].dists[o.id] } if opponents.length >= 2
 opponents.each do |o|
 dist = @grid[o.row][o.col].dists[o.id]
 if !tookAction && dist <= 3 && (dist < myDist || (dist == myDist && o.id < @my_id))
 # This opponent will beat me if I don't block them
 tookAction = block(o)
 end
 end
 end
 # Follow the shortest path
 puts @grid[me.row][me.col].successorString(@my_id) unless tookAction
end

EDIT: If you want to run the program, try the following input:

9 9 2 0
0 7 10
8 3 10
0

The first line of the input is given once at the start of the game and sets these variables:

  • w: width of the board
  • h: height of the board
  • player_count: number of players (2 or 3)
  • my_id: id of my player (0 = 1st player, 1 = 2nd player, ...)

Every game turn, my program expects the following input:

  • One line per player, containing:
    • col: x-coordinate of the player
    • row: y-coordinate of the player
    • walls_left: number of walls available for the player
  • A line containing the number of walls present
  • One line per wall, containing:
    • wall_col: x-coordinate of the wall
    • wall_row: y-coordinate of the wall
    • wall_orientation: wall orientation ('H' or 'V')

The program gives one line of output, either:

  • A direction to move in (LEFT, RIGHT, UP, or DOWN); or
  • A new wall placement putX putY putOrientation
asked Jul 3, 2017 at 22:31
\$\endgroup\$

2 Answers 2

5
\$\begingroup\$

A few things to improve the game experience:

  1. Add a shebang: #!/usr/bin/env ruby, this will help the users run your code from the console like this: ./great_escape_bot.rb.
  2. Add a banner or anything to let the user know that input is required and what he should do next an example here.
  3. I don't know your experience with testing, but for a program that's small as this it would be worth to have a small test suite to check that everything will work as expected, you can try minitest. This will not affect your game submission but it will help you refactor your code easily without and with confidence.
    This is a good tutorial to get you started with minitest: link. The main idea being that you have a file that test your implementation to make sure your code is working as expected without having to check it manually (this is an example):

```

def test_it_turns_left
 assert_equal Game.move_to(9), 'Robot moved to 9,0' 
end
  1. Use the recommended code style for ruby, one thing that you can do to help it be more ruby-like is to use 2 spaces instead of tabs to align your code.

  2. Consider adding everything inside a module and use more classes (while still keeping everything in the same class), that will increase the readability.
    Example:

```

#!/usr/bin/env ruby
module Game
 class BinaryHeap
 # Code
 end
 class Runner
 # Code
 end
 class ErrorChecker
 # Code
 end
end
Game::Runner.run

That's all I can say for now since I can't run the program, maybe write in the beginning of the file if there are any restrictions to the environment, or maybe force the restriction when the file is ran.

Depending on my time I'll decide to debug it later, without unit tests I don't know if my environment or the code is at fault.

answered Jul 4, 2017 at 16:23
\$\endgroup\$
2
  • \$\begingroup\$ Thanks for the tips! I added the shebang and example input. I can't print anything else to the console, because the contest system will reject it. I hadn't seen the style guide, that's a great resource! Could you expand a little on numbers #3 and #5? \$\endgroup\$ Commented Jul 4, 2017 at 19:44
  • \$\begingroup\$ I have updated my answer to give you examples. After I'll have time to look more closely at the code and make it run I'll get back to you. \$\endgroup\$ Commented Jul 5, 2017 at 13:00
4
\$\begingroup\$

Consider the forwardable library

Consider using the built-in forwardable library to build forwarding methods. Instead of this:

class BinaryHeap
 ...
 def size
 @elements.size
 end
 def empty?
 @elements.empty?
 end

You can do this:

require "forwardable"
class BinaryHeap
 extend Forwardable
 ...
 def_delegators :@elements,
 :size,
 :empty?

Use $stderr instead of STDERR

Consider writing to $stderr instead of STDERR. The difference is that STDERR is a constant, whereas $stderr is a global variable that, by default, refers to STDERR. Among other things, code writing to $stderr is easier to test than code writing to STDERR, because the test can temporarily reassign $stderr to a StringIO in order to caputre the output.

Don't use return at the end of a method

When returning a value at the end of a method:

 return false
end

Omit the return:

 false
end

The value of a method is the value of the last expression evaluated by the method.

answered Jul 10, 2017 at 3:29
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.