Skip to content

Instantly share code, notes, and snippets.

@tmattio
Created January 29, 2025 17:29
Show Gist options
  • Save tmattio/d31fa591a889cdd0db3c1849183f61ca to your computer and use it in GitHub Desktop.
Save tmattio/d31fa591a889cdd0db3c1849183f61ca to your computer and use it in GitHub Desktop.
Demo of a Space Invaders game in OCaml.
(executable
(public_name space-invaders)
(name space_invaders)
(libraries raylib))
(lang dune 3.18)
(name space_invaders)
(package
(name space_invaders)
(depends ocaml raylib))
(** Demo of a Space Invaders game in OCaml.
This is a simple version of the classic Space Invaders game. It demonstrates
the basic game loop, state management, and rendering in OCaml using the
Raylib library.
The game features a player ship that can move left and right, and a grid of
enemies that move left and right and descend periodically. The player can
"shoot" by pressing the Space key, which instantly destroys all enemies. The
player loses a life if they press Enter, and the game ends when the player
runs out of lives.
The game is rendered using simple shapes and text, and the player and enemy
designs are custom and not exact replicas of the original game. *)
(** Configuration parameters for the game. These values can be tweaked to change
window size, ship speed, etc. *)
module Conf = struct
(** Width of the game window. *)
let screen_width = 800
(** Height of the game window. *)
let screen_height = 600
(** Width of the player's ship. *)
let ship_width = 50
(** Height of the player's ship. *)
let ship_height = 20
(** Speed at which the player's ship moves left or right. *)
let ship_speed = 5
(** Frames per second. The original Space Invaders ran at 60 FPS. *)
let fps = 60
(** Number of lives the player starts with. In the original game, you usually
start with 3 lives. *)
let initial_lives = 3
(** How often (in frames) enemies descend vertically. The classic game moves
them down periodically after shifting left-right. *)
let enemy_descent_interval = 120
end
module Player = struct
type t = {
x : int; (** Horizontal position *)
y : int; (** Vertical position *)
lives : int;
}
(** Represents the player's ship. *)
(** Create a new player centered at the bottom of the screen. *)
let create () =
{
x = (Conf.screen_width - Conf.ship_width) / 2;
y = Conf.screen_height - Conf.ship_height - 20;
lives = Conf.initial_lives;
}
(** Moves the player left or right depending on key input. In the original
game, the ship can only move horizontally. *)
let move player =
let new_x =
if
Raylib.is_key_down Raylib.Key.Right
&& player.x < Conf.screen_width - Conf.ship_width
then player.x + Conf.ship_speed
else if Raylib.is_key_down Raylib.Key.Left && player.x > 0 then
player.x - Conf.ship_speed
else player.x
in
{ player with x = new_x }
(** Reduces the player's lives by one. In classic Space Invaders, a life is
lost when the ship is hit by an enemy projectile. *)
let take_damage player =
if player.lives > 1 then { player with lives = player.lives - 1 }
else { player with lives = 0 }
(** Draw the player's ship in a slightly stylized way, adding triangles to
mimic a small turret shape. The design is custom, not an exact replica
from the original. *)
let draw player =
let x, y = (player.x, player.y) in
let ship_color = Raylib.Color.white in
Raylib.draw_rectangle x y Conf.ship_width Conf.ship_height ship_color;
(* Simple triangle "top" of the ship. *)
Raylib.draw_triangle
(Raylib.Vector2.create
(float_of_int (x + (Conf.ship_width / 2)))
(float_of_int (y - 10)))
(Raylib.Vector2.create (float_of_int x) (float_of_int y))
(Raylib.Vector2.create
(float_of_int (x + Conf.ship_width))
(float_of_int y))
ship_color;
(* Stub "wings" extending out to the left and right. *)
Raylib.draw_rectangle (x - 10) (y + 5) 10 10 ship_color;
Raylib.draw_rectangle (x + Conf.ship_width) (y + 5) 10 10 ship_color
end
module Enemy = struct
(** Classic Space Invaders features three distinct enemy designs with
different point values. We track them here, though point calculation is
simplified in this demo. *)
type enemy_type = Crab | Squid | Octopus
type t = { x : int; y : int; kind : enemy_type }
(** Each enemy is destroyed in one hit in the original game. We store just
position and type. *)
let create x y kind = { x; y; kind }
(** Return a color based on enemy type. In the original, each alien type
usually had a unique shape and color. *)
let color enemy =
match enemy.kind with
| Squid -> Raylib.Color.red
| Crab -> Raylib.Color.yellow
| Octopus -> Raylib.Color.green
(** Draw each enemy in a simple, custom shape. This is not an exact
pixel-perfect replica of the original. *)
let draw enemy =
let x, y = (enemy.x, enemy.y) in
let enemy_color = color enemy in
match enemy.kind with
| Crab ->
Raylib.draw_rectangle x y 30 15 enemy_color;
Raylib.draw_rectangle (x - 5) (y + 15) 10 5 enemy_color;
Raylib.draw_rectangle (x + 25) (y + 15) 10 5 enemy_color
| Squid ->
Raylib.draw_triangle
(Raylib.Vector2.create (float_of_int (x + 15)) (float_of_int y))
(Raylib.Vector2.create (float_of_int x) (float_of_int (y + 20)))
(Raylib.Vector2.create
(float_of_int (x + 30))
(float_of_int (y + 20)))
enemy_color;
Raylib.draw_rectangle (x + 5) (y + 20) 5 5 enemy_color;
Raylib.draw_rectangle (x + 20) (y + 20) 5 5 enemy_color
| Octopus ->
Raylib.draw_rectangle x y 30 20 enemy_color;
Raylib.draw_rectangle (x + 5) (y + 20) 5 5 enemy_color;
Raylib.draw_rectangle (x + 20) (y + 20) 5 5 enemy_color
(** Enemies in the original game are always killed in one shot, so returning
[None] means the enemy is removed from the list. *)
let take_damage _enemy = None
(** Manage a grid (or swarm) of enemies, similar to the original row/column
layout. *)
module Grid = struct
type enemy = t
type t = {
enemies : enemy list;
base_speed : int; (** The initial horizontal speed of the wave. *)
initial_count : int;
(** How many enemies were originally in this wave. *)
direction : int; (** 1 = moving right, -1 = moving left. *)
frames : int; (** Frame counter for descent timing. *)
}
(** Create a grid of enemies for a given wave. Each wave can be faster
(increasing difficulty). *)
let create ~cols ~rows ~spacing ~wave =
let wave_speed_increment = 1 in
let base_speed = 2 + ((wave - 1) * wave_speed_increment) in
let total = cols * rows in
(* Position the enemies in a grid, offset from the top-left. *)
let enemies =
List.init total (fun i ->
let x = (i mod cols * spacing) + 100 in
let y = (i / cols * spacing) + 50 in
(* The original game used distinct sprites for each row:
top rows were often Squid, lower rows were Crab or Octopus.
Here, we cycle through them. *)
let row_index = i / cols in
let kind =
match row_index mod 3 with 0 -> Squid | 1 -> Crab | _ -> Octopus
in
create x y kind)
in
{ enemies; base_speed; initial_count = total; direction = 1; frames = 0 }
(** Draw every enemy in the grid. *)
let draw grid = List.iter draw grid.enemies
(** Move the entire swarm and handle their direction switching + occasional
descent. This replicates the signature "marching" motion from the
original game. *)
let move grid =
let current_count = List.length grid.enemies in
let destroyed = grid.initial_count - current_count in
(* Classic Speedup Mechanic:
As you destroy more enemies, their movement speed increases.
The factor is adjustable via [speedup_factor]. *)
let speedup_factor = 6 in
let dynamic_speed = grid.base_speed + (destroyed / speedup_factor) in
(* Determine if the swarm needs to reverse direction
(when it hits the right or left edge). *)
let left_most =
List.fold_left (fun acc e -> min acc e.x) max_int grid.enemies
in
let right_most =
List.fold_left (fun acc e -> max acc e.x) min_int grid.enemies
in
let new_direction =
if right_most >= Conf.screen_width - 30 then -1
else if left_most <= 0 then 1
else grid.direction
in
(* Move horizontally. *)
let new_enemies =
List.map
(fun e -> { e with x = e.x + (new_direction * dynamic_speed) })
grid.enemies
in
(* Descend every [enemy_descent_interval] frames. *)
let new_frames = grid.frames + 1 in
let descend = new_frames mod Conf.enemy_descent_interval = 0 in
let enemies =
if descend then List.map (fun e -> { e with y = e.y + 10 }) new_enemies
else new_enemies
in
{ grid with enemies; direction = new_direction; frames = new_frames }
(** Damage all enemies (simulate hitting them), removing them from the list
if destroyed. TODO: Remove this when we implement bullet mechanics. *)
let damage_all grid =
let new_enemies = List.filter_map take_damage grid.enemies in
{ grid with enemies = new_enemies }
(** Check if all enemies are gone. *)
let all_dead grid = grid.enemies = []
end
end
module Game = struct
(** Possible states of the game, following a simple state-machine approach. *)
type game_state = Menu | Playing | Game_over
type state = {
player : Player.t;
enemies : Enemy.Grid.t;
score : int;
wave : int;
game_state : game_state;
}
(** Initialize a fresh game state. *)
let init_state () =
let player = Player.create () in
let enemies = Enemy.Grid.create ~cols:5 ~rows:3 ~spacing:60 ~wave:1 in
{ player; enemies; score = 0; wave = 1; game_state = Menu }
(** Update logic when in the Menu state. *)
let update_menu state =
if Raylib.is_key_pressed Raylib.Key.Enter then
{ state with game_state = Playing }
else state
(** Update logic when in the Playing state. *)
let update_playing state =
let new_player = Player.move state.player in
let new_enemies = Enemy.Grid.move state.enemies in
(* TODO: For demonstration, pressing Space instantly damages all enemies.
We leave the implementation of the bullet mechanics as next step. *)
let new_enemies =
if Raylib.is_key_pressed Raylib.Key.Space then
Enemy.Grid.damage_all new_enemies
else new_enemies
in
(* For demonstration, pressing Enter => player takes damage.
Typically you'd be hit by enemy shots. *)
let new_player =
if Raylib.is_key_pressed Raylib.Key.Enter then
Player.take_damage new_player
else new_player
in
let game_state = if new_player.lives = 0 then Game_over else Playing in
(* If all enemies are destroyed, move to the next wave.
We grant 100 points each time you clear a wave. *)
if Enemy.Grid.all_dead new_enemies then
let next_wave = state.wave + 1 in
{
state with
enemies = Enemy.Grid.create ~cols:5 ~rows:3 ~spacing:60 ~wave:next_wave;
wave = next_wave;
score = state.score + 100;
}
else { state with player = new_player; enemies = new_enemies; game_state }
(** Update logic when in the Game_over state. *)
let update_game_over state =
if Raylib.is_key_pressed Raylib.Key.Enter then init_state () else state
(** Main update function that dispatches based on the current game_state. *)
let update state =
match state.game_state with
| Menu -> update_menu state
| Playing -> update_playing state
| Game_over -> update_game_over state
let draw_menu () =
let title = "OCaml Game Invader" in
let title_size = 30 in
let title_width = Raylib.measure_text title title_size in
let subtitle = "Press Enter to Start" in
let subtitle_size = 20 in
let subtitle_width = Raylib.measure_text subtitle subtitle_size in
let center_x w = (Conf.screen_width - w) / 2 in
Raylib.draw_text title (center_x title_width)
((Conf.screen_height / 2) - 60)
title_size Raylib.Color.white;
Raylib.draw_text subtitle (center_x subtitle_width) (Conf.screen_height / 2)
subtitle_size Raylib.Color.gray
let draw_playing state =
Player.draw state.player;
Enemy.Grid.draw state.enemies;
Raylib.draw_text
(Printf.sprintf "Score: %d" state.score)
10 10 20 Raylib.Color.green;
Raylib.draw_text
(Printf.sprintf "Lives: %d" state.player.lives)
10 40 20 Raylib.Color.red;
Raylib.draw_text
(Printf.sprintf "Wave: %d" state.wave)
10 70 20 Raylib.Color.blue
let draw_game_over () =
let game_over_text = "GAME OVER" in
let game_over_size = 40 in
let game_over_width = Raylib.measure_text game_over_text game_over_size in
let prompt = "Press Enter to Restart" in
let prompt_size = 20 in
let prompt_width = Raylib.measure_text prompt prompt_size in
let center_x w = (Conf.screen_width - w) / 2 in
Raylib.draw_text game_over_text (center_x game_over_width)
((Conf.screen_height / 2) - 60)
game_over_size Raylib.Color.red;
Raylib.draw_text prompt (center_x prompt_width) (Conf.screen_height / 2)
prompt_size Raylib.Color.gray
(** Draw the entire game state to the screen. *)
let draw state =
Raylib.begin_drawing ();
Raylib.clear_background Raylib.Color.black;
let () =
match state.game_state with
| Menu -> draw_menu ()
| Playing -> draw_playing state
| Game_over -> draw_game_over ()
in
Raylib.end_drawing ()
(** Game loop: initializes, runs until window is closed, then cleans up. *)
let run () =
Raylib.init_window Conf.screen_width Conf.screen_height
"OCaml Space Invaders";
Raylib.set_target_fps Conf.fps;
let rec loop state =
if not (Raylib.window_should_close ()) then (
let new_state = update state in
draw new_state;
loop new_state)
in
loop (init_state ());
Raylib.close_window ()
end
let () = Game.run ()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment