Space Merchant (#71)

This week's Ruby Quiz is to build a complete game with the help of your fellow Ruby Programmers. This is a multipart Ruby Quiz all in one week. Each person should select one task they wish to solve and leave the other tasks for other solutions.

The Game

When I was young, I loved to play BBS door games. A very popular game I was hooked on was called TradeWars 2002. While the BBS is pretty much a thing of the past, that game has been cloned in some form or another for just about every platform that ever existed.

The premise of the game is simple: you play a space trader flying around the galaxy buying and selling goods. The main goal is to find places to buy goods at a low price and then unload those goods at another location with a higher price, turning a profit. Most versions of the game allow you to use that money to buy bigger and better space ships, which in turn allows you to carry more goods and make bigger profits. Many versions of the game don't have a clear ending, though I have seen a version that eventually allowed you to buy a moon to retire on.

Common Elements

The thread tying all the pieces of the game together is the Player object. It is the centralized storage for game state information, like how much money the player currently has.

The other item we need is a trivial event loop. The options a player has available at any given time are a function of where they are. For example, while flying through space the player probably has commands to choose a destination, but when docked with a space station the commands will likely center around buying and selling goods.

The following code provides both elements:

ruby
#!/usr/local/bin/ruby -w

# space_merchant.rb

require "singleton"

module SpaceMerchant
VERSION = "1.0"

# We will use a basic Hash, but feel free to add methods to it.
class Player
instance_methods.each { |meth| undef_method(meth) unless meth =~ /^__/ }

include Singleton
def initialize
@game_data = Hash.new
end

def method_missing( meth, *args, &block )
@game_data.send(meth, *args, &block)
end
end
end

if __FILE__ == $0
require "galaxy" # Task 1
require "sector" # Task 2
require "station" # Task 3
require "planet" # Task 4

# collect beginning player information
player = SpaceMerchant::Player.instance

puts
puts "Welcome to Space Merchant #{SpaceMerchant::VERSION}, " +
"the Ruby Quiz game!"
puts

print "What would you like to be called, pilot? "
loop do
name = gets.chomp

if name =~ /\S/
player[:name] = name

puts "#{player[:name]} it is."
puts

puts "May you find fame and fortune here in the Ruby Galaxy..."
puts

break
else
print "Please enter a name: "
end
end

player[:credits] = 1000
# we initialize player[:location], it should be changed to move the player
player[:location] = SpaceMerchant::Galaxy.instance.starting_location

catch(:quit) do # use throw(:quit) to exit the game
# primary event loop
loop { player[:location].handle_event(player) }
end
end

As you can see in the event loop, all Sectors, Stations, and Planets need to support a handle_event() method that gives the player some choices, fetches an action from the keyboard, and responds appropriately.

The other four pieces I leave to you...

SpaceMerchant::Galaxy (Task 1)

We need an object representing the play-space as a whole. The Galaxy is responsible for creating all of the game locations and allowing event code to access these locations.

There should only be one Galaxy, so please make it a Singleton (support an instance() class method to retrieve it).

The first concern of the Galaxy is to construct Sector, Planet and Station objects. Sectors are pieces of space the players can move between. Think of them as squares on the game board. These areas must be connected, so the player can move from location to location. Sectors will support these creation methods, to aid this process:

Sector::new( name, location = nil )
Sector#link( other_sector )
Sector#add_planet( planet )
Sector#add_station( station )

Don't sweat too much over the names for the Sectors, TradeWars simply numbered them: Sector 1, Sector 2, ..., Sector 1051, ... This works out surprisingly well because players can enter letter commands for game actions (say l for land) and numbers to move to a new Sector.

The location parameter is an optional way to divide Sectors into groups. For example, Sectors 1 through 10 were always "Fed Space" in TradeWars.

The add_*() methods are for placing Planet and Station objects in the Sector. Both are trivial to construct (again be creative with the names):

Planet.new( sector, name )
Station.new( sector, name )

Let's keep the number objects fairly small, just so we don't overwhelm the player. Something like up to five Planets, possibly one Station, and a maximum of six links to nearby Sectors sounds sane to me (but use your best judgement). Of course, a Sector can be empty.

Please provide these iterators to help the other classes locate objects as well:

Galaxy::find_sectors { |sector| ... }
Galaxy::find_planets { |planet| ... }
Galaxy::find_stations { |station| ... }

Finally, the Galaxy should provide a pathfinding algorithm, for use in navigation:

Galaxy::find_path( start_sector, finish_sector, *avoid_sectors )

This method should return an Array of Sectors beginning with the start_sector and ending with the finish_sector. If the optional avoid_sectors are provided, they should not be used. It's okay to return nil, if a path could not be found, but this should only be possible with avoid_sectors.

Random idea: some games have wormholes, one way links between Sectors. This can make for very interesting layouts. Just be careful if you add wormholes to make sure all Sectors can still be reached from all other Sectors.

SpaceMerchant::Sector (Task 2)

(See Galaxy for the creation methods Sector needs to support.)

The Sector is the main interface for moving around the Galaxy. The Sector should show the player their location, and request a command:

Sector 302
The Reaches

Station: ABC Station
Planets: Myr III, Myr IV, Myr VII

(D)ock with station
(L)and on planet
(P)lot a course

(Q)uit game

Or warp to nearby sector: [4], [25], 1021, [9919]

Feel free to deviate from that in anyway you choose.

With the Sector you are in charge of how the player can move about. Should they only be allowed to move to nearby Sectors or can they plot long range courses? Perhaps only to Sectors they have visited before? You decide.

Any game level functions also belong here. Quit is obvious, but can you think of an easy way to support save?

Random ideas: consider supporting random encounters with computer controlled ships to make the galaxy feel more alive. "Unidentified vessel, this is the Galactic Patrol and we suspect you are transporting illegal cargo... Prepare to be boarded!"

Also consider supporting other space features like nebulas, black holes, asteroid belts, etc. How should these affect the player?

SpaceMerchant::Station (Task 3)

(See Galaxy for the constructor Station needs to support.)

Stations are where the player can buy and sell goods. There may be some set categories of goods available in the Galaxy, but an individual Station should probably just offer to trade in a subset of those.

Some games have Stations set prices based on where they are in the Galaxy while other games have the prices fluctuate everywhere over time. You can even have Stations set their prices based on demand, which could rise and fall as the player trades there.

The player's ship should probably have a limited capacity of goods it can carry at a time. I purposefully haven't set this, so that Station implementations could flesh out the player's ship as they saw fit. Perhaps some Stations could even offer ship upgrades.

Random idea: The original TradeWars game even allowed players to choose a criminal path. They could try stealing goods from a Station, to trade elsewhere. There were penalties for getting caught though and eventually word of your crimes got around and you weren't safe from the other denizens of space.

SpaceMerchant::Planet (Task 4)

(See Galaxy for the constructor Planet needs to support.)

Planets have had numerous functions in the various incarnations of space trading games. In the spirit of that, I'm leaving this section as a sort of free-for-all.

My suggestion is to use the planets as a kind of a quest engine. Allow the player to pick-up unique cargos or people and transport them to other planets in the Galaxy. See if you can get these quests to build on each other and form a basic plot for the game.

Random idea: Some games have had items for sale which allowed the creation and destruction of planets. You might even be able to tie that into the missions somehow.

Sharing Your API

When you have finished a task, you are invited to send in a message telling others what you built, even if the spoiler period has not yet passed. You may include documentation for some methods your object provides, or detail some variables you set on the Player. This may help others to choose a different task and take advantage of features you provided.


Quiz Summary

The primary focus of this quiz, for me, was to see how well a handful of developers could quickly throw something together, without much knowledge of what the other guys were doing. I think it went very well. I forgot several basic things in my specification (like how Planet and Station needed a name() accessor), but convention and common sense seemed to get us through with little trouble. Too cool.

Obviously, I can't show all the code that was written this week. Instead, I will try to hit on some highlights.

Development in Isolation

Since we were each just building a part of the whole, one of the big questions became, how do I test my part? I built the Station, which doesn't really require much from the other pieces. Sector and Galaxy help you move from Station to Station, but that turns out to be trivial to bypass. I really just needed to pretend I was docking at Station after Station. To do that, I added the following code to the end of station.rb:

ruby
if __FILE__ == $PROGRAM_NAME
player = {:credits => 1000}

loop do
if player[:location].nil?
player[:location] = SpaceMerchant::Station.new(nil, "Test")
end

player[:location].handle_event(player)
end
end

The idea here is that when you lift off from a Station, you will go back into the Sector. So if we pass Station some Sector substitute that is easy to watch for, nil for example, we can just replace that object with a newly constructed Station whenever we see it. This simulates flying from Sector to Sector, docking at Stations.

Some pieces depended on the others more heavily though, requiring more complete solutions for testing. Ross Bamford built Galaxy, which requires at least a minimal representations of the other celestial objects. Ross solved this by mocking the other objects with the needed functionality:

ruby
if $0 == __FILE__
# The comparable stuff is needed only by the tests,
# not the Galaxy impl itself.
class Named #:nodoc: all
def initialize(sector, name); @name = name.to_s; end
def name; @name; end
alias :to_s :name
def inspect; "#{self.class.name}:#{@name}"; end
def ==(o); name == o.name; end
def <=>(o); name <=> o.to_s; end
end

class Sector < Named #:nodoc: all
def initialize(name, location = nil)
super(nil, name)
@location, @planets, @stations, @links = location, [], [], []
end
attr_accessor :location, :planets, :stations, :links
def add_planet(planet); @planets << planet; end
def add_station(station); @stations << station; end
def link(o); @links << o; end
def ==(o)
begin
name == o.name &&
planets == o.planets &&
stations == o.stations &&
links.length == o.links.length
rescue NoMethodError
false
end
end
end

class Planet < Named #:nodoc: all
end

class Station < Named #:nodoc: all
end

# ...

Ross started with the minimal Named functionality that all objects share. I would have provided this in the quiz, if I was as smart as Ross. From there, Ross just adds in the functionality Galaxy requires. Note how unused details (like the sector parameter to new()) are just casually ignored. The goal is to build only what is needed to test the Galaxy implementation.

The Singleton Shortcut

I know how we all love a good method_missing() trick, so here's my favorite for this week, again from Ross:

ruby
class Galaxy
include Singleton

# ...

class << self
# tired of writing 'Galaxy.instance' in tests...
def method_missing(sym, *args, &blk) #:nodoc:
instance.send(sym, *args, &blk)
end
end

# ...

Obviously, there are other solutions to the problem the comment describes, but this particular trick made for a nice interface, I thought. Observe:

ruby
Galaxy.instance.find_planets { |planet| ... }
# ... becomes...
Galaxy.find_planets { |planet| ... }

That might come in handy with other uses of Singleton, I think.

The Big Event

Another detail of this quiz the solvers had to work with was how do handle events. Here's a handle_event() method for Sector, by Timothy Bennett:

ruby
# ...

def handle_event ( player )
player[:visited_sectors] ||= []
player[:visited_sectors] << self \
unless player[:visited_sectors].find { |sector| sector == self }
print_menu
choice = gets.chomp
case choice
when /d/i: choose_station
when /l/i: choose_planet
when /p/i: plot_course
when /q/i: throw(:quit)
when /\d+/: warp player, choice
else invalid_choice
end
end

# ...

Aside from the elegant menu dispatch at the end of the method, the main point of interest is the first line. We all had to add our individual elements to the Player object as needed, which required a little defensive programming. When the Player first arrives in a Sector, there is no :visited_sectors key (the game script doesn't create one). This is probably a sign that I should have provided an initialization hook in the quiz, but optional assignments like the above still might have been needed for things not known in advance. Luckily the ||= operator is just perfect for this kind of work.

I won't show all the all of the event methods used above, but here is one of them:

ruby
# ...

def choose_station
player = Player.instance
puts "There are no stations to dock with!" if @stations.empty?
if @stations.size == 1
dock @stations[0], player
else
@stations.each_with_index do |station, index|
puts "(#{index + 1}) #{station.name}"
end
puts "Enter the number of the station to dock with: "

station_index = gets.chomp.to_i - 1
if @stations[station_index]
dock @stations[station_index], player
else
puts "Invalid station."
end
end
end

# ...

I really liked how this method would just intelligently make the choice, if there was only one, or prompt the user when a decision needed to be made. This made for a better playing experience for sure.

Manufacturing Fun and Destruction

The final aspect of this quiz was, of course, innovation. I left the specification very open in the hopes that someone would grab the ball and run...

ruby
class UsableItem
attr_reader :rarity, :name, :description

def initialize (name, description = "", rarity = 0.7, &block)
@effect = block if block_given?
@name = name
@description = description
@rarity = rarity
end

def use (player)
if @effect
@effect.call player
else
puts "#{name} has no effect."
end
end

def to_s
name
end
end

Obviously, that is just a name, description, and rarity attached to a block (from Timothy Bennett's planet.rb), but just look at this example of the earth-shattering fun to be had with an object like this:

ruby
# ...

omega = SpaceMerchant::UsableItem.new( "Omega",
"Don't push that button. Please.",
0.9 ) do |player|
planet = player[:location]
player[:location] = planet.sector
puts
puts "You hear a terrible rumbling as the Vogon constructor fleet"
puts "descends upon #{planet.name}. You scramble to your"
puts "ship and launch just in time to avoid becoming space dust."
puts
player[:location].planets.slice!(player[:location].planets.index(planet))
end

# ...

I love it.

A big thank you to all who played with my pet project. Hopefully you didn't blow up your planet doing so.

Tomorrow we will continue our focus on essential Ruby programming skills with Breaking and Entering 101...