I'm not a beginner to programming, but I am to programming in OOP (effectively at least). So to learn better I wrote an object oriented game of Snakes and Ladders. For background, see this question.
I wrote four classes: Board, Cell, Portal, and Player. All are self explanatory except for Portal
. A Portal object represents a snake or a ladder cell. Portals are just cells with behaviour. Their behaviour is identical save for the direction they teleport you to. Snakes send you back, while ladders send you forward.
A Cell object keeps track of any players that land on it. It is also aware of its location.
class Cell
attr_reader :location, :players
def initialize(input)
@location = input.fetch(:location)
@players = []
end
def exit(player)
evict player
end
def enter(player, board)
admit player
player.set_cell(self)
end
private
def admit(player)
players << player
end
def evict(player)
players.delete(player)
end
end
A Portal object inherits from Cell. It adds behaviour, and has a destination
attribute. It overrides the enter
method in its superclass. Instead of admitting the player it tells the board to send it to the portal's destination.
class Portal < Cell
attr_reader :destination
def initialize(input)
@destination = input.fetch(:destination)
super
raise ArgumentError, "Location and destination can not be the same" if location.equal?(destination)
end
def enter(player, board)
board.move player, location, destination
end
end
The Player object must know of the Cell it resides in. It is also responsible for rolling the die.
class Player
attr_reader :name
attr_accessor :current_cell
def initialize(input)
@name = input.fetch(:name)
end
def to_s
name
end
def roll_dice
rand(1..6)
end
def set_cell(cell)
self.current_cell = cell
end
def position
if current_cell
current_cell.location
else
-1 # represents off the board state
end
end
end
Finally, the Board keeps track of the current player, the number of turns, and moves players in between cells. It also decides if the game has a winner.
class Board
attr_reader :grid, :players, :die
attr_accessor :turn
def initialize(input = {})
@grid = input.fetch(:grid, default_grid)
@players = []
@turn = 0
end
def add_player(player)
players << player
end
def get_cell(index)
grid[index]
end
def move(player, from, to)
get_cell(from).exit player if game_started?
get_cell(to).enter player, self
end
def play_turn
current_player_position = current_player.position
roll = current_player.roll_dice
if winner?(roll)
puts "#{current_player} rolls a #{roll} and wins! Congratulations!"
exit
end
move current_player, current_player.position, current_player.position + roll
puts "#{current_player} rolls a #{roll} and moves from #{current_player.position - roll} to #{current_player.position}"
increment_turn
play_turn
end
private
def default_grid
Array.new(100)
end
def current_player
players.fetch(turn % players.size)
end
def increment_turn
self.turn += 1
end
def game_started?
turn > 0
end
def winner?(last_roll)
# +1 is to account for arrays starting at 0. So cell 1 is in fact cell 2 on a board.
(current_player.position + 1 + last_roll) >= grid.size
end
end
Concerns
I think I've done a decent job for my first crack, but I know this can be improved.
- I am not sure about the die rolling and player moving mechanic. It feels clunky. Is passing the cell to the user a necessary dependency? I can't think of a better way to get the player to move and change its position.
- The play turn uses recursion, but again, I'm not sure how to write it better.
That arrays in Ruby start at 0 is causing me all sorts of confusion.
- To render the board I must add 1 to each cell location.
- I must add 1 to the player position or deduct 1 from the grid size to work out the winner.
- Players start off the board. I must check if the player has a cell, and if not, I have to return -1 to represent the off the board state.
There are probably lots of issues I'm not aware of. My objectives are the following. I think so far I might have succeeded with 2, but failed with 1 and 3.
- Make the code easy to modify. Let's say I want to add a Rule object that can store different winning criteria (roll to an excess of 100 or land exactly on 100).
- Introduce other cell subclasses with different effects.
- Make the game state easy to save so that I can player across HTTP requests.
Here's an example of running the game:
game = SnakesAndLadders.classic
m = SnakesAndLadders::Player.new name: "Mario", color: "Red"
l = SnakesAndLadders::Player.new name: "Luigi", color: "Green"
game.add_player m
game.add_player l
game.play_turn