DEV Community

Cover image for Dungeon & Dragons & Functors
Thomas Haessle
Thomas Haessle

Posted on

Dungeon & Dragons & Functors

When I discover or want to explain a concept, I quite like to rely on a Kata, a short exercise that highlight a programming practice. I therefore propose the DnD Kata, whose objective is to model a team of characters from the Dungeon and Dragons role-playing game. Of course, modeling the set of rules is a complex exercise.

We will content ourselves here with:
1) representing a character by:

  • his/her name
  • his/her race
  • his/her skills, including and of course his racial bonuses

2) A team is a collection of characters, which can be of different races.

The objective of this solution is to illustrate OCaml functors and demonstrate how they contribute to applying the S.O.L.I.D principles in OCaml.

This post is a translation from my blog post: https://oteku.github.io/ocaml-functors/ (FR)

About DnD

Dunjon & Dragons a.k.a DnD is a role playing game where players play heroes in a fantasy setup.
The main setup for this game is Faerûn, a continent of the Abeir-Toril planet.
We will use the Dungeons & Dragons 5th Edition System under the Open-Gaming License (OGL).

We are the Dwarves

First we want to modelize Dwarves, one of the playable races in Faerûn, by their names.

We already know that a good way to have a namespace in OCaml is to use modules, so we can start with this representation:

module Dwarf = struct
  type t = string
end
Enter fullscreen mode Exit fullscreen mode

In this implementation, the type of the module is infered. We can also make it explicit by adding a module signature and modelize Elves at the same time:

module Dwarf : sig
  type t = string
end = struct
  type t = string
end

module Elf : sig
  type t = string
end = struct
  type t = string
end
Enter fullscreen mode Exit fullscreen mode

At this step we notice that the 2 modules are sharing the same signature. Since both Elf and Dwarf modules are representing playable heroes, it seems legit and we would make explicit that all playable heroes are sharing the same signature. To do that we can use a module type:

module type PLAYABLE = sig
  type t = string
end

module Dwarf : PLAYABLE = struct
  type t = string
end

module Elf : PLAYABLE = struct
  type t = string
end
Enter fullscreen mode Exit fullscreen mode

Other modules do not need to know the shape of a PLAYABLE.t, they only need to know it exists and the module should expose functions to work with it.

We call this make an abstraction:

module type PLAYABLE = sig
  type t
  val to_string : t -> string
  val of_string : string -> t
end
Enter fullscreen mode Exit fullscreen mode

Now each module of type PLAYABLE must implement those functions. Let's do it:

module Dwarf : PLAYABLE  = struct
  type t = {name : string}
  let to_string dwarf = dwarf.name
  let of_string name = {name}
end

module Elf : PLAYABLE = struct
  type t = string
  let to_string elf = elf
  let of_string name = name
end
Enter fullscreen mode Exit fullscreen mode

Since t is abstract, you may notice that each module implementing PLAYABLE may have a different concret type for t. It's totally fine while they respect their module type contract.

Other modules cannot access a concrete value of t, but we can create a dwarf or get a string representation.

let gimly = Dwarf.of_string "Gimly"
let () = Dwarf.to_string gimply |> print_endline
Enter fullscreen mode Exit fullscreen mode

Heroes have abilities

In DnD, a Hero is also represented by its abilities.
There is several option rules for abilities at the creation, we will only implement the Standard scores one. At the beginning each ability have a value of 10:

module Abilities = struct
  type t = {
    strength : int
  ; dexterity : int
  ; constitution : int
  ; intelligence : int
  ; wisdom : int
  ; charisma : int
  }

  let init () =  {
    strength = 10
  ; dexterity = 10
  ; constitution = 10
  ; intelligence = 10
  ; wisdom = 10
  ; charisma = 10
  }
end
Enter fullscreen mode Exit fullscreen mode

We can upgrade our Dwarf modules this way:

module Dwarf: PLAYABLE  = struct
  type t = {name : string ; abilities : Abilities.t}
  let to_string dwarf = dwarf.name
  let of_string name = {name ; abilities = Abilities.init()}
end
Enter fullscreen mode Exit fullscreen mode

Naming for our function is no more logical, so we will update PLAYABLE module type and then Elf and Dwarf modules:

module type PLAYABLE = sig
  type t
  val name : t -> string
  val make : string -> t
