Exercise 52: The Start of Your Web Game

We're coming to the end of the book, and in this exercise I'm going to really challenge you. When you're done, you'll be a reasonably competent Ruby beginner. You'll still need to go through a few more books and write a couple more projects, but you'll have the skills to complete them. The only thing in your way will be time, motivation, and resources.

In this exercise, we won't make a complete game, but instead we'll make an "engine" that can run the game from Exercise 47 in the browser. This will involve refactoring Exercise 43, mixing in the structure from Exercise 47, adding automated tests, and finally creating a web engine that can run the games.

This exercise will be huge, and I predict you could spend anywhere from a week to months on it before moving on. It's best to attack it in little chunks and do a bit a night, taking your time to make everything work before moving on.

Refactoring the Exercise 43 Game

You've been altering the gothonweb project for two exercises and you'll do it one more time in this exercise. The skill you're learning is called "refactoring," or as I like to call it, "fixing stuff." Refactoring is a term programmers use to describe the process of taking old code, and changing it to have new features or just to clean it up. You've been doing this without even knowing it, as it's second nature to building software.

What you'll do in this part is take the ideas from Exercise 47 of a testable "map" of Rooms, and the game from Exercise 43, and combine them together to create a new game structure. It will have the same content, just "refactored" to have a better structure.

The first step is to grab the code from ex47/lib/ex47/game.rb and copy it to lib/gothonweb/map.rb and copy the ex47/tests/ex47_tests.rb file to tests/map_tests.rb and run rake test again to make sure it keeps working. You'll need to change the require in test_map.rb to match the new gothonweb/map.rb location.

Note

I won't show you the output of a test run. Just assume that you should be doing it and it'll look like the above unless you have an error.

Once you have the code from Exercise 47 copied over, it's time to refactor it to have the Exercise 43 map in it. I'm going to start by laying down the basic structure, and then you'll have an assignment to make the map.rb file and the map_tests.rb file complete.

First you'll want to put the original Room class into a module so put this right at the top of map.rb:

1
module Map

Next you need to write up the rooms. Here's the complete room descriptions, but you can also put in dummy text for these and then fill in the full text later when the game is working. These go after the Room class you already have from Exercise 47:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
    CENTRAL_CORRIDOR = Room.new("Central Corridor",
    """
    The Gothons of Planet Percal #25 have invaded your ship and destroyed
    your entire crew.  You are the last surviving member and your last
    mission is to get the neutron destruct bomb from the Weapons Armory,
    put it in the bridge, and blow the ship up after getting into an 
    escape pod.

    You're running down the central corridor to the Weapons Armory when
    a Gothon jumps out, red scaly skin, dark grimy teeth, and evil clown costume
    flowing around his hate filled body.  He's blocking the door to the
    Armory and about to pull a weapon to blast you.
    """)


    LASER_WEAPON_ARMORY = Room.new("Laser Weapon Armory",
    """
    Lucky for you they made you learn Gothon insults in the academy.
    You tell the one Gothon joke you know:
    Lbhe zbgure vf fb sng, jura fur fvgf nebhaq gur ubhfr, fur fvgf nebhaq gur ubhfr.
    The Gothon stops, tries not to laugh, then busts out laughing and can't move.
    While he's laughing you run up and shoot him square in the head
    putting him down, then jump through the Weapon Armory door.

    You do a dive roll into the Weapon Armory, crouch and scan the room
    for more Gothons that might be hiding.  It's dead quiet, too quiet.
    You stand up and run to the far side of the room and find the
    neutron bomb in its container.  There's a keypad lock on the box
    and you need the code to get the bomb out.  If you get the code
    wrong 10 times then the lock closes forever and you can't
    get the bomb.  The code is 3 digits.
    """)


    THE_BRIDGE = Room.new("The Bridge",
    """
    The container clicks open and the seal breaks, letting gas out.
    You grab the neutron bomb and run as fast as you can to the
    bridge where you must place it in the right spot.

    You burst onto the Bridge with the netron destruct bomb
    under your arm and surprise 5 Gothons who are trying to
    take control of the ship.  Each of them has an even uglier
    clown costume than the last.  They haven't pulled their
    weapons out yet, as they see the active bomb under your
    arm and don't want to set it off.
    """)


    ESCAPE_POD = Room.new("Escape Pod",
    """
    You point your blaster at the bomb under your arm
    and the Gothons put their hands up and start to sweat.
    You inch backward to the door, open it, and then carefully
    place the bomb on the floor, pointing your blaster at it.
    You then jump back through the door, punch the close button
    and blast the lock so the Gothons can't get out.
    Now that the bomb is placed you run to the escape pod to
    get off this tin can.

    You rush through the ship desperately trying to make it to
    the escape pod before the whole ship explodes.  It seems like
    hardly any Gothons are on the ship, so your run is clear of
    interference.  You get to the chamber with the escape pods, and
    now need to pick one to take.  Some of them could be damaged
    but you don't have time to look.  There's 5 pods, which one
    do you take?
    """)


    THE_END_WINNER = Room.new("The End",
    """
    You jump into pod 2 and hit the eject button.
    The pod easily slides out into space heading to
    the planet below.  As it flies to the planet, you look
    back and see your ship implode then explode like a
    bright star, taking out the Gothon ship at the same
    time.  You won!
    """)


    THE_END_LOSER = Room.new("The End",
    """
    You jump into a random pod and hit the eject button.
    The pod escapes out into the void of space, then
    implodes as the hull ruptures, crushing your body
    into jam jelly.
    """
    )

