Here is one more solution to the Sokoban quiz. I think people might  
be interested in seeing this one because 1) it uses a different  
implementation strategy then the solutions posted back in 2004, and  
2) it is somewhat more complete than those solutions.

I know that the Sokoban quiz was posted a long time ago. But I didn't  
even know that Ruby existed back then. I first heard about Ruby and  
became interested it in April of this year. In June, I picked up Best  
of Ruby Quiz. It was from that book that I learned about the Sokoban  
quiz.

I had a lot of fun implementing Sokoban in Ruby. The only difficulty  
I encountered was in dealing with the Curses module, for which l was  
not able to locate up-to-date documentation. In the end, I had to  
fall back on trial-and-error to get the code involving Curses methods  
to work.

Regards, Morton

--------------------------- start of code ---------------------------

#! /usr/bin/ruby -w
# Author: Morton Goldberg
#
# Date: July 13, 2006
#
# An implementation of the Sokoban game based on the model-view- 
controller
# design pattern. It also avoids using case blocks, using hashes  
instead.
# The user interface is implemented with Curses. Games are saved and
# restored using YAML.

require 'curses'

WELCOME = 'Welcome to Sokoban 1.0 -- press h if you need help'

# The SOKOBAN environment variable must be defined and point to the  
folder
# where the file "levels.txt" can be found. Further, any files  
written out
# (such as saved games, level maps, and completion certificates) will be
# written to this folder.
FOLDER = ENV['SOKOBAN']

# LEVELS is the name of a file containing a collection of level maps  
to be
# loaded during start-up.
LEVELS = "levels.txt"

# GAME is the name given to a saved game. If such a file exists in  
FOLDER,
# the game can be restored from it at any time during play.
GAME = "sokoban.yaml"

# Unit vectors representing one-step moves in the four cardinal  
directions.
UVEC = {
    ?e => [0, 1],
    ?w => [0, -1],
    ?n => [-1, 0],
    ?s => [1, 0]
    }

# A sokoban represents the warehouse worker. It knows the following  
things:
#     Where it is
#     How to move around the level map
#     How to push crates
class Sokoban

    # Transition table for simple moves.
    MOVE_TABLE = {
       '@ ' => ' @',
       '@.' => ' +',
       '+ ' => '.@',
       '+.' => '.+'
       }

    # Transition table for crate-pushing moves.
    PUSH_TABLE = {
       '@o ' => ' @o',
       '@o.' => ' @*',
       '@* ' => ' +o',
       '@*.' => ' +*',
       '+o ' => '.@o',
       '+o.' => '.@*',
       '+* ' => '.+o',
       '+*.' => '.+*'
       }

    # Given a level's map, return the position of the token representing
    # the sokoban. When successful, returns an array of form [row, col];
    # otherwise, it returns nil.
    def Sokoban.find(level_map)
       level_map.each_with_index do |row, i|
          j = row.index(/[@+]/)
          return [i, j] if j
       end
       return nil
    end

    attr_reader :row, :col

    # Argument position must be an array of the form [row, col].
    def initialize(position)
       @row = position[0]
       @col = position[1]
    end

    # Perform a simple move; i.e., no crate push.
    # Argument token must be is one of ?e, ?w, ?n, or ?s.
    # Argument map must be a level map.
    def move(token, map)
       dr, dc = UVEC[token]
       r, c = @row + dr, @col + dc
       old = map[@row][@col, 1] + map[r][c, 1]
       new = MOVE_TABLE[old]
       if new then
          rows = [@row, r]
          cols = [@col, c]
          @row = r
          @col = c
          return [rows, cols, old, new]
       else
          return [nil, nil, nil, nil]
       end
    end

    # Perform a crate-pushing move.
    # Argument token must be is one of ?e, ?w, ?n, or ?s.
    # Argument map must be a level map.
    def push(token, map)
       dr, dc = UVEC[token]
       r, c = @row + dr, @col + dc
       rr, cc = r + dr, c + dc
       old = map[@row][@col, 1] + map[r][c, 1] + map[rr][cc, 1]
       new = PUSH_TABLE[old]
       if new then
          rows = [@row, r, rr]
          cols = [@col, c, cc]
          @row = r
          @col = c
          return [rows, cols, old, new]
       else
          return [nil, nil, nil, nil]
       end
    end

    def to_s
       "[#@row, #@col]"
    end

end

# A model represents the state of the level being played. It  
maintains the
# level map and knows the following things:
#     What moves are valid
#     When a level is complete
#     How to perform valid moves
#     How to undo previous moves
class Model

    PASS   = ' '   # marks empty passage cell
    EMPTY  = '.'   # marks empty storage cell
    CRATE  = 'o'   # marks crate in pasaage cell
    FILLED = '*'   # marks crate in storage cell

    # The collection of level maps.
    # Levels are 1-based, so level 0 is just a place holder and is  
