Browse Source

Initial commit.

Daniel Sanchez 3 years ago
commit
943daab775
17 changed files with 1110 additions and 0 deletions
  1. 2 0
      .gitignore
  2. 42 0
      README.md
  3. 39 0
      build.zig
  4. BIN
      dodgeballz.gif
  5. BIN
      game.wasm
  6. 25 0
      index.html
  7. 149 0
      script.js
  8. 64 0
      src/Board.zig
  9. 103 0
      src/GameState.zig
  10. 62 0
      src/JS.zig
  11. 86 0
      src/Player.zig
  12. 116 0
      src/enemy.zig
  13. 55 0
      src/main.zig
  14. 107 0
      src/particle.zig
  15. 100 0
      src/projectile.zig
  16. 104 0
      src/utils.zig
  17. 56 0
      style.css

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+zig-cache/
+*.o

+ 42 - 0
README.md

@@ -0,0 +1,42 @@
+# DodgeBallz
+
+[DodgeBalls](https://github.com/daneelsan/DodgeBalls) implemented in zig.
+
+This mini game shows how Zig, WASM, Javascript and HTML5 canvas can interact.
+
+In particular, there are lots of back and forth between imported and exported wasm functions.
+Among those imported functions are functions that draw things on a canvas, e.g. `arc` or `strokeRect`.
+
+![dodgeballz](./dodgeballz.gif)
+
+## Build
+
+To build the wasm module, run:
+
+```shell
+$ zig build game -Drelease=true --verbose
+
+$ ls game.*
+game.o    game.wasm (7.2K)
+```
+
+## Run
+
+Start up the server in this repository's directory:
+
+```shell
+python3 -m http.server
+```
+
+Go to your favorite browser and type to the URL `localhost:8000`. You should see the checkboard changing colors.
+
+## TODOs
+
+-   Find a better way to debug from the wasm module. I used a lot of `console.log` to debug, but given that we can only pass a limited set of types between JS and WASM (e.g. `i32` or `f32`), this was tedious. Perhaps there is a way to write a zig `Writer` that writes into the console.
+
+-   Of course, there are many improvements to the game. The score could be made to depend on the size of the enemy, the number of enemies could increase depending on the score achieved, etc.
+
+## Resources
+
+-   https://github.com/daneelsan/DodgeBalls
+-   https://github.com/daneelsan/minimal-zig-wasm-canvas

+ 39 - 0
build.zig

@@ -0,0 +1,39 @@
+const std = @import("std");
+
+const page_size = 65536; // in bytes
+// initial and max memory must correspond to the memory size defined in script.js.
+const wasm_initial_memory = 10 * page_size;
+const wasm_max_memory = wasm_initial_memory;
+
+pub fn build(b: *std.build.Builder) void {
+    // Adds the option -Drelease=[bool] to create a release build, which we set to be ReleaseSmall by default.
+    b.setPreferredReleaseMode(.ReleaseSmall);
+    // Standard release options allow the person running `zig build` to select
+    // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall.
+    const mode = b.standardReleaseOptions();
+
+    const lib = b.addSharedLibrary("game", "src/main.zig", .unversioned);
+    lib.setBuildMode(mode);
+    lib.setTarget(.{
+        .cpu_arch = .wasm32,
+        .os_tag = .freestanding,
+        .abi = .musl,
+    });
+    lib.setOutputDir(".");
+
+    // https://github.com/ziglang/zig/issues/8633
+    lib.import_memory = true; // import linear memory from the environment
+    lib.initial_memory = wasm_initial_memory; // initial size of the linear memory (1 page = 64kB)
+    lib.max_memory = wasm_initial_memory; // maximum size of the linear memory
+    // lib.global_base = 6560; // offset in linear memory to place global data
+
+    // Pass options from the build script to the files being compiled. This is awesome!
+    const lib_options = b.addOptions();
+    lib.addOptions("build_options", lib_options);
+    lib_options.addOption(usize, "memory_size", wasm_max_memory);
+
+    lib.install();
+
+    const step = b.step("game", "Compiles src/main.zig");
+    step.dependOn(&lib.step);
+}

BIN
dodgeballz.gif


BIN
game.wasm


+ 25 - 0
index.html

@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <link rel="stylesheet" href="style.css">
+    <title>Dodge Ballz</title>
+</head>
+<body>
+    <div id="current-score-div">
+        <span>Score:&nbsp;</span>
+        <span id="current-score">0</span>
+    </div>
+    <div id="game-over-container-div">
+        <div id="final-score-div">
+            <h1 id="final-score">0</h1>
+            <p id="points-p">Points</p>
+            <button id="start-game-button">Start Game</button>
+        </div>
+    </div>
+    <canvas id="game-canvas"></canvas>
+    <script src="script.js"></script>
+</body>
+</html>

+ 149 - 0
script.js

@@ -0,0 +1,149 @@
+//document.addEventListener("DOMContentLoaded", main, false);
+
+const startGameButton = document.getElementById("start-game-button");
+const currentScoreElement = document.getElementById("current-score");
+const finalScoreElement = document.getElementById("final-score");
+
+const canvas = document.getElementById("game-canvas");
+const context = canvas.getContext("2d");
+
+canvas.width = window.innerWidth;
+canvas.height = window.innerHeight;
+
+const gameOverContainer = document.getElementById("game-over-container-div");
+
+/* utilities */
+
+function colorToStyle(r, g, b, a) {
+    return "rgb(" + r + ", " + g + ", " + b + ", " + a + ")";
+}
+
+/* WASM imported symbols */
+
+function jsRandom() {
+    return Math.random();
+}
+
+function jsClearRectangle(x, y, width, height) {
+    // context.clearRect(x, y, width, height);
+    context.fillStyle = "rgba(0, 0, 0, .1)";
+    context.fillRect(x, y, width, height);
+}
+
+function jsDrawCircle(x, y, radius, r, g, b, a) {
+    context.beginPath();
+    context.arc(x, y, radius, 0, Math.PI * 2);
+    context.fillStyle = colorToStyle(r, g, b, a);
+    context.fill();
+}
+
+function jsDrawRectangle(x, y, width, height, r, g, b, a) {
+    context.beginPath();
+    context.strokeRect(x, y, width, height);
+    context.fillStyle = colorToStyle(r, g, b, a);
+    context.lineWidth = 5;
+    context.fill();
+}
+
+function jsUpdateScore(score) {
+    currentScoreElement.innerHTML = score;
+}
+
+// Lots of jsConsole<type> functions. Leaving it here until I find a better way to print to console from wasm.
+function jsConsoleLogu32(n) {
+    console.log("u32: " + n);
+}
+
+function jsConsoleLogf32(n) {
+    console.log("f32: " + n);
+}
+
+function jsConsoleLogbool(b) {
+    console.log("bool: " + b);
+}
+
+function jsConsoleLogVector2D(x, y) {
+    console.log("{x: " + x + ", y:" + y + "}");
+}
+
+var memory = new WebAssembly.Memory({
+    initial: 10 /* pages */,
+    maximum: 10 /* pages */,
+});
+
+const wasm = {
+    imports: {
+        env: {
+            jsRandom: jsRandom,
+            jsClearRectangle: jsClearRectangle,
+            jsDrawCircle: jsDrawCircle,
+            jsDrawRectangle: jsDrawRectangle,
+            jsUpdateScore: jsUpdateScore,
+
+            jsConsoleLogu32: jsConsoleLogu32,
+            jsConsoleLogf32: jsConsoleLogf32,
+            jsConsoleLogbool: jsConsoleLogbool,
+            jsConsoleLogVector2D: jsConsoleLogVector2D,
+            memory: memory,
+        },
+    },
+    exports: {},
+};
+
+function loadGame() {
+    WebAssembly.instantiateStreaming(fetch("game.wasm"), wasm.imports).then((result) => {
+        wasm.exports = result.instance.exports;
+        window.addEventListener("keydown", (event) => {
+            const key = event.key;
+            const char = key.charCodeAt(0);
+            wasm.exports.key_down(char);
+        });
+        window.addEventListener("keyup", (event) => {
+            const key = event.key;
+            const char = key.charCodeAt(0);
+            wasm.exports.key_up(char);
+        });
+        window.addEventListener("click", (event) => {
+            const client_x = event.clientX;
+            const client_y = event.clientY;
+            wasm.exports.shoot_projectile(client_x, client_y);
+        });
+        startGameButton.addEventListener("click", (event) => {
+            restartGame();
+        });
+        wasm.exports.game_init(canvas.width, canvas.height);
+        restartGame();
+        // gameOverContainer.style.display = "flex";
+    });
+}
+
+function resetGame() {
+    currentScoreElement.innerHTML = 0;
+    gameOverContainer.style.display = "none";
+    wasm.exports.game_reset();
+}
+
+function runGame() {
+    wasm.exports.game_step();
+
+    if (!wasm.exports.is_game_over()) {
+        window.requestAnimationFrame(runGame);
+    } else {
+        // If the game is over, show the Game Over container (with the start buttong) and the achieved score.
+        gameOverContainer.style.display = "flex";
+        finalScoreElement.innerHTML = wasm.exports.get_score();
+    }
+}
+
+function restartGame() {
+    resetGame();
+    // Set the rate at which the enemies will be spawned.
+    setInterval(wasm.exports.spawn_enemy, 500);
+    runGame();
+}
+
+function main() {
+    loadGame();
+}
+
+main();

+ 64 - 0
src/Board.zig

@@ -0,0 +1,64 @@
+const std = @import("std");
+const assert = std.debug.assert;
+
+const JS = @import("JS.zig");
+const utils = @import("utils.zig");
+
+const Ball2D = utils.Ball2D;
+const RGBColor = utils.RGBColor;
+const Vector2D = utils.Vector2D;
+
+pos: Vector2D,
+width: f32,
+height: f32,
+color: RGBColor,
+
+const Self = @This();
+
+pub fn init(width: f32, height: f32) Self {
+    assert(0.0 <= width and 0.0 <= height);
+    return .{
+        .pos = Vector2D.init(0.0, 0.0),
+        .color = RGBColor.init(255, 255, 255),
+        .width = width,
+        .height = height,
+    };
+}
+
+// Useful for resetting the position of the player.
+pub inline fn center(self: Self) Vector2D {
+    return .{
+        .x = self.pos.x + self.width / 2,
+        .y = self.pos.y + self.height / 2,
+    };
+}
+
+pub fn step(self: Self) void {
+    JS.clearRectangle(self.pos, self.width, self.height);
+    JS.drawRectangle(self.pos, self.width, self.height, self.color);
+}
+
+// Computes a random position (close to the board's margin) for a newly generated enemy.
+pub fn getRandomPosition(self: Self, radius: f32) Vector2D {
+    if (JS.random() < 0.5) {
+        return .{
+            .x = if (JS.random() < 0.5) (self.pos.x + radius) else (self.pos.x + self.width - radius),
+            .y = JS.random() * (self.height - radius) + self.pos.y + radius,
+        };
+    } else {
+        return .{
+            .x = JS.random() * (self.width - radius) + self.pos.x + radius,
+            .y = if (JS.random() < 0.5) (self.pos.y + radius) else (self.pos.y + self.height - radius),
+        };
+    }
+}
+
+pub fn isOutOfBoundary(self: Self, ball: Ball2D) bool {
+    const x = ball.pos.x;
+    const y = ball.pos.y;
+    const radius = ball.radius;
+    return ((x + radius < self.pos.x) or
+        (x - radius > self.width + self.pos.x) or
+        (y + radius < self.pos.y) or
+        (y - radius > self.height + self.pos.y));
+}

+ 103 - 0
src/GameState.zig

@@ -0,0 +1,103 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+const Board = @import("Board.zig");
+const EnemyArrayList = @import("enemy.zig").EnemyArrayList;
+const Player = @import("Player.zig");
+const ParticleArrayList = @import("particle.zig").ParticleArrayList;
+const ProjectileArrayList = @import("projectile.zig").ProjectileArrayList;
+const JS = @import("JS.zig");
+const utils = @import("utils.zig");
+
+const Ball2D = utils.Ball2D;
+const Vector2D = utils.Vector2D;
+
+game_over: bool = false,
+score: u32 = 0,
+
+allocator: Allocator,
+
+board: Board,
+player: Player,
+enemies: EnemyArrayList,
+projectiles: ProjectileArrayList,
+particles: ParticleArrayList,
+
+const Self = @This();
+
+pub fn init(allocator: Allocator, board_width: f32, board_height: f32) Self {
+    var board = Board.init(board_width, board_height);
+    var player = Player.init(board.center());
+    var enemies = EnemyArrayList.init();
+    var projectiles = ProjectileArrayList.init();
+    var particles = ParticleArrayList.init();
+    return .{
+        .allocator = allocator,
+        .board = board,
+        .player = player,
+        .enemies = enemies,
+        .projectiles = projectiles,
+        .particles = particles,
+    };
+}
+
+pub fn deinit(self: Self) void {
+    self.allocator.deinit();
+}
+
+pub fn reset(self: *Self) void {
+    self.game_over = false;
+    self.score = 0;
+    self.player.reset(self);
+    self.enemies.reset(self);
+    self.projectiles.reset(self);
+    self.particles.reset(self);
+}
+
+pub fn spawnEnemy(self: *Self) void {
+    self.enemies.spawn(self);
+}
+
+pub fn shootProjectile(self: *Self, client_pos: Vector2D) void {
+    self.projectiles.emit(self, client_pos);
+}
+
+pub fn handleCollisions(self: *Self) void {
+    const player = self.player;
+
+    for (self.enemies.array_list.items) |*enemy| {
+        if (Ball2D.collision(player.ball, enemy.ball)) {
+            // If some enemy touches the player, game over.
+            self.game_over = true;
+            return;
+        }
+
+        for (self.projectiles.array_list.items) |*projectile, p_i| {
+            if (Ball2D.collision(projectile.ball, enemy.ball)) {
+                // If a projectile and an enemy collide, we do 3 things:
+                //    * Delete the projectile
+                //    * Generate impact particles
+                //    * reduce the radius of the enemy
+                self.projectiles.delete(p_i);
+                self.particles.generate(self, enemy, projectile);
+                enemy.ball.radius *= 0.8;
+            }
+        }
+    }
+}
+
+// This updates the score and callbacks the jsUpdateScore function coming from JS.
+pub fn updateScore(self: *Self) void {
+    // Score should probably depend on the enemy size.
+    self.score += 100;
+    JS.updateScore(self.score);
+}
+
+pub fn step(self: *Self) void {
+    self.board.step();
+    self.player.step(self);
+    self.enemies.step(self);
+    self.projectiles.step(self);
+    self.particles.step(self);
+    self.handleCollisions();
+}

+ 62 - 0
src/JS.zig

@@ -0,0 +1,62 @@
+const utils = @import("utils.zig");
+
+const Ball2D = utils.Ball2D;
+const RGBColor = utils.RGBColor;
+const Vector2D = utils.Vector2D;
+
+extern fn jsRandom() f32;
+extern fn jsClearRectangle(x: f32, y: f32, width: f32, height: f32) void;
+extern fn jsDrawCircle(x: f32, y: f32, radius: f32, r: u8, g: u8, b: u8, a: f32) void;
+extern fn jsDrawRectangle(x: f32, y: f32, width: f32, height: f32, r: u8, g: u8, b: u8, a: f32) void;
+
+extern fn jsUpdateScore(score: u32) void;
+
+// Lots of imported functions that log to console. See related comment in script.js.
+extern fn jsConsoleLogu32(n: u32) void;
+extern fn jsConsoleLogf32(n: f32) void;
+extern fn jsConsoleLogbool(b: bool) void;
+extern fn jsConsoleLogVector2D(x: f32, y: f32) void;
+
+pub inline fn random() f32 {
+    return jsRandom();
+}
+
+pub inline fn clearRectangle(pos: Vector2D, width: f32, height: f32) void {
+    jsClearRectangle(pos.x, pos.y, width, height);
+}
+
+pub inline fn drawBall2D(ball: Ball2D) void {
+    const p = ball.pos;
+    const r = ball.radius;
+    const c = ball.color;
+    jsDrawCircle(p.x, p.y, r, c.r, c.g, c.b, c.a);
+}
+
+pub inline fn drawRectangle(pos: Vector2D, width: f32, height: f32, color: RGBColor) void {
+    jsDrawRectangle(pos.x, pos.y, width, height, color.r, color.g, color.b, color.a);
+}
+
+pub inline fn updateScore(score: u32) void {
+    jsUpdateScore(score);
+}
+
+// TODO: Write a wasm Writer.
+pub fn consoleLog(comptime T: type, val: T) void {
+    switch (T) {
+        u32 => {
+            jsConsoleLogu32(val);
+        },
+        f32 => {
+            jsConsoleLogf32(val);
+        },
+        bool => {
+            jsConsoleLogbool(val);
+        },
+        Vector2D => {
+            jsConsoleLogVector2D(val.x, val.y);
+        },
+        else => {
+            @compileError("consoleLog does not support the given type");
+        },
+    }
+}

+ 86 - 0
src/Player.zig

@@ -0,0 +1,86 @@
+const std = @import("std");
+const assert = std.debug.assert;
+
+const GameState = @import("GameState.zig");
+const JS = @import("JS.zig");
+const utils = @import("utils.zig");
+
+const Ball2D = utils.Ball2D;
+const RGBColor = utils.RGBColor;
+const Vector2D = utils.Vector2D;
+
+const Keyboard = struct {
+    up: f32 = 0,
+    down: f32 = 0,
+    left: f32 = 0,
+    right: f32 = 0,
+
+    pub fn keyDown(self: *Keyboard, key: u8) void {
+        switch (key) {
+            'w' => self.up = -1,
+            'a' => self.left = -1,
+            's' => self.down = 1,
+            'd' => self.right = 1,
+            else => {},
+        }
+    }
+
+    pub fn keyUp(self: *Keyboard, key: u8) void {
+        switch (key) {
+            'w' => self.up = 0,
+            'a' => self.left = 0,
+            's' => self.down = 0,
+            'd' => self.right = 0,
+            else => {},
+        }
+    }
+};
+
+ball: Ball2D,
+keyboard: Keyboard = Keyboard{},
+
+const initial_radius: f32 = 10.0;
+const initial_velocity = Vector2D.init(0.0, 0.0);
+const initial_color = RGBColor.init(255, 255, 255);
+const speed: f32 = 5.0;
+
+const Self = @This();
+
+pub fn init(pos: Vector2D) Self {
+    const ball = Ball2D.init(pos, initial_velocity, initial_radius, initial_color);
+    return .{
+        .ball = ball,
+    };
+}
+
+pub fn reset(self: *Self, game_state: *GameState) void {
+    // Default position is the center of the board.
+    self.ball.pos = game_state.board.center();
+    self.ball.vel = Vector2D.init(0.0, 0.0);
+}
+
+pub fn update(self: *Self, game_state: *GameState) void {
+    const vel_x = speed * (self.keyboard.left + self.keyboard.right);
+    const vel_y = speed * (self.keyboard.up + self.keyboard.down);
+
+    const board = game_state.board;
+    const pos = self.ball.pos;
+    const radius = self.ball.radius;
+
+    // Player can't move outside the bounding region of the board.
+    self.ball.pos = Vector2D.init(
+        std.math.clamp(pos.x + vel_x, board.pos.x + radius, board.pos.x + board.width - radius),
+        std.math.clamp(pos.y + vel_y, board.pos.y + radius, board.pos.y + board.height - radius),
+    );
+
+    self.ball.vel = Vector2D.displacement(self.ball.pos, pos);
+}
+
+pub fn draw(self: Self) void {
+    JS.drawBall2D(self.ball);
+}
+
+pub fn step(self: *Self, game_state: *GameState) void {
+    self.update(game_state);
+    self.draw();
+}

+ 116 - 0
src/enemy.zig

@@ -0,0 +1,116 @@
+const std = @import("std");
+const assert = std.debug.assert;
+
+const GameState = @import("GameState.zig");
+const JS = @import("JS.zig");
+const utils = @import("utils.zig");
+
+const Ball2D = utils.Ball2D;
+const RGBColor = utils.RGBColor;
+const Vector2D = utils.Vector2D;
+
+pub const Enemy = struct {
+    ball: Ball2D,
+
+    const Self = @This();
+
+    pub fn init(pos: Vector2D, vel: Vector2D, radius: f32, color: RGBColor) Self {
+        assert(0.0 <= radius);
+        return .{
+            .ball = Ball2D.init(pos, vel, radius, color),
+        };
+    }
+
+    pub fn update(self: *Self) void {
+        self.ball.pos.x += self.ball.vel.x;
+        self.ball.pos.y += self.ball.vel.y;
+    }
+
+    pub fn draw(self: Self) void {
+        JS.drawBall2D(self.ball);
+    }
+};
+
+// Enemies can only be one of these 5 colors.
+const enemy_colors: [5]RGBColor = .{
+    RGBColor.init(0x8E, 0xCA, 0xE6),
+    RGBColor.init(0x21, 0x9E, 0xBC),
+    RGBColor.init(0xE6, 0x39, 0x46),
+    RGBColor.init(0xFF, 0xB7, 0x03),
+    RGBColor.init(0xFB, 0x85, 0x00),
+};
+
+pub const EnemyArrayList = struct {
+    array_list: std.ArrayListUnmanaged(Enemy),
+
+    const max_count = 10;
+    const min_radius = 10;
+    const max_radius = 40;
+    const initial_speed = 2;
+
+    const Self = @This();
+
+    pub fn init() Self {
+        return .{
+            .array_list = std.ArrayListUnmanaged(Enemy){},
+        };
+    }
+
+    pub fn reset(self: *Self, game_state: *GameState) void {
+        self.array_list.clearAndFree(game_state.allocator);
+    }
+
+    pub inline fn count(self: Self) usize {
+        return self.array_list.items.len;
+    }
+
+    pub inline fn delete(self: *Self, index: usize) void {
+        _ = self.array_list.swapRemove(index);
+    }
+
+    pub inline fn push(self: *Self, game_state: *GameState, enemy: Enemy) void {
+        self.array_list.append(game_state.allocator, enemy) catch unreachable;
+    }
+
+    pub fn spawn(self: *Self, game_state: *GameState) void {
+        const board = game_state.board;
+        const player = game_state.player;
+
+        if (self.count() < max_count) {
+            const radius = JS.random() * (max_radius - min_radius) + min_radius;
+            const pos = board.getRandomPosition(radius);
+            // New enemies will move towards the current position of the player.
+            const vel = Vector2D.direction(player.ball.pos, pos).scalarMultiply(initial_speed);
+            const color = enemy_colors[@floatToInt(usize, JS.random() * 5)];
+
+            const enemy = Enemy.init(pos, vel, radius, color);
+            self.push(game_state, enemy);
+        }
+    }
+
+    pub fn step(self: *Self, game_state: *GameState) void {
+        const board = game_state.board;
+        var items = self.array_list.items;
+
+        var i: usize = 0;
+        while (i < self.count()) {
+            var enemy = &items[i];
+            const is_out = board.isOutOfBoundary(enemy.ball);
+            if (is_out or enemy.ball.radius < min_radius) {
+                if (!is_out) {
+                    // Why would you give free points?.
+                    game_state.updateScore();
+                }
+                // Don't update the index if we remove an item from the list, it still valid (see ArrayList.swapRemove).
+                self.delete(i);
+                continue;
+            }
+            i += 1;
+        }
+
+        for (items) |*enemy| {
+            enemy.update();
+            enemy.draw();
+        }
+    }
+};

+ 55 - 0
src/main.zig

@@ -0,0 +1,55 @@
+const std = @import("std");
+const build_options = @import("build_options");
+
+const GameState = @import("GameState.zig");
+const utils = @import("utils.zig");
+
+extern var memory: u8;
+
+var fixed_buffer: std.heap.FixedBufferAllocator = undefined;
+var game_state: GameState = undefined;
+
+export fn game_init(board_width: f32, board_height: f32) void {
+    // The imported memory is actually the value of the first element of the memory.
+    // To use as an slice, we take the address of the first element and cast it into a slice of bytes.
+    // We pass the memory size using build options (see build.zing).
+    var memory_buffer = @ptrCast([*]u8, &memory)[0..build_options.memory_size];
+
+    // When the upper bound of memory can be established, FixedBufferAllocator is a great choice.
+    fixed_buffer = std.heap.FixedBufferAllocator.init(memory_buffer);
+    const allocator = fixed_buffer.allocator();
+
+    game_state = GameState.init(allocator, board_width, board_height);
+}
+
+export fn game_reset() void {
+    game_state.reset();
+}
+
+export fn game_step() void {
+    game_state.step();
+}
+
+export fn spawn_enemy() void {
+    game_state.spawnEnemy();
+}
+
+export fn get_score() u32 {
+    return game_state.score;
+}
+
+export fn is_game_over() bool {
+    return game_state.game_over;
+}
+
+export fn key_down(key: u8) void {
+    game_state.player.keyboard.keyDown(key);
+}
+
+export fn key_up(key: u8) void {
+    game_state.player.keyboard.keyUp(key);
+}
+
+export fn shoot_projectile(client_x: f32, client_y: f32) void {
+    game_state.shootProjectile(utils.Vector2D.init(client_x, client_y));
+}

+ 107 - 0
src/particle.zig

@@ -0,0 +1,107 @@
+const std = @import("std");
+const assert = std.debug.assert;
+
+const Enemy = @import("enemy.zig").Enemy;
+const GameState = @import("GameState.zig");
+const JS = @import("JS.zig");
+const Projectile = @import("projectile.zig").Projectile;
+const utils = @import("utils.zig");
+
+const Ball2D = utils.Ball2D;
+const RGBColor = utils.RGBColor;
+const Vector2D = utils.Vector2D;
+
+const Particle = struct {
+    ball: Ball2D,
+
+    const Self = @This();
+    const friction = 0.99;
+
+    pub fn init(pos: Vector2D, vel: Vector2D, radius: f32, color: RGBColor) Self {
+        assert(0.0 <= radius);
+        return .{
+            .ball = Ball2D.init(pos, vel, radius, color),
+        };
+    }
+
+    pub fn update(self: *Self) void {
+        // Particles slow down as they move away from the impacted area.
+        self.ball.vel.x *= friction;
+        self.ball.vel.y *= friction;
+        self.ball.pos.x += self.ball.vel.x;
+        self.ball.pos.y += self.ball.vel.y;
+        // Particles also become more transparent as they move away.
+        // When the transparency reaches 0, the particle is removed (see below).
+        self.ball.color.a -= 0.01;
+    }
+
+    pub fn draw(self: Self) void {
+        JS.drawBall2D(self.ball);
+    }
+};
+
+pub const ParticleArrayList = struct {
+    array_list: std.ArrayListUnmanaged(Particle),
+
+    const Self = @This();
+
+    pub fn init() Self {
+        return .{
+            .array_list = std.ArrayListUnmanaged(Particle){},
+        };
+    }
+
+    pub fn reset(self: *Self, game_state: *GameState) void {
+        self.array_list.clearAndFree(game_state.allocator);
+    }
+
+    pub inline fn count(self: Self) usize {
+        return self.array_list.items.len;
+    }
+
+    pub inline fn delete(self: *Self, index: usize) void {
+        _ = self.array_list.swapRemove(index);
+    }
+
+    pub inline fn push(self: *Self, game_state: *GameState, particle: Particle) void {
+        self.array_list.append(game_state.allocator, particle) catch unreachable;
+    }
+
+    pub fn generate(self: *Self, game_state: *GameState, enemy: *Enemy, projectile: *Projectile) void {
+        const pos = projectile.ball.pos;
+
+        var i: f32 = 0.0;
+        // The size of the enemy determines how many particles will be produced.
+        while (i < enemy.ball.radius) : (i += 1.0) {
+            // The particle generated goes in a random direction.
+            const vel = Vector2D.init(
+                (JS.random() * 2 - 1) * (4 * JS.random()),
+                (JS.random() * 2 - 1) * (4 * JS.random()),
+            );
+            const radius = JS.random() * 2;
+            const particle = Particle.init(pos, vel, radius, enemy.ball.color);
+            self.push(game_state, particle);
+        }
+    }
+
+    pub fn step(self: *Self, game_state: *GameState) void {
+        const board = game_state.board;
+        var items = self.array_list.items;
+
+        var i: usize = 0;
+        while (i < self.count()) {
+            var particle = &items[i];
+            if (particle.ball.color.a <= 0.0 or board.isOutOfBoundary(particle.ball)) {
+                // Don't update the index if we remove an item from the list, it still valid.
+                self.delete(i);
+                continue;
+            }
+            i += 1;
+        }
+
+        for (items) |*particle| {
+            particle.update();
+            particle.draw();
+        }
+    }
+};

+ 100 - 0
src/projectile.zig

@@ -0,0 +1,100 @@
+const std = @import("std");
+const assert = std.debug.assert;
+
+const GameState = @import("GameState.zig");
+const JS = @import("JS.zig");
+const utils = @import("utils.zig");
+
+const Ball2D = utils.Ball2D;
+const RGBColor = utils.RGBColor;
+const Vector2D = utils.Vector2D;
+
+pub const Projectile = struct {
+    ball: Ball2D,
+
+    const Self = @This();
+
+    pub fn init(pos: Vector2D, vel: Vector2D, radius: f32, color: RGBColor) Self {
+        assert(0.0 <= radius);
+        return .{
+            .ball = Ball2D.init(pos, vel, radius, color),
+        };
+    }
+
+    pub fn update(self: *Self) void {
+        self.ball.pos.x += self.ball.vel.x;
+        self.ball.pos.y += self.ball.vel.y;
+    }
+
+    pub fn draw(self: Self) void {
+        JS.drawBall2D(self.ball);
+    }
+};
+
+pub const ProjectileArrayList = struct {
+    array_list: std.ArrayListUnmanaged(Projectile),
+
+    const radius = 5.0;
+    const color = RGBColor.init(255, 255, 255);
+
+    const Self = @This();
+
+    pub fn init() Self {
+        return .{
+            .array_list = std.ArrayListUnmanaged(Projectile){},
+        };
+    }
+
+    pub fn reset(self: *Self, game_state: *GameState) void {
+        self.array_list.clearAndFree(game_state.allocator);
+    }
+
+    pub inline fn count(self: Self) usize {
+        return self.array_list.items.len;
+    }
+
+    pub inline fn delete(self: *Self, index: usize) void {
+        _ = self.array_list.swapRemove(index);
+    }
+
+    pub inline fn push(self: *Self, game_state: *GameState, projectile: Projectile) void {
+        self.array_list.append(game_state.allocator, projectile) catch unreachable;
+    }
+
+    pub fn emit(self: *Self, game_state: *GameState, client_pos: Vector2D) void {
+        const player_ball = game_state.player.ball;
+
+        // The projectile's direction depends on event.clientX and event.clientY coming from JS.
+        const direction = Vector2D.displacement(client_pos, player_ball.pos).unitVector();
+
+        const pos = player_ball.pos.vectorAdd(direction.scalarMultiply(player_ball.radius + radius));
+
+        // The projectile's speed depends on the speed of the player at the current moment.
+        const boost = player_ball.vel.magnitude() * 0.5;
+        const vel = Vector2D.init(direction.x, direction.y).scalarMultiply(2 + boost);
+
+        const projectile = Projectile.init(pos, vel, radius, color);
+        self.push(game_state, projectile);
+    }
+
+    pub fn step(self: *Self, game_state: *GameState) void {
+        const board = game_state.board;
+        var items = self.array_list.items;
+
+        var i: usize = 0;
+        while (i < self.count()) {
+            var projectile = &items[i];
+            if (board.isOutOfBoundary(projectile.ball)) {
+                // Don't update the index if we remove an item from the list, it still valid.
+                self.delete(i);
+                continue;
+            }
+            i += 1;
+        }
+
+        for (items) |*projectile| {
+            projectile.update();
+            projectile.draw();
+        }
+    }
+};

+ 104 - 0
src/utils.zig

@@ -0,0 +1,104 @@
+const std = @import("std");
+const math = std.math;
+const assert = std.debug.assert;
+
+pub const Vector2D = struct {
+    x: f32,
+    y: f32,
+
+    const Self = @This();
+
+    pub fn init(x: f32, y: f32) Self {
+        return .{
+            .x = x,
+            .y = y,
+        };
+    }
+
+    pub inline fn magnitude(self: Self) f32 {
+        return math.sqrt(self.x * self.x + self.y * self.y);
+    }
+
+    pub fn unitVector(self: Self) Self {
+        const r = self.magnitude();
+        return .{
+            .x = self.x / r,
+            .y = self.y / r,
+        };
+    }
+
+    pub inline fn displacement(vf: Self, vi: Self) Self {
+        return .{
+            .x = vf.x - vi.x,
+            .y = vf.y - vi.y,
+        };
+    }
+
+    pub inline fn direction(vf: Self, vi: Self) Self {
+        return vf.displacement(vi).unitVector();
+    }
+
+    pub inline fn distance(vf: Self, vi: Self) f32 {
+        return vf.displacement(vi).magnitude();
+    }
+
+    pub fn scalarMultiply(self: Self, scalar: f32) Self {
+        return .{
+            .x = self.x * scalar,
+            .y = self.y * scalar,
+        };
+    }
+
+    pub fn vectorAdd(self: Self, vector: Self) Self {
+        return .{
+            .x = self.x + vector.x,
+            .y = self.y + vector.y,
+        };
+    }
+};
+
+pub const RGBColor = struct {
+    r: u8,
+    g: u8,
+    b: u8,
+    a: f32 = 1.0,
+
+    const Self = @This();
+
+    pub fn init(r: u8, g: u8, b: u8) Self {
+        return .{
+            .r = r,
+            .g = g,
+            .b = b,
+        };
+    }
+
+    pub fn setAlpha(self: *Self, a: f32) void {
+        assert(0.0 <= a and a <= 1.0);
+        self.a = a;
+    }
+};
+
+pub const Ball2D = struct {
+    pos: Vector2D,
+    vel: Vector2D,
+    radius: f32,
+    color: RGBColor,
+
+    const Self = @This();
+
+    pub fn init(pos: Vector2D, vel: Vector2D, radius: f32, color: RGBColor) Self {
+        assert(0.0 <= radius);
+        return .{
+            .pos = pos,
+            .vel = vel,
+            .radius = radius,
+            .color = color,
+        };
+    }
+
+    pub fn collision(ball1: Self, ball2: Self) bool {
+        const distance = Vector2D.distance(ball1.pos, ball2.pos);
+        return distance < (ball1.radius + ball2.radius);
+    }
+};

+ 56 - 0
style.css

@@ -0,0 +1,56 @@
+body {
+    margin: 0;
+    background: black;
+}
+
+#current-score-div {
+    position: fixed;
+    color: white;
+    font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Verdana, sans-serif;
+    font-size: large;
+    margin-top: .5%;
+    margin-left: .5%;
+}
+
+#game-over-container-div {
+    position: fixed;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+}
+
+#final-score-div {
+    background: white;
+    color: black;
+    text-align: center;
+    max-width: 28rem;
+    width: 100%;
+    padding: 1.5rem;
+    border-radius: 1rem;
+}
+
+#final-score {
+    font-size: 2.25rem;
+    line-height: 2.5rem;
+    font-weight: 700;
+    line-height: 1;
+}
+
+#points-p {
+    font-size: medium;
+    color: rgba(55, 65, 81, 1);
+    margin: 1rem;
+}
+
+#start-game-button {
+    background-color: rgba(59, 130, 246, 1);
+    color: white;
+    width: 100%;
+    padding-top: 0.75rem;
+    padding-bottom: 0.75rem;
+    border-radius: 1rem;
+}