Back in my school days, when my friend and I were first learning C++,
we were voracious readers and exhausted our school’s programming
curriculum very quickly. Our teacher challenged us to make something
larger so we’d stop playing Tribes all
the time. It worked: I spent the rest of the term building a text-mode
platformer using <conio.h>
, and he spent the rest of
the term building and tweaking a small text-mode dungeon crawler.
Many new Haskellers make it through initial material (everything up
to and including the Monad
typeclass, let’s say), write a
couple of “Hello, world!”-tier projects that use the IO
type, but struggle to make the jump to industrial libraries and/or find
projects that excite them. I think text-mode games can grow very
smoothly alongside a programmer learning a new language, so here’s some
thoughts on how to get started, how you might extend a game, and some
advice for Haskell specifically.
A text-mode dungeon crawler can start very small. My friend began with a core encounter loop, which was very much like a Pokémon battle: the player was placed into combat with a monster, given a choice between attacking and fleeing, and repeated this loop until either the player ran off or one defeated the other. You could imagine it looking something like:
There is a goblin in front of you.
You can ATTACK or RUN. What do you do?
[HP 98/100]> attack
You hit the goblin for 5 damage!
The goblin hits you for 7 damage!
There is a goblin in front of you.
You can ATTACK or RUN. What do you do?
[HP 91/100]> run
Okay, coward! See you later.
In Haskell, we might manually pass state between all our functions, and that state could be as simple as:
data GameState = GameState
playerHP :: Int
{ monsterHP :: Int
, }
Once this is working, there are a lot of ways to extend it. Some ideas of things to add:
Character generation:
Randomness. Pretty much anything can be made more interesting with randomness:
Fight a gauntlet of monsters, until the player runs out of HP.
Have the player visit a town between fights. This makes the game switch between (at least) two modes: fighting and shopping.
Items:
Skills and Spells:
Have more types of things (monsters, items, spells, &c.).
Maps:
On the Haskell side, your goal should be to keep things as simple as
possible. A big ball of IO
with do
-expressions
everywhere is completely fine if it keeps you hacking on and
extending your game. Don’t look at the dizzying array of advanced
Haskell features, libraries, and techniques; wait until what you have
stops scaling and only then look for solutions. Still, some
Haskell-specific ideas might be helpful:
Start by passing your GameState
in and out of
functions manually. When this gets annoying, look at structuring your
game around a StateT GameState IO
monad.
lift
, maybe you want to test stateful computations that
don’t need to do I/O), consider mtl
and structuring your
program around MonadState GameState m
constraints.When your “ball of IO
mud” gets too big to handle,
start extracting pure functions from it. Once you have some
IO
actions and some pure functions, that’s a great time to
practice using the Functor
, Applicative
and
Monad
operators to weave the two worlds together.
hlint
at this point, as its suggestions are
designed to help you recognise common patterns:-- Actual hlint output
Found:
do x <- m
pure (g x)
Perhaps:
do g <$> m
tasty
library to organise tests into groups, and
tasty-hunit
for actual unit tests.A “command parser” like this is more than enough at first:
playerCommand :: GameState -> IO GameState
= do
playerCommand s putStrLn "What do you do?"
<- getLine
line case words line of
"attack"] -> attack s
["run"] -> run s
[-> do
_ putStrLn "I have no idea what that means."
playerCommand s
Later on, you might want to parse to a concrete command type. This gives you a split like:
data Command = Attack | Run
parseCommand :: String -> Maybe Command
getCommand :: IO (Maybe Command) -- uses 'parseCommand' internally
runCommand :: Command -> GameState -> IO GameState
Even later on, you might want to use a parser combinator library to parse player commands.
When your command lines become complicated, that might be a good
time to learn the haskeline
library. You can then add
command history, better editing, and command completion to your game’s
interface.
Reading from data files doesn’t need fancy parsing either. Colon-separated fields can get you a long way — here’s how one might configure a list of monsters:
# Name:MinHP:MaxHP:MinDamage:MaxDamage
Goblin:2:5:1:4
Ogre:8:15:4:8
The parsing procedure is really simple:
'#'
.':'
traverse
).You might eventually want to try reading your configuration from JSON
files (using aeson
), Dhall files, or an SQLite
database.
If passing your configuration everywhere becomes annoying, think
about adding a ReaderT Config
layer to your monad
stack.
Ignore the String
vs. Text
vs. ByteString
stuff until something makes you care.
String
is fine to get started, and when it gets annoying
(e.g., you start using libraries that work over Text
, which
most of them do), turn on OverloadedStrings
and switch your
program over to use Text
.
A bit of colour can give a game — even a text-mode one — a lot of “pop”.
Text
, try the
safe-coloured-text
library to add a bit of colour.Don’t worry about lens
; just use basic record
syntax. Once you get frustrated by the record system, look at using
GHC’s record extensions like DuplicateRecordFields
,
NamedFieldPuns
and RecordWildCards
.
lens
, and only as much as you need to
view/modify/update nested records in an ergonomic way. Remember, the
point is to keep moving!A project like this can grow as far as you want, amusing you for a weekend or keeping you tinkering for years. Textmode games are an exceptionally flexible base on which to try out new languages or techniques. Start small, enjoy that incremental progress and use the problems you actually hit to help you choose what to learn about.