10
\$\begingroup\$

So I'm one of those weirdos over here talking about making a game engine for text-based (think Zork) games. There was pretty recently another question by someone else working on the same thing, if you're interested.

Anyway, this is a prototype of the code for that engine, but instead of reading for a file (and being written in Python) it has all the data hardcoded (and is written in Ruby).

Note: Because it's all going to be dynamically generated anyway, I'm not looking for advice on DRYing up either LOCATIONS or GAME_PROPS.

What I'm specifically looking for here is:

  • Readability tips -- I have a ton of comments, but I dunno if they explain enough.
  • Ways to make the code more compact and readable, though not at the expense of readability.
  • Features to implement -- nothing major, like "read and parse the data from a file", but small things to make it easier, like "let the user put in a string to be printed instead of requiring boilerplate code".

Other suggestions are welcome as well, but the three points above are the focus of the question.

Since this is just a prototype, I'm not concerned with speed or memory efficiency, though any tips that can be implemented without sacrificing understandability are welcome.

LOCATIONS = {
        # NOTE: These properties _must_ be defined for the game to work properly:
        #   connections       array of Symbol
        #   allow_entrance    lambda
        #   on_enter          lambda
        #   on_exit           lambda
        #   on_stay           lambda
        #   on_block          lambda

        # Normally we'd get all this from the game files, but here it's hardcoded because I'm not writing the entire
        #   gosh-darned engine right now >:(
        center:
                {
                        connections:     [:left, :right],
                        allow_entrance:  lambda {|_| true},
                        on_enter:        lambda {|_| puts 'Welcome to the center.'},
                        on_exit:         lambda {|_| puts 'You are leaving the center.'},
                        on_stay:         lambda {|_| puts 'Please enter a command.'},
                        on_block:        lambda {|_| puts 'You are not allowed into this room.'},
                },
        left:
                {
                        connections:     [:center],
                        allow_entrance:  lambda {|_| true},
                        on_enter:        lambda {|_| puts 'Welcome to the left.'},
                        on_exit:         lambda do |player|
                            puts 'You are leaving the left. Take a point!'
                            player[:score] += 1
                        end,
                        on_stay:         lambda {|_| puts 'Please enter a command.'},
                        on_block:        lambda {|_| puts 'You are not allowed into this room.'},
                },
        right:
                {
                        connections:     [:center],
                        allow_entrance:  lambda {|_| true},
                        on_enter:        lambda do |player|
                            puts 'Welcome to the right.'
                            unless player[:inventory].any? { |item| item[:name] == 'Taco of life' }
                                player[:inventory] << {
                                        name: 'Taco of life',
                                        description: 'A taco with a mysterious inscription: "pffftftpfpfffttff-- *gasp* *squee*"',
                                        on_command: lambda do |_, p|
                                            if p[:dead]
                                                p[:dead] = false
                                                p[:inventory].delete_if { |item| item[:name] == 'Taco of life' }
                                                puts 'You died, but were resurrected by your taco of life!'
                                            end
                                        end
                                }
                                puts 'You got a Taco of Life!'
                            end
                        end,
                        on_exit:         lambda {|_| puts 'You are leaving the right.'},
                        on_stay:         lambda {|_| puts 'Please enter a command.'},
                        on_block:        lambda {|_| puts 'You are not allowed into this room.'},
                },
}

GAME_PROPS = {
        # Maker-defined stuff
        on_command: lambda do |cmd, player|
            player[:change_location].call(cmd.split(/\s+/)[1].to_sym) if /goto .*/i === cmd
            player[:dead] = true if /die/i === cmd
            if /stop/i === cmd
                player[:dead] = true
                return false
            end
            true
        end,
        on_death: lambda do |player|
            puts 'GAME OVER!'
            puts "You died with #{player[:score]} points."
            puts "Here's what you had on you when you died:"
            if player[:inventory].length == 0
                puts '  Nothing!'
            else
                player[:inventory].each do |item|
                    puts "  #{item[:name]}: #{item[:description]} "
                end
            end
        end,

        # Maker-editable stuff
        location: :center,
        dead: false,
        inventory: [], #Items have a name, description, and an optional command listener
        score: 0,

        #Maker-accessible stuff that shouldn't be maker-editable
        turn_count: 0,
        change_location: lambda do |new_loc|
            # Where we are now
            current = LOCATIONS[GAME_PROPS[:location]]
            if LOCATIONS[new_loc].nil?
                puts "#{new_loc} does not exist!"
            else
                # If we can get from here to new_loc
                if current[:connections].include? new_loc
                    # If new_loc will let us in
                    if LOCATIONS[new_loc][:allow_entrance].call(GAME_PROPS)
                        # We're leaving here, so let's bid current adieu
                        current[:on_exit].call(GAME_PROPS) unless current[:on_exit].nil?
                        # Then step into the next room
                        GAME_PROPS[:location] = new_loc
                        # Bid bonjour to new_loc
                        LOCATIONS[new_loc][:on_enter].call(GAME_PROPS) unless LOCATIONS[new_loc][:on_enter].nil?
                        # And make sure that the game knows we moved
                        GAME_PROPS[:location_changed] = true
                    else
                        # Aww, they didn't let us in, so let's find out why (unless we can't know)
                        LOCATIONS[new_loc][:on_block].call(GAME_PROPS) unless LOCATIONS[new_loc][:on_block].nil?
                    end
                else
                    # You can't get to new_loc from here; let's explain that:
                    puts "There is no path from here to #{new_loc}!"
                end
            end
        end,

        # Behind-the-scenes stuff -- neither maker-editable nor -viewable
        # Only in here so that it's in the same place as everything else about the game
        location_changed: false,
}

