JavaScript Does Not Get the Rules.
Compile Your Rust Game to WebAssembly

For ten articles, the only thing that has ever run our game is main(), in Rust, in a terminal.
Today, a browser runs it.
Not a rewrite. Not a port. The same library, with the same twenty-eight tests, compiled for a different machine. The browser gets a handful of operations and a save that is already text. It does not get the rules.
By the end, a web page shows this:
One hour away.
Stage 1 -> 128 Level 1 -> 47 Gold 0 -> 16637
Stage 128 and level 47 are the numbers tests/idle_hour.rs has been checking since the ninth article. Same engine. Same hour. The call came from JavaScript.
Along the way we meet WebAssembly, two tools that do the glue work, and one new rule about what is allowed to cross a border.
Our starting point
This series builds a small idle game: a player clicks to fight slimes, earns gold and XP, and keeps progressing while you are away.
If you are only reading along, skip past this code to "A second machine."
If you are building along, you need the library as it stood at the end of the last article: a project called rustbound-idle with seven source files and two outside tests. (If you kept your project, you already have all of this. The article_10 branch of the companion repository has it too.)
rustbound-idle/
├── Cargo.toml
├── Cargo.lock
├── src/
│ ├── lib.rs
│ ├── main.rs
│ ├── rules.rs
│ ├── enemy.rs
│ ├── player.rs
│ ├── game.rs
│ └── save.rs
└── tests/
├── idle_hour.rs
└── save_and_restore.rs
Cargo.toml:
Cargo.toml
[package]
name = "rustbound-idle"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
src/lib.rs · 10 lines
mod enemy;
mod game;
mod player;
mod rules;
mod save;
pub use enemy::Enemy;
pub use game::Game;
pub use player::{Player, PurchaseOutcome};
pub use save::LoadError;
src/main.rs · 71 lines
use rustbound_idle::{Game, PurchaseOutcome};
fn main() {
let mut game = Game::new();
while game.stage <= 3 {
let stage = game.stage;
let level = game.player.level;
game.click();
if game.stage > stage {
println!(
"Stage {stage} cleared! Gold: {} XP: {}/{}",
game.player.gold,
game.player.xp,
game.player.xp_needed()
);
if game.player.level > level {
println!(
"Level up! Now level {}. Click damage: {}",
game.player.level,
game.player.click_damage()
);
}
println!(
" Next up: {} with {} health.",
game.enemy.name, game.enemy.health
);
}
match game.buy_click_upgrade() {
PurchaseOutcome::Purchased => println!(
"Bought click upgrade {}! Click damage: {} Gold left: {}",
game.player.click_upgrade_level,
game.player.click_damage(),
game.player.gold
),
PurchaseOutcome::NotEnoughGold => {}
}
}
let save = game.save_json();
println!("Saved: {save}");
drop(game);
let mut game = match Game::from_saved_json(&save) {
Ok(game) => game,
Err(error) => {
println!("Could not load the save: {error:?}");
return;
}
};
println!("Loaded. Stage {}, level {}.", game.stage, game.player.level);
let stage = game.stage;
let level = game.player.level;
let gold = game.player.gold;
game.tick(3600);
println!("One hour away.");
println!(
"Stage {} -> {} Level {} -> {} Gold {} -> {}",
stage, game.stage, level, game.player.level, gold, game.player.gold
);
}
src/rules.rs · 105 lines
pub(crate) fn click_damage(base: u32, bonus: u32) -> u32 {
base.saturating_add(bonus)
}
pub(crate) fn health_after_hit(health: u32, damage: u32) -> u32 {
health.saturating_sub(damage)
}
pub(crate) fn is_defeated(health: u32) -> bool {
health == 0
}
pub(crate) fn enemy_health_for_stage(stage: u32) -> u32 {
stage.saturating_mul(10).saturating_add(10)
}
pub(crate) fn gold_reward_for_stage(stage: u32) -> u32 {
stage.saturating_mul(2).saturating_add(3)
}
pub(crate) fn xp_reward_for_stage(stage: u32) -> u32 {
stage.saturating_mul(2).saturating_add(4)
}
pub(crate) fn xp_to_next_level(level: u32) -> u32 {
level
.saturating_sub(1)
.saturating_mul(15)
.saturating_add(20)
}
pub(crate) fn base_damage_for_level(level: u32) -> u32 {
level.saturating_add(4)
}
pub(crate) fn idle_damage_for_level(level: u32) -> u32 {
level
}
pub(crate) fn click_upgrade_bonus(upgrades: u32) -> u32 {
upgrades.saturating_mul(2)
}
pub(crate) fn click_upgrade_cost(upgrades: u32) -> u32 {
upgrades.saturating_add(1).saturating_mul(10)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn click_damage_adds_base_and_bonus() {
assert_eq!(click_damage(3, 4), 7);
}
#[test]
fn health_never_drops_below_zero() {
assert_eq!(health_after_hit(3, 10), 0);
}
#[test]
fn zero_health_is_defeated() {
assert!(is_defeated(0));
assert!(!is_defeated(1));
}
#[test]
fn later_stages_have_tougher_enemies() {
assert_eq!(enemy_health_for_stage(1), 20);
assert_eq!(enemy_health_for_stage(2), 30);
}
#[test]
fn later_stages_pay_better() {
assert_eq!(gold_reward_for_stage(1), 5);
assert_eq!(gold_reward_for_stage(2), 7);
assert_eq!(xp_reward_for_stage(1), 6);
assert_eq!(xp_reward_for_stage(2), 8);
}
#[test]
fn levels_need_more_xp_as_they_go() {
assert_eq!(xp_to_next_level(1), 20);
assert_eq!(xp_to_next_level(2), 35);
}
#[test]
fn levels_make_the_player_hit_harder() {
assert_eq!(base_damage_for_level(1), 5);
assert_eq!(base_damage_for_level(2), 6);
}
#[test]
fn idle_damage_grows_with_level() {
assert_eq!(idle_damage_for_level(1), 1);
assert_eq!(idle_damage_for_level(2), 2);
}
#[test]
fn upgrades_cost_more_each_time() {
assert_eq!(click_upgrade_cost(0), 10);
assert_eq!(click_upgrade_cost(1), 20);
}
}
src/enemy.rs · 19 lines
use crate::rules::{enemy_health_for_stage, gold_reward_for_stage, xp_reward_for_stage};
pub struct Enemy {
pub name: String,
pub health: u32,
pub(crate) gold_reward: u32,
pub(crate) xp_reward: u32,
}
impl Enemy {
pub(crate) fn for_stage(stage: u32) -> Enemy {
Enemy {
name: String::from("Training Slime"),
health: enemy_health_for_stage(stage),
gold_reward: gold_reward_for_stage(stage),
xp_reward: xp_reward_for_stage(stage),
}
}
}
src/player.rs · 114 lines
use crate::rules::{
base_damage_for_level, click_damage, click_upgrade_bonus, click_upgrade_cost, xp_to_next_level,
};
pub struct Player {
pub gold: u32,
pub xp: u32,
pub level: u32,
pub click_upgrade_level: u32,
}
#[derive(Debug, PartialEq)]
pub enum PurchaseOutcome {
Purchased,
NotEnoughGold,
}
impl Player {
pub(crate) fn new() -> Player {
Player {
gold: 0,
xp: 0,
level: 1,
click_upgrade_level: 0,
}
}
pub(crate) fn add_gold(&mut self, amount: u32) {
self.gold = self.gold.saturating_add(amount);
}
pub(crate) fn add_xp(&mut self, amount: u32) {
self.xp = self.xp.saturating_add(amount);
while self.xp >= self.xp_needed() {
self.xp -= self.xp_needed();
self.level = self.level.saturating_add(1);
}
}
pub fn xp_needed(&self) -> u32 {
xp_to_next_level(self.level)
}
pub fn click_damage(&self) -> u32 {
click_damage(self.base_damage(), self.bonus_damage())
}
fn base_damage(&self) -> u32 {
base_damage_for_level(self.level)
}
fn bonus_damage(&self) -> u32 {
click_upgrade_bonus(self.click_upgrade_level)
}
pub(crate) fn buy_click_upgrade(&mut self) -> PurchaseOutcome {
let cost = click_upgrade_cost(self.click_upgrade_level);
if self.gold < cost {
return PurchaseOutcome::NotEnoughGold;
}
self.gold -= cost;
self.click_upgrade_level = self.click_upgrade_level.saturating_add(1);
PurchaseOutcome::Purchased
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leveling_up_keeps_leftover_xp() {
let mut player = Player::new();
player.add_xp(25);
assert_eq!(player.level, 2);
assert_eq!(player.xp, 5);
}
#[test]
fn a_big_reward_can_skip_levels() {
let mut player = Player::new();
player.add_xp(60);
assert_eq!(player.level, 3);
assert_eq!(player.xp, 5);
}
#[test]
fn buying_an_upgrade_needs_gold() {
let mut player = Player::new();
assert_eq!(player.buy_click_upgrade(), PurchaseOutcome::NotEnoughGold);
assert_eq!(player.click_upgrade_level, 0);
assert_eq!(player.bonus_damage(), 0);
}
#[test]
fn buying_an_upgrade_spends_gold() {
let mut player = Player::new();
player.gold = 12;
assert_eq!(player.buy_click_upgrade(), PurchaseOutcome::Purchased);
assert_eq!(player.gold, 2);
assert_eq!(player.click_upgrade_level, 1);
assert_eq!(player.bonus_damage(), 2);
}
}
src/game.rs · 158 lines
use crate::enemy::Enemy;
use crate::player::{Player, PurchaseOutcome};
use crate::rules::{health_after_hit, idle_damage_for_level, is_defeated};
pub struct Game {
pub stage: u32,
pub player: Player,
pub enemy: Enemy,
}
impl Game {
pub fn new() -> Game {
Game {
stage: 1,
player: Player::new(),
enemy: Enemy::for_stage(1),
}
}
pub fn click(&mut self) {
attack(&self.player, &mut self.enemy);
self.resolve_hit();
}
pub fn tick(&mut self, seconds: u32) {
for _ in 0..seconds {
let damage = idle_damage_for_level(self.player.level);
self.enemy.health = health_after_hit(self.enemy.health, damage);
self.resolve_hit();
}
}
fn resolve_hit(&mut self) {
if is_defeated(self.enemy.health) {
self.defeat_enemy();
}
}
fn defeat_enemy(&mut self) {
self.player.add_gold(self.enemy.gold_reward);
self.player.add_xp(self.enemy.xp_reward);
self.stage = self.stage.saturating_add(1);
self.enemy = Enemy::for_stage(self.stage);
}
pub fn buy_click_upgrade(&mut self) -> PurchaseOutcome {
self.player.buy_click_upgrade()
}
}
impl Default for Game {
fn default() -> Game {
Game::new()
}
}
fn attack(player: &Player, enemy: &mut Enemy) {
enemy.health = health_after_hit(enemy.health, player.click_damage());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attack_reduces_enemy_health() {
let player = Player::new();
let mut enemy = Enemy::for_stage(1);
enemy.health = 10;
attack(&player, &mut enemy);
assert_eq!(enemy.health, 5);
}
#[test]
fn a_new_game_starts_at_stage_one() {
let game = Game::new();
assert_eq!(game.stage, 1);
assert_eq!(game.enemy.health, 20);
assert_eq!(game.player.gold, 0);
}
#[test]
fn clicking_damages_the_enemy() {
let mut game = Game::new();
game.click();
assert_eq!(game.enemy.health, 15);
}
#[test]
fn defeating_an_enemy_pays_the_player() {
let mut game = Game::new();
game.enemy.health = 1;
game.click();
assert_eq!(game.stage, 2);
assert_eq!(game.enemy.health, 30);
assert_eq!(game.player.gold, 5);
assert_eq!(game.player.xp, 6);
}
#[test]
fn one_second_is_one_idle_hit() {
let mut game = Game::new();
game.tick(1);
assert_eq!(game.enemy.health, 19);
}
#[test]
fn zero_seconds_changes_nothing() {
let mut game = Game::new();
game.tick(0);
assert_eq!(game.stage, 1);
assert_eq!(game.enemy.health, 20);
}
#[test]
fn enough_time_defeats_the_enemy() {
let mut game = Game::new();
game.tick(20);
assert_eq!(game.stage, 2);
assert_eq!(game.enemy.health, 30);
assert_eq!(game.player.gold, 5);
assert_eq!(game.player.xp, 6);
}
#[test]
fn one_long_tick_matches_many_short_ones() {
let mut long = Game::new();
let mut short = Game::new();
long.tick(45);
for _ in 0..45 {
short.tick(1);
}
assert_eq!(long.stage, short.stage);
assert_eq!(long.enemy.health, short.enemy.health);
assert_eq!(long.player.gold, short.player.gold);
assert_eq!(long.player.xp, short.player.xp);
assert_eq!(long.player.level, short.player.level);
}
}
src/save.rs · 130 lines
use crate::enemy::Enemy;
use crate::game::Game;
use serde::{Deserialize, Serialize};
const SAVE_VERSION: u32 = 1;
#[derive(Debug, PartialEq)]
pub enum LoadError {
Unreadable,
UnsupportedVersion(u32),
}
#[derive(Serialize, Deserialize)]
struct PlayerSave {
gold: u32,
xp: u32,
level: u32,
click_upgrade_level: u32,
}
#[derive(Serialize, Deserialize)]
struct SaveData {
save_version: u32,
stage: u32,
player: PlayerSave,
enemy_health: u32,
}
impl Game {
pub fn save_json(&self) -> String {
let save = SaveData {
save_version: SAVE_VERSION,
stage: self.stage,
player: PlayerSave {
gold: self.player.gold,
xp: self.player.xp,
level: self.player.level,
click_upgrade_level: self.player.click_upgrade_level,
},
enemy_health: self.enemy.health,
};
serde_json::to_string(&save).expect("a save made of numbers should always serialize")
}
pub fn from_saved_json(json: &str) -> Result<Game, LoadError> {
let save: SaveData = match serde_json::from_str(json) {
Ok(save) => save,
Err(_) => return Err(LoadError::Unreadable),
};
if save.save_version != SAVE_VERSION {
return Err(LoadError::UnsupportedVersion(save.save_version));
}
let mut game = Game::new();
game.stage = save.stage;
game.player.gold = save.player.gold;
game.player.xp = save.player.xp;
game.player.level = save.player.level;
game.player.click_upgrade_level = save.player.click_upgrade_level;
game.enemy = Enemy::for_stage(save.stage);
game.enemy.health = save.enemy_health;
Ok(game)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_new_game_saves_its_starting_numbers() {
let json = Game::new().save_json();
assert_eq!(
json,
r#"{"save_version":1,"stage":1,"player":{"gold":0,"xp":0,"level":1,"click_upgrade_level":0},"enemy_health":20}"#
);
}
#[test]
fn a_loaded_game_rebuilds_the_enemy_from_the_rules() {
let mut game = Game::new();
game.tick(600);
let restored = Game::from_saved_json(&game.save_json()).expect("the save should load");
assert_eq!(restored.stage, game.stage);
assert_eq!(restored.enemy.health, game.enemy.health);
assert_eq!(restored.enemy.gold_reward, game.enemy.gold_reward);
assert_eq!(restored.enemy.xp_reward, game.enemy.xp_reward);
}
#[test]
fn nonsense_is_not_a_save() {
assert!(matches!(
Game::from_saved_json("definitely not a save"),
Err(LoadError::Unreadable)
));
}
#[test]
fn a_save_with_missing_pieces_is_not_a_save() {
let json = r#"{"save_version":1,"stage":4}"#;
assert!(matches!(
Game::from_saved_json(json),
Err(LoadError::Unreadable)
));
}
#[test]
fn a_save_from_another_version_is_refused() {
let json = r#"{
"save_version": 99,
"stage": 1,
"player": { "gold": 0, "xp": 0, "level": 1, "click_upgrade_level": 0 },
"enemy_health": 20
}"#;
assert!(matches!(
Game::from_saved_json(json),
Err(LoadError::UnsupportedVersion(99))
));
}
}
tests/idle_hour.rs · 11 lines
use rustbound_idle::Game;
#[test]
fn an_hour_away_moves_the_game_forward() {
let mut game = Game::new();
game.tick(3600);
assert_eq!(game.stage, 128);
assert_eq!(game.player.level, 47);
}
tests/save_and_restore.rs · 18 lines
use rustbound_idle::Game;
#[test]
fn a_restored_game_plays_the_same_hour() {
let mut original = Game::new();
original.tick(600);
let mut restored = Game::from_saved_json(&original.save_json()).expect("the save should load");
original.tick(3600);
restored.tick(3600);
assert_eq!(restored.stage, original.stage);
assert_eq!(restored.enemy.health, original.enemy.health);
assert_eq!(restored.player.gold, original.player.gold);
assert_eq!(restored.player.xp, original.player.xp);
assert_eq!(restored.player.level, original.player.level);
}
Run cargo test, and twenty-eight tests pass. Run cargo run, and the player clears three stages, saves, loads, and spends an hour away.
A second machine
Everything we have compiled so far has been for the computer in front of you. cargo build asks Rust to produce a program for this machine: this processor, this operating system.
Rust can produce programs for other machines too. A phone. A server with a different processor. Each one is called a target, and you pick it when you build.
WebAssembly is one of those targets.
It is not a real machine. It is a description of one, agreed on by the people who make browsers, so that every browser can run the same compiled code. Compile Rust for it, and the result is a file a browser can load and run, the way it loads and runs JavaScript.
The target has a long name:
wasm32-unknown-unknown
The three parts are the processor, the vendor, and the operating system. The processor is 32-bit WebAssembly. The other two are unknown, and they mean it.
There is no operating system. No files, no clock, no terminal, no println!. A program compiled for this target can do arithmetic and talk to whoever loaded it, and that is all.
That should sound like a problem.
It is not, and the reason is the wall we built two articles ago. Our library has never opened a file, never asked the time, never printed. It takes numbers and text, and it gives back numbers and text. main.rs does the printing, and main.rs is not coming with us.
Install the target:
rustup target add wasm32-unknown-unknown
That downloads Rust's standard library, compiled for WebAssembly, so that String and Vec and saturating_add all exist on the other side. It takes a moment, and it only happens once.
Question: Why does main.rs stay behind when we compile the library for the browser?
- WebAssembly can only compile files named
lib.rs main.rsprints to a terminal, and this browser target has no terminal- JavaScript automatically replaces every Rust
main()function
Answer: 2. main.rs prints to a terminal, and this browser target has no terminal
Explanation: The engine only calculates with numbers and text. The terminal-specific printing stays in
main.rson the machine that has a terminal.
Two tools
WebAssembly has one more limitation that matters to us.
At the raw WebAssembly boundary we are using today, values cross as WebAssembly's small built-in types. A Rust String or struct cannot cross as a JavaScript string or object by itself.
Our front door takes a &str and returns a String. Those are not numbers.
Someone has to write the code that turns a Rust String into a JavaScript string, one byte at a time, on both sides of the line. That code is tedious, and it is the same every time, so a tool writes it. Two tools, in fact:
- wasm-bindgen is a Rust crate and a command-line program. The crate gives us an attribute to mark what JavaScript should be able to call. The program reads a compiled
.wasmfile and writes the JavaScript glue for everything marked. - wasm-pack runs the whole build:
cargo buildfor the WebAssembly target, thenwasm-bindgen, then a shrinking pass, and puts the results in one folder, ready for a web page.
Install wasm-pack:
cargo install wasm-pack
cargo install builds a program from crates.io and puts it on your path. This one takes a couple of minutes.
The other tool comes in through Cargo.toml, the way serde did. Open it and make two changes:
Cargo.toml
[package]
name = "rustbound-idle"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
wasm-bindgen = "0.2"
The dependency is the familiar part.
The [lib] section is new. It tells Cargo what shape to build the library in.
rlib is the shape Rust uses: it is what main.rs and the tests have been linking against all along. cdylib is a shape other languages can load. Think of a front door for the family and a side door for guests. We ask for both, so that cargo run and cargo test keep using the door they know, and wasm-pack gets one it can open.
Build:
cargo build
Cargo downloads wasm-bindgen and a few helpers, compiles them, and finishes with nothing to say. Nothing is marked yet.
Run cargo test too. Twenty-eight.
Do not export the engine
Here is the tempting move: put the wasm-bindgen attribute on Game, on Player, on Enemy, and let JavaScript have everything.
We are not going to do that, for the same reason rules is not on the front door.
Game has never heard of a browser. The moment it carries a browser attribute, it has. Every future change to the engine would have to ask whether JavaScript still likes the shape. And JavaScript would be free to reach past the methods, read the fields, and start doing arithmetic of its own.
So we build an adapter: a small type that holds a Game, and shows JavaScript only what we choose.
It is a separate concern, so it gets a separate module. Create src/web.rs:
src/web.rs
use crate::game::Game;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(js_name = Game)]
pub struct WebGame {
inner: Game,
}
#[wasm_bindgen(js_class = Game)]
impl WebGame {
#[wasm_bindgen(constructor)]
pub fn new() -> WebGame {
WebGame { inner: Game::new() }
}
pub fn click(&mut self) {
self.inner.click();
}
pub fn tick(&mut self, seconds: u32) {
self.inner.tick(seconds);
}
}
And register it. In src/lib.rs, add mod web; under mod save;. It does not go on the front door. JavaScript will not come in through lib.rs; it comes in through the glue.
Now read web.rs from the top.
use wasm_bindgen::prelude::*; brings in everything the crate expects us to use. A prelude is a crate's "you will want all of these" list. The * means all of it.
#[wasm_bindgen] is an attribute, like #[derive(...)] and #[test]: a note to the compiler, attached to the thing below it. This one says JavaScript should be able to see this.
WebGame is the adapter. It has one field, inner, holding the real Game. The field is private. JavaScript cannot see inside, and neither can anything outside this file.
js_name = Game on the struct, and js_class = Game on the impl block, say what the type is called on the JavaScript side. In Rust it is WebGame, so the two never collide. In the browser it is Game, because that is what it is.
#[wasm_bindgen(constructor)] marks new() as the thing new Game() runs in JavaScript.
click() and tick() are the smallest methods in the project. They hand the call to inner and that is all. That is what an adapter does. The rules stay on the far side.
Question: Why does WebGame hold a private Game instead of exporting every engine field?
- Private fields make WebAssembly download faster
- The adapter keeps JavaScript's interface small and leaves the rules in Rust
- Rust structs cannot contain public fields
Answer: 2. The adapter keeps JavaScript's interface small and leaves the rules in Rust
Explanation: JavaScript gets a few operations chosen by the adapter. It cannot reach into the engine and start calculating a second version of a rule.
Build it:
cargo build
It compiles with nothing to say. On your own machine, cargo build still reads the attribute and checks that everything it marks could cross to JavaScript, which is about to matter. But it exports nothing. The result is a normal library, which is why cargo test keeps working, and we will lean on that before we are done.
Only some things can cross
buy_click_upgrade() is next. The engine's version returns a PurchaseOutcome. Try handing that straight across. Add this inside the impl WebGame block:
src/web.rs
#[wasm_bindgen(js_name = buyClickUpgrade)]
pub fn buy_click_upgrade(&mut self) -> PurchaseOutcome {
self.inner.buy_click_upgrade()
}
And add use crate::player::PurchaseOutcome; under the first use line.
The js_name attribute works on methods too. Rust calls it buy_click_upgrade, because that is how Rust names things. JavaScript calls it buyClickUpgrade, because that is how JavaScript names things. Each side gets its own convention and neither has to flinch.
Build, and Rust refuses, because this type is not allowed across:
error[E0277]: the trait bound `PurchaseOutcome: IntoWasmAbi` is not satisfied
--> src/web.rs:10:1
|
10 | #[wasm_bindgen(js_class = Game)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unsatisfied trait bound
|
help: the trait `IntoWasmAbi` is not implemented for `PurchaseOutcome`
(There are several of these, and one names a cousin trait, WasmDescribe. They all say the same thing: this type cannot cross.)
IntoWasmAbi is the trait for can be handed to JavaScript. Numbers have it. bool has it. String and &str have it, thanks to the glue. Types marked with #[wasm_bindgen] have it.
PurchaseOutcome does not, and we are not going to mark it, because it belongs to the engine.
So what crosses instead?
false is still not an answer. We settled that in the seventh article, and a border does not change it. What crosses is the name of the answer, as text:
src/web.rs
#[wasm_bindgen(js_name = buyClickUpgrade)]
pub fn buy_click_upgrade(&mut self) -> String {
match self.inner.buy_click_upgrade() {
PurchaseOutcome::Purchased => String::from("purchased"),
PurchaseOutcome::NotEnoughGold => String::from("notEnoughGold"),
}
}
Replace the version you just wrote with this one.
The match is doing the translating. Rust's enum stays an enum. JavaScript gets a string it can compare against. If a third outcome ever appears, the match refuses to compile until it has a name too, which is the same protection we had before.
Question: What crosses into JavaScript when an upgrade cannot be bought?
- The whole
PurchaseOutcomeenum definition false- The string
"notEnoughGold"
Answer: 3. The string "notEnoughGold"
Explanation: The adapter translates the Rust enum variant into text. The engine keeps the enum, including Rust's exhaustive
matchcheck.
Build, and it is quiet again.
A snapshot for the screen
If JavaScript ever computes click damage, there are two programs that know how, and one day they will disagree.
That is the rule this whole section protects.
The browser is going to draw a screen, and a screen needs numbers: the stage, the gold, how much a click does, what the next upgrade costs. Some of those are fields. Some are rules. We could hand JavaScript the fields and let it work out the rest, and that is exactly the thing it must never do.
So the adapter answers all of it at once, as one piece of JSON, and Rust does every bit of arithmetic before the text leaves.
Add this at the bottom of web.rs, after the impl WebGame block:
src/web.rs
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Snapshot {
stage: u32,
gold: u32,
xp: u32,
xp_needed: u32,
level: u32,
click_damage: u32,
click_upgrade_level: u32,
click_upgrade_cost: u32,
enemy_name: String,
enemy_health: u32,
enemy_max_health: u32,
}
impl Snapshot {
fn of(game: &Game) -> Snapshot {
Snapshot {
stage: game.stage,
gold: game.player.gold,
xp: game.player.xp,
xp_needed: game.player.xp_needed(),
level: game.player.level,
click_damage: game.player.click_damage(),
click_upgrade_level: game.player.click_upgrade_level,
click_upgrade_cost: game.player.click_upgrade_cost(),
enemy_name: game.enemy.name.clone(),
enemy_health: game.enemy.health,
enemy_max_health: enemy_health_for_stage(game.stage),
}
}
}
And at the top of the file, two more use lines, under the one for PurchaseOutcome:
src/web.rs
use crate::rules::enemy_health_for_stage;
use serde::Serialize;
A Snapshot is a flat list of everything a screen might want, taken at one moment. It only needs Serialize, not Deserialize, because nobody ever reads a snapshot back in. It is a picture, not a save.
Three lines in of() deserve a look.
xp_needed, click_damage and click_upgrade_cost are not copied. They are asked for. Each one is a rule, applied in Rust, and only the answer goes in the picture.
enemy_max_health is the same idea. The enemy does not store its full health; the rule enemy_health_for_stage() knows it, and the stage is right there.
game.enemy.name.clone() is new. A String owns its text, and two things cannot own the same text at once. The enemy owns that name. The snapshot wants its own, so .clone() makes a second copy. (The u32 fields do not need this. A number is small enough that Rust copies it without asking.)
And the attribute above the struct:
src/web.rs
#[serde(rename_all = "camelCase")]
This is the article where JavaScript finally reads our JSON, so this is the article where the names change shape. Rust writes click_damage. JavaScript expects clickDamage. rename_all tells serde to convert every field name on the way out, and the Rust code stays the way Rust likes it.
The save is not getting this. JavaScript stores a save and hands it back; it never looks inside. Changing the save's field names would be a new save version for the benefit of nobody.
Two small things remain before it builds.
click_upgrade_cost() does not exist on Player yet. The cost has only ever been needed inside a purchase. Now a screen wants to show it. In src/player.rs, add a method under click_damage():
src/player.rs
pub fn click_upgrade_cost(&self) -> u32 {
click_upgrade_cost(self.click_upgrade_level)
}
And since the player can answer that now, buy_click_upgrade() should ask instead of reaching for the rule. Change its first line from:
src/player.rs
let cost = click_upgrade_cost(self.click_upgrade_level);
to:
src/player.rs
let cost = self.click_upgrade_cost();
Then the method that hands the picture over. Back in web.rs, inside impl WebGame, under buy_click_upgrade():
src/web.rs
#[wasm_bindgen(js_name = stateJson)]
pub fn state_json(&self) -> String {
let snapshot = Snapshot::of(&self.inner);
serde_json::to_string(&snapshot).expect("a Snapshot can always be written as JSON")
}
That expect() is the same promise save_json() makes, for the same reason: a struct of numbers and one string cannot fail to become JSON.
Question: Why does the snapshot contain clickDamage instead of making JavaScript calculate it?
- JavaScript cannot add two numbers
- Rust should remain the only place that knows the damage rule
- JSON requires every number to be calculated twice
Answer: 2. Rust should remain the only place that knows the damage rule
Explanation: The screen receives the answer it needs. If JavaScript repeated the formula, the two versions could disagree after a later rule change.
Build. Quiet.
Saves cross too
Saving is already text, so it crosses as it is:
src/web.rs
#[wasm_bindgen(js_name = saveJson)]
pub fn save_json(&self) -> String {
self.inner.save_json()
}
Loading is the interesting one, because loading can fail, and now the failure has to cross a border.
Try the obvious thing first:
src/web.rs
#[wasm_bindgen(js_name = fromJson)]
pub fn from_json(json: &str) -> Result<WebGame, LoadError> {
match Game::from_saved_json(json) {
Ok(inner) => Ok(WebGame { inner }),
Err(error) => Err(error),
}
}
Add use crate::save::LoadError; to the use lines, and build. Rust refuses again, and this time the complaint is about the error half:
error[E0277]: the trait bound `Result<WebGame, LoadError>: ReturnWasmAbi` is not satisfied
--> src/web.rs:13:1
|
13 | #[wasm_bindgen(js_class = Game)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `ReturnWasmAbi` is not implemented for `Result<WebGame, LoadError>`
|
help: the trait `ReturnWasmAbi` is implemented for `Result<T, E>`
|
779 | / impl<T, E> ReturnWasmAbi for Result<T, E>
780 | | where
781 | | T: IntoWasmAbi,
782 | | E: Into<JsValue>,
Same story as the purchase, with one new piece of information at the bottom.
A Result can cross, as long as both halves can. WebGame is fine. LoadError is not, and the help says what the error half needs to be: something that can become a JsValue, which is the crate's word for any JavaScript value.
A String can. So the adapter translates the error the same way it translated the purchase: into words.
src/web.rs
#[wasm_bindgen(js_name = fromJson)]
pub fn from_json(json: &str) -> Result<WebGame, String> {
match Game::from_saved_json(json) {
Ok(inner) => Ok(WebGame { inner }),
Err(LoadError::Unreadable) => Err(String::from("That save could not be read.")),
Err(LoadError::UnsupportedVersion(version)) => Err(format!(
"That save is version {version}, and this game cannot read it."
)),
}
}
main() prints a failed load with {error:?}, which shows the variant's name: Could not load the save: Unreadable. That is fine in a terminal only we read. A player deserves a sentence, so the adapter writes one for each way a load can fail.
format!() is println!()'s quieter sibling. Same curly braces, but instead of printing the result, it hands it back as a String.
What does JavaScript see when the Result is an Err? The glue throws it. On the other side, Game.fromJson(text) either returns a game or throws the message, and a try/catch catches it. We will use that next time, when there is a save to load.
Build. Quiet. Here is web.rs so far, for checking against. The Snapshot code at the bottom is unchanged:
src/web.rs
use crate::game::Game;
use crate::player::PurchaseOutcome;
use crate::rules::enemy_health_for_stage;
use crate::save::LoadError;
use serde::Serialize;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(js_name = Game)]
pub struct WebGame {
inner: Game,
}
#[wasm_bindgen(js_class = Game)]
impl WebGame {
#[wasm_bindgen(constructor)]
pub fn new() -> WebGame {
WebGame { inner: Game::new() }
}
pub fn click(&mut self) {
self.inner.click();
}
pub fn tick(&mut self, seconds: u32) {
self.inner.tick(seconds);
}
#[wasm_bindgen(js_name = buyClickUpgrade)]
pub fn buy_click_upgrade(&mut self) -> String {
match self.inner.buy_click_upgrade() {
PurchaseOutcome::Purchased => String::from("purchased"),
PurchaseOutcome::NotEnoughGold => String::from("notEnoughGold"),
}
}
#[wasm_bindgen(js_name = stateJson)]
pub fn state_json(&self) -> String {
let snapshot = Snapshot::of(&self.inner);
serde_json::to_string(&snapshot).expect("a Snapshot can always be written as JSON")
}
#[wasm_bindgen(js_name = saveJson)]
pub fn save_json(&self) -> String {
self.inner.save_json()
}
#[wasm_bindgen(js_name = fromJson)]
pub fn from_json(json: &str) -> Result<WebGame, String> {
match Game::from_saved_json(json) {
Ok(inner) => Ok(WebGame { inner }),
Err(LoadError::Unreadable) => Err(String::from("That save could not be read.")),
Err(LoadError::UnsupportedVersion(version)) => Err(format!(
"That save is version {version}, and this game cannot read it."
)),
}
}
}
Seven things JavaScript can do. That is the whole border.
Test it where you are
We have not compiled for WebAssembly yet, and we can already test the adapter, because on this machine it is ordinary Rust.
Add a tests module at the bottom of web.rs:
src/web.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_new_game_reports_its_starting_state() {
let game = WebGame::new();
assert_eq!(
game.state_json(),
r#"{"stage":1,"gold":0,"xp":0,"xpNeeded":20,"level":1,"clickDamage":5,"clickUpgradeLevel":0,"clickUpgradeCost":10,"enemyName":"Training Slime","enemyHealth":20,"enemyMaxHealth":20}"#
);
}
#[test]
fn a_purchase_answers_in_words() {
let mut game = WebGame::new();
assert_eq!(game.buy_click_upgrade(), "notEnoughGold");
game.tick(100);
assert_eq!(game.buy_click_upgrade(), "purchased");
}
#[test]
fn a_save_crosses_the_border_and_back() {
let mut game = WebGame::new();
game.tick(600);
let save = game.save_json();
let restored = WebGame::from_json(&save).expect("the save should load");
assert_eq!(restored.state_json(), game.state_json());
}
#[test]
fn nonsense_is_refused_at_the_border() {
assert_eq!(
WebGame::from_json("banana").err(),
Some(String::from("That save could not be read."))
);
}
}
The first test is the one to read slowly. That is the exact text a browser will receive for a new game: every name in camelCase, every rule already applied. Click damage is 5 because level 1 hits for 5. The upgrade costs 10. The slime has 20 of 20.
The last test uses .err(), which turns a Result into an Option: Some(error) if it failed, None if it did not. We cannot assert_eq! on the whole Result, because WebGame has no PartialEq and does not need one. Comparing just the error half is enough.
Run them:
cargo test
Thirty-two. Thirty in the library, and the two outside tests.
If you see twenty-eight, the mod web; line is missing from lib.rs. Without it, the file is not part of the library at all, and nothing complains until much later, in the browser. Add the line and run the tests again.
Build for the browser
Now the other machine.
wasm-pack build --target web --out-dir web/pkg
--target web asks for glue that a plain web page can import, with no bundler and no Node. --out-dir web/pkg says where to put it.
The first run is slow. wasm-pack fetches the wasm-bindgen command-line program to match the crate version in Cargo.lock, compiles the library for wasm32-unknown-unknown in release mode, runs the glue generator, and shrinks the result. After that it is a few seconds.
When it finishes, the last lines look like this:
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended
[INFO]: :-) Done in 24.46s
[INFO]: :-) Your wasm pkg is ready to publish at /home/you/rustbound-idle/web/pkg.
Your time and path will differ, and the note about optional fields is safe to ignore. Look in the folder:
web/pkg/
├── .gitignore
├── package.json
├── rustbound_idle.d.ts
├── rustbound_idle.js
├── rustbound_idle_bg.wasm
└── rustbound_idle_bg.wasm.d.ts
rustbound_idle_bg.wasm is the compiled game, about 110 KB. (We will look at that number again when it is time to ship.) rustbound_idle.js is the glue: the JavaScript that loads the .wasm file and wraps every marked method so that strings go in and out as strings. package.json describes the folder to JavaScript tooling we are not using.
The two .d.ts files are the ones to open. They are written for TypeScript, a version of JavaScript with types, but you do not need to know TypeScript to read this; it is a list of everything JavaScript can call, and it is the clearest summary of what we just built. The Game class is near the top of rustbound_idle.d.ts; everything below it is glue you can ignore:
web/pkg/rustbound_idle.d.ts
export class Game {
free(): void;
[Symbol.dispose](): void;
buyClickUpgrade(): string;
click(): void;
static fromJson(json: string): Game;
constructor();
saveJson(): string;
stateJson(): string;
tick(seconds: number): void;
}
Every js_name we chose is in there. fromJson is static, because it makes a Game rather than being called on one, which is the same reason from_saved_json() was an associated function.
free() and [Symbol.dispose] are the two we did not write. A Game on the Rust side lives in WebAssembly's memory, and JavaScript's garbage collector cannot see in there. free() is how JavaScript gives a game back when it is done with it, and the other is a newer spelling of the same thing. We will not need either today.
The .gitignore inside pkg/ contains a single *. The whole folder is generated, like target/, and it stays out of version control.
If instead you see this:
Error: crate-type must be cdylib to compile to WebAssembly. Add the following to your Cargo.toml file:
[lib]
crate-type = ["cdylib", "rlib"]
then the [lib] section did not make it into Cargo.toml. Add it and run the build again.
And if the build succeeds but the .d.ts file has no Game class in it, mod web; is missing from lib.rs. The page will later complain that the module "does not provide an export named 'Game'", which is the same problem seen from the other side.
A page that runs the engine
Create web/index.html, next to the pkg folder:
web/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Rustbound Idle</title>
</head>
<body>
<h1>Rustbound Idle</h1>
<pre id="output">Loading the engine...</pre>
<script type="module">
import init, { Game } from "./pkg/rustbound_idle.js";
await init();
const game = new Game();
const before = JSON.parse(game.stateJson());
game.tick(3600);
const after = JSON.parse(game.stateJson());
document.querySelector("#output").textContent =
`One hour away.\nStage ${before.stage} -> ${after.stage} ` +
`Level ${before.level} -> ${after.level} ` +
`Gold ${before.gold} -> ${after.gold}`;
</script>
</body>
</html>
The script is the whole point, so read it line by line.
import init, { Game } from "./pkg/rustbound_idle.js"; brings in two things from the glue. Game is our class. init is a function that fetches rustbound_idle_bg.wasm and loads it into the browser.
await init(); does that loading. It is the one step that takes time, which is why the page says "Loading the engine..." until it is done. (If you have been told await only works inside an async function: at the top of a <script type="module"> it is allowed. That is one of the things type="module" buys us.)
new Game() runs our constructor. game.stateJson() runs our snapshot. game.tick(3600) runs the same loop that has run in every test since the eighth article.
JSON.parse() turns our text into a JavaScript object, so before.stage and before.gold just work. The names are camelCase, and that is rename_all paying off.
The sandbox below exercises only that browser-side handoff. It does not pretend to compile or load the generated .wasm package. Run it once, then change the three values in after and run it again. The sentence should follow the snapshot without learning a single game rule.
HTML
<h2>Rustbound Idle</h2>
<pre id="output">Waiting for a snapshot...</pre>
JavaScript
const snapshotText = '{"stage":1,"gold":0,"xp":0,"xpNeeded":20,"level":1,"clickDamage":5,"clickUpgradeLevel":0,"clickUpgradeCost":10,"enemyName":"Training Slime","enemyHealth":20,"enemyMaxHealth":20}';
const before = JSON.parse(snapshotText);
const after = {
...before,
stage: 128,
level: 47,
gold: 16637,
};
document.querySelector("#output").textContent =
`One hour away.\nStage ${before.stage} -> ${after.stage} ` +
`Level ${before.level} -> ${after.level} ` +
`Gold ${before.gold} -> ${after.gold}`;
Live example · render the Rust snapshot on the canonical article.
Then the last lines print the same sentence main() prints. Everything else on the page is scaffolding.
Now serve it. A browser will not load a module or a .wasm file from a plain file on disk; both have to come from a web server, even a trivial one. Python ships with a trivial one:
python3 -m http.server 8080 --directory web
(On Windows the command is usually python rather than python3.)
Open http://localhost:8080/ in a browser.
Rustbound Idle
One hour away.
Stage 1 -> 128 Level 1 -> 47 Gold 0 -> 16637
Stage 128. Level 47.
Those are the numbers in tests/idle_hour.rs, and they have been since the ninth article. The test runs on your machine, in Rust. The page runs in a browser, in WebAssembly, called from JavaScript. Same library. Same answer.
If you open index.html directly instead, by double-clicking it, the page stays on "Loading the engine..." and the browser console says something like:
Access to script at 'file:///.../web/pkg/rustbound_idle.js' from origin 'null'
has been blocked by CORS policy: Cross origin requests are only supported for
protocol schemes: chrome, chrome-untrusted, data, http, https.
That is the browser refusing to load modules from file://. It is not a bug in our code. Use the server.
One habit to pick up now: after you change the Rust and rebuild, browsers like to keep serving the old .wasm file. If a number on the page does not change when you expected it to, do a hard refresh (Ctrl+Shift+R, or Cmd+Shift+R on a Mac) before you go looking for a bug.
One more thing to notice: cargo run still works. Try it. The terminal version prints its three stages and its hour, exactly as before. We did not replace a program. We gave the same library a second door.
The complete program
If you are only reading along, skip to the last two sections.
If you have been following along, here is everything that changed. src/rules.rs, src/enemy.rs, src/game.rs, src/save.rs, src/main.rs, and the two files in tests/ are exactly as they were in the starting point.
The project now looks like this:
rustbound-idle/
├── Cargo.toml
├── Cargo.lock
├── src/
│ ├── lib.rs
│ ├── main.rs
│ ├── rules.rs
│ ├── enemy.rs
│ ├── player.rs
│ ├── game.rs
│ ├── save.rs
│ └── web.rs
├── tests/
│ ├── idle_hour.rs
│ └── save_and_restore.rs
└── web/
├── index.html
└── pkg/ (generated by wasm-pack)
Cargo.toml:
Cargo.toml
[package]
name = "rustbound-idle"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
wasm-bindgen = "0.2"
src/lib.rs:
src/lib.rs
mod enemy;
mod game;
mod player;
mod rules;
mod save;
mod web;
pub use enemy::Enemy;
pub use game::Game;
pub use player::{Player, PurchaseOutcome};
pub use save::LoadError;
Only the impl block in src/player.rs changes; the struct, the enum, and the tests are as they were.
src/player.rs · changed 56-line impl
src/player.rs
impl Player {
pub(crate) fn new() -> Player {
Player {
gold: 0,
xp: 0,
level: 1,
click_upgrade_level: 0,
}
}
pub(crate) fn add_gold(&mut self, amount: u32) {
self.gold = self.gold.saturating_add(amount);
}
pub(crate) fn add_xp(&mut self, amount: u32) {
self.xp = self.xp.saturating_add(amount);
while self.xp >= self.xp_needed() {
self.xp -= self.xp_needed();
self.level = self.level.saturating_add(1);
}
}
pub fn xp_needed(&self) -> u32 {
xp_to_next_level(self.level)
}
pub fn click_damage(&self) -> u32 {
click_damage(self.base_damage(), self.bonus_damage())
}
pub fn click_upgrade_cost(&self) -> u32 {
click_upgrade_cost(self.click_upgrade_level)
}
fn base_damage(&self) -> u32 {
base_damage_for_level(self.level)
}
fn bonus_damage(&self) -> u32 {
click_upgrade_bonus(self.click_upgrade_level)
}
pub(crate) fn buy_click_upgrade(&mut self) -> PurchaseOutcome {
let cost = self.click_upgrade_cost();
if self.gold < cost {
return PurchaseOutcome::NotEnoughGold;
}
self.gold -= cost;
self.click_upgrade_level = self.click_upgrade_level.saturating_add(1);
PurchaseOutcome::Purchased
}
}
src/web.rs · 137 lines
src/web.rs
use crate::game::Game;
use crate::player::PurchaseOutcome;
use crate::rules::enemy_health_for_stage;
use crate::save::LoadError;
use serde::Serialize;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(js_name = Game)]
pub struct WebGame {
inner: Game,
}
#[wasm_bindgen(js_class = Game)]
impl WebGame {
#[wasm_bindgen(constructor)]
pub fn new() -> WebGame {
WebGame { inner: Game::new() }
}
pub fn click(&mut self) {
self.inner.click();
}
pub fn tick(&mut self, seconds: u32) {
self.inner.tick(seconds);
}
#[wasm_bindgen(js_name = buyClickUpgrade)]
pub fn buy_click_upgrade(&mut self) -> String {
match self.inner.buy_click_upgrade() {
PurchaseOutcome::Purchased => String::from("purchased"),
PurchaseOutcome::NotEnoughGold => String::from("notEnoughGold"),
}
}
#[wasm_bindgen(js_name = stateJson)]
pub fn state_json(&self) -> String {
let snapshot = Snapshot::of(&self.inner);
serde_json::to_string(&snapshot).expect("a Snapshot can always be written as JSON")
}
#[wasm_bindgen(js_name = saveJson)]
pub fn save_json(&self) -> String {
self.inner.save_json()
}
#[wasm_bindgen(js_name = fromJson)]
pub fn from_json(json: &str) -> Result<WebGame, String> {
match Game::from_saved_json(json) {
Ok(inner) => Ok(WebGame { inner }),
Err(LoadError::Unreadable) => Err(String::from("That save could not be read.")),
Err(LoadError::UnsupportedVersion(version)) => Err(format!(
"That save is version {version}, and this game cannot read it."
)),
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Snapshot {
stage: u32,
gold: u32,
xp: u32,
xp_needed: u32,
level: u32,
click_damage: u32,
click_upgrade_level: u32,
click_upgrade_cost: u32,
enemy_name: String,
enemy_health: u32,
enemy_max_health: u32,
}
impl Snapshot {
fn of(game: &Game) -> Snapshot {
Snapshot {
stage: game.stage,
gold: game.player.gold,
xp: game.player.xp,
xp_needed: game.player.xp_needed(),
level: game.player.level,
click_damage: game.player.click_damage(),
click_upgrade_level: game.player.click_upgrade_level,
click_upgrade_cost: game.player.click_upgrade_cost(),
enemy_name: game.enemy.name.clone(),
enemy_health: game.enemy.health,
enemy_max_health: enemy_health_for_stage(game.stage),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_new_game_reports_its_starting_state() {
let game = WebGame::new();
assert_eq!(
game.state_json(),
r#"{"stage":1,"gold":0,"xp":0,"xpNeeded":20,"level":1,"clickDamage":5,"clickUpgradeLevel":0,"clickUpgradeCost":10,"enemyName":"Training Slime","enemyHealth":20,"enemyMaxHealth":20}"#
);
}
#[test]
fn a_purchase_answers_in_words() {
let mut game = WebGame::new();
assert_eq!(game.buy_click_upgrade(), "notEnoughGold");
game.tick(100);
assert_eq!(game.buy_click_upgrade(), "purchased");
}
#[test]
fn a_save_crosses_the_border_and_back() {
let mut game = WebGame::new();
game.tick(600);
let save = game.save_json();
let restored = WebGame::from_json(&save).expect("the save should load");
assert_eq!(restored.state_json(), game.state_json());
}
#[test]
fn nonsense_is_refused_at_the_border() {
assert_eq!(
WebGame::from_json("banana").err(),
Some(String::from("That save could not be read."))
);
}
}
web/index.html · 29 lines
web/index.html
Rustbound Idle
Rustbound Idle
Loading the engine...
<script type="module">
import init, { Game } from "./pkg/rustbound_idle.js";
await init();
const game = new Game();
const before = JSON.parse(game.stateJson());
game.tick(3600);
const after = JSON.parse(game.stateJson());
document.querySelector("#output").textContent =
`One hour away.\nStage ${before.stage} -> ${after.stage} ` +
`Level ${before.level} -> ${after.level} ` +
`Gold ${before.gold} -> ${after.gold}`;
</script>
You do not need to add web/pkg to your own .gitignore. The one wasm-pack wrote inside it already keeps the folder out.
Run all three:
cargo test
wasm-pack build --target web --out-dir web/pkg
python3 -m http.server 8080 --directory web
Thirty-two tests pass, the package builds, and the page at http://localhost:8080/ shows the hour away. Clippy should be quiet.
What we learned
Along the way, we encountered:
- compile targets, and
wasm32-unknown-unknown - what WebAssembly can and cannot do on its own
wasm-bindgenandwasm-packcargo install[lib] crate-type, and the difference betweenrlibandcdylib#[wasm_bindgen],js_name,js_class, andconstructor- a prelude, and
use ...::* - an adapter that holds the engine instead of exposing it
- which types can cross a language border, and translating the ones that cannot
- a snapshot that applies every rule before the text leaves Rust
#[serde(rename_all = "camelCase")].clone()for aStringformat!()- a
Resultcrossing into JavaScript as a return or a throw .err()on aResult- testing a WebAssembly adapter as plain Rust
- ES modules,
import,await init(), and whyfile://will not do
You do not need to memorize all of that yet.
Two ideas are worth keeping.
The first is that the border is a translation, not a window. Nothing on the far side can see Game. It sees seven operations, each of which hands numbers or text across, and every rule has already been applied by the time the text leaves. The match in buy_click_upgrade() and the match in from_json() are the only places the two worlds touch, and both of them turn a Rust enum into words.
The second is that the hard part was already done. This article added one file to the library, one line to lib.rs, a four-line method to player.rs, and four lines of configuration. Everything else was two articles ago, when we decided main() should not know any formulas, and last time, when we decided a save should be text. A library that never printed, never asked the time, and never opened a file turned out to be a library that compiles for a machine with none of those things.
That is what the wall was for.
Something to click
Our engine runs in a browser now. Nobody can play it.
There is no button. There is no screen. There is a <pre> tag and a number.
Next time, we put a face on it: one custom HTML element, <idle-game>, that draws the snapshot, turns a click on a button into game.click(), keeps the clock, and saves to the browser's storage so that closing the tab is not game over either.
The browser will own time and storage and pixels.
It still will not get the rules.
Small function. Bigger game.
About the author
- Web developer building full-stack applications with JavaScript, React, Node.js, and modern web APIs.
Comments
Join the discussionNo comments yet. Be the first to comment.
Rustbound - Build Your First Rust App, One Function at a Time
Learn Rust by building a real idle game from small functions to a complete WebAssembly app with testing, persistence, Web Components, PWA support, and deployment.