end

module Dwarf: PLAYABLE  = struct
  type t = {name : string ; abilities : Abilities.t}
  let name dwarf = dwarf.name
  let make name = {name ; abilities = Abilities.init()}
end

module Elf: PLAYABLE  = struct
  type t = {name : string ; abilities : Abilities.t}
  let name elf = elf.name
  let make name = {name ; abilities = Abilities.init()}
end
Enter fullscreen mode Exit fullscreen mode

Races give modifiers

The Darves have a constition bonus +2.

In OCaml, modules are first-class, it means you can use module as value. So we can create a new module type to represent a bonus and functions to represent a bonus of 2:

module type BONUS = sig
  type t
  val value : t
end

let bonus_2 : (module BONUS with type t = int) = (module struct
    type t = int
    let value = 2
end)
Enter fullscreen mode Exit fullscreen mode

bonus_2 is a module as value. Because t is abstract we must add a type witness with type t = int.

To unwrap the value of the bonus we also need a getter:

let get_bonus b = let module M = (val (b : (module BONUS with type t = int))) in M.value
Enter fullscreen mode Exit fullscreen mode

If you need more explaination about First-Class, you should read : https://dev.realworldocaml.org/first-class-modules.html

Now we can write:

module Dwarf: PLAYABLE  = struct
  type t = {name : string ; abilities : Abilities.t}
  let name dwarf = dwarf.name
  let make name = {name ; abilities = Abilities.init()}
  let constitution dwarf = dwarf.abilities.constitution + get_bonus bonus_2
end
Enter fullscreen mode Exit fullscreen mode

Also are Elves, Half-orc, Halflings, Tieflings

Dwarves are not the only race in Faerun. Each have a different constitution bonus. Half orcs have +1 while Elves, Halflings and Tieflings don't have constitution bonus.

When data varies inside a function we add a function parameter to avoid code duplication. We can do the same at module level. OCaml provides functors which are functional modules : function from module to module.

So we can create a Race functor:

module Race (B : BONUS with type t = int) : PLAYABLE  = struct
  type t = {name : string ; abilities : Abilities.t}
  let name character = character.name
  let make name = {name ; abilities = Abilities.init()}
  let constitution_bonus = B.value (* here we get the value from module B *)
  let constitution character = character.abilities.constitution + constitution_bonus
end
Enter fullscreen mode Exit fullscreen mode

You read this as : the functor Race take a module B of type BONUS whom type t is int as parameter and then return a module of type PLAYBLE.

Then we can easily have our modules:

(* we add a function to manage all bonus *)
let bonus (x:int) : (module BONUS with type t = int) = (module struct
    type t = int
    let value = x
end)

(* we use our Race functor to create the five races *)
module Dwarf = Race (val bonus 2)
module Elf = Race (val bonus 0)
module Tiefling = Race (val bonus 0)
module Halfling = Race (val bonus 0)
module HalfOrc = Race (val bonus 1)
Enter fullscreen mode Exit fullscreen mode

All abilities may have bonus

Functors are not limited to one parameter, so we can use the same trick to manage all bonuses:

module Race
    (BS : BONUS with type t = int)
    (BD : BONUS with type t = int)
    (BC : BONUS with type t = int)
    (BI : BONUS with type t = int)
    (BW : BONUS with type t = int)
    (BCh : BONUS with type t = int) : PLAYABLE  = struct
  type t = {name : string ; abilities : Abilities.t}
  let name character = character.name
  let make name = {name ; abilities = Abilities.init()}
  let bonus = Abilities.{
      strength = BS.value
    ; dexterity = BD.value
    ; constitution = BC.value
    ; intelligence = BI.value
    ; wisdom = BW.value
    ; charisma = BCh.value
    }
  let abilities character = Abilities.{
      strength = character.abilities.strength + bonus.strength
    ; dexterity = character.abilities.dexterity + bonus.dexterity
    ; constitution = character.abilities.constitution + bonus.constitution
    ; intelligence = character.abilities.intelligence + bonus.intelligence
    ; wisdom = character.abilities.wisdom + bonus.wisdom
    ; charisma = character.abilities.charisma + bonus.charisma
    }
end

module Dwarf = Race (val bonus 0) (val bonus 0) (val bonus 2)(val bonus 0) (val bonus 0) (val bonus 0)
Enter fullscreen mode Exit fullscreen mode