After this you need to connect these rooms to create the map using Room.add_paths:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    ESCAPE_POD.add_paths({
        '2' => THE_END_WINNER,
        '*' => THE_END_LOSER
    })

    GENERIC_DEATH = Room.new("death", "You died.")

    THE_BRIDGE.add_paths({
        'throw the bomb' => GENERIC_DEATH,
        'slowly place the bomb' => ESCAPE_POD
    })

    LASER_WEAPON_ARMORY.add_paths({
        '0132' => THE_BRIDGE,
        '*' => GENERIC_DEATH
    })

    CENTRAL_CORRIDOR.add_paths({
        'shoot!' => GENERIC_DEATH,
        'dodge!' => GENERIC_DEATH,
        'tell a joke' => LASER_WEAPON_ARMORY
    })

    START = CENTRAL_CORRIDOR

Lastly, we'll need a way to find rooms by their names, load them, and save them to a "session". I'll explain wha a session is in the next section, but enter this code for the last part of map.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
    # A whitelist of allowed room names. We use this so that
    # bad people on the internet can't access random variables
    # with names.  You can use Test::constants and Kernel.const_get
    # too.
    ROOM_NAMES = {
        'CENTRAL_CORRIDOR' => CENTRAL_CORRIDOR,
        'LASER_WEAPON_ARMORY' => LASER_WEAPON_ARMORY,
        'THE_BRIDGE' => THE_BRIDGE,
        'ESCAPE_POD' => ESCAPE_POD,
        'THE_END_WINNER' => THE_END_WINNER,
        'THE_END_LOSER' => THE_END_LOSER,
        'START' => START,
    }

    def Map::load_room(session)
        # Given a session this will return the right room or nil
        return ROOM_NAMES[session[:room]]
    end

    def Map::save_room(session, room)
        # Store the room in the session for later, using its name
        session[:room] = ROOM_NAMES.key(room)
    end

end

You'll notice that there are a couple of problems with our Room class and this map:

  1. There are parts in the original game where we ran code that determined things like the bomb's keypad code, or the right pod. In this game we just pick some defaults and go with it, but later you'll be given Study Drills to make this work again.
  2. I've made a generic_death ending for all of the bad decisions, which you'll have to finish for me. You'll need to go back through and add in all the original endings and make sure they work.
  3. I've got a new kind of transition labeled "*" that will be used for a "catch-all" action in the engine.