#Since we need something to start the game, aside from a blank screen
LOCATIONS[GAME_PROPS[:location]][:on_enter].call(GAME_PROPS)
until GAME_PROPS[:dead]
    print '>' # Inspired by Zork, like everything else
    command = gets.chomp

    # A state variable to keep track of whether or not we should keep executing handlers.
    # This way, (for example) the universal handler can force the player to die, and an 
    #   item can't keep him alive despite that.
    continue_execution = true

    # We haven't moved yet this turn (since the turn just started)
    GAME_PROPS[:location_changed] = false

    # The universal command listener is the highest priority, so it gets executed first
    continue_execution = GAME_PROPS[:on_command].call(command, GAME_PROPS) unless GAME_PROPS[:on_command].nil?

    # Execute the current room's listener if...
    continue_execution = LOCATIONS[GAME_PROPS[:location]][:on_command].call(command, GAME_PROPS) if 
            # execution of more listeners wasn't canceled by the ones before AND the room has a listener to execute
            continue_execution &&   !LOCATIONS[GAME_PROPS[:location]][:on_command].nil?

    # Then, if the room and universal don't mind, execute each item's command listener (in order)
    GAME_PROPS[:inventory].each do |item|
        # We stop caring about continue_execution because the order of the items is arbitrary (though predictable)
        #   so it's up to the gamemaker to make sure they don't clash. 
        # Item listeners are executed last so that an item can revive the player
        item[:on_command].call(command, GAME_PROPS) unless item[:on_command].nil?
    end if continue_execution

    # We'd better tell the room we're staying (Unless we died) (or didn't stay)
    LOCATIONS[GAME_PROPS[:location]][:on_stay].call(GAME_PROPS) unless GAME_PROPS[:location_changed] || GAME_PROPS[:dead]

    GAME_PROPS[:turn_count] += 1 # No ++ in Ruby -- see http://stackoverflow.com/q/3660563/1863564
end
# The game is over -- let's have it so the game maker can have the last laugh (or terrified scream, or whatever)
GAME_PROPS[:on_death].call(GAME_PROPS)

It takes no command-line arguments.

And here's an example game:

Welcome to the center.
>goto left
You are leaving the center.
Welcome to the left.
>goto right
There is no path from here to right!
Please enter a command.
>goto center
You are leaving the left. Take a point!
Welcome to the center.
>goto right
You are leaving the center.
Welcome to the right.
You got a Taco of Life!
>goto center
You are leaving the right.
Welcome to the center.
>goto right
You are leaving the center.
Welcome to the right.
>die
You died, but were resurrected by your taco of life!
Please enter a command.
>goto center
You are leaving the right.
Welcome to the center.
>goto right
You are leaving the center.
Welcome to the right.
You got a Taco of Life!
>stop
GAME OVER!
You died with 1 point.
Here's what you had on you when you died:
  Taco of life: A taco with a mysterious inscription: "pffftftpfpfffttff-- *gasp* *squee*"

The lines starting with > are the user input.

\$\endgroup\$
  • 1
    \$\begingroup\$ The tale of a mysterious Taco of Life... nice question ;D \$\endgroup\$ – Phrancis Jun 3 '15 at 3:02
  • 4
    \$\begingroup\$ @Phrancis I was thinking "The Tale of the Three Rooms and a Taco of Life" actually \$\endgroup\$ – Fund Monica's Lawsuit Jun 3 '15 at 3:03
  • 3
    \$\begingroup\$ I was going to try to say something like a smartass, but I just can't... \$\endgroup\$ – Phrancis Jun 3 '15 at 3:05
  • \$\begingroup\$ You need someone better at ruby than me, but I do find the blocks of delegates a bit strange. It seems that these should be classes in their own right. Of course, it could be my oop background skewing my vision. \$\endgroup\$ – RubberDuck Jun 4 '15 at 1:27
  • \$\begingroup\$ @RubberDuck Those are because parsing a file into a Hash is simpler (for me) than parsing it into a class, since the former can be done by looping through each line and using the stuff before a colon or comma or whatever as the key and the rest as a value. Creating a class is a little more annoying and fiddly. Plus, it's easier to convert get score to GAME_PROPS[:score] than player.score, and using a hash makes it very easy to add new key-value pairs. \$\endgroup\$ – Fund Monica's Lawsuit Jun 4 '15 at 1:33
