Creating a Cinch plugin part 2: a word game bot for IRC
This is the second part of a tutorial for creating a cinch IRC bot game. If you haven't read the first part then you can read it here.
This part will go through the basics of creating a cinch plugin, and will give you a basic implementation of the word game.
Creating the folder structure
We'll use the same structure that's commonly used in gems. This makes it easier to package the cinch plugin as a gem at a later stage, if you want to do so. We want to have a top-level folder for our plugin, containing the structure lib/cinch/plugins
.
$ mkdir -p cinch-wordgame/lib/cinch/plugins
$ cd cinch-wordgame/lib/cinch/plugins
Then, create a file called word_game.rb
- this will contain our plugin. Add the following:
# lib/cinch/plugins/word_game.rb
require 'cinch'
module Cinch::Plugins
class WordGame
include Cinch::Plugin
match(/word start/)
def execute(m)
m.reply "Starting a new word game"
end
end
end
Now, let's create a test file that we can use to run the bot. In the top directory ("cinch-wordgame") create a file called test.rb
, which looks like this:
require 'cinch'
require_relative "lib/cinch/plugins/word_game"
bot = Cinch::Bot.new do
configure do |c|
c.server = "irc.freenode.net"
c.channels = ["#wordgametest"]
c.nick = "wordgame"
c.plugins.plugins = [
Cinch::Plugins::WordGame
]
end
end
bot.start
If you run this file then your bot will join the IRC channel #wordgame
on freenode. Join the channel and run !word start
to see the bot reply with:
Starting a new word game
Well, it's not particularly exciting yet, but at least you now have an easy way of testing your changes. You can kill the bot's ruby process with Ctrl-C
.
Multiple matches
We need a way of starting the game and guessing a word. That means that there are at least two different message matchers that we need to add for the same plugin. We already have !word start
, but we also want something like !guess [word]
to actually make a guess. Fortunately, that's easy to do:
class WordGame
include Cinch::Plugin
match(/word start/, method: :start)
def start(m)
m.reply "Starting a new word game"
end
match(/guess (\S+)/, method: :guess)
def guess(m, guessed_word)
m.reply "Your guess was #{guessed_word}"
end
end
You can add multiple regular expression matches, and specify which method is used for each match. Our guess match records the word that the user gives, and passes it through to the guess
method (regular expression group matches are passed as arguments).
This is a bit better, but it's nowhere near a game yet. To get to the point of it being a playable game, we need to meet the following criteria:
The bot needs a list of words
When the game starts, the bot should choose a random word
When a guess is made, the bot should check if a game has been started
If it has, the bot should say whether the guessed word comes before or after the bot's word (and also whether it is a real word)
If the guess is correct then the bot should announce the winner and end the game
Loading some words
For this, we need a dictionary. If you're using Ubuntu, you already have one - you'll find it in "/etc/dictionaries-common/words". If you're not, download one here. It contains approximately 10,000 words, and it will do nicely for our purposes.
It makes sense to keep this concept encapsulated, so let's create a Dictionary
class. We don't need it to be accessible outside of the WordGame class, so we can nest it for the time being:
class WordGame
#...
class Dictionary
# Create a new dictionary, with words loaded from a file
def self.from_file(filename)
words = []
File.foreach(filename) do |word|
if word[0] == word[0].downcase && !word.include?("'")
words << word.strip
end
end
self.new(words)
end
def initialize(words)
@words = words
end
def random_word
@words.sample
end
def word_valid?(word)
@words.include? word
end
end
end
The dictionary class is initialized with an array of words. To build it from a dictionary file, I've added a class method Dictionary.from_file(filename)
. This assumes that each line of the file is a new word. I noticed that the Ubuntu dictionary includes proper nouns and words with apostrophes, so we only add them to our array of words if they are lowercase and don't include an apostrophe.
The dictionary object gives us two methods, random_word
, which returns a random word from the list, and word_valid?
, which allows us to pass in a word to check whether it exists in our dictionary.
Now, we need to create a loaded dictionary class. We can put this in the plugin class' initialize
method:
class WordGame
include Cinch::Plugin
def initialize(*)
super
@dict = Dictionary.from_file "/etc/dictionaries-common/words"
end
#...
end
Change the path to the location of your dictionary file. Eventually we'll make it a configuration option for the plugin, but it's fine for it to be hard-coded for the time being. Note that it's very important to call super, otherwise cinch won't be able to initialize the plugin properly.
Starting a game
When the game starts, we want to choose a random word, and save this word so that we can compare it against guesses. We also want an easy way of comparing a word against another. To do this, we can create a very simple wrapper class for a word (again, we'll nest it within the plugin class):
class WordGame
#...
class Word < Struct.new(:word)
def before_or_after?(other_word)
word < other_word ? "before" : "after"
end
def ==(other_word)
word == other_word
end
end
end
Let's go back to our start
method and create a Word
to mark the start of a game:
class WordGame
#...
match(/word start/, method: :start)
def start(m)
m.reply "Starting a new word game"
@word = Word.new @dict.random_word
end
#...
end
Making a guess
Now we can respond to guesses from the user. In our guess
method:
class WordGame
#...
match(/guess (\S+)/, method: :guess)
def guess(m, guessed_word)
if @word
if @dict.word_valid? guessed_word
if @word == guessed_word
m.reply "#{m.user}: congratulations, that's the word! You win!"
@word = nil
else
m.reply "My word comes #{@word.before_or_after?(guessed_word)} #{guessed_word}."
end
else
m.reply "#{m.user}: sorry, #{guessed_word} isn't a word. At least, as far as I know"
end
else
m.reply "You haven't started a game yet. Use `!word start` to do that."
end
end
#...
end
It looks like a lot is going on, but it's actually fairly simple - it's just that there are quite a few paths that a guess could take.
Check to see whether a game has been started. If not, we let the user know how to start one.
Check whether the guessed word is in our dictionary, and tell the user if it isn't.
Finally, compare it to the random word we chose. If it's the same, then tell the user that they've one, and clear out the word. Otherwise, tell them whether the word comes before or after in the dictionary.
It's worth splitting this out into other methods, as the nested if
statements scream "refactor me". However, you now have a working game - go and try it out! Here's the full code so far, just in case something went wrong in the following of this tutorial:
require 'cinch'
module Cinch::Plugins
class WordGame
include Cinch::Plugin
def initialize(*)
super
@dict = Dictionary.from_file "/etc/dictionaries-common/words"
end
match(/word start/, method: :start)
def start(m)
m.reply "Starting a new word game"
@word = Word.new @dict.random_word
end
match(/word peek/, method: :peek)
def peek(m)
m.reply "The word is #{@word.word}"
end
match(/guess (\S+)/, method: :guess)
def guess(m, guessed_word)
if @word
if @dict.word_valid? guessed_word
if @word == guessed_word
m.reply "#{m.user}: congratulations, that's the word! You win!"
@word = nil
else
m.reply "My word comes #{@word.before_or_after?(guessed_word)} #{guessed_word}."
end
else
m.reply "#{m.user}: sorry, #{guessed_word} isn't a word. At least, as far as I know"
end
else
m.reply "You haven't started a game yet. Use `!word start` to do that."
end
end
class Dictionary
def initialize(words)
@words = words
end
def self.from_file(filename)
words = []
File.foreach(filename) do |word|
if word[0] == word[0].downcase && !word.include?("'")
words << word.strip
end
end
self.new(words)
end
def initialize(words)
@words = words
end
def random_word
@words.sample
end
def word_valid?(word)
@words.include? word
end
end
class Word < Struct.new(:word)
def before_or_after?(other_word)
word < other_word ? "before" : "after"
end
def ==(other_word)
word == other_word
end
end
end
end
Cheating
When you're playing this for the first time, you probably want to check that it's working correctly. Therefore, you might like to add a peek
command temporarily, which prints out the word:
class WordGame
#...
match(/word peek/, method: :peek)
def peek(m)
m.reply "The word is #{@word.word}"
end
#...
end
When you're happy that it's working, replace it with this cheat
command, which ends the game if someone uses it (plus piles on the guilt):
class WordGame
#...
match(/word cheat/, method: :cheat)
def cheat(m)
m.reply "#{m.user}: really? You're giving up? Fine, the word is #{@word.word}"
@word = nil
end
#...
end
Go and play
Congratulations, you now have a working word game! In the next and final part of the tutorial, I'll go through how to add configuration options and a help command.