Once you've that written, here's the new automated test tests/test_map.rb that you should have to get yourself started:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
require "gothonweb/map.rb"
require "test/unit"

class TestMap < Test::Unit::TestCase

    def test_room()
        gold = Map::Room.new("GoldMap::Room",
                    """This room has gold in it you can grab. There's a
                door to the north.""")
        assert_equal( "GoldMap::Room", gold.name)
        assert_equal({}, gold.paths)
    end

    def test_room_paths()
        center = Map::Room.new("Center", "Test room in the center.")
        north = Map::Room.new("North", "Test room in the north.")
        south = Map::Room.new("South", "Test room in the south.")

        center.add_paths({'north'=> north, 'south'=> south})
        assert_equal(north, center.go('north'))
        assert_equal(south, center.go('south'))

    end

    def test_map()
        start = Map::Room.new("Start", "You can go west and down a hole.")
        west = Map::Room.new("Trees", "There are trees here, you can go east.")
        down = Map::Room.new("Dungeon", "It's dark down here, you can go up.")

        start.add_paths({'west'=> west, 'down'=> down})
        west.add_paths({'east'=> start})
        down.add_paths({'up'=> start})

        assert_equal(west, start.go('west'))
        assert_equal(start, start.go('west').go('east'))
        assert_equal(start, start.go('down').go('up'))
    end

    def test_gothon_game_map()
        assert_equal(Map::GENERIC_DEATH, Map::START.go('shoot!'))
        assert_equal(Map::GENERIC_DEATH, Map::START.go('dodge!'))

        room = Map::START.go('tell a joke')
        assert_equal(Map::LASER_WEAPON_ARMORY, room)

        # complete this test by making it play the game
    end

    def test_session_loading()
        session = {}

        room = Map::load_room(session)
        assert_equal(room, nil)

        Map::save_room(session, Map::START)
        room = Map::load_room(session)
        assert_equal(room, Map::START)

        room = room.go('tell a joke')
        assert_equal(room, Map::LASER_WEAPON_ARMORY)

        Map::save_room(session, room)
        assert_equal(room, Map::LASER_WEAPON_ARMORY)
    end
end

Your task in this part of the exercise is to complete the map and make the automated test completely validate the whole map. This includes fixing all the generic_death objects to be real endings. Make sure this works really well and that your test is as complete as possible because we'll be changing this map later and you'll use the tests to make sure it keeps working.

Sessions and Tracking Users

At a certain point in your web application you'll need to keep track of some information and associate it with the user's browser. The web (because of HTTP) is what we like to call "stateless," which means each request you make is independent of any other requests being made. If you request page A, put in some data, and click a link to page B, all the data you sent to page A just disappears.

The solution to this is to create a little data store (usually in a database or on the disk) that uses a number unique to each browser to keep track of what that browser was doing. This is called "session tracking" uses cookies in the browser to maintain the state of the user through the application. In the little Sinatra framework it's fairly easy by adding this line at the top:

enable :sessions

Put that line where you put your set calls, and then you can use the session like this:

# set a value in the session for later
session[:room] = "central_corridor"

# get the room in another handler with
current_room = session[:room]

This is what we were doing in map.rb with the Map::load_room and Map::store_room to keep track of what room the player is currently in. You could also use this to keep track of choices they've made, if monsters have died, or how much damage the player has sustained.

You can read more about Sinatra's sessions at the project's README at http://www.sinatrarb.com/intro.html#Using%20Sessions.

Creating an Engine

You should have your game map working and a good unit test for it. I now want you to make a simple little game engine that will run the rooms, collect input from the player, and keep track of where a player is in the game. We'll be using the sessions you just learned to make a simple game engine that will:

  1. Start a new game for new users.
  2. Present the room to the user.
  3. Take input from the user.
  4. Run user input through the game.
  5. Display the results and keep going until the user dies.

