Programming WebAssembly with Rust Part 3: Targeting JS with Wasm-Bindgen
Introduction
I have been reading through this book which goes through the intricacies and benefits of building to and using Wasm for creating modules that can be run in polyglot environments. I will be augmenting and summarizing the book chapters throughout these articles.
WebAssembly (Wasm) has opened new doors for web development, allowing developers to write high-performance code in languages like Rust, C++, and C, which can then be run in the browser. However, one of the key challenges in using WebAssembly is effectively integrating it with JavaScript, the language of the web. This is where wasm-bindgen comes into play.
wasm-bindgen is a powerful tool designed to facilitate the interaction between Wasm and JavaScript. It creates JavaScript bindings that enable seamless interoperation between the two languages, making it much easier to leverage the strengths of Rust within a web environment.
The code for this article can be found in this github repo
Basic Example
In this section, we’ll walk through building a basic template that integrates wasm-bindgen with Webpack. This setup will allow you to write Rust code, compile it to WebAssembly, and then seamlessly import and use it within your JavaScript application.
Creating the Rust Library
In this section, we’ll write a basic Rust library that uses wasm-bindgen to expose Rust functions to JavaScript. We’ll also show how to import and call native JavaScript functions directly from Rust code.
Writing Basic Functions with wasm-bindgen
Let’s begin by writing a simple Rust function that can interact with JavaScript. First, we need to import wasm-bindgen and use it to expose our Rust functions to JavaScript.
extern crate wasm_bindgen;
use wasm_bindgen::prelude::*;
Importing a JavaScript Function
Using wasm-bindgen, we can import and use JavaScript functions directly in our Rust code. In this example, we will import the native JavaScript alert
function, which displays a popup in the browser.
// import window.alert
#[wasm_bindgen]
extern "C" {
fn alert(s: &str);
}
The extern "C"
block is required to tell Rust that this function is defined elsewhere—in this case, in the JavaScript environment.
Exporting a Rust Function to JavaScript
#[wasm_bindgen]
pub fn hello(name: &str) {
alert(&format!("Hello: {}!", name));
}
In this code, the hello
function takes a name
as an argument, formats it into a greeting, and passes the formatted string to the alert
function. The #[wasm_bindgen]
macro ensures that this function is exported and available for JavaScript to call.
Building
We can now compile our Rust library to Wasm by running
cargo build - target wasm32-unknown-unknown
and then generate the appropriate JS bindings using wasm-bindgen with
wasm-bindgen target/wasm32-unknown-unknown/debug/bindgenhello.wasm --out-dir .
Calling Rust Functions from JavaScript
Once we’ve built our WebAssembly module and generated the necessary bindings, calling Rust functions from JavaScript becomes as simple as importing a JavaScript module — thanks to wasm-bindgen.
Importing the WebAssembly Module
Here’s an example of how you can dynamically import the WebAssembly module and call the hello
function we defined earlier:
const wasm = import("./bindgenhello");
wasm
.then(module => {
// Once loaded, call the hello function exported from Rust
module.hello("world!");
})
.catch(console.error);
Webpacking and Serving the WebAssembly Module
Once we have our Rust code compiled into WebAssembly and ready to integrate with JavaScript, the next step is to bundle the JavaScript and WebAssembly files together and serve them efficiently. For this, we’ll use Webpack, a popular JavaScript bundler, and ensure that it handles WebAssembly modules correctly.
Webpack Configuration for WebAssembly
To handle WebAssembly files (.wasm
) alongside JavaScript, Webpack needs some special configurations.
Below is a Webpack configuration file that sets up the bundling process for our project:
const path = require("path")
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
module.exports = {
entry: "./index.js",
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'index.js'
},
experiments: {
asyncWebAssembly: true
},
plugins: [
new HtmlWebpackPlugin()
],
mode: "development"
}
Now, we can run our template example with
webpack-dev-server
Rogue WASM: Building a Roguelike Dungeon Explorer Game
In this section, we will combine the power of WebAssembly (WASM) with the JavaScript library ROT.js to create a simple roguelike dungeon explorer game. By utilizing WebAssembly and Rust, we can bring the performance benefits of compiled languages to the game logic while using ROT.js to handle the grid-based rendering, map generation, and input handling.
Integrating JavaScript Functions and ROT.js with Rust
In our Rust engine, we use WebAssembly to interact with JavaScript functions for logging, displaying alerts, rendering graphics with ROT.js, and invoking our custom made exported JS functions. Below is the Rust code for setting up these JavaScript functions within Rust:
#[wasm_bindgen]
extern "C" {
fn alert(s: &str);
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
pub type Display;
#[wasm_bindgen(method, structural, js_namespace = ROT)]
fn draw(this: &Display, x: i32, y: i32, ch: &str);
#[wasm_bindgen(method, structural, js_name = draw, js_namespace = ROT)]
fn draw_color(this: &Display, x: i32, y: i32, ch: &str, color: &str);
}
#[wasm_bindgen(module = "/index.js")]
extern "C" {
fn stats_updated(stats: JsValue);
}
Player Core
The PlayerCore
struct handles core aspects of the player character, including movement, health, and rendering on the map. It uses ROT.js to display the player and updates stats via a JavaScript function.
#[derive(Serialize)]
pub struct Stats {
pub hitpoints: i32,
pub max_hitpoints: i32,
pub moves: i32
}
#[derive(PartialEq, Eq, PartialOrd, Clone, Debug, Hash)]
struct GridPoint {
pub x: i32,
pub y: i32,
}
#[wasm_bindgen]
pub struct PlayerCore {
location: GridPoint,
moves: i32,
display: Display,
hp: i32,
max_hp: i32,
icon: String,
color: String
}
#[wasm_bindgen]
impl PlayerCore {
#[wasm_bindgen(constructor)]
pub fn new(x: i32, y: i32, icon: &str, color: &str, display: Display) -> PlayerCore {
PlayerCore {
location: GridPoint { x, y },
moves: 0,
display: display,
hp: 100,
max_hp: 100,
icon: icon.to_string(),
color: color.to_string()
}
}
pub fn x(&self) -> i32 {
self.location.x
}
pub fn y(&self) -> i32 {
self.location.y
}
pub fn draw(&self) {
&self.display.draw_color(self.location.x, self.location.y, &self.icon, &self.color);
}
pub fn emit_stats(&self) {
let stats: Stats = Stats { hitpoints: self.hp, max_hitpoints: self.max_hp, moves: self.moves };
stats_updated(JsValue::from_serde(&stats).unwrap());
}
pub fn move_to(&mut self, x: i32, y: i32) {
self.location = GridPoint {x, y};
self.draw();
self.moves += 1;
self.emit_stats();
}
pub fn take_damage(&mut self, hit_points: i32) -> i32 {
self.hp = self.hp - hit_points;
self.emit_stats();
self.hp
}
}
Key methods include:
draw()
: Renders the player on the grid using thedraw_color
method from ROT.js.emit_stats()
: Sends the player's current stats to JavaScript for UI updates.move_to()
: Updates the player's position and redraws them at the new coordinates.take_damage()
: Reduces the player's health when they take damage, updating the stats accordingly.
Game Engine
The Engine
struct contains a Display
for rendering, a HashMap
to store the grid points of the map, and an optional GridPoint
for the prize's location.
#[wasm_bindgen]
pub struct Engine {
display: Display,
points: HashMap<GridPoint, String>,
prize_location: Option<GridPoint>
}
#[wasm_bindgen]
impl Engine {
#[wasm_bindgen(constructor)]
pub fn new(display: Display) -> Engine {
Engine {
display: display,
points: HashMap::new(),
prize_location: None
}
}
pub fn open_box(&mut self, pc: &mut PlayerCore, x: i32, y: i32) {
let spot = GridPoint {x, y};
let v = self.points.get(&spot).unwrap();
if v != "*" {
alert("There's no box here.");
return;
}
if let Some(ref loc) = self.prize_location {
if *loc == spot {
alert("Congratulations! You found the prize");
} else {
alert("Booby Trap! health -30");
pc.take_damage(30);
}
self.remove_box(spot.x, spot.y);
}
}
pub fn on_dig(&mut self, x: i32, y: i32, val: i32) {
if val == 0 {
let pt: GridPoint = GridPoint {x, y};
self.points.insert(pt, ".".to_string());
}
}
fn remove_box(&mut self, x: i32, y: i32) {
let loc = GridPoint { x, y };
self.points.insert(loc, ".".to_owned());
}
pub fn place_box(&mut self, x: i32, y: i32) {
let pt: GridPoint = GridPoint {x, y};
self.points.insert(pt, "*".to_string());
}
pub fn mark_box_as_prize(&mut self, x: i32, y: i32) {
let g = GridPoint { x, y };
if let Some(v) = self.points.get(&g) {
if v == "*" {
self.prize_location = Some(g);
}
}
}
pub fn draw_map(&self) {
for (pt, ch) in &self.points {
self.display.draw(pt.x, pt.y, &ch);
}
}
pub fn is_cell_free(&self, x: i32, y: i32) -> bool {
let pt: GridPoint = GridPoint {x, y};
match self.points.get(&pt) {
Some(v) => v == "*" || v == ".",
None => false,
}
}
pub fn redraw_at(&self, x: i32, y: i32) {
let g: GridPoint = GridPoint {x, y};
if let Some(v) = self.points.get(&g) {
self.display.draw(x, y, &v);
}
}
pub fn move_player(&mut self, pc: &mut PlayerCore, x: i32, y: i32) {
self.redraw_at(pc.x(), pc.y());
pc.move_to(x, y);
}
}
Key methods in the engine include:
open_box()
: Allows the player to open boxes in the game, checking if there’s a prize or a trap. It usesalert()
to notify the player of the result.on_dig()
: Updates the grid when the player digs a spot.place_box()
andmark_box_as_prize()
: These methods place boxes on the grid and mark one as the prize.draw_map()
: Loops through the grid and uses ROT.js to render the map.move_player()
: Moves the player to a new location and redraws the map to reflect the player's movement.
HTML layout
The layout is structured into two main parts: the game canvas, where the player interacts with the map, and a stats container that displays player health and movement details.
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<title>Rogue WebAssembly</title>
<script src="https://cdn.jsdelivr.net/npm/rot-js@2/dist/rot.js"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<style>
.row {
display: flex;
}
.row_cell {
flex: 1
}
</style>
</head>
<body>
<div class="row">
<div class="row_cell" style="text-align: center;">
<h1>Rogue WebAssembly</h1>
</div>
</div>
<div class="row">
<div class="row_cell" id="rougueCanvas"></div>
<div class="row_cell" id="statsContainer", style="padding: 15px;">
<div class="row_cell" style="text-align: center;">
<h2>Stats</h2>
</div>
<p>
<span><b>HitPoints:</b></span>
<span id="hitpoints">0</span> /
<span id="max_hitpoints">0</span>
</p>
<p>
<span><b>Moves:</b></span>
<span id="moves">0</span>
</p>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>
In this Html we import in the ROT.js to use it’s game logic and bootstrap for styling. We also make elements to hold the game display — #rougueCanvas — and we have a container to display all the user stats.
JS Integration
This section bridges the gap between the WebAssembly (WASM) core functionality and the JavaScript frontend, utilizing wasm-bindgen for importing WebAssembly modules and seamlessly integrating them into the JavaScript environment
Importing WASM Modules
To enable interaction between JavaScript and WebAssembly, we use wasm-bindgen to import WebAssembly modules directly into the JavaScript code.
import { Engine, PlayerCore } from './roguewasm';
Enemy Code
The Enemy
object represents the enemy in the game. It uses the WASM-based PlayerCore for movement and draws the enemy on the game map. The enemy uses the A* algorithm from ROT.js to follow the player.
const Enemy = {
core: null,
init: function (x, y) {
this.core = new PlayerCore(x, y, "B", "#f00", Game.display);
this.core.draw();
},
act: function () {
const x = Game.player.getX();
const y = Game.player.getY();
const passableCallback = function (x, y) {
return Game.engine.is_cell_free(x, y);
}
const astar = new ROT.Path.AStar(x, y, passableCallback, {topology: 4});
const path = [];
const pathCallback = function (x, y) {
path.push([x,y]);
}
astar.compute(this.core.x(), this.core.y(), pathCallback);
path.shift();
if (path.length <= 1) {
Game.rotengine.lock();
alert("Game over - you were captured by the Enemy!!");
} else {
const x = path[0][0];
const y = path[0][1];
Game.engine.move_player(this.core, x, y);
}
}
}
- Pathfinding: The enemy moves towards the player using the A* algorithm, navigating around obstacles.
- Movement: It moves step-by-step towards the player’s position by calling Game.engine.move_player.
Player Code
The Player
object allows the player to move around the map and interact with the environment. Like the enemy, it uses PlayerCore for its core functionality.
const Player = {
core: null,
init: function (x, y) {
this.core = new PlayerCore(x, y, "@", "#ff0", Game.display);
this.core.draw();
},
act: function () {
Game.rotengine.lock();
window.addEventListener("keydown", this.handleEvent);
},
handleEvent: function (e) {
const keyMap = {};
keyMap[38] = 0;
keyMap[33] = 1;
keyMap[39] = 2;
keyMap[34] = 3;
keyMap[40] = 4;
keyMap[35] = 5;
keyMap[37] = 6;
keyMap[36] = 7;
var code = e.keyCode;
if (code == 13 || code == 32) {
console.log("OPEN")
Game.engine.open_box(Player.core, Player.core.x(), Player.core.y());
return;
}
if (!(code in keyMap)) {
return;
}
const dir = ROT.DIRS[8][keyMap[code]];
const newX = Player.core.x() + dir[0];
const newY = Player.core.y() + dir[1];
if (!Game.engine.is_cell_free(newX, newY)) {
return;
}
Game.engine.move_player(Player.core, newX, newY);
window.removeEventListener("keydown", this.handleEvent);
Game.rotengine.unlock();
},
getX: function () {
return this.core.x();
},
getY: function () {
return this.core.y();
}
}
- Movement: The player uses key events to move in one of eight directions, or to interact with objects (e.g., open boxes).
- Key Mapping: Key codes are mapped to directions (using ROT.js direction system) and corresponding WASM engine methods like move_player and open_box.
Game Code
The Game
object ties everything together, initializing the display, setting up the map, and managing the game loop.
const Game = {
display: null,
engine: null,
rotengine: null,
player: null,
enemy: null,
init: function () {
this.display = new ROT.Display({width: 125, height: 40});
document.getElementById("rougueCanvas").appendChild(this.display.getContainer())
this.engine = new Engine(this.display);
this.generateMap();
const scheduler = new ROT.Scheduler.Simple();
scheduler.add(this.player, true);
scheduler.add(this.enemy, true);
this.rotengine = new ROT.Engine(scheduler);
this.rotengine.start();
},
generateMap: function () {
const digger = new ROT.Map.Digger();
const freeCells = [];
const digCallback = function (x, y, value) {
if (value === 0) {
const key = `${x},${y}`;
freeCells.push(key);
}
this.engine.on_dig(x, y, value);
}
digger.create(digCallback.bind(this));
this.generateBoxes(freeCells);
this.engine.draw_map();
const playerLoc = this.getRandomLoction(freeCells);
this.player = Player;
this.player.init(playerLoc[0], playerLoc[1]);
const enemyLoc = this.getRandomLoction(freeCells);
this.enemy = Enemy;
this.enemy.init(enemyLoc[0], enemyLoc[1]);
},
generateBoxes: function (freeCells) {
for (let i = 0; i < 10; i ++) {
const pos = this.getRandomLoction(freeCells);
this.engine.place_box(pos[0], pos[1]);
if (i == 9) {
this.engine.mark_box_as_prize(pos[0], pos[1]);
}
}
},
getRandomLoction: function (freeCells) {
const index = Math.floor(ROT.RNG.getUniform() * freeCells.length);
const key = freeCells.splice(index, 1)[0];
const parts = key.split(",");
const x = parseInt(parts[0]);
const y = parseInt(parts[1]);
return [x,y]
}
}
- Map Generation: The map is generated using ROT.Map.Digger, and boxes (including one prize box) are placed randomly.
- Game Loop: The ROT.Engine is used to alternate turns between the player and enemy.
Exposing WASM to Update UI
Our JS module exposes a function to update the player’s stats in the UI, allowing the WebAssembly core to communicate with the HTML page.
export function stats_updated(stats) {
document.getElementById("hitpoints").textContent = stats.hitpoints;
document.getElementById("max_hitpoints").textContent = stats.max_hitpoints;
document.getElementById("moves").textContent = stats.moves;
}
Fronted in Rust with YEW
Rust’s growing ecosystem for web development has made it possible to build frontend applications entirely in Rust using frameworks like Yew. Yew is a modern framework that allows developers to create interactive web applications, leveraging WebAssembly (Wasm) under the hood. It provides a React-like experience with its component-based architecture, reactive state management, and familiar HTML templating.
use yew::prelude::*;
use web_sys::console;
struct Model {
value: i64
}
#[function_component(App)]
fn app() -> Html {
let state = use_state(|| Model {
value: 0
});
let increment = {
let state = state.clone();
Callback::from(move |_| {
console::log_1(&"Plus 1".into());
state.set(Model {value: state.value + 1});
})
};
let decrement = {
let state = state.clone();
Callback::from(move |_| {
console::log_1(&"Minus 1".into());
state.set(Model {value: state.value - 1});
})
};
html! {
<div>
<button onclick={increment}>{"Increment"}</button>
<button onclick={decrement}>{"Decrement"}</button>
<p>{state.value}</p>
</div>
}
}
fn main() {
yew::Renderer::<App>::new().render();
}
Yew’s syntax and structure are intentionally designed to feel familiar to developers who have used modern JavaScript frameworks, especially React. It features:
- Component-based architecture: Each part of the UI is a component.
- Hooks for state management: Similar to React’s
useState
, Yew hasuse_state
. - HTML Macros: Yew uses the
html!
macro to inject HTML-like syntax into Rust, making it easy to build dynamic UIs.
In my personal opinion, YEW is a very cool example of the power WASM brings — enabling us to build web apps in something non JS based. However, JS still remains the king of web development and will not be usurped by a WASM based framework anytime soon.
Conclusion
wasm-bindgen
is an essential tool for improving the integration between WebAssembly and JavaScript, allowing seamless communication between the two. This enables Rust applications compiled to WebAssembly to easily call JavaScript functions, interact with DOM elements, and utilize existing web APIs. By leveraging wasm-bindgen
, developers can write Rust code that integrates deeply with the browser environment without needing to switch contexts or languages.
With frameworks like Yew, we can go a step further and build entire web applications purely in Rust. Yew’s React-like design, combined with WebAssembly, allows developers to harness Rust’s memory safety, performance, and tooling advantages on the frontend, all while staying in a familiar component-based, declarative paradigm.