not used.
    @@maps = ['#']

    # Load a collection of Sokoban level maps from the specified path.
    def Model.load_maps(path)
       File.open(path, "r") do |f|
          map = []
          f.each_line do |line|
             if line =~ /^\s*#/ then
                map << line.chomp
             elsif ! map.empty? then
                @@maps << map
                map = []
             end
          end
          @@maps << map unless map.empty?
       end
    end

    # Returns the number of levels available for play.
    def Model.levels
       @@maps.length - 1
    end

    attr_reader :map, :rows, :cols, :sokoban, :moves_made

    # Argument level must be an integer in range 1..Model.levels.
    def initialize(level)
       @level = level
       @moves_made = 0
       @history = []
       # Need a deep copy because it will be destructively modified  
during
       # game play.
       @map = @@maps[@level].collect {|r| String.new(r)}
       @rows = @map.length
       @cols = (@map.collect {|r| r.length}).max
       @sokoban = Sokoban.new(Sokoban.find(@map))
    end

    # Returns true if the move is valid and false if it is not. A  
valid move
    # produces the appropriate change in the level's map.
    # Game moves are represented by single character tokens (?e, ?w, ? 
n, ?s)
    # indicating the direction of the move.
    def move(token)
       dr, dc = UVEC[token]
       adjacent = @map[@sokoban.row + dr][@sokoban.col + dc, 1]
       if adjacent == PASS || adjacent == EMPTY then
          rows, cols, old, new = @sokoban.move(token, @map)
       elsif adjacent == CRATE || adjacent == FILLED then
          rows, cols, old, new = @sokoban.push(token, @map)
       else
          return false
       end
       return false unless new
       # Move is valid, so update the level map.
       rows.length.times do |k|
          map_row = @map[rows[k]]
          map_row[cols[k]] = new[k]
       end
       # Update the undo history.
       @history << [rows, cols, old]
       @moves_made = @history.length
       return true
    end

    # Complete undo is simple to implement, but rather memory intensive.
    def undo
       return false if @history.empty?
       rows, cols, old = @history.pop
       rows.length.times do |k|
          map_row = @map[rows[k]]
          map_row[cols[k]] = old[k]
       end
       @moves_made = @history.length
       @sokoban = Sokoban.new([rows[0], cols[0]])
       return true
    end

    # The level is complete when the level map contains no crate tokens.
    def level_complete?
       crates = @map.collect do |row|
          row.include?(CRATE)
       end
       ! crates.any?
    end

end

# A view knows how to draw a visual representation of the level being  
played.
class View

    include Curses

    # A view must be initialized with an instance of Model.
    def initialize(model)
       @model = model
       # The spaces needed on the left side of level's map to center it.
       @left_margin =  ' '* ((cols - @model.cols) / 2)
       # Put four blank lines before the top line of the level's map.
       @top_margin = 4
    end

    # Draw the level's map in the screen buffer.
    def draw
       @model.map.each_with_index do |row, i|
          setpos(@top_margin + i, 0)
          addstr(@left_margin + row)
       end
    end

end

