The latest instance of the course can be found at: O1: 2024
Luet oppimateriaalin englanninkielistä versiota. Mainitsit kuitenkin taustakyselyssä osaavasi suomea. Siksi suosittelemme, että käytät suomenkielistä versiota, joka on testatumpi ja hieman laajempi ja muutenkin mukava.
Suomenkielinen materiaali kyllä esittelee englanninkielisetkin termit. Myös suomenkielisessä materiaalissa käytetään ohjelmaprojektien koodissa englanninkielisiä nimiä kurssin alkupään johdantoesimerkkejä lukuunottamatta.
Voit vaihtaa kieltä A+:n valikon yläreunassa olevasta painikkeesta. Tai tästä: Vaihda suomeksi.
Chapter 9.1: Interactive Fiction and Code Quality
About This Page
Questions Answered: Some more practice on an application with multiple classes, please? How can I write code that’s easy to develop further?
Topics: A few principles of program design. A few words about
modifiability and refactoring. Additional practice on Map
s.
What Will I Do? Read first, program next. Prepare for the time-consuming, open-ended programming assignment that makes up Chapter 10.2.
Rough Estimate of Workload:? Three or four hours.
Points Available: B80.
Introduction: A Forest Adventure
The AdventureDraft project a text-based adventure game. In such games, the player controls a character in the game world by issuing textual commands; the game also uses text to describe the character’s current location.
Launch o1.adventure.draft.ui.AdventureTextUI
. The game will run in the text console:
Spend a few moments with the game. You can instruct the player to go north, go west, and rest, for example. Type quit to end the game.
If you wish, you can take a look at a map of the tiny game word in forest_map.gif
.
You’ll notice that the game works but is low in entertainment. That’s something you’ll get to fix in a 10.1 assignment. As for the present chapter, we’ll explore the given implementation and improve its quality through refactoring.
About AdventureDraft
Right now: take some time to examine the given program.
The rest of this chapter assumes that you are more or less familiar with AdventureDraft! Read through the entire code. You don’t have to dig into every implementation detail but you should try to get a general sense of each class and each method.
This diagram may also help.
Keep the code on hand as you read on.
Don’t read on before you’ve explored the code.
Let’s Extend AdventureDraft
Suppose we want to:
- add a new area to the game world;
- add a couple more directions of movement (e.g., go up might send the player up a tree); and
- offer a GUI that presents the game in a separate window as shown.
Let’s look at each goal in turn. We’ll find that, as given, the code doesn’t readily bend to our will.
Adding a New Area
Our first goal, adding an area to the game, doesn’t pose any problems yet. We can just
write the new are into class Adventure
:
val northPole = new Area("The North Pole", "You find yourself at the North Pole. BRRR!")
northForest.northNeighbor = Some(northPole)
northPole.southNeighbor = Some(northForest)
Adding Directions
Adding directions of movement — up and down, for example — is harder. It turns out that we’ll have to modify the program in several places.
- Of course, we have to edit
Adventure
where it defines the game map. That’s where we list all the areas and all the directions of movement that lead from one area to another. - Class
Area
needs new instance variables:upNeighbor
anddownNeighbor
. - So that the player character can move in these new directions,
we need a couple more
if
s in thego
method of classPlayer
:if (direction == "up")
etc. - In order for the new directions to show up in area descriptions,
we need to edit
printAreaInfo
in classAdventure
to add a couple of additionalif
s:if (area.upNeighbor.isDefined) print(" up")
etc.
This isn’t great. We’d like our program to be easy to modify. Such a simple extension of existing functionality should not require changes to several parts of the program.
What’s more, our already repetitive code just keeps getting more and more redundant as we add directions.
The source of the problems is the way the logic that deals with neighboring areas is scattered all over the program.
Let’s refactor the code in stages.
Refactoring #1: introducing a method parameter
Let’s try to put all the code that deals with neighboring areas into class Area
.
As a first step, let’s eliminate the repetitive list of neighbors in class Player
,
whose go
method initially looks like this:
def go(direction: String) = {
var destination: Option[Area] = None
if (direction == "north") {
destination = this.location.northNeighbor
} else if (direction == "east") {
destination = this.location.eastNeighbor
} else if (direction == "south") {
destination = this.location.southNeighbor
} else if (direction == "west") {
destination = this.location.westNeighbor
}
// ...
We wouldn’t like to modify Player
at all just to introduce a new direction.
The class therefore shouldn’t contain any code that lists all the specific directions
available in the game.
It’s not too hard to fix this. Instead of asking an Area
object for its “northern
neighbor”, “eastern neighbor”, and so on, we’ll ask the area to tell us its “neighbor in
direction X”:
def go(direction: String) = {
val destination = this.location.neighbor(direction)
// ...
Our code is now shorter. The redundancy is gone and there are no more mentions of
specific directions. We no longer need to edit go
if new directions are added to the
game. This is great!
Of course, now we need to provide a neighbor
method in class Area
. Here’s an
uneducated implementation:
class Area(var name: String, var description: String) {
private var northNeighbor: Option[Area] = None
private var eastNeighbor: Option[Area] = None
private var southNeighbor: Option[Area] = None
private var westNeighbor: Option[Area] = None
def neighbor(direction: String) =
if (direction == "north")
this.northNeighbor
else if (direction == "east")
this.eastNeighbor
else if (direction == "south")
this.southNeighbor
else if (direction == "west")
this.westNeighbor
else
None
Bonus info
Programmers sometimes refer to different refactorings by name. What we did here, for instance, may be called “parameterizing a method”. If you’re interested, take a look at the long Catalog of Refactorings.
Our program is already better than it was, but there’s still plenty of room for complaint. In particular:
- The method
neighbor
(above) still contains a repetitive list of directions. Fair enough, it’s located within classArea
, so at least any additions to the list will be made in that class. - We need to add a new instance variable in
Area
for each additional direction (e.g.,upNeighbor
). - The
Adventure
class is no simpler. ItsprintAreaInfo
method still needs to attend to any added direction separately.
An already-familiar tool rids us of the first two complaints:
Refactoring #2: using a Map
to eliminate redundancy
The neighbor
method has one job: to fetch the Area
object that corresponds to
a given direction (string). Sounds like a job for a Map
.
import scala.collection.mutable.Map
class Area(var name: String, var description: String) {
private val neighbors = Map[String, Area]()
def neighbor(direction: String) = this.neighbors.get(direction)
get
an Option[Area]
so that neighbor
works
exactly as planned.This implementation is both simpler and easier to modify. We no longer need a list of
directions in the source code. neighbor
works on any string that we use as a direction.
Remark on abstraction
Those refactorings rely on abstraction: our refactored code deals with the general case rather than the various individual cases. This abstraction is very intuitive: it is natural for us to think of the player character as “moving to the neighboring area in direction X” rather than as “checking if the direction is north and moving north if it is, otherwise checking if the direction is east, etc.”
Refactoring #3: the game map
We didn’t yet deal with how each area’s neighbors are set up as we construct the game world.
Our original Adventure
class says this:
middle.northNeighbor = Some(northForest)
middle.eastNeighbor = Some(tangle)
middle.southNeighbor = Some(southForest)
middle.westNeighbor = Some(clearing)
northForest.eastNeighbor = Some(tangle)
northForest.southNeighbor = Some(middle)
northForest.westNeighbor = Some(clearing)
// etc.
This code isn’t compatible with our revised Area
class, which doesn’t have
northNeighbor
and its specific siblings. Let’s instead give Area
a method that adds a
key–value pair in the Map
:
def setNeighbor(direction: String, neighbor: Area) = {
this.neighbors += direction -> neighbor
}
In Adventure
, we can now discard the Option
wrappers and just call setNeighbor
:
middle.setNeighbor("north", northForest)
middle.setNeighbor("east", tangle)
middle.setNeighbor("south", southForest)
middle.setNeighbor("west", clearing)
northForest.setNeighbor("east", tangle)
northForest.setNeighbor("south", middle)
northForest.setNeighbor("west", clearing)
// etc.
Notice that we can now add new directions without changing any of the other classes,
even Area
. It suffices to pass a new string to setNeighbor
.
How would you add a treehouse that the player can climb up to? (Do it if you want to practice.)
Our program is already significantly better, but let’s indulge in a bit of showboating.
Since Adventure
constructs the entire game world at once, it would be nice if we could
add multiple neighbors with a single method call. Perhaps we could define the entire world
map like this:
middle.setNeighbors(Vector("north" -> northForest, "east" -> tangle, "south" -> southForest, "west" -> clearing ))
northForest.setNeighbors(Vector( "east" -> tangle, "south" -> middle, "west" -> clearing ))
southForest.setNeighbors(Vector("north" -> middle, "east" -> tangle, "south" -> southForest, "west" -> clearing ))
clearing.setNeighbors(Vector("north" -> northForest, "east" -> middle, "south" -> southForest, "west" -> northForest))
tangle.setNeighbors(Vector("north" -> northForest, "east" -> home, "south" -> southForest, "west" -> northForest))
home.setNeighbors(Vector( "west" -> tangle ))
Area
objects to have a setneighbors
method that...And here is setNeighbors
for class Area
:
def setNeighbors(exits: Vector[(String, Area)]) = {
for (exit <- exits) {
this.neighbors += exit
}
}
String
and an Area
.Map
that stores the area’s neighbors.More implementations
As an additional trick, you may wish to know that mutable Map
s
have a ++=
method that adds a collectionful of key–value pairs
at once. We can use it to implement setNeighbors
as shown below.
def setNeighbors(exits: Vector[(String, Area)]) = {
this.neighbors ++= exits
}
As yet another trick, you may wish to know that you can use the
asterisk *
to define a method that takes an arbitrary number of
parameters. So this works, too:
def setNeighbors(exits: (String, Area)*) = {
this.neighbors ++= exits
}
Above, exits
is a container for all the parameter values of type
(String, Area)
that we pass in. This solution also simplifies the
code in class Adventure
:
middle.setNeighbors("north" -> nForest, "east" -> tangle, "south" -> sForest, "west" -> clearing)
nForest.setNeighbors( "east" -> tangle, "south" -> middle, "west" -> clearing)
sForest.setNeighbors("north" -> middle, "east" -> tangle, "south" -> sForest, "west" -> clearing)
clearing.setNeighbors("north" -> nForest, "east" -> middle, "south" -> sForest, "west" -> nForest )
tangle.setNeighbors("north" -> nForest, "east" -> home, "south" -> sForest, "west" -> nForest )
home.setNeighbors( "west" -> tangle )
Incidentally, many of the methods in the Scala API make use of this
“asterisk trick”, which is better known as variable arguments or
“varargs”. For instance, the fact that you can pass in any number of
parameters to a new map in Map(...)
is based on this technique.
Refactoring #4: bringing methods and data together
As far as adding directions is concerned, we have one more point to cover: printAreaInfo
in class Adventure
, which is now in this shape:
def printAreaInfo() = {
val area = this.player.location
println("\n\n" + area.name)
println("-" * area.name.length)
println(area.description)
print("\nExits available:")
if (area.northNeighbor.isDefined) {
print(" north")
}
if (area.eastNeighbor.isDefined) {
print(" east")
}
if (area.southNeighbor.isDefined) {
print(" south")
}
if (area.westNeighbor.isDefined) {
print(" west")
}
println()
println()
}
(Side note: The code uses print
, a function that is like println
except that it
doesn’t change lines after printing out the designated characters. So we get the exits
listed on a single line of output.)
We’ve already established the original problem with printAreaInfo
: the code lists
numerous specific directions. What’s more, the method doesn’t even work any more, since
our refactored program no longer has separate variables for each direction.
Another grumble is that this method of class Adventure
operates almost exclusively on
data that is stored elsewhere (in an Area
object). In object-oriented programming, we’d
usually like to define operations in the same place with the data being operated on. By
doing so, we boost the program’s cohesion (koheesio): the “togetherness” of the
pieces that make up each program component. A cohesive program is easier to read and
maintain.
We can solve these problems by moving printAreaInfo
from Adventure
to Area
, where the
pertinent data is. As part of that move, let’s edit the method to use the Map
that we’ve
stored in neighbors
:
def printAreaInfo() = {
println("\n\n" + this.name)
println(this.name.replaceAll(".", "-"))
println(this.description)
println("\nExits available: " + this.neighbors.keys.mkString(" ") + "\n\n")
}
How much did we accomplish?
The text adventure is now easier to develop further than it was. You’ve hopefully learned something about refactoring programs already. But we didn’t do everything we intended yet.
At the beginning of the chapter, we planned to furnish the game with a graphical interface. As things stand, this aim is hard to realize, because AdventureDraft’s implementation breaks a key principle of application design:
On User Interfaces and Domain Models
Chapters 1.2 and 2.7 identified the model and the user interface as the two primary components of any application program. Many of the programs you’ve seen since are split in two thus.
Even though AdventureDraft also has packages named o1.adventure.draft.ui
(the user interface)
and o1.adventure.draft
(the model), that division hasn’t been adequately implemented:
- The user interface comprises a single app object that merely
creates an
Adventure
object and calls itsrun
method. The UI doesn’t actually take care of interacting with the user by printing out text and reading input. - On the other hand, the classes of the model, which should only
represent the game world and its internal logic, don’t stick to
their task: they print out various things in any which method.
One of the methods in class
Adventure
even stops to read input, despite that class belonging to the domain model.
Testing is another concern
In addition to any end-user interfaces, programmers often write test code for checking that the domain model works correctly. Such a test program calls various methods and passes in selected parameter values independently of any user interface or end user.
(Systematic testing is both common and useful, but we won’t go deeper into that now; O1’s follow-on courses will say more.)
So what to do about our goal of replacing the console UI with a GUI as illustrated above? We would need to change the code throughout, replacing input and output instructions with GUI code. And what if we wanted to offer the user their pick of two alternative interfaces — the console UI and the GUI? Do we need two entirely separate versions of the program, with lots of duplicate code?
Let’s phrase our question in more general terms: what if we wish to use our program’s domain model in a different user interface or even a different application?
Separating model from interface
In a well-designed application, the model will be as independent as possible from any user interface (and any other code that uses the model, such as a test program). The model’s classes don’t communicate with end users; they don’t, for instance, print out anything, read any input, open any GUI windows, or react to button clicks. It should be possible to change the user interface, or replace it entirely, without touching the model.
Further reading
Search online to find out what sort of programming practice goes by the name Model–View–Controller or MVC.
Also, what do class View
in the o1
library and its constructor
parameter have to do with all that?
A user interface portrays the model for the end user to observe (e.g., as printouts about the game world); it also receives user inputs that it uses to manipulate the model somehow (e.g., commands to move the player character). The user interface calls the methods of the objects that make up the model and, therefore, must know in detail which sort of model it is an interface to; it can’t be independent of the model.
Adventure, the Improved Project
Adventure is a better version of AdventureDraft. It is better in that:
- It has been refactored as discussed above.
- Its user interface has been separated from the domain model:
- All
print
ing and keyboard-reading takes place in classAdventureTextUI
. - The classes of the domain model don’t print anything. They merely return strings that describe areas, the results of player actions, events in the game world, etc. It’s up to the user interface to determine how those strings are presented to the human player. (We stick with the assumption that any interface for this game will be text-based in one way or another.)
- All
- There is also a graphical user interface. It launches from
the app object
AdventureGUI
. - The project contains a tiny
Item
class. In the Scaladocs, there are several methods that involveItem
s, although they haven’t been implemented as code yet. You’ll deal with that in the programming assignment below.
Now study the improved Adventure
Fetch Adventure. Study it on your own. Note how it differs from AdventureDraft.
Don’t forget to study class Action
, too, even though our
discussion in this chapter has barely touched on it.
Assignment: Extend the Game
Develop the game in project Adventure by adding items to it. That is:
- Each
Area
should be able to hold items. TheItem
class has been written already, but the other classes don’t use it yet; you’ll be fixing that. - The items in an area should appear as part of the area description of the player character’s current location.
- The player should be able to pick up items with get itemname and to drop them with drop itemname.
- The player should be able to look at the items in their possession with examine itemname.
- The command inventory should produce a listing of the items that the player is currently carrying.
- The win condition should be more elaborate. It’s no longer enough that the player finds their way home; they should first get their hands on a remote control and some batteries and only then head home with those two items in their possession.
See below for an example game session.
Instructions and hints
- The given classes need additional methods that implement the desired functionality. See the Scaladocs.
- There is a comment in
Adventure.scala
that specifies the initial locations of the remote control and the batteries. Map
s are well suited for storingItem
s. Use them for storing items. The items in this game have unique names; that is, no two items can have the same name.- First add the items to the
Area
s of the game world. Then work on the in-game commands that deal with items. - Make sure your program produces an output that exactly matches the
example run below, or A+ will be upset.
- Note: In the example run, multiple items are never simultaneously available in a single location. What that happens, your program should list all the items separated by spaces (in any order) just like it lists the exits.
Example output
You are lost in the woods. Find your way back home. Better hurry, 'cause Scalatut elämät is on real soon now. And you can't miss Scalkkarit, right? Forest ------ You are somewhere in the forest. There are a lot of trees here. Birds are singing. Exits available: west north south east Command: go east You go east. Tangle of Bushes ---------------- You are in a dense tangle of bushes. It's hard to see exactly where you're going. Exits available: west north south east Command: go east You go east. Home ---- Home sweet home! Now the only thing you need is a working remote control. Exits available: west Command: inventory You are empty-handed. Home ---- Home sweet home! Now the only thing you need is a working remote control. Exits available: west Command: go east You can't go east. Home ---- Home sweet home! Now the only thing you need is a working remote control. Exits available: west Command: go west You go west. Tangle of Bushes ---------------- You are in a dense tangle of bushes. It's hard to see exactly where you're going. Exits available: west north south east Command: get remote There is no remote here to pick up. Tangle of Bushes ---------------- You are in a dense tangle of bushes. It's hard to see exactly where you're going. Exits available: west north south east Command: go west You go west. Forest ------ You are somewhere in the forest. A tangle of bushes blocks further passage north. Birds are singing. Exits available: west south east Command: go west You go west. Forest Clearing --------------- You are at a small clearing in the middle of forest. Nearly invisible, twisted paths lead in many directions. You see here: battery Exits available: west north south east Command: examine battery If you want to examine something, you need to pick it up first. Forest Clearing --------------- You are at a small clearing in the middle of forest. Nearly invisible, twisted paths lead in many directions. You see here: battery Exits available: west north south east Command: get battery You pick up the battery. Forest Clearing --------------- You are at a small clearing in the middle of forest. Nearly invisible, twisted paths lead in many directions. Exits available: west north south east Command: inventory You are carrying: battery Forest Clearing --------------- You are at a small clearing in the middle of forest. Nearly invisible, twisted paths lead in many directions. Exits available: west north south east Command: examine battery You look closely at the battery. It's a small battery cell. Looks new. Forest Clearing --------------- You are at a small clearing in the middle of forest. Nearly invisible, twisted paths lead in many directions. Exits available: west north south east Command: go south You go south. Forest ------ The forest just goes on and on. You see here: remote Exits available: west north south east Command: get remote You pick up the remote. Forest ------ The forest just goes on and on. Exits available: west north south east Command: examine remote You look closely at the remote. It's the remote control for your TV. What it was doing in the forest, you have no idea. Problem is, there's no battery. Forest ------ The forest just goes on and on. Exits available: west north south east Command: inventory You are carrying: remote battery Forest ------ The forest just goes on and on. Exits available: west north south east Command: go east You go east. Tangle of Bushes ---------------- You are in a dense tangle of bushes. It's hard to see exactly where you're going. Exits available: west north south east Command: drop remot You don't have that! Tangle of Bushes ---------------- You are in a dense tangle of bushes. It's hard to see exactly where you're going. Exits available: west north south east Command: drop remote You drop the remote. Tangle of Bushes ---------------- You are in a dense tangle of bushes. It's hard to see exactly where you're going. You see here: remote Exits available: west north south east Command: drop remote You don't have that! Tangle of Bushes ---------------- You are in a dense tangle of bushes. It's hard to see exactly where you're going. You see here: remote Exits available: west north south east Command: go east You go east. Home ---- Home sweet home! Now the only thing you need is a working remote control. Exits available: west Command: go west You go west. Tangle of Bushes ---------------- You are in a dense tangle of bushes. It's hard to see exactly where you're going. You see here: remote Exits available: west north south east Command: get remot There is no remot here to pick up. Tangle of Bushes ---------------- You are in a dense tangle of bushes. It's hard to see exactly where you're going. You see here: remote Exits available: west north south east Command: get remote You pick up the remote. Tangle of Bushes ---------------- You are in a dense tangle of bushes. It's hard to see exactly where you're going. Exits available: west north south east Command: go east You go east. Home at last... and phew, just in time! Well done!
Submission form
A+ presents the exercise submission form here.
A few ideas for reflection
- Above, we gave
Area
the methodsneighbor
,setNeighbor
, andsetNeighbors
as well as a private variableneighbors
that points to aMap
. We could have madeneighbors
public, and if we had, we wouldn’t have needed the three methods. (Why not?) Can you spot anything questionable about this proposal? (Here’s one perspective: the Law of Demeter.) - How would you refactor
Action
to eliminate theif
s and the repetitive creating ofSome
objects? Hint: use aMap
and function objects. Is your solution substantially better or worse than the given one? - How about refactoring the game so that
Action
is an abstract class or trait, whose various subtypes correspond to the various in-game commands? How would you createAction
objects under such a scheme? Hint: try a factory method (Chapter 5.1). Is your solution substantially better or worse than the given one?
Like so many other programming problems, the questions don’t have an unambiguous best answer.
Summary of Key Points
- The way that a program is designed and implemented affects how easy it is to extend and otherwise modify it.
- Refactoring a program can substantially improve various aspects of program quality, such as extensibility and readability.
- Eliminating dependencies between program components tends to make the program easier to modify. Implicit dependencies are particularly toxic to modifiability.
- Links to the glossary: refactoring; implicit coupling, cohesion, DRY; model vs. user interface; abstraction.
Start thinking about your own game!
We’ll return to Adventure in Chapter 10.1. That chapter consists of a single programming assignment, which is big enough that Week 10 is actually two weeks long. Here’s the summary: “Create your own text adventure game by editing the Adventure project as you please.” You can start brainstorming already. Make sure to start implementing your game as soon as you can!
Feedback
Please note that this section must be completed individually. Even if you worked on this chapter with a pair, each of you should submit the form separately.
Credits
Thousands of students have given feedback that has contributed to this ebook’s design. Thank you!
Weeks 1 to 13 of the ebook, including the assignments and weekly bulletins, have been written in Finnish and translated into English by Juha Sorva.
Weeks 14 to 20 are by Otto Seppälä. That part of the ebook isn’t available during the fall term, but we’ll publish it when it’s time.
The appendices (glossary, Scala reference, FAQ, etc.) are by Juha Sorva unless otherwise specified on the page.
The automatic assessment of the assignments has been programmed by Riku Autio, Jaakko Kantojärvi, Teemu Lehtinen, Timi Seppälä, Teemu Sirkiä, and Aleksi Vartiainen.
The illustrations at the top of each chapter, and the similar drawings elsewhere in the ebook, are the work of Christina Lassheikki.
The animations that detail the execution Scala programs have been designed by Juha Sorva and Teemu Sirkiä. Teemu Sirkiä and Riku Autio have done the technical implementation, relying on Teemu’s Jsvee and Kelmu toolkits.
The other diagrams and interactive presentations in the ebook are by Juha Sorva.
The O1Library software has been developed by Aleksi Lukkarinen and Juha Sorva. Several of its key components are built upon Aleksi’s SMCL library.
The pedagogy of using tools from O1Library (such as Pic
) for simple graphical programming
is inspired by the textbooks How to Design Programs by Flatt, Felleisen, Findler, and
Krishnamurthi and Picturing Programs by Stephen Bloch.
The course platform A+ has been created by Aalto’s LeTech research group and is largely developed by students. The current lead developer is Jaakko Kantojärvi; many other students of computer science and information networks are also active on the project.
For O1’s current teaching staff, please see Chapter 1.1.
Area
a new instance variableneighbors
. It refers to aMap
whose keys are strings (names of directions) and whose values areArea
objects.