Help with structuring an data in an OCaml game

Hi everyone! I’m looking for some general help with a problem I am having in an application I am developing, I’ve been using OCaml for the last 5 years in my spare time, so I know a fair but about the basic language but sometimes struggle with the more advanced concepts like GDATs

I am developing a multiplayer card game using OCaml as a server, communicating with a web client via websockets using JSON as the interchange format.

Each card has an associated ability when used that can arbitrarily change the state of the game (currently Game.t), some cards can take “arguments” that are chosen by the user (who to use the ability on, for example). I envisage a function similar with the following type to take this action: val make_card_action : Game.t -> Player.t -> arguments -> Game.t, where Player.t is the player making the action. arguments would be some type defined somewhere for each card and another function would parse JSON into this type: val arguments_of_json : Yojson.t -> arguments option.

Additionally the cards present in the game are chosen before the game starts, so these must be stored in a set of some kind (in my current attempt I create a set using Set.Make(Card))

So my question is quite open really - what is the best way to structure the cards, I have thought about using one module to represent a card (Card.t), which could contain the functions above, eg:

module Card = struct
  type t = {
    name: string;
    description: string;
    make_action: Game.t -> Player.t -> Yojson.t -> Game.t;
  } [@@deriving sexp]

  let create
      ~description
      ~make_action
      name = {
      name; description; make_action
    }

  let name t = t.name
  let description t = t.description

  let compare t1 t2 = String.compare t1.name t2.name

  let json_of_card { name; description; gives_money; is_required; min_players; } : Yojson.t =
  (* Converts to a JSON format *)

  let make_card_action t = t.make_action
end

module CardSet = Set.Make(Card)

(* ... make some cards ... *)

let all_cards = [card1; card2; card3]

A disadvantage of this approach is that I can’t think of a way to type arguments ahead of time, not a deal breaker but I would like to do this to make it clearer how to add new cards.

My second idea was to use a combination of functors and first class modules:

module type Card_definition = sig
  val name: string
  val description: string

  type arguments

  val arguments_of_json : Yojson.t -> arguments
  val make_action : Game.t -> Player.t -> arguments -> Game.t
end

module type Card = sig
  val name: string
  val description: string

  val json_of_card: Yojson.t

  type arguments
  val arguments_of_json : Yojson.t -> arguments
  val make_action : arguments -> unit
end

module Make_card(Definition : Card_definition): Card = struct
  let name = Definition.name
  let description = Definition.description

  let json_of_card : Yojson.t = (* Card JSON format *)

  type arguments = Definition.arguments
  let arguments_of_json = Definition.arguments_of_json
  let make_action = Definition.make_action
end

module CardSet = Set.Make(Card) (* Error: Unbound module Card - I don't know if something like this is possible as it's only a module type *)

(* ... make some cards ... *)

let all_cards = [(module card1); (module card2); (module card3)]

The advantage of this is I could store all my cards as individual modules and create more easily, however I can’t think of a way to make it work with a Set

My last thought was some combination of the above, something like this (obviously this won’t work but it’s demonstrating my thinking):

module type Card_definition = sig
  val name: string
  val description: string

  type arguments

  val arguments_of_json : Yojson.t -> arguments
  val make_action : arguments -> unit
end

module type Card = sig
  type t
  type arguments

  val compare: t -> t -> int

  val name: t -> string
  val description: t -> string

  val json_of_card: t -> Yojson.t

  val arguments_of_json: Yojson.t -> arguments
  val make_action: arguments -> unit
end

module type Card_instance = sig
  module Card: Card
  val singleton: Card.t
end

