The Nature of Code: Example I.1 - Traditional Random Walk (Godot 4 + F#)

Date: 2023-05-03 | create | ctech | godot | fsharp | the-nature-of-code |

This is part of The Nature of Code (Godot 4 + F#) Series.

Overview

In this post I'll be detailing my solution to Exercise I.1 (Traditional Random Walk) from The Nature of Code.

Rules

The task is basically to create a random walker that can move in one of four directions (up, down, left, right).

An outline of its functionality requirements:

  • Traditional Random Walker
    • Position (x, y)
    • Init - Start at middle of screen
    • Step - Take a step in a random direction (up, down, left, right)
    • Display - Ability to Display itself

Solution Overview

For this solution, I had two main goals:

  • Keep it as simple as possible (we'll learn more as we go)
  • Experiment with paradigms for Godot <> F# interop at scale

We have some unique constraints when trying to use Godot with F# - namely that the interop isn't great. This basically enforces a hard divide between Godot code and F# code because we need an intermediary translation layer (C#).

I talk at length about Godot 4 + F# interop strategies in Godot 4: Script with F#.

This forces us to drop certain patterns (mostly OO-leaning with shared state) and lean towards other patterns where this separation is okay. Luckily FP-style code with pure functions actually works pretty well within these kinds of constraints so I'll be trying to write this stuff in a more FP style.

Godot 4 + F#: Architecture

Godot 4 + F#: Architecture

The general architecture will be "Clean Code"-esque:

  • External: Godot
  • Presentation / External Port - C#
  • App / Domain Logic - F#

For a deeper dive into this Godot - C# - F# architecture, see: Godot 4: Script with F#

This may seem overly complicated (and tbh it might be, I'm still experimenting with this paradigm) but I think it gets a lot clearer when we look at the actual code.

Solution Code

Basically we'll just have two files:

  • RandomWalkerHolder.cs - A C# file that translates from Godot to our F# domain logic (lifecycle events, state, etc)
  • RandomWalker.fs - An F# file that handles our Walker (and actually our entire scene / game functionality)

We'll add the C# script to a 3D node in our scene, which will update its own position.

Note: Probably in a future version of this script we'd separate the Scene logic from the Walker logic but I wanted to keep it simple for now.

I_1_TraditionalRandomWalkerHolder.cs

using Godot;
using System;

using ScriptsFs;

public partial class I_1_TraditionalRandomWalkerHolder : Node3D
{
	private I_1_TraditionalRandomWalker.SceneState SceneState { get; set; }

	// Called when the node enters the scene tree for the first time.
	public override void _Ready()
	{
		this.SceneState = I_1_TraditionalRandomWalker.Create();
	}

	// Called every frame. 'delta' is the elapsed time since the previous frame.
	public override void _Process(double delta)
	{
		SceneState = I_1_TraditionalRandomWalker.Update(
			this.SceneState,
			new I_1_TraditionalRandomWalker.ProcessEvent (
				DateTimeOffset.Now.ToUnixTimeMilliseconds()
			)
		);

		Position = new Vector3(
			SceneState.Walker.Position.X,
			SceneState.Walker.Position.Y,
			0f
		);
	}
}

This file is a Node3D attached to a Node3D in our Godot scene. Essentially all it does is connect Godot to our F# game logic.

  • On _Ready - Calls F# to get the initial state of our scene and saves that to the object
  • On _Process - Calls F# with our updated data to get the new state of the scene and updates Godot with the return values

I_1_TraditionalRandomWalker.fs

namespace ScriptsFs 

open Godot
open Microsoft.FSharp.Reflection
open System

module I_1_TraditionalRandomWalker = 
    let walkStepMagnitude = 1f

    let random = new Random()

    type StepDirections =
        | Up
        | Down
        | Left
        | Right 

    type TraditionalRandomWalker = {
        Position : Vector2
    }

    type ProcessEvent = {
        ElapsedTimeMs : int64
    }

    type SceneState = {
        Walker : TraditionalRandomWalker
    }

    let CreateWalker (xPosition : float32) (yPosition : float32) : TraditionalRandomWalker = 
        {
            Position = Vector2(xPosition, yPosition)
        }

    let getNextWalkerPosition : TraditionalRandomWalker -> TraditionalRandomWalker =
        // Ham: This is ugly, but only way to enumerate DU cases
        // Source: https://stackoverflow.com/questions/6997083/how-to-enumerate-a-discriminated-union-in-f
        let allDirections : StepDirections list = 
            FSharpType.GetUnionCases typeof<StepDirections>
            |> Seq.map (fun(caseInfo) ->
                FSharpValue.MakeUnion(caseInfo, [||])
                :?> StepDirections)
            |> Seq.toList

        fun (walker : TraditionalRandomWalker) -> 
            let index = random.Next(0, allDirections.Length) 
            let locationDelta = 
                match allDirections[index] with 
                | Up -> (0f, 1f)
                | Down -> (0f, -1f)
                | Left -> (-1f, 0f)
                | Right -> (1f, 0f)
            let newPositionTuple = ((
                walker.Position.X + (fst locationDelta),
                walker.Position.Y + (snd locationDelta)
            ))

            {
                walker with 
                    Position = (
                        new Vector2(
                            (fst newPositionTuple),
                            (snd newPositionTuple)
                        )
                    )
            }

    let TimeBasedDebounce<'a> (debounceRateMs : int64) : (unit -> 'a) -> 'a -> int64 -> 'a =
        let mutable lastExecutedTimeMs = 0L

        fun (fn : unit -> 'a) (currentReturn : 'a) (currentTimeMs : int64) ->
            match currentTimeMs with
            | t when (currentTimeMs > (lastExecutedTimeMs + debounceRateMs)) ->
                    lastExecutedTimeMs <- currentTimeMs
                    fn()
            | _ -> currentReturn

    let UpdateWalker : ProcessEvent -> TraditionalRandomWalker -> TraditionalRandomWalker =
        let updateWalkerFn = TimeBasedDebounce<TraditionalRandomWalker> 200
        
        fun (processEvent : ProcessEvent) (walker : TraditionalRandomWalker) ->
            updateWalkerFn (fun() -> (getNextWalkerPosition walker)) walker processEvent.ElapsedTimeMs

    let Update (sceneState : SceneState) (processEvent : ProcessEvent) : SceneState =
        let updateWalker = UpdateWalker processEvent sceneState.Walker 

        {
            Walker = updateWalker
        }

    let Create : unit -> SceneState = 
        fun() ->
            {
                Walker = CreateWalker 0f 0f
            }

This script handles the entire scene including how to create and update the scene. The beauty of an approach like this is that we have very little coupling to any one implementation - it's easy to see how we might be able to port this code for use anywhere - it's not tied to Godot. The downside is that we're adding some extra complexity through abstraction.

  • At the top of the file, we're declaring all our types. This is very common in F# and a good practice I think in most programs.
  • We then define functions we need for the scene to function, including Create and Update and some helper functions for each smaller entity CreateWalker and UpdateWalker.
  • Most of this code is pure - meaning we have a deterministic link between inputs and outputs. The main exception to this is the creation of Random but we could pass that through as well if we want.

Next Steps

That's it for this code dive. Let me know if you have questions or suggestions for improving this code. I'm still playing around with how best to define / separate code in this kind of environment so think many of these ideas will evolve as I work through the book.

Want more like this?

The best / easiest way to support my work is by subscribing for future updates and sharing with your network.