For your use case it's not so convenient, we have to remember the order of bonuses. We already have a type that represent all abilities values Abilities.t, just use it instead of int:

(* just create a bonus function that take a Abilities.t and return a Bonus module *)
let bonus (x:Abilities.t) : (module BONUS with type t = Abilities.t) = (module struct
    type t = Abilities.t
    let value = x
end)

(* the functor `Race` take a module `B` of type `BONUS` whom type `t` is `Abilities.t`
** as parameter and then return a module of type `PLAYBLE`  *)
module Race
    (B : BONUS with type t = Abilities.t) : PLAYABLE  = struct
  type t = {name : string ; abilities : Abilities.t}
  let name character = character.name
  let make name = {name ; abilities = Abilities.init()}
  let bonus = Abilities.{
      strength = B.value.strength
    ; dexterity = B.value.dexterity
    ; constitution = B.value.constitution
    ; intelligence = B.value.intelligence
    ; wisdom = B.value.wisdom
    ; charisma = B.value.charisma
    }
  let abilities character = Abilities.{
      strength = character.abilities.strength + bonus.strength
    ; dexterity = character.abilities.dexterity + bonus.dexterity
    ; constitution = character.abilities.constitution + bonus.constitution
    ; intelligence = character.abilities.intelligence + bonus.intelligence
    ; wisdom = character.abilities.wisdom + bonus.wisdom
    ; charisma = character.abilities.charisma + bonus.charisma
    }
end

(* create our Dwarf module *)
module Dwarf = Race (val bonus Abilities.{
    strength = 0
  ; dexterity = 0
  ; constitution = 2
  ; intelligence = 0
  ; wisdom = 0
  ; charisma = 0
  })
Enter fullscreen mode Exit fullscreen mode

To be more concise and explicit we can work from a no_bonus value:

let no_bonus = Abilities.{
    strength = 0
  ; dexterity = 0
  ; constitution = 0
  ; intelligence = 0
  ; wisdom = 0
  ; charisma = 0
  }

module Dwarf = Race (val bonus Abilities.{
    no_bonus with constitution = 2
  })
module Elf = Race (val bonus Abilities.{
    no_bonus with dexterity = 2
  })
module Halfling = Race (val bonus Abilities.{
    no_bonus with dexterity = 2
  })
module Tiefling = Race (val bonus Abilities.{
    no_bonus with charisma = 2  ; intelligence = 1
  })
module HalfOrc = Race (val bonus Abilities.{
    no_bonus with strength = 2
  })
Enter fullscreen mode Exit fullscreen mode

Summary

At the end of this section you should have:


module Abilities = struct
  type t = {
    strength : int
  ; dexterity : int
  ; constitution : int
  ; intelligence : int
  ; wisdom : int
  ; charisma : int
  }

  let init () =  {
    strength = 10
  ; dexterity = 10
  ; constitution = 10
  ; intelligence = 10
  ; wisdom = 10
  ; charisma = 10
  }
end

module type BONUS = sig
  type t
  val value : t
end

let bonus (x:Abilities.t) : (module BONUS with type t = Abilities.t) = 
(module struct
  type t = Abilities.t
  let value = x
end)

let no_bonus = Abilities.{
    strength = 0
  ; dexterity = 0
  ; constitution = 0
  ; intelligence = 0
  ; wisdom = 0
  ; charisma = 0
  }

module type PLAYABLE = sig
  type t
  val make : string -> t
  val name : t -> string
  val abilities : t -> Abilities.t
end


module Race
    (B : BONUS with type t = Abilities.t) : PLAYABLE  = struct
  type t = {name : string ; abilities : Abilities.t}
  let name character = character.name
  let make name = {name ; abilities = Abilities.init()}
  let bonus = Abilities.{
      strength = B.value.strength
    ; dexterity = B.value.dexterity
    ; constitution = B.value.constitution
    ; intelligence = B.value.intelligence
    ; wisdom = B.value.wisdom
    ; charisma = B.value.charisma
    }
  let abilities character = Abilities.{
      strength = character.abilities.strength + bonus.strength
    ; dexterity = character.abilities.dexterity + bonus.dexterity
    ; constitution = character.abilities.constitution + bonus.constitution
    ; intelligence = character.abilities.intelligence + bonus.intelligence
    ; wisdom = character.abilities.wisdom + bonus.wisdom
    ; charisma = character.abilities.charisma + bonus.charisma
    }