Note

If you did not install rerun in Exercise 50 then you'll want to install it now for doing the rest of this exercise.

To do this, you're going to take the trusty bin/app.rb you've been hacking on and create a fully working, session-based game engine. The catch is I'm going to make a very simple one with basic HTML files, and it'll be up to you to complete it. Here's the base engine:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
require 'sinatra'
require './lib/gothonweb/map.rb'

set :port, 8080
set :static, true
set :public_folder, "static"
set :views, "views"
enable :sessions
set :session_secret, 'BADSECRET'

get '/' do
    session[:room] = 'START'
    redirect to('/game')
end

get '/game' do
    room = Map::load_room(session)

    if room
        erb :show_room, :locals => {:room => room}
    else
        erb :you_died
    end
end


post '/game' do
    room = Map::load_room(session)
    action = params[:action]

    if room
        next_room = room.go(action) || room.go("*")

        if next_room
            Map::save_room(session, next_room)
        end

        redirect to('/game')
    else
        erb :you_died
    end
end

There are even more new things in this script, but amazingly it's an entire web-based game engine in a small file. The biggest "hack" in the script are the lines that bring the sessions back, which is needed so that debug mode reloading works. Otherwise, each time you refresh the page the sessions will disappear and the game won't work.

You should next delete views/hello_form.erb and views/index.erb and create the two views mentioned in the above code. Here's a very simple views/show_room.erb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<h1> <%= room.name %> </h1>

<pre>
<%= room.description %>
</pre>

<% if room.name == "death" || room.name == "The End" %>
    <p><a href="/">Play Again?</a></p>
<% else %>
    <p>
    <form action="/game" method="POST">
        - <input type="text" name="action"> <input type="SUBMIT">
    </form>
    </p>
<% end %>

That is the template to show a room as you travel through the game. Next you need one to tell someone they died in the case that they got to the end of the map on accident, which is views/you_died.erb:

1
2
3
4
<h1>You Died!</h1>

<p>Looks like you bit the dust.</p>
<p><a href="/">Play Again</a></p>

With those in place, you should now be able to do the following:

1. Get the test tests/test_app.rb working again so that you are testing the game. You won't be able to do much more than a few clicks in the game because of sessions, but you should be able to do some basics. 3. Run the rerun 'ruby bin/app.rb' script and test the game.

You should be able to refresh and fix the game like normal, and work with the game HTML and engine until it does all the things you want it to do.

Your Final Exam

Do you feel like this was a huge amount of information thrown at you all at once? Good, I want you to have something to tinker with while you build your skills. To complete this exercise, I'm going to give you a final set of exercises for you to complete on your own. You'll notice that what you've written so far isn't very well built; it is just a first version of the code. Your task now is to make the game more complete by doing these things:

  1. Fix all the bugs I mention in the code, and any that I didn't mention. If you find new bugs, let me know.
  2. Improve all of the automated tests so that you test more of the application and get to a point where you use a test rather than your browser to check the application while you work.
  3. Make the HTML look better.
  4. Research logins and create a signup system for the application, so people can have logins and high scores.
  5. Complete the game map, making it as large and feature-complete as possible.
  6. Give people a "help" system that lets them ask what they can do at each room in the game.
  7. Add any other features you can think of to the game.
  8. Create several "maps" and let people choose a game they want to run. Your bin/app.rb engine should be able to run any map of rooms you give it, so you can support multiple games.
  9. Finally, use what you learned in Exercises 48 and 49 to create a better input processor. You have most of the code necessary; you just need to improve the grammar and hook it up to your input form and the GameEngine.

Good luck!

Buy DRM-Free

When you buy directly from the author, Zed A. Shaw, you'll get a professional quality PDF and hours of HD Video, all DRM-free and yours to download.

$29.99

Buy Directly From The Author

Or, you can read Learn Ruby the Hard Way for free right here, video lectures not included.