Building a roguelike in F# from scratch - Display Errors

in #programming8 years ago (edited)

Introduction

Today I have added logging and the display of error messages generated by the player commands. If you have been following the series so far you will know the commands contain a set of validators that generate error messages if that command is not valid. Validation is ordered such that they pick the best message the describes what went wrong.

These errors will now be logged so they can be displayed when the level is redrawn. Below is the error displayed when I tried to take items from the tile above me and it has no items.

What you should be able to see today is how localized the changes are, really low impact. Which is what you want when coding.

Level

First up somewhere to store these messages. I started to explore the state monad at first but it did not really buy me anything in this case. You will find the commented out implementation of the state monad in the Monads module in the GitHub Repo but that is out of scope today.

I settled on storing the messages in the level. The idea being as the turn is run the messages will accumulate in the level, when I render the level I flush the messages ready for the next turn.

A simple cons list was used for this, which is a simple single linked list. One issue with a cons list is that you add the messages to the head of the list and I want to display them in the order they occur. This is solved by reversing the list when I want to use it, sounds costly but actually cheap enough. Immutable state is always a trade off, in this case add speed traded against consume speed.

Here is the updated Level with the messages list and associated lens.

type level = 
    {
        playerId: int

        map: map; 
        doors: Map<vector, door>; 
        actors: Map<int, actor>
        items: Map<vector, List<item>>

        mapActors: Map<vector, int>

        messages: List<string>
    }

let messages_ = 
    (fun level -> level.messages), 
    (fun messages level -> {level with messages = messages})    

Queries

This one is not really related, it was a clean up of some noise in the game loop. The original function was isAlive but turns out the more logical choice is isDead. isPlayerDead just builds on that and is a shortcut to test the player state.

let isDead actor =
    actor |> Optic.get (currentHealth_) = 0 

let isPlayerDead level =
    level |> expectActor level.playerId |> isDead

Operations

Just two new function in the operations, the CRUD layer. One function to add a new message to the message log and the other to flush the contents. Notice now flush does not need to specify the level being passed in. This is because the optic results in a function from level to level, you can write it long hand but that adds noise. This is partial application at work.

let log message level =
    let messages = message :: (level |> Optic.get messages_)
    level |> Optic.set messages_ messages

let flush = Optic.set messages_ []

// Long hand
// let flush level = level |> Optic.set messages_ []

PlayerInput

Once the messages were displayed I found I kept getting the "Invalid command" message. That was the final command to build when selecting a command.

The order the commands are built in is important here. The error will be from the last command we tried to build. open door is after move, if we can't move we then try and see if it is because of a closed or locked door. This way we get the correct error displayed.

let private selectActorCommand direction actorId level =
    resultOrElse {
        return! selectCommand isValidMove buildMoveActorCommand direction actorId level
        return! selectCommand canOpenDoor buildOpenDoorCommand direction actorId level
        //return! invalidCommand level
    }

Then getPlayerCommand was renamed to getCommandForActor, it could be used for any actor. getPlayerCommand lifts some more logic out of the game loop and gets the command for the player. You will see the payoff later in the game loop.

let getCommandForActor = handleKeyPress None 

let getPlayerCommand level = level.playerId |> getCommandForActor

Render Engine

This was again small changes. The error log is now displayed after the level is rendered. The print function simply lifts some common code to stop duplication.

let render level = 
    let buildRow map currentY = 
        xs map 
            |> Seq.map (fun nextX -> vector.create nextX currentY)
            |> Seq.map (renderTile level)
            |> Seq.toArray
            |> System.String
    
    let print strings = 
        strings |> Seq.iter (printfn "%s")
        printfn ""

    Console.Clear()

    ys level.map |> Seq.map (buildRow level.map) |> print
    level.messages |> List.rev |> print

    level

Game Loop

First up is message logging, this was so simple. When the turn is run I simply log the message into the level, before it was just thrown away.

let private runTurn playerCommand level = 
    let turnResult = 
        result {
            let! playerMoved = level |> playerCommand
            let! aiMoved = playerMoved |> runAi
            return aiMoved
        }

    match turnResult with
    | Valid turnLevel -> turnLevel
    | Invalid message -> level |> log message
    // Before the change
    // | Invalid _ -> level 

The game loop is really clean now, easy to see what is going on. Because all the functions are level to level they just pipe together.

  • Get the player command
  • Run the turn
  • Render the result
  • Flush the log

You can now see why I change isAlive to isDead, no need to not the result any more.

let rec gameLoop level =
    let playerCommand = level |> getPlayerCommand 
    let turnLevel = level |> runTurn playerCommand |> render |> flush 
    if turnLevel |> isPlayerDead then
        ()
    else
        turnLevel |> gameLoop

Finally a slight tweak to how we start. Because the render is no in a pipeline we now need to render once before we start the game loop

let main argv = 
    render testLevel |> gameLoop
    0 // return an integer exit code

Summary

This is where world of pure functions and immutable state really starts to shine. You can see how easy it is to plug more layers and abstractions into the code and have minimal impact on the existing code base. Everything is so single minded and focused they become great building blocks while the immutable state coupled with strict static typing means you know 100% you are not breaking other parts of the code base. They still do what they have always done and nothing can change that unless you change the function itself.

And for those who have only just found the series, here is the story so far for Building a roguelike in F# from scratch :

The current game commands are:

  • W - Up
  • A - Left
  • S - Down
  • D - Right
  • Space - Idle
  • T + [WASD] - Take all items in that direction
  • O + [WASD] - Open the door in that direction
  • C + [WASD] - Close the door in that direction

Finally the GitHub Repo

Feel free to ask questions and happy coding :)

Woz

Coin Marketplace

STEEM 0.13
TRX 0.33
JST 0.034
BTC 110716.57
ETH 4295.49
USDT 1.00
SBD 0.82