6
\$\begingroup\$

I largely ignored the comments on the first read to see how clear the code is without them, and other than a couple small naming things the code is fairly clear. Having the same hash represent the game data and game state is an interesting approach, but it seems it'll work well enough for this style of game. The only organizational thing I would suggest is breaking up the GAME_PROPS hash into the parts that are meant to be configurable, changed by gameplay, or defined by the game maker, since you're likely to wind up getting them from different things anyway.

For naming concerns, naming the areas 'left', 'right', and 'center' seems odd. Usually in games like Zork you say something like 'go north'. It prevents the user from having to care about the unique names of each room. Giving them ids or arbitrary names illustrates the problem a bit--what we really want is for the rooms to have an id, and in the connections list pair each connection with a keyword to access that connection. For example:

R1:
            {
                    connections:     {R2: :left, R3: :right],
                    allow_entrance:  lambda {|_| true},
                    on_enter:        lambda {|_| puts 'Welcome to the center.'},
                    on_exit:         lambda {|_| puts 'You are leaving the center.'},
                    on_stay:         lambda {|_| puts 'Please enter a command.'},
                    on_block:        lambda {|_| puts 'You are not allowed into this room.'},
            },
...

And then this code:

if current[:connections].include? new_loc
    # If new_loc will let us in
    if LOCATIONS[new_loc][:allow_entrance].call(GAME_PROPS)
    ...

would become

room_id = current[:connections].select{|rid, connection| connection == new_loc}.keys.first
if room_id
    if LOCATIONS[room_id][:allow_entrance].call(GAME_PROPS)
    ...

You can also assign the location has to a local variable to reduce access depth, and if you do decide to have at least the locations be proper objects (you can always have them delegate [] to the hash they're constructed with!), you can provide any of the magic methods (meta-programming) to have the room check if it has a room at the requested connection:

if room.send(new_loc)
    ...

Update: We can make it easier by swapping the key/value pairs in connections, so that a room description looks like this:

R1:
    {
        connections:     {left: :R2, right: :R3],
        allow_entrance:  lambda {|_| true},
        ...
    }

Then, when checking a users input for room changing, we get the easier code:

if current[:connections][new_loc.to_sym]
    room_id = current[:connections][new_loc.to_sym] # because we shouldn't assign stuff in if checks... :)
    if LOCATIONS[room_id][:allow_entrance].call(GAME_PROPS)
        ...
\$\endgroup\$
  • \$\begingroup\$ Note that this is a demo of a game engine. LOCATIONS is just to demo it, since that's where most of the codey stuff takes place. However, I like the bit about giving them IDs and keywords -- I was trying to figure out how to get rid of that, and couldn't, so thanks for that. Could you elaborate on your last section, though? I'm not sure entirely what you mean by it. (Also, could you explain what current[:connections].select{|rid, connection| connection == new_loc}.keys.first does?) \$\endgroup\$ – Fund Monica's Lawsuit Jun 4 '15 at 3:28
  • \$\begingroup\$ The last section being if room.send(new_loc)? It's just meant as an object level helper for the other line you asked about, pointing out that you can use method_missing or other meta techniques to deal with what users might try to say. \$\endgroup\$ – Maya Webster Jun 4 '15 at 4:23
  • \$\begingroup\$ The long line there about current[:connections] 'selects' a key/value pair matching the connection, rid being the key, connection being the value (ruby-doc.org/core-2.2.0/Hash.html#method-i-select), and since we (probably) only have one such connection, and we want the room id, we just grab the first key we got by doing that. \$\endgroup\$ – Maya Webster Jun 4 '15 at 4:24
  • \$\begingroup\$ I'm still not quite understanding the purpose of creating a wrapper object (if that's what it is; I'm confused about the whole thing). Could you join the Cactus discussion room so you can keep explaining it over and over until I get it? I'm also confused as to why you need to use select, rather than just [new_loc.to_sym]. \$\endgroup\$ – Fund Monica's Lawsuit Jun 4 '15 at 4:40
  • \$\begingroup\$ The wrapper object comment is only if you want to, pointing out some stuff you can do with it. You seemed to want to use the hash syntax, so I wast pointing out that you can have both by defining (or aliasing) the hash access method. The select clause is because we're looking for a match on the value of the hash, not the key. We can easily swap the keys and values in this case though, which (after getting some sleep) makes things easier in this case, so I'll update the answer for that. \$\endgroup\$ – Maya Webster Jun 4 '15 at 12:40

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.