module Make_card(Definition : Card_definition): Card_instance = struct
  module Card = (struct
    type t = {
      name: string;
      description: string;
    }

    type arguments = Definition.arguments

    let name t = t.name
    let description t = t.description

    let compare t1 t2 = String.compare t1.name t2.name

    let json_of_card t : Yojson.t = `Null

    let arguments_of_json = Definition.arguments_of_json
    let make_action = Definition.make_action
  end : Card)

  let singleton : Card.t = {
    name: Definition.name;
    description: Definition.description;
  }
end

module CardSet = Set.Make(Card)

(* ... make some cards ... *)

let all_cards = [(module card1); (module card2); (module card3)]

I’m not sure what the best way to approach this problem is, my feeling is there is some feature of the language I can use to achieve this (FC modules, functors, open types, GADTs),
but I’ve not got much experience using any of these so any advice on how to approach this would be very much appreciated. Any questions or if something is not clear let me know and I can try and vie more detail. Thanks a lot!

Hi Corin,
I am not sure I can help, but let me try.
It’s not fully clear to me what the arguments are for, as your different implementations don’t seem to make the same assumptions.

Do the arguments for a given card vary in the course of a game, or are they fixed ?
If arguments is fixed, then it basically reifies the action Player.t -> Game.t -> Game.t of a card, right ?

Do you have any distinction that you want to make between cards with different arguments types ? For instance how they could be combined or not.
If not, then all cards could have the same arguments type, or you might not need the arguments type at all.

If you do need cards to take in any arguments type, then the type could be 'a card, and you could hide the arguments behind an existential to combine them together type e_card = E : 'a card -> e_card. You are then still able to look under the E at the arguments type, but only within a scope, and the type should not escape.

Hope this helps,

Thanks for your response!

So when a player uses a card they are able to select specific options for that card, for example which player to use the ability on. Say the card’s ability was it stole money from another player, the player that uses the card needs to pick which player to steal from and how much to take. In this example the definition of arguments would look like this:

type arguments = {
  player: Player.t;
  amount: int;
}

So the type of arguments is fixed on a per-card basis, but the values of that type are different each time make_card_action is performed. Under the hood arguments are provided by a client via a JSON call, that I planned to be parsed into an arguments value with this function: val arguments_of_json : Yojson.t -> arguments option.

I think your suggestion makes sense, I hadn’t thought of parameterising the type like that. Do you mean something like this?

module Card = struct
  type 'a t = {
    name: string;
    description: string;
    arguments_of_json: Yojson.Basic.t -> 'a;
    make_action: 'a -> unit
  }
  let create name description arguments_of_json make_action = {
    name;
    description;
    arguments;
    arguments_of_json;
    make_action
  }

  let compare t1 t2 = String.compare t1.name t2.name
end

module CardSet = Set.Make(Card)

type magic_card_args = { player: Player.t }
let magic_card = Card.create
  "Magic"
  "Some description"
  (fun json -> (* ...*))
  (fun args -> (* ...*))

I’m not familar with existential types so I don’t really understand what type e_card = E : 'a card -> e_card means or how it will help?

Thanks a lot in advanced!

Can you try something like this?

type destroy = D of card_id
type cards_count = CC of int 
type mana_reduce = MR

type _ card_effect = 
| DestroyEnemyCard : destroy -> destroy card_effect
| ExtraDraw : cards_count -> cards_count card_effect
| ReduceManaCost : mana_reduce card_effect
(* I'm so glad that I played Hearthstone to be able to prepare 
   real-world example and even more I glad that I removed it *)

...
type magic_card = 
  { name: string
  ; desc: string
  ; effects: 'a . 'a card_effect list
  (* Using existential to allow card to have multiple effects 
     of different sorts *)
  }

I don’t totally understand what are you trying to achieve and GADTs can be very unpleasant journey for beginner (because of complicated type checking and lack of variance annotations) but at least you will become better OCamler :slight_smile:

If I understand correctly your problem it can be simplified to defining cards as

type 'arg typed_card = {
    name: string;
    description: string;
    make_action: Game.t -> Player.t -> 'arg > Game.t;
  }

and then wondering if you can have a sets of cards with different argument types. The short answer is no: items in a lists or sets need to have the same type. Similarly, if the player draw a random card, it needs to draw a card of a well-defined type.

A simple solution to your issue would to group all possible actions of args in a variant:

type 'a typed_action = Game.t -> Player.t -> arguments -> Game.t
type action =
    | Int of int typed_action
    | Float of float typed_action
type card = {
    name: string;
    description: string;
    make_action: action;
  }

There are more complex possible designs if this simple one does not fit your need, but identifying why this solution does not fit your need will probably help to not consume all of your complexity budget.

1 Like