Smalltalk the fun way/Number Guessing Game
In this tutorial we'll create a "number-guessing game".
It goes as follows: somebody thinks a random number and you try to guess it in as few attempts as possible.
Each time you make a guess, you are given you one of the following answers:
- "My number is bigger"
- "My number is smaller"
- "You guessed right!"
When you finally guess the number, the game ends.
We'll start with a version without UI, and then make it more interactive.
A first Implementation
editIn our case it is the computer the one "thinking" the random number. But how do we generate random numbers in Smalltalk to begin with?
By reading the help of class Random
, we come across this two handy usage forms:
(6 to: 12) atRandom.
10 atRandom.
You can try that out in a Workspace.
Let's begin by creating a class NumberGuessingGame
. It should have the instance variable number
, which will hold the random number the player will try to guess.
Here is the class template:
Object subclass: #NumberGuessingGame
instanceVariableNames: 'number'
classVariableNames: ''
poolDictionaries: ''
category: 'Smalltalk-Fun'
We'll implement the initialization method as follows:
initialize
number := (1 to: 100) atRandom.
For now we'll be interacting with the game directly through the Workspace. Go to a Workspace and create an instance of our game:
game := NumberGuessingGame new.
But we can't play yet! We would like to make guesses and receive an answer. It should work like this:
game responseForGuess: 34. "My number is bigger"
game responseForGuess: 70. "My number is smaller"
An simple implementation of this method could look like this:
responseForGuess: guess
(number > guess) ifTrue: [^ 'My number is bigger'].
(number < guess) ifTrue: [^ 'My number is smaller'].
^ 'You guessed right!'
And now the game can begin.
Try it out in the Workspace, by entering guesses and "print-it" to see the answer.
Better Interaction
editAlthough it is possible to play by printing message-sends in the Workspace, it's not as inviting as it could be. Let's make this a more "interactive" experience.
For basic using interaction, like showing alerts or asking for information, there is the class UIManager
. There is a "default" (singleton) instance of this class already present in the system and ready to use.
First, we'll have the game ask us a number. The way to ask for input is by using the method request:
. It shows a dialog box where the user can enter some text. The call returns that text as a string. Try this in a Workspace, and make sure you "print" the resulting value:
UIManager default request: 'What is your name?'. "James Bond"
The dialog looks like this:
Second, we'll have the game tell us in a dialog box what the response is. This time the method is inform:
. Expanding on the example above, select and evaluate these statements:
| name |
name := UIManager default request: 'What is your name?'.
UIManager default inform: 'Hello, ', name.
It should look like this:
Armed with this knowledge we can implement our corresponding methods. One to ask for a number guess, and the other to show a response to the player:
askForGuess
^ (UIManager default request: 'What is your guess?') asNumber.
showResponseToGuess: guess
| response |
response := self responseForGuess: guess.
UIManager default inform: response.
Remember how the return value of request:
is of kind String
? This is why we are converting it to a number with asNumber
.
It seems that we are ready to use these two methods together. Let's do just that in a new method:
play
| guess |
guess := self askForGuess.
self showResponseToGuess: guess.
Go ahead and try it out in the Workspace:
game play.
This is how the guess-input should look like:
This is how a response should look like:
The game-loop
editAs you might have noticed, our game is short-lived. It only asks and responds once. Then it stops.
We need to do this repeatedly until the number is correctly guessed. The way to repeat a block of statements in Smalltalk is precisely by using the class Block
. Take a look at this class, especially in the category "controlling".
We'll be using the method doWhileFalse:
in order to keep asking until the guess equals the random number. We use doWhileFalse:
and not whileFalse:
because we want our block to be evaluated at least once, so that "guess" has at least one initial value.
The new version of play
looks like this:
play
| guess |
[ guess := self askForGuess.
self showResponseToGuess: guess ] doWhileFalse: [ guess = number ].
Try it out:
game play.
Now the game-loop is in place. The only problem is that the game keeps "remembering" the initial random number. Even after you won.
One solution of course is to "re-initialize" the game on the Workspace, like this:
game initialize.
It would be much nicer if this as part of our game-loop... but where exacly? Let's look at our alternatives:
- Before the game begins? It doesn't seem right, since we already chose a random number when the game was initialized.
- After the game finishes? It doesn't seem right either, since you could want to ask for the random number after you won.
This seems like a dilemma. If we just could know if a game was won or not... then we could re-initialize the game at the beginning, only if it was previously won.
We'll just add that information to our game.
Adding game status
editIn order to store the game status we will introduce a new instance variable. Modify the class to look like this:
Object subclass: #NumberGuessingGame
instanceVariableNames: 'number finished'
classVariableNames: ''
poolDictionaries: ''
category: 'Smalltalk-Fun'
The variable finished
will be initially false
, and become true
when the game was won.
Accordingly, change the initialization method like this:
initialize
number := (1 to: 100) atRandom.
finished := false.
Now we are ready to make the necessary changes to the "game-loop".
Our first change will be:
play
| guess |
finished ifTrue: [self initialize].
[ guess := self askForGuess.
self showResponseToGuess: guess ] doWhileFalse: [ guess = number ].
This makes sure that if we re-start the game, it will pick re-initialize itself if the game was finished. And remember that in the initialization we set finished
back to false
.
However, something is missing: we never set "finished" to true
after the game is won!
Let's do just that:
play
| guess |
finished ifTrue: [self initialize].
[ guess := self askForGuess.
self showResponseToGuess: guess ] doWhileFalse: [ guess = number ].
finished := true.
Refactoring the game-loop
editThe "play" method now does what we want. But the intention is somehow lost in too many low-level details. This goes against the "Smalltalk way" of doing things, and could use some refactoring.
Separation of concepts
editWe'll try to separate concepts to different, intention revealing, methods. But before we do, allow me modify the assignment of finished
so that it's confined to the repeating block. You'll see why later:
play
| guess |
finished ifTrue: [self initialize].
[ guess := self askForGuess.
self showResponseToGuess: guess.
finished := (guess = number) ] doWhileFalse: [ finished ]
A first consequence of this change is that we can switch to the more intuitive whileFalse:
(at least if you have experience in other programming languages). Since we don't have "guess" in the condition anymore, we can re-structure our look like this:
play
| guess |
finished ifTrue: [self initialize].
[ finished ] whileFalse: [
guess := self askForGuess.
self showResponseToGuess: guess.
finished := (guess = number) ]
Now, let's try to find out what our method is doing. We could come up with:
- It makes sure the game is properly re-initialized, if needed
- It performs the steps of a game interation
- It repeats the steps in a loop until the game is won
We'll refactor the re-initialization and the game-iteration to these methods:
reinitializeIfNeeded
finished ifTrue: [ self initialize ]
doOneIteration
| guess |
guess := self askForGuess.
self showResponseToGuess: guess.
finished := (guess = number).
The refactored version of play
now looks like this:
play
self reinitializeIfNeeded.
[ finished ] whileFalse: [ self doOneIteration ]
Different levels of abstraction
editSomething just does not feel right about the presence of finished
in both doOneIteration
and play
. And that is that we are mixing abstraction levels: we have nice intention-revealing methods for the user interaction, the game-iteration and re-initialization, yet we still deal with the finished
variable directly. This is not elegant and something we could also improve.
For that, let's create these methods:
isFinished
^ finished
beFinished
finished := true.
And modify the calling sites accordingly:
doOneIteration
| guess |
guess := self askForGuess.
self showResponseToGuess: guess.
(guess = number) ifTrue: [self beFinished ]
play
self reinitializeIfNeeded.
[ self isFinished ] whileFalse: [ self doOneIteration ]
Before you go any further and try to play, make sure you re-initialize the game instance in the Workspace one last time:
game initialize.
Otherwise you'll get an error when you try to "play". This is because we introduced a new variable (finished
) that remained un-initialized (with nil
) in our existing instance. Alternatively, you can create a new instance altogether:
game := NumberGuessingGame new.
Now we are ready to play again, as many times as we want.
Number of tries
editThe goal of this game is not only to guess a number, but to do so in "as few attempts as possible".
In order to make the game more challenging we could tell the user how many times he/she tried at the end. Let's introduce a new instance variable for this purpose:
Object subclass: #NumberGuessingGame
instanceVariableNames: 'number finished tries'
classVariableNames: ''
poolDictionaries: ''
category: 'Smalltalk-Fun'
Don't forget to initialize it:
initialize
number := (1 to: 100) atRandom.
finished := false.
tries := 0.
Each time the player makes a guess, this counter should go up by one. We stick to our hesitation to manipulate instance variables without revealing our intention, so we create this method:
increaseTries
tries := tries + 1
And call it thus:
doOneIteration
| guess |
guess := self askForGuess.
self showResponseToGuess: guess.
self increaseTries.
(guess = number) ifTrue: [self beFinished ]
The message at the end also needs to be changed. It should mention the number of tries.
One way to accomplish this is by concatenating two strings:
'Number of tries: ', tries asString.
Another by using the format:
method of String
:
'Number of tries: {1}' format: {tries}.
And yet another, by using streams:
'' writeStream
nextPutAll: 'Number of tries: ';
nextPutAll: tries;
contents.
To better illustrate, we'll combine the last two. This is how it looks like:
resposeForGuess: numberGuess
number > numberGuess
ifTrue: [ ^ 'My number is bigger' ].
number < numberGuess
ifTrue: [ ^ 'My number is smaller' ].
^ '' writeStream
nextPutAll: 'You guessed right!';
cr;
nextPutAll: ('Number of tries: {1}' format: {tries});
contents
It it very correct and object oriented, but looks pretty much complicated for just concatenating two strings and a number.
Of course that part could also be written like this:
^ 'You guessed right!
Number of tries: {1}' format: {tries}.
It is up to you.
However, if when choosing this variant you have to be careful that the sentence "Number of..." really is at the beginning of the next line. This is because within string-literals Smalltalk will honor all newlines, tabs, etc. In our case we have an explicit newline character.
Now play again, and see how many tries you need in average to beat the computer.
Have fun!