Building a roguelike in F# from scratch - Game Loop Time!!!

in #programming7 years ago (edited)

Introduction

The command structure and game loop are the focus this time, at last :)

There have also been some supporting changes which I will see the full detail in the GitHub Repo.

The change list is as follows:

  • Monads module - Comprehension for the Option type added
  • Vector module - Compass direction vectors added
  • GameTypes module - Extra actor lens added
  • Queries module - Reorganisation and some extra queries added
  • Validation module - isValidMove now take direction instead of location
  • Operations module - moveActor now take direction instead of location
  • Commands modules - new module, see below
  • PlayerInput module - new module, see below
  • RenderEngine module - new module with a stub for the render engine
  • GameLoop module - new module, see below

Function Composition

Time for some serious mental gymnastics if you are not used to function composition before. This might feel like the game "which cup is the ball under" so refer back to Monad Primer in the Validation article if required.

The vital thing to remember about F# is when you call a function with less arguments than it takes you get back a function.

let add x y = x + y
let add2 = add 2
let result = add2 3 // == 2 + 3 = 5

Using this technique you reshape functions to match the hole you want to plog them into. This is why argument order is so important, the last argument being the most common argument you want to operate on.

In my case, the level is how I hange everything together so it is the last argument.

Commands

All commands in the game, no matter the action, have the same simple API. They take a level to operate on and return the updated level or an error message of what went wrong.

level -> result<level>

They are built by command factory functions and consist of Validation and the CRUD Operation that performs the command action.

These are composed together as follows.

let private composeCommand validation operation level =
    result {
        let! validLevel = validation level
        return operation validLevel
    }

The validation is run and it valid the operation is run; otherwise the error message is returned. The composition should make more sense in a moment.

First is the idle command factory, this is where the player decides they want to skip their turn. This is a simple identity type operation.

let buildIdleCommand (actorId: actorId) = 
    (fun (level: level) -> Valid level)

This takes an actorId and returns a command that does nothing to the level for that actor. Taking in the actor id appears pointless but there is a reason for this that will become obvious a little later.

And next is the move actor command factory.

let buildMoveActorCommand direction actorId =
    let validator = isValidMove direction actorId
    let operation = moveActor direction actorId
    composeCommand validator operation```

This one might take a little thought. The signatures for isValidMove and moveActor both take three arguments (direction actorId and level) as shown below.

isValidMove direction actorId level
moveActor direction actorId level

But in the factory I only call them with two arguments, they are partially applied. This means the results are functions with the following signatures.

isValidMove = (level -> result<level>)
moveActor = (level -> level

The composeCommand function takes these two functions and uses the results monad to construct a new function with the signature:

level -> result<level>

None of the validation or operation code is actually run until it is given the level to operate on. It is similar to composing instances of classes together except the data being composed is functions.

Player Input

This is the actually the first code in the entire code base that makes use of side effects, reading the keyboard.

let getPlayerCommand =
    match Console.ReadKey().Key with
    | ConsoleKey.W -> buildMoveActorCommand north
    | ConsoleKey.A -> buildMoveActorCommand east
    | ConsoleKey.S -> buildMoveActorCommand south
    | ConsoleKey.D -> buildMoveActorCommand west
    | _ -> buildIdleCommand

This is a turn based game, so it waits for player input and then prepares the command to run. This has two points of interest:

  • It does not supply the actorID to the command factories. That is done later on.
  • The reason for buildIdleCommand taking the actorId now becomes obvious, it is no longer a special case.

The Game Loop.... FINALLY

Actually there is no loop at all, F# has tail call optimization so we can safely use recursion and the stack will never blow out. Recursion is how you handle loop mutations in F#.

So the entire game loop does the following:

  • Get the player command from the factory and give it the player id.
  • Run the player command followed by all the AI using runTurn
  • Check if the player is dead. If dead it exits returning Unit (Bit like a void type) otherwise it renders the new level and then calls the gameLoop again.
let rec gameLoop level =
    let playerCommand = getPlayerCommand level.playerId
    let turnLevel = level |> runTurn playerCommand
    let player = level |> expectActor turnLevel.playerId
    if not (isAlive player) then
        ()
    else
        render turnLevel
        gameLoop turnLevel

Simple enough, so on to running a turn. This is a monadic composition. It runs the player command, if that results in an error it returns the level unchanged. If successful it runs all the commands for the npcs, which will be generated by the AI.

Notice how the only place in the code base is calling side effect functions, the game loop. It calls getPlayerCommand and render, everything else is pure.

let runTurn level = 
    let turnResult = 
        result {
            let! playerMoved = level |> getPlayerCommand level.playerId
            let! aiMoved = playerMoved |> runAi
            return aiMoved
        }

    match turnResult with
    | Valid turnLevel -> turnLevel
    | Invalid _ -> level

The AI is really sparse at the moment, they all just stand idle. The only point of interest being if an npc command fails they are just ignored that turn. This should never happen at the moment as they are all sequential but could if I thread them all later on.

let private getAiCommand = buildIdleCommand

let runAiCommand level command  =
    match level |> command with
    | Valid updatedLevel -> updatedLevel
    | Invalid _ -> level

let runAi (level: level) =
    let npdCommands = level |> npcIds |> Seq.map getAiCommand
    Valid (npdCommands |> Seq.fold runAiCommand level)

This is all standard map/reduce type code.

  • Get all the npcIds and build their commands
  • Run the commands in turn accumulating the updated level each generates.

Summary

We are now close to being able to run this. A simple static test level builder and an ASCII render and it will be possible to move around the map.

The code is clean, the functions are small and modular so it will be easy to refactor later on and no mutation the data at all. Unless I have screwed up this should run first go... Oh the optimism :)

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

Finally the GitHub Repo

Feel free to ask questions and happy coding :)

Woz

Sort:  

Nearly everything you do is of no importance, but it is important that you do it.

- Mahatma Gandhi

Congratulations @woz.software! You have completed some achievement on Steemit and have been rewarded with new badge(s) :

Award for the number of posts published

Click on any badge to view your own Board of Honor on SteemitBoard.
For more information about SteemitBoard, click here

If you no longer want to receive notifications, reply to this comment with the word STOP

By upvoting this notification, you can help all Steemit users. Learn how here!