end

module Dwarf = Race (val bonus Abilities.{
    no_bonus with constitution = 2
  })
module Elf = Race (val bonus Abilities.{
    no_bonus with dexterity = 2
  })
module Halfling = Race (val bonus Abilities.{
    no_bonus with dexterity = 2
  })
module Tiefling = Race (val bonus Abilities.{
    no_bonus with charisma = 2  ; intelligence = 1
  })
module HalfOrc = Race (val bonus Abilities.{
    no_bonus with strength = 2
  })
(* We can add new race with ease. Humans have +1 for all abilities *)
module Human = Race (val bonus Abilities.{
    strength = 1
  ; dexterity = 1
  ; constitution = 1
  ; intelligence = 1
  ; wisdom = 1
  ; charisma = 1
  })
Enter fullscreen mode Exit fullscreen mode

United color of Faerûn

Each player may play a character from different race. How to modelize a team ?

The companions of the Hall

The companions is a book from R.A. Salvatore a novelist who has written many novels set in Faerûn

We can create value for our teammates:

let catti = Human.make "Catti-brie"
let regis = Halfling.make "Regis"
let brenor = Dwarf.make "Bruenor Battlehammer"
let wulfgar = Human.make "Wulfgar"
let drizzt = Elf.make "Drizzt Do'Urden"
Enter fullscreen mode Exit fullscreen mode

What if we create the companions:

 let companions = [catti; regis; bruenor; wulfgar;  drizzt]
Enter fullscreen mode Exit fullscreen mode

Error: This expression has type HalfLing.t but an expression was expected of type
Human.t

Remember the type of list has type type 'a t = 'a list , inference engine set'a = Human.t because it's the type of he first element of our list catti, but regis type is Halfling.t.

How could we help the compiler ? Type parameters must be concrete types.

(* won't compile PLAYABLE is a module type  *)
 type team = PLAYABLE.t list

(* won't compile RACE is a functor
** aka a function from module to module  *)
 type team = RACE.t list
Enter fullscreen mode Exit fullscreen mode

In reality, there is nothing too complicated, the main point is that OCaml lists are monomorphic, so we need a unique type that can represent a character, whatever their race:

type buddy =
  | Dwarf of Dwarf.t
  | Elf of Elf.t
  | Halfling of Halfling.t
  | Tiefling of Tiefling.t
  | HalfOrc of HalfOrc.t
  | Human of Human.t

let companions = [Human catti; Halfling regis; Dwarf bruenor; Human wulfgar;  Elf drizzt]
Enter fullscreen mode Exit fullscreen mode

However, there are many other races in Faerûn, as well as variants. Drizzt for example is actually a dark elf and not an elf. It would be more appropriate to use polymorphic variants in order to facilitate the extensions of our library, because we are still at the early stage of a real character generator:

let companions_final = 
    [`Human catti; `Halfling regis; `Dwarf bruenor; `Human wulfgar;  `Elf drizzt]
Enter fullscreen mode Exit fullscreen mode

whose type will be

val companions_final :
  [> `Dwarf of Dwarf.t
   | `Elf of Elf.t
   | `Halfling of Halfling.t
   | `Human of Human.t ]
  list =
  [`Human <abstr>; `Halfling <abstr>; `Dwarf <abstr>; `Human <abstr>;
   `Elf <abstr>]
Enter fullscreen mode Exit fullscreen mode

Take away

1 - OCaml provides abstractions for:

  • namespaces: module
  • protocole: module type
  • extension: functor
  • default value or implementation: functor or first-class module
    • functors are function from module to module
    • first-class modules are values and give a way to communicate between the type level and the module level. Exemple: a function from value to module.

2 - S.O.L.I.D is not only a OOP good pratice:

  • Single responsibility principle => use module
  • Open/closed principle => use module
  • Liskov substitution principle => use module type
  • Interface segregation principle => use module type
  • Dependency inversion principle => use functor

Top comments (1)

Collapse
 
idkjs profile image
Alain

Absolutely fantastic. Thank you. I had to port it to reason to ge the most out of it. github.com/idkjs/reason-functors-kata