How to make an HTML5-Canvas game with OCaml

With a snake game example

You can compile OCaml code to Javascript using the rescript compiler version 8.4.2.
It implements an OCaml syntax version 4.06.1.

It's planned that rescript removes the ocaml syntax in future versions, but we will maybe still be able to use rescript version 8.4.2 in the future.
It will maybe still be there for a while and this version already provides enough functionalities to make plenty of things.

Let's start with a very simple example that just draws a rectangle in a canvas:

draw.ml
open Canvas

let () =
  let canvas = getElementById document "my_canvas" in
  let ctx = getContext canvas "2d" in

  fillStyle ctx "#DDD";
  fillRect ctx 180 100 240 200;
;;

This file, 'draw.ml' just draws a rectangle.

This file requires 4 functions to be linked with javascript, in order to produce the expected result.
We provide it in the file 'canvas.res' below:

canvas.res
type document  // the abstract type that represents the html document
type context  // the abstract type for the 2d contect

@val external document: document = "document"
@val external window: Dom.element = "window"

@send external getElementById: (document, string) => Dom.element = "getElementById"
@send external getContext: (Dom.element, string) => context = "getContext"

@send external fillRect: (context, int, int, int, int) => unit = "fillRect"
@set external fillStyle: (context, string) => unit = "fillStyle"

'canvas.res' contains bindings to the javascript functions that we need.
For a better interface, you should probably use the project 'rescript-webapi' instead:
github.com > rescript-webapi

Here are the commands to compile these 2 files:

bsc canvas.res
bsc -I . draw.ml > draw.js

The Javascript output is very readable, which is great to compare with javascript tutorials and documentations:

Result: draw.js
// Generated by ReScript, PLEASE EDIT WITH CARE
'use strict';

var canvas = document.getElementById("my_canvas");
var ctx = canvas.getContext("2d");

ctx.fillStyle = "#DDD";
ctx.fillRect(180, 100, 240, 200);

/* canvas Not a pure module */

You can include this javascript file in an html5-document containing a canvas-tag:

draw.html
<!DOCTYPE html>
<html>
<head>
<style rel="stylesheet" type="text/css">
body {
  background-color:#999;
}
canvas {
  background-color:#BBB;
  border:1px solid #666;
  margin:80px;
}
</style>
</head>
<body>

<canvas id="my_canvas" width="600" height="400">
</canvas>

<script>var exports = {};</script>
<script type="text/javascript" src="./draw.js"></script>

</body>
</html>

Now let's make a simple snake game:

snake.ml
let width, height = (640, 480)

type pos = int * int

type game_state = {
  pos_snake: pos;
  seg_snake: pos list;
  dir_snake: [`left | `right | `up | `down];
  pos_fruit: pos;
  game_over: bool;
}

let canvas = Canvas.getElementById Canvas.document "my_canvas"
let ctx = Canvas.getContext canvas "2d"

let red   = "#F00"
let green = "#0F0"
let blue  = "#00F"
let black = "#000"

let fill_rect color (x, y) =
  Canvas.fillStyle ctx color;
  Canvas.fillRect ctx x y 20 20;
;;


let display_game state =
  let bg_color, snake_color, fruit_color =
    if state.game_over
    then (red, black, green)
    else (black, blue, red)
  in
  (* background *)
  Canvas.fillStyle ctx bg_color;
  Canvas.fillRect ctx 0 0 width height;

  fill_rect fruit_color state.pos_fruit;
  List.iter (fill_rect snake_color) state.seg_snake;
;;



let rec pop = function
  | [_] -> []
  | hd :: tl -> hd :: (pop tl)
  | [] -> invalid_arg "pop"


let rec new_pos_fruit seg_snake =
  let new_pos =
    (20 * Random.int 32,
     20 * Random.int 24)
  in
  if List.mem new_pos seg_snake
  then new_pos_fruit seg_snake
  else (new_pos)


let update_state req_dir (
  { pos_snake;
    seg_snake;
    pos_fruit;
    dir_snake;
    game_over;
  } as state) =

  if game_over then state else
  let dir_snake =
    match dir_snake, req_dir with
    | `left, `right -> dir_snake
    | `right, `left -> dir_snake
    | `up, `down -> dir_snake
    | `down, `up -> dir_snake
    | _ -> req_dir
  in
  let pos_snake =
    let x, y = pos_snake in
    match dir_snake with
    | `left  -> (x - 20, y)
    | `right -> (x + 20, y)
    | `up    -> (x, y - 20)
    | `down  -> (x, y + 20)
  in
  let game_over =
    let x, y = pos_snake in
    List.mem pos_snake seg_snake
    || x < 0 || y < 0
    || x >= width
    || y >= height
  in
  let seg_snake = pos_snake :: seg_snake in
  let seg_snake, pos_fruit =
    if pos_snake = pos_fruit
    then (seg_snake, new_pos_fruit seg_snake)
    else (pop seg_snake, pos_fruit)
  in
  { pos_snake;
    seg_snake;
    pos_fruit;
    dir_snake;
    game_over;
  }


let () =
  Random.self_init ();
  let initial_state = {
    pos_snake = (100, 100);
    seg_snake = [
      (100, 100);
      ( 80, 100);
      ( 60, 100);
    ];
    pos_fruit = (200, 200);
    dir_snake = `right;
    game_over = false;
  } in

  let state = ref initial_state in
  let req_dir = ref !state.dir_snake in

  let keychange_event ev =
    req_dir :=
      match ev.Canvas.keyCode with
      | 37 -> `left
      | 38 -> `up
      | 39 -> `right
      | 40 -> `down
      | _ -> (!state.dir_snake)
  in

  let animate () =
    state := update_state !req_dir !state;
    display_game !state;
    ()
  in

  Canvas.addKeyEventListener Canvas.window "keydown" keychange_event true;

  let _ = Canvas.setInterval animate (1000/7) in
  ()
;;

Add some more bindings in the file 'canvas.res':

canvas.res

type key_event = {
  keyCode: int,
  key: string,
}

@send external addKeyEventListener: (Dom.element, string, key_event => unit, bool) => unit = "addEventListener"

type intervalID

@val external setInterval: (unit => unit, int) => intervalID = "setInterval"

Compile:

bsc canvas.res
bsc -I . snake.ml > snake.js
sed -i -e 's!.*require.*!!g' snake.js

Here I replace all the 'requires' from the beginning of 'snake.js' by 'caml-platform.js' included in the HTML page.
This file groups in a single file the javascript implementations of the usual ocaml functions that are located in 'bs-platform/lib/js/' from the rescript system version 8.4.2.

It's possible to use a javascript bundler like 'esbuild' but the result is then not very readable anymore, and it also packs everything into a closed function which makes it difficult to have interactions with the outside other javascript elements.

snake.html
<!DOCTYPE html>
<html>
<head>
<style rel="stylesheet" type="text/css">
body {
  background-color:#999;
}
canvas {
  background-color:#BBB;
  border:1px solid #666;
  margin:80px;
}
</style>
</head>
<body>

<canvas id="my_canvas" width="640" height="480">
</canvas>

<script type="text/javascript" src="./caml-platform.js"></script>

<script>var exports = {};</script>
<script type="text/javascript" src="./snake.js"></script>

</body>
</html>

See the result (move with arrows of the keyboard).

Find other examples with sources there.


© 2022 Florent Monnier
Content provided under CC-by-sa license.
Version of this document: 2024-07-30.a