# Provide a Curses-based approximation to the alert box widgets provided
# by GUIs. Somewhat crude but useful as well as easy to use.
class AlertBox < Curses::Window

    # Aids in determining the size of an alert's frame.
    # Returns the height and width of a frame will closely fit the
    # specified text. Provides for a border and left and right margins.
    def AlertBox.size(text)
       text = text.split("\n")
       [text.length + 2, (text.map {|m| m.length}).max + 6]
    end

    # Aids in centering an alert on the screen.
    # Returns a frame that will closely fit the specified text. Provides
    # for a border and left and right margins.
    def AlertBox.center(text)
       h, w = size(text)
       [(Curses::lines - h) / 2, (Curses::cols - w) / 2, h, w]
    end

    # rect is the alert's frame, an array of the form [top_row, top_col,
    # heigth, width].
    # text is the alert's content, a string consisting of one or more
    # lines.
    def initialize(rect, text)
       @top_y = rect[0]
       @top_x = rect[1]
       @height = rect[2]
       @width = rect[3]
       @text = text.split("\n")
       super(@height, @width, @top_y, @top_x)
       box(?#, ?#, ?#)
    end

    # Display the alert on the screen.
    def show
       @text.length.times do |i|
          setpos(i + 1, 3)
          addstr(@text[i])
       end
       refresh
    end

    RESULT = {?y => true, ?n => false}

    # Display the alert and wait for a key press.
    # Return true if the user preses y.
    # Return false if the user presses n.
    # Beep on any other keystrokes.
    def ask_y_or_n
       show
       Curses::noecho
       key_chr = nil
       loop do
          key_chr = getch
          break if key_chr == ?y || key_chr == ?n
          Curses::beep
       end
       Curses::echo
       RESULT[key_chr]
    end

end

# A controller gets the player's keystrokes and translates them in to  
game
# actions.
class Controller

    require 'yaml'
    include Curses

    # Keystroke command dispatch table
    DISPATCH = Hash.new(:beep)

    # general commands
    DISPATCH[?A]         = :abort       # abort
    DISPATCH[?h]         = :key_help    # show help
    DISPATCH[?l]         = :new_level   # change level
    DISPATCH[?m]         = :map_help    # show map legend & sokoban  
position
    DISPATCH[?n]         = :up_level    # advance to next level
    DISPATCH[?p]         = :dn_level    # return to previous level
    DISPATCH[?q]         = :quit        # quit
    DISPATCH[?r]         = :restore     # restore game
    DISPATCH[?s]         = :save        # save game
    DISPATCH[?w]         = :write_map   # write map to file

    # movement
    DISPATCH[Key::RIGHT] = :go_east     # right arrow = one step east
    DISPATCH[Key::LEFT]  = :go_west     # left arrow = one step west
    DISPATCH[Key::UP]    = :go_north    # up arrow = one step north
    DISPATCH[Key::DOWN]  = :go_south    # down arrow = one step south
    DISPATCH[?z]         = :undo        # undo previous move

    def initialize
       unless FOLDER then
          puts "SOKOBAN environment variable not set"
          exit(false)
       end
       map_file = FOLDER + LEVELS
       if File.exists?(map_file) then
          Model.load_maps(map_file)
       else
          puts "Can't find Sokoban levels file"
          exit(false)
       end
       init_screen
       begin
          cbreak
          stdscr.keypad(true)
          @command_line = lines - 1
          @status_line = lines - 2
          @level = 1
          @model = Model.new(@level)
          @view = View.new(@model)
          @key_chr = nil
          say(WELCOME)
          run
       ensure
          close_screen
          puts $debug unless $debug.empty?
       end
    end

    # Command ask-and-dispatch loop
    def run
       catch(:game_over) do
          loop do
             @view.draw
             ask_cmd
             send(DISPATCH[@key_chr])
          end
       end
    end

    # Handle request to abort -- exit immediately without reminding tihe
    # user to save.
    def abort
       throw(:game_over)
    end

SAVE_ALERT = <<TXT
Do you want to save your game before you quit?

Press y to save
Press n to quit without saving
TXT

    # Handle request to quit -- befoe exiting, remind tihe user to save.
    def quit
       alert = AlertBox.new(AlertBox.center(SAVE_ALERT), SAVE_ALERT)
       save if alert.ask_y_or_n
       throw(:game_over)
    end

KEY_INFO = <<INFO
Sokoban keystroke commands
----------------------------------------
General commands
A      immediate quit
h      display this message
l      go to another level -- you will
        be asked for the level number
m      show legend for level map
n      go to next level
p      go to previous level
q      quit -- you will be asked to save
r      restore saved game
s      save game to disk
w      write level map to disk

Movement commands
Right-arrow  move one step east
Left-arrow   move one step west
Up-arrow     move one step north
Down-arrow   move one step south
z            undo previous move

Press any key to dismiss
INFO

    # Handle request for infomation on keystroke commands.
    def key_help
       alert = AlertBox.new(AlertBox.center(KEY_INFO), KEY_INFO)
       alert.show
       ask_cmd
       clear
    end

MAP_INFO = <<INFO
Sokoban map symbols
-----------------------------
@  sokoban (warehouse worker)
+  sokban on storage bin

.  empty storage bin
o  crate needing to be stored
*  crate stored in a bin

#  wall or other obstacle

Press any key to dismiss
INFO

    # Handle request for infomation on map symbols.
    def map_help
       say("Sokoban is at #{@model.sokoban}")
       alert = AlertBox.new(AlertBox.center(MAP_INFO), MAP_INFO)
       alert.show
       ask_cmd
       clear
    end

    # Handle request to change to another level.
    def new_level
       current = @level
       msg = ask_level
       if @level == current then
          say(msg)
       else
          set_level(msg)
       end
    end

    # Handle request to go to the next level.
    def up_level
       nxt = @level + 1
       if nxt > Model.levels then
          beep
       else
          @level = nxt
          set_level("Starting level #@level")
       end
    end

    # Handle request to go to the previous level.
    def dn_level
       nxt = @level - 1
       if nxt < 1 then
          beep
       else
          @level = nxt
          set_level("Starting level #@level")
       end
    end

    # Change to the requested level.
    def set_level(msg)
       @model = Model.new(@level)
       @view = View.new(@model)
       clear
       say(msg)
    end

    # Handle request to write the current level map out to disk.
    # The level map is written to FOLDER. The file name is generated  
from
    # the current level and the number of moves made. For example, if  
a map
    # is written out for level 3 at move 117, the map file is named
    # "level_map.3.117.txt".
    def write_map
       path = FOLDER + "level_map.#@level.#{@model.moves_made}.txt"
       text =
          "Level: #@level\nMove: #{@model.moves_made}\n\n" +
          @model.map.join("\n")
       File.open(path, 'w') {|f| f.write(text)}
       say("Level map written to disk")
    end

    # Handle request to save the current state of game to a YAML file  
from
    # which it can be restored at some later time.
    def save
       game_file = FOLDER + GAME
       game = {'level' => @level, 'model' => @model}
       File.open(game_file, 'w') do |f|
          YAML.dump(game, f)
          say("Game saved to disk")
       end
    end

    # Handle request to restore a game from a YAML file.
    def restore
       game_file = FOLDER + GAME
       if File.exists?(game_file) then
          game = YAML.load_file(game_file)
          @level = game['level']
          @model = game['model']
          @view = View.new(@model)
          clear
          say("Game restored from disk")
       else
          say("Cant find game file on disk")
       end
    end

    # Handle request to move eastward.
    def go_east
       go(?e, "Moved east")
    end

    # Handle request to move westward.
    def go_west
       go(?w, "Moved west")
    end

    # Handle request to move northward.
    def go_north
       go(?n, "Moved north")
    end

    # Handle request to move southward.
    def go_south
       go(?s, "Moved south")
    end

CERTIFICATE_ALERT = <<TXT
Level Completed
---------------------------------------------------------
You qualify for a certificate to commemorate your success

Press y to have the certificate issued
Press n to skip the certificate
TXT

    # Ask the model to move the sokoban in the direction indicated by  
token.
    # if move succeeded, check for level completion.
    def go(token, msg)
       if @model.move(token) then
          if @model.level_complete? then
             @view.draw
             say("Congratulations! You have completed level #@level")
             alert = AlertBox.new(AlertBox.center(CERTIFICATE_ALERT),
                                  CERTIFICATE_ALERT)
             write_certificate if alert.ask_y_or_n
             clear
             up_level
          else
             say(msg)
          end
       else
          beep
       end
    end

    # Ask the model to undo the sokoban's last move. Decrement the  
move count
    # if successful.
    def undo
       if @model.undo then
          say("Undid move #{@model.moves_made + 1}")
       else
          beep
       end
    end

    # Display a message on the status line. The message will be  
prefixed by
    # the level number and the move count.
    def say(text)
       text = "Level #@level, move #{@model.moves_made}: " + text
       setpos(@status_line, 0)
       addstr(text.ljust(cols))
       refresh
    end

    # Display a prompt for imput on the command line.
    # Return the user's response (a string).
    def ask_str(prompt)
       w = prompt.length
       setpos(@command_line, 0)
       addstr(prompt.ljust(cols))
       setpos(@command_line, w)
       refresh
       getstr
    end

    COMMAND_PROMPT = '>> '
    CURSOR_COLUMN = COMMAND_PROMPT.length

    # Prompt for and get a keystroke command.
    def ask_cmd
       setpos(@command_line, 0)
       addstr(COMMAND_PROMPT.ljust(cols))
       setpos(@command_line, CURSOR_COLUMN)
       refresh
       noecho
       @key_chr = getch
       echo
    end

    LEVEL_PROMPT = "What level do you want to play? "

    # Ask the user for a level number. If the response is valid,  
accept it;
    # if not, the current level persists.
    def ask_level
       prompt = LEVEL_PROMPT + "[1 - #{Model.levels}]: "
       begin
          response = ask_str(prompt).to_i
          if (1..Model.levels).include?(response) then
             @level = response
             msg = "Starting level #@level"
          else
             raise RangeError
          end
       rescue
          # Resume current level.
          msg = "Level change cancelled"
       end
       return msg
    end

    # Write a certificate of completion for the current level out to  
disk.
    # The certificate is written to FOLDER. The file name is  
generated from
    # the USER environment variable, the current level, and the  
number of
    # moves it took to complete the level. For example, if user "mg"
    # completes leve 3 in 435 moves, the certificate file is named
    # "mg.3.435.txt". The file's contents repeat the information  
contained
    # in the file name in a more readable format and adds the date.
    def write_certificate
       user = ENV['USER']
       date = Time.now.strftime("%d/%m/%Y")
       path = FOLDER + "#{user}.#@level.#{@model.moves_made}.txt"
       text = <<-TXT
          Sokoban Certificate of Completiion
          ----------------------------------
          Date: #{date}
          Level: #@level
          Moves: #{@model.moves_made}
          Player: #{user}
       TXT
       text.gsub!(/^\s+/, '')
       File.open(path, 'w') {|f| f.write(text)}
    end

end

$debug = []
Controller.new

---------------------------  end of code  ---------------------------