Text-Mode Games as First Haskell Projects

3 days ago 1

Posted on May 28, 2022 by Jack Kelly

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 Simple Game

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 }

Extending Your Game

Once this is working, there are a lot of ways to extend it. Some ideas of things to add:

  • Character generation:

    • Begin with something simple, like just giving your fighter a name.
    • Add stats.
    • Add skills.
    • Add classes. Fighter/Rogue/Magic User is a classic split for a reason.
  • Randomness. Pretty much anything can be made more interesting with randomness:

    • Chance to hit
    • Damage values
    • Player stats
    • Monster stats
    • Gold drops
  • Fight a gauntlet of monsters, until the player runs out of HP.

    • Track high scores during a session.
    • Track high scores between sessions, by writing them to a file.
  • Have the player visit a town between fights. This makes the game switch between (at least) two modes: fighting and shopping.

    • There won’t be much to do in town at first, but some easy options are “buy healing” and “deposit gold”.
    • Once you have items in your game, add an item shop and and an item stash.
  • Items:

    • Simple consumables (like healing potions or food) are a great place to start.
    • An equipment system can be as simple as “which weapon are you taking into the next fight?”.
  • Skills and Spells:

    • A skill or magic system opens up the player’s options beyond “fight” and “run”, making each combat round much more interesting.
  • Have more types of things (monsters, items, spells, &c.).

    • Configure this at first with a simple data structure in one of your modules.
    • Later on, you might want to try reading it from a file.
  • Maps:

    • A simpler step before a full world map is to run each combat in a generated arena.
    • Maps add all sorts of new things to hack on: pathfinding algorithms, data structures, graph representation, procedural generation, terrain, etc.

Haskell-Specific Advice

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.

    • When that gets annoying (maybe you’re sick of writing 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.

    • Set up 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
    • Once you have a decent number of pure functions kicking around, your game is probably so big that you can no longer test it in a single sitting. This is a good point to start setting up tests - I like the 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 playerCommand s = do putStrLn "What do you do?" line <- getLine 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:

    • Split the file into lines.
    • Ignore any line that’s blank or begins with '#'.
    • Split the remaining lines on ':'
    • Parse the lines into records and return them (hint: 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”.

    • After you’ve got your codebase using Text, try the safe-coloured-text library to add a bit of colour.
    • Many modern terminals support emoji. While I’m not an emoji fan (that’s a rant for another time), it’s an easy way to add some pictures to your game.
  • 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.

    • Once you get sick of writing deeply nested record updates, only then consider 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!

Go Forth and Hack (and Slash)!

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.

Read Entire Article