Accessing the HTML Canvas from Rust and WebAssembly
Mục tiêu: Learn how to interact with the browser’s DOM from Rust code compiled to WebAssembly. This task focuses on finding and gaining control of an HTML `` element using the web-sys crate, which is the first step towards rendering graphics on the web.
Congratulations on successfully setting up and verifying your Rust to WebAssembly compilation pipeline! That final “Hello from Rust and WebAssembly!” message in your console was a huge milestone, confirming that all the complex tooling is working in harmony. You’ve built the bridge between the high-performance world of Rust and the universally accessible web browser.
Now, it’s time to cross that bridge and start interacting with the browser environment itself. Our goal is to move beyond simply logging messages and begin the process of drawing graphics. The very first step is to gain control over the HTML elements on our page from within our Rust code. Specifically, we need to find the <canvas> element we created, which will serve as our digital drawing board. To do this, we’ll use the web-sys crate to interact with the browser’s Document Object Model (DOM).
Understanding the DOM and web-sys
The Document Object Model (DOM) is a programming interface for web documents. The browser represents every HTML file as a tree-like structure of objects, where each HTML tag (like <body> or <canvas>) is a “node” in the tree. Using the DOM API, we can programmatically find, modify, add, or delete these nodes.
The web-sys crate is our safe and strongly-typed gateway to this API. Instead of dealing with generic JavaScript objects, web-sys provides us with specific Rust types like Window, Document, and HtmlCanvasElement, allowing the Rust compiler to catch errors for us at compile time.
Let’s write a function that acts as the new entry point for our application. This function will find and secure a reference to the core components we need: the global window object, the document object representing our page, and finally, our <canvas> element.
Adding Console Logging from Rust
To get immediate feedback from our Rust code directly in the browser console, we can use the console API provided by web-sys. This is much more convenient than returning strings to JavaScript. To enable this, we first need to activate the corresponding feature in our Cargo.toml.
Open Cargo.toml and add 'console_log' to your web-sys features list.
# In Cargo.toml
# Highlight the added 'console_log' feature
[dependencies]
wasm-bindgen = "0.2.84"
js-sys = "0.3.64"
web-sys = { version = "0.3.64", features = [
'Window',
'Document',
'Element',
'HtmlCanvasElement',
'CanvasRenderingContext2d',
'console_log', # <-- Add this line
]}
# ... rest of the file
This simple change instructs Cargo to compile and include the Rust bindings for the console.log function.
Implementing the Entry Point in Rust
Now, let’s replace the code in src/lib.rs with our new application entry point. This code will contain the logic for accessing the DOM.
// src/lib.rs
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
// This macro allows us to log to the browser console.
// We use a custom name `log` to avoid conflicts with the `log` crate.
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
// This is our new entry point. The `#[wasm_bindgen]` attribute tells wasm-bindgen
// to make this function available to JavaScript.
#[wasm_bindgen]
pub fn run() -> Result<(), JsValue> {
// web_sys::window() returns the global window object. This is your main entry
// point to all browser APIs. It returns an `Option<Window>`, because in some
// environments (like web workers), there is no window. We use `.expect()`
// to panic if it's not there, as our engine can't run without a window.
let window = web_sys::window().expect("no global `window` exists");
// From the window, we can get the document object. The document represents
// the HTML page itself. It also returns an `Option`, so we use `.expect()`.
let document = window.document().expect("should have a document on window");
// We use the document to find our canvas element by its ID.
// `get_element_by_id` returns an `Option<Element>`, so we handle the case
// where an element with that ID isn't found.
let canvas = document
.get_element_by_id("game-canvas")
.expect("document should have a canvas element");
// The element we get from `get_element_by_id` is a generic `web_sys::Element`.
// We know it's a canvas, but the type system doesn't. We need to cast it
// to the specific `HtmlCanvasElement` type. `dyn_into` is provided by the
// `JsCast` trait and performs this conversion. It returns a `Result`,
// which we can `unwrap()` or handle, as the cast could fail if the element
// wasn't actually a canvas.
let canvas: web_sys::HtmlCanvasElement = canvas
.dyn_into::<web_sys::HtmlCanvasElement>()
.map_err(|_| ())
.unwrap();
// If we've reached this point, we have successfully acquired the canvas.
// Let's log a success message to the browser console.
log("Successfully acquired canvas element from Rust!");
Ok(())
}
Code Breakdown:
extern "C"block: We explicitly import the JavaScriptconsole.logfunction.#[wasm_bindgen(js_namespace = console)]tellswasm-bindgenthat this function exists within theconsoleobject in JavaScript.run() -> Result<(), JsValue>: Our new function is namedrun. We’ve changed its return type toResult<(), JsValue>. This is a common pattern for fallible operations inwasm-bindgen, allowing us to propagate JavaScript exceptions up if something goes wrong..expect("..."): We useexpectto handle theOptiontypes returned bywindow()anddocument(). In the context of our game engine, if there’s no window or document, the application cannot run at all, so panicking with a clear error message is an acceptable strategy.get_element_by_id("game-canvas"): This is the Rust equivalent of the JavaScriptdocument.getElementById('game-canvas'). It finds the HTML element with the matching ID.dyn_into::<web_sys::HtmlCanvasElement>(): This is a crucial step. Rust is statically typed, but the DOM is not.get_element_by_idreturns a genericElement. We, as the programmers, know it’s a canvas. Thedyn_intomethod (from theJsCasttrait, which you mustuse) attempts a dynamic cast. It returns aResultwhich will beOk(HtmlCanvasElement)on success or anErrif the element was, for example, a<p>tag. Weunwrap()it here for simplicity, asserting that our HTML is correct.
Updating the JavaScript Loader
Since we’ve changed our Rust entry point from hello_world to run, we must also update our bootstrap.js file to call the new function.
// bootstrap.js
// Import the `run` function instead of `hello_world`.
import init, { run } from './pkg/rust_wasm_game_engine.js';
async function main() {
// Initialize the WASM module.
await init();
// Call the new `run` function to start our application.
run();
}
// Call the main async function.
main();
Build and Verify
You have now modified the Rust source, the JavaScript loader, and your Cargo configuration. It’s time to rebuild and see the results.
- In your terminal, run the build command again:
bash wasm-pack build --target web - Make sure your local Python web server is still running. If you stopped it, restart it with
python3 -m http.server. - Refresh the page at
http://localhost:8000in your browser. - Open the developer console (
F12).
Instead of the old “Hello World” message, you should now see our new success message, logged directly from your Rust code:
Successfully acquired canvas element from Rust!
This confirms that your Rust code has successfully navigated the DOM tree, found the specific canvas element by its ID, and safely cast it to the correct type.
Next Steps
You are now holding a typed reference to the canvas element inside your Rust code. This is the key that unlocks the door to rendering. In the very next task, we will use this HtmlCanvasElement object to get its CanvasRenderingContext2d, which is the object that contains all the actual drawing methods like fillRect, beginPath, and arc.
Further Reading
- MDN: The
windowobject: The global object in a browser context. - MDN: The
documentobject: The entry point to the page’s content. - The
wasm-bindgenBook onJsCast: A deeper look into casting between JavaScript types. web-sysdocumentation forHtmlCanvasElement: Explore all the properties and methods available on a canvas element from Rust.
Get the CanvasRenderingContext2d from the canvas element.
Mục tiêu:
You’ve successfully established a connection between your Rust code and the browser’s DOM, culminating in acquiring a typed web_sys::HtmlCanvasElement. This is a fantastic achievement! You now hold a direct reference to the drawing surface on your web page.
However, the HtmlCanvasElement itself is just a blank container. Think of it as a pristine, empty artist’s canvas. To actually paint on it, you need the tools: the brushes, the paints, and the palette. In the world of the Canvas API, these tools are encapsulated within a special object called a rendering context.
Acquiring the “Paintbrush”: The 2D Rendering Context
For our 2D game engine, the specific toolset we need is the CanvasRenderingContext2d. This object is the heart of 2D drawing in the browser. It exposes the entire suite of methods for rendering shapes, text, and images, such as fillRect(), beginPath(), arc(), and drawImage(). Without this context object, our canvas element remains just an empty rectangle on the page.
Our current task is to ask the canvas element for its 2D rendering context and store it in a variable, typed correctly, so we can use it in the upcoming steps. This process will feel very familiar, as it once again involves method calls that can fail and require us to cast from a generic type to a specific one.
Let’s modify our run function in src/lib.rs to get this context. We’ll pick up right where we left off, immediately after we’ve successfully acquired and cast the canvas element.
Here are the additions to your src/lib.rs file.
// src/lib.rs
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
#[wasm_bindgen]
pub fn run() -> Result<(), JsValue> {
let window = web_sys::window().expect("no global `window` exists");
let document = window.document().expect("should have a document on window");
let canvas = document
.get_element_by_id("game-canvas")
.expect("document should have a canvas element");
let canvas: web_sys::HtmlCanvasElement = canvas
.dyn_into::<web_sys::HtmlCanvasElement>()
.map_err(|_| (()))
.unwrap();
// --- NEW CODE STARTS HERE ---
// To draw on the canvas, we need to get its "2d" rendering context.
// The `get_context` method on the canvas element returns a Result containing
// an Option of a generic JavaScript Object.
let context = canvas
.get_context("2d")
// The first `unwrap` panics if the `get_context` call returns an error (a JsValue).
.unwrap()
// The second `unwrap` panics if the Result's Ok value is None, which would
// mean a 2d context is not supported or already in use.
.unwrap()
// Just like with the canvas element, the context is returned as a generic
// `js_sys::Object`. We must cast it to the specific type we know it is:
// `CanvasRenderingContext2d`.
.dyn_into::<web_sys::CanvasRenderingContext2d>()
.unwrap();
// If we've reached this point, we have the context! Let's log to confirm.
log("Successfully acquired 2D rendering context!");
// --- NEW CODE ENDS HERE ---
Ok(())
}
Deconstructing the Code
Let’s break down the new lines you’ve added. This is a dense but powerful sequence of operations.
canvas.get_context("2d"): This is the core of the operation. We call theget_contextmethod on ourHtmlCanvasElementinstance. The string argument"2d"specifies that we are requesting the 2D rendering context. Other possible values include"webgl"or"webgl2"if we were building a 3D engine.-
The Return Type: The
web-sysbinding for this method is carefully typed to reflect all the ways it can fail in JavaScript. Its full return signature isResult<Option<js_sys::Object>, JsValue>.Result<..., JsValue>: The outerResulthandles JavaScript exceptions. If asking for the context throws an error, this will be anErr(JsValue).Option<...>: The innerOptionhandles the case where no error is thrown, but a context still can’t be provided (e.g., the browser doesn’t support a 2D context, or a conflicting context like WebGL has already been created for this canvas). In this case, it returnsOk(None).js_sys::Object: If successful, we getOk(Some(object)), but the object is a generic JavaScriptObject, not the specific context type.
-
.unwrap().unwrap(): This is a quick way to handle the nestedResultandOption.- The first
.unwrap()operates on theResult. If it’s anErr, our program willpanic!. - The second
.unwrap()operates on theOption. If it’sNone, our program willpanic!. For our engine, not being able to get a 2D context is a fatal error, so panicking is a reasonable approach for now.
- The first
.dyn_into::<web_sys::CanvasRenderingContext2d>(): This should look familiar! We are once again using thedyn_intomethod from theJsCasttrait to perform a dynamic type cast. We are telling the Rust type system, “I know this genericjs_sys::Objectis actually aCanvasRenderingContext2d.” This returns anotherResult, which weunwrap()to get our final, typed context object.
After running this code, the context variable now holds the powerful object we’ll use for all future drawing operations. The new log message will confirm this when you build and run the code.
Next Steps
You now have a handle to both the canvas and its drawing context. These are the foundational pieces of our rendering system. However, just having them as local variables in the run function isn’t enough; we need a way to store them so that our main game loop can access them on every frame.
In the next task, we will create the central Game struct. This struct will act as the primary container for the state of our entire engine, and the very first things we’ll put inside it are the canvas context and other core properties.
Further Reading
To deepen your understanding of the browser APIs we’ve just interacted with, these MDN documents are the definitive source.
HTMLCanvasElement.getContext(): The official documentation for the JavaScript method we just called. It details the different context types and options.CanvasRenderingContext2D: An overview of the 2D context object and a portal to all of its drawing methods. This page will become your best friend when implementing the rendering system.web-sysdocumentation forCanvasRenderingContext2d: The Rust-specific documentation for the type you just cast to. You can explore all the available methods as they are defined in Rust.
Encapsulate Game State in a Rust Game Struct
Mục tiêu: Create a central Game struct in a Rust-based WebAssembly application to hold the game’s state, such as the rendering context and canvas dimensions. This involves defining the struct, implementing an associated new function to act as a constructor, and refactoring the main run function to initialize and use this new state container.
Excellent! You’ve successfully acquired the CanvasRenderingContext2d, which is the essential “paintbrush” for our engine. Right now, both the canvas and context variables exist only within the scope of our run function. Once run finishes, they are discarded. For a game engine that needs to persist state from one frame to the next, this won’t work. We need a permanent, central place to store not just the rendering context, but all the information that defines the current state of our game.
This is where a central Game struct comes into play. In game development, it’s a common and powerful pattern to create a single struct or class that acts as the primary container for all game-related data. This struct will hold the rendering context, the dimensions of the game world, and eventually, the ECS World, player scores, and more. It becomes the single source of truth for our entire engine.
Defining the Game Struct
Let’s create this foundational struct. It will be a simple Rust struct, which is a custom data type that lets you package together and name multiple related values that make up a meaningful group. Our first version will hold the rendering context and the dimensions of our canvas.
We’ll also create an associated function called new. This is a Rust convention for functions that create a new instance of a struct, acting like a constructor. This function will take the necessary components (like the canvas and context), extract the required information (like width and height), and return a fully initialized Game instance.
Let’s update src/lib.rs to include this new Game struct and integrate it into our run function.
// src/lib.rs
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
// --- NEW CODE STARTS HERE ---
// The `Game` struct will hold the state of our application.
// For now, it contains the 2D rendering context and the dimensions of the canvas.
pub struct Game {
// The context is the gateway to all drawing functions on the canvas.
context: web_sys::CanvasRenderingContext2d,
// We store the canvas dimensions to use in our rendering and game logic.
width: u32,
height: u32,
}
// This `impl` block is where we'll define methods and associated functions for our Game struct.
impl Game {
// The `new` function is an associated function that acts as a constructor.
// It takes the canvas element, gets its dimensions, and acquires the 2D context.
pub fn new() -> Self {
// This code is moved from the `run` function.
let window = web_sys::window().expect("no global `window` exists");
let document = window.document().expect("should have a document on window");
let canvas = document
.get_element_by_id("game-canvas")
.expect("document should have a canvas element");
let canvas: web_sys::HtmlCanvasElement = canvas
.dyn_into::<web_sys::HtmlCanvasElement>()
.map_err(|_| (()))
.unwrap();
let context = canvas
.get_context("2d")
.unwrap()
.unwrap()
.dyn_into::<web_sys::CanvasRenderingContext2d>()
.unwrap();
// Return a new instance of the Game struct, initializing its fields.
Self {
context,
width: canvas.width(),
height: canvas.height(),
}
}
}
// --- NEW CODE ENDS HERE ---
#[wasm_bindgen]
pub fn run() -> Result<(), JsValue> {
// --- CODE MODIFIED HERE ---
// Create a new instance of our Game struct.
let game = Game::new();
// Log the dimensions to the console to confirm it worked.
// The `format!` macro is used to create a formatted string.
log(&format!(
"Game initialized with canvas dimensions: {}x{}",
game.width, game.height
));
// --- MODIFICATION ENDS HERE ---
Ok(())
}
Deconstructing the Code
Let’s analyze the new structure we’ve created.
-
pub struct Game { ... }: We declare a new public struct namedGame.context: web_sys::CanvasRenderingContext2d: This field will hold our precious rendering context. When we create an instance ofGame, the context object will be moved into the struct, and theGameinstance will now own it.width: u32,height: u32: We store the canvas dimensions as unsigned 32-bit integers (u32), which is the type returned by thecanvas.width()andcanvas.height()methods. Storing these means we don’t have to constantly query the canvas element for its size.
-
impl Game { ... }: This is an implementation block forGame. All methods and associated functions related toGamewill go in here.pub fn new() -> Self: We define a public associated function callednew.- It takes no arguments because it will find the canvas itself.
-> Self: This is a special return type in Rust that is an alias for the type theimplblock is for—in this case,Game.- We moved all the DOM-querying logic from the
runfunction into here. This is great for organization, as all the setup logic is now contained within theGame’s constructor. Self { ... }: This is the syntax for creating an instance of the struct. We initialize each field with the appropriate value.contextis moved in directly, andwidthandheightare populated by calling the respective methods on theHtmlCanvasElement.
-
run()function changes:- The
runfunction is now much cleaner. Its only job is to instantiate ourGamestate by callingGame::new(). let game = Game::new();: We create a local variablegamewhich holds our application state.log!(&format!(...)): We use theformat!macro to construct a newStringthat includes the width and height from ourgameinstance. This log will serve as our verification that theGamestruct was created correctly and that it successfully extracted the canvas dimensions.
- The
Build and Verify
As always, let’s compile our changes and see the result.
- In your terminal, run the build command:
bash wasm-pack build --target web - Ensure your local web server is running and refresh
http://localhost:8000. - Open the developer console (
F12). You should now see a new message:
Game initialized with canvas dimensions: 800x600
This confirms that our Game struct was successfully created and populated with the correct data from the canvas element.
Next Steps
We’ve successfully encapsulated our engine’s state. However, there’s a lingering problem. The game variable created in run is still a local variable. It is created, we log its properties, and then it is immediately destroyed when run returns. This is a problem because our game loop will need to access and modify this Game instance on every single frame.
The next task is crucial and introduces a key concept for Rust in a JavaScript environment. We will use Rc<RefCell<T>> to create a shared, mutable reference to our Game struct. This smart pointer combination will allow us to safely share and modify our Game state from within the game loop closure, solving the lifetime problem and paving the way for an interactive application.
Further Reading
- The Rust Book: Using Structs to Structure Related Data: The definitive chapter on what structs are and how to use them.
- The Rust Book: Methods and Associated Functions: A clear explanation of
implblocks and the difference between methods (with&self) and associated functions (like ournew). - Rust
format!Macro: Documentation for the powerful string formatting macro we used for logging.
Implement Shared Game State with Rc> in Rust
Mục tiêu: Refactor the Rust-WASM game engine to manage persistent, mutable game state by wrapping the ‘Game’ struct in an Rc>. This solves Rust’s ownership and borrowing challenges, enabling the state to be safely shared and modified across multiple animation frame callbacks.
You’ve done a masterful job of organizing our engine’s state into a central Game struct. This is a critical step towards building a clean and maintainable codebase. However, as you’ve likely noticed, our run function currently creates this Game instance, logs its properties, and then… it ends. As soon as the run function finishes, the game variable goes out of scope, and the Game struct, along with the precious rendering context it holds, is destroyed.
This is Rust’s ownership and lifetime system working exactly as designed, ensuring memory safety. But for our game engine, it presents a challenge. We need the Game state to live on, to persist across multiple frames, and to be accessible and modifiable by our future game loop. This loop will be a Rust function (a “closure”) that we pass to the browser’s requestAnimationFrame API. The browser will then call this closure repeatedly, once for each frame.
This sets up a classic ownership puzzle in Rust. How can we have a long-lived piece of data (Game) that can be safely accessed and modified by a series of short-lived closures? The answer lies in two of Rust’s most powerful “smart pointers”: Rc<T> and RefCell<T>.
The Ownership Conundrum: Sharing Data with Callbacks
In Rust, a value can only have one owner. When we create the closure for the first animation frame, we would need to move ownership of our Game instance into it. But then, when we need to schedule the next animation frame, we would no longer have access to the Game instance because it’s now owned by the first closure. We need a way for multiple “owners” (each frame’s closure) to share access to the same piece of data.
Part 1: Shared Ownership with Rc<T>
This is precisely the problem that Rc<T>, or Reference-Counted pointer, is designed to solve. Rc<T> is a smart pointer that allows a single value to have multiple owners. It works by keeping a count of how many active references (Rc pointers) exist for a given piece of data.
- When you
clone()anRc<T>, you are not deep-copying the underlying data (our potentially largeGamestruct). Instead, you are just creating a new pointer to the same data and incrementing the reference count by one. This is a very cheap operation. - When an
Rc<T>pointer goes out of scope, the reference count is decremented. - When the reference count reaches zero, it means there are no more owners, and the underlying data can be safely deallocated.
This perfectly solves our sharing problem! We can create an Rc<Game>, and then for each animation frame, we can clone() it and move the clone into the new closure.
However, Rc<T> has one major restriction, enforced by the compiler for safety: it only gives you immutable access to the data. You can have many pointers to the Game state, but you cannot change it through any of them. For a game that needs to update character positions, scores, and animations, this is a deal-breaker. We need to modify our state.
Part 2: Runtime Mutability with RefCell<T>
This brings us to the second piece of the puzzle: RefCell<T>. This smart pointer provides interior mutability. It’s a mechanism that moves Rust’s strict compile-time borrowing rules to run-time.
Normally, the Rust compiler ensures you never have a mutable reference (&mut T) at the same time as any other references (mutable or immutable). RefCell<T> wraps your data and allows you to ask for mutable or immutable references at runtime using its borrow_mut() and borrow() methods. The borrowing rules are still enforced, but instead of a compile error, violating them (e.g., asking for two mutable borrows at the same time) will cause your program to panic!.
This is the trade-off: you gain flexibility at the cost of moving safety checks from compile-time to run-time. In the context of a single-threaded WASM application where we control the flow, this is a safe and common pattern.
The Power Duo: Rc<RefCell<Game>>
When we combine these two smart pointers, we create a type that solves our problem perfectly: Rc<RefCell<Game>>.
- The outer
Rcallows us to have multiple, shared owners of our game state, which we can clone and pass into our game loop closures. - The inner
RefCellallows us to mutably borrow theGamedata from within those closures, even though theRcitself only provides shared access.
This combination is the canonical way to handle shared, mutable state in single-threaded Rust applications, and it’s exactly what we need for our engine.
Implementing the Shared State
Let’s modify src/lib.rs to wrap our Game instance in this powerful container.
First, we need to bring Rc and RefCell into scope. Add these use statements at the top of your file.
// src/lib.rs
// Add these two lines at the top
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
// ... rest of the file is the same until the `run` function
Now, let’s update the run function to use our new types.
// In src/lib.rs
// ... Game struct and impl block are unchanged ...
#[wasm_bindgen]
pub fn run() -> Result<(), JsValue> {
// --- CODE MODIFIED HERE ---
// 1. Create a new `Game` instance.
let game_instance = Game::new();
// 2. Wrap the `Game` instance in a `RefCell` to allow for interior mutability.
let game_cell = RefCell::new(game_instance);
// 3. Wrap the `RefCell` in an `Rc` to allow for shared ownership.
// This `game` variable now holds our shared, mutable state.
let game = Rc::new(game_cell);
// To access the data, we first need to borrow it from the RefCell.
// `borrow()` provides an immutable reference. If we wanted to change the
// state, we would use `game.borrow_mut()`.
log(&format!(
"Game state created successfully. Canvas dimensions: {}x{}",
game.borrow().width,
game.borrow().height
));
// --- MODIFICATION ENDS HERE ---
Ok(())
}
We can also write the creation more concisely in a single line:
// A more idiomatic way to write the same thing:
let game = Rc::new(RefCell::new(Game::new()));
This single line of code creates our Game state, wraps it to be mutable, and then wraps it again to be shared. The game variable is now a reference-counted smart pointer to a cell containing our game state.
When you build and run this code (wasm-pack build --target web), you will see the same log message as before, but the underlying mechanics are now profoundly different. You have successfully created a state object that is ready to be shared and modified by the game loop we are about to create.
Next Steps
We’ve successfully encapsulated our game’s state in a structure that solves Rust’s complex ownership rules for our callback-driven environment. We are now perfectly poised to create the beating heart of our engine: the main game loop. In the very next task, we will create a Rust closure that will be executed on every animation frame, and we will pass our newly created Rc<RefCell<Game>> into it, finally giving our engine life.
Further Reading
Understanding Rc<T> and RefCell<T> is fundamental to writing idiomatic Rust for many scenarios. These chapters from “The Rust Programming Language” book are the definitive guides.
Rc<T>, the Reference Counted Smart Pointer: Explains shared ownership in detail.RefCell<T>and the Interior Mutability Pattern: A deep dive into runtime borrowing and when to use it.- Standard Library Documentation:
Create the main loop function that will be called on every frame.
Mục tiêu:
You’ve brilliantly set up our engine’s core state management by encapsulating the Game struct within an Rc<RefCell<T>>. This was a deeply important step, tackling Rust’s strict ownership and borrowing rules head-on to prepare for the callback-driven nature of a web environment. Our Game state is now shareable and mutable, ready to be acted upon.
The question now is, who acts upon it, and when? The answer is the game loop, the perpetual, beating heart of every game ever made.
The Anatomy of a Game Loop
A game loop is a simple concept: it’s a block of code that runs repeatedly, once for every single frame displayed on the screen. In each iteration, it performs three essential tasks:
- Process Input: Check for player actions (key presses, mouse clicks).
- Update State: Move characters, check for collisions, update scores.
- Render: Draw the new state of the game world to the screen.
In traditional game development, you might write this as a while (game_is_running) loop. However, in the browser, this approach would freeze the entire web page. Instead, we must use the browser’s own optimized scheduling mechanism: window.requestAnimationFrame. This function asks the browser to call a specific function of our choosing right before the next repaint. To create a continuous loop, that function will, as its last action, request itself to be called again for the next frame.
Our task now is to create the Rust function that will serve as this per-frame callback. This function will contain all the logic for a single “tick” of our engine.
Creating the tick Method and the Loop Closure
First, let’s create a dedicated method on our Game struct that will contain the logic for a single frame. This is excellent for organization. We’ll call it tick.
Second, we’ll create the actual loop function inside run. This will be a Rust closure. A closure is a flexible, anonymous function that can “capture” (or enclose) variables from its surrounding environment. This is perfect for our needs, as our loop function must capture the shared Rc<RefCell<Game>> state.
Let’s modify src/lib.rs to implement this structure.
// src/lib.rs
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
pub struct Game {
context: web_sys::CanvasRenderingContext2d,
width: u32,
height: u32,
}
impl Game {
pub fn new() -> Self {
// ... (this function is unchanged)
let window = web_sys::window().expect("no global `window` exists");
let document = window.document().expect("should have a document on window");
let canvas = document
.get_element_by_id("game-canvas")
.expect("document should have a canvas element");
let canvas: web_sys::HtmlCanvasElement = canvas
.dyn_into::<web_sys::HtmlCanvasElement>()
.map_err(|_| (()))
.unwrap();
let context = canvas
.get_context("2d")
.unwrap()
.unwrap()
.dyn_into::<web_sys::CanvasRenderingContext2d>()
.unwrap();
Self {
context,
width: canvas.width(),
height: canvas.height(),
}
}
// --- NEW METHOD ---
// This method represents a single "tick" or frame of our game.
// It takes a mutable reference to self because it will modify the game state.
pub fn tick(&mut self) {
// This is where our per-frame logic will live.
// For now, we'll leave it empty. In the upcoming tasks, we will add
// logic here to clear the screen, run ECS systems, and render entities.
}
}
#[wasm_bindgen]
pub fn run() -> Result<(), JsValue> {
// Create the shared, mutable game state.
let game = Rc::new(RefCell::new(Game::new()));
log(&format!(
"Game state created successfully. Canvas dimensions: {}x{}",
game.borrow().width,
game.borrow().height
));
// --- NEW CODE: DEFINE THE MAIN LOOP FUNCTION ---
// Create a clone of our game's Rc pointer. This clone will be "owned"
// by our game loop closure. `Rc::clone` is cheap; it only increments
// a reference counter, it doesn't copy the `Game` data.
let game_clone = Rc::clone(&game);
// This is our main loop function! It's a Rust closure.
// A closure is an anonymous function that can "capture" variables
// from its environment. Here, we use the `move` keyword to force the
// closure to take ownership of the variables it uses—in this case, `game_clone`.
let main_loop = move || {
// On each frame, we get a mutable borrow of our game state...
// `borrow_mut()` gives us temporary, exclusive, mutable access to the Game struct.
// This is where the power of `RefCell` comes into play.
game_clone.borrow_mut().tick();
};
// For now, we've only defined the `main_loop` closure. It isn't being called yet.
// The next step will be to pass this closure to the browser's animation frame scheduler.
log("Main loop function has been defined, but not yet started.");
Ok(())
}
Deconstructing the New Code
Let’s carefully analyze the important new pieces.
-
pub fn tick(&mut self): We’ve added a new method to ourGamestruct.- It takes
&mut selfas its parameter. This signifies that thetickmethod needs mutable access to theGameinstance it’s called on. This is essential, as updating the game state (like moving a character) is a mutating operation. - For now, its body is empty, but this function is now the designated home for all our future per-frame logic.
- It takes
let game_clone = Rc::clone(&game);: Before creating the closure, we clone theRc. This is a crucial part of the ownership pattern. Thegamevariable will be dropped at the end of therunfunction, but thegame_clonewill be moved into the closure, keeping theGamedata alive. Because it’s anRc, the reference count will be 2 for a moment, and then drop to 1 whenrunfinishes, preventing the data from being deallocated.-
let main_loop = move || { ... };: This is the definition of our game loop function as a closure.move: Themovekeyword before the parameter list||tells the closure to take ownership of any captured variables. In this case, it takes ownership ofgame_clone. This is necessary because the closure will outlive the current function (run).game_clone.borrow_mut().tick();: This is the heart of the loop. On every frame, it will:- Call
borrow_mut()on theRefCellto get temporary, exclusive, mutable access to theGamedata. This is the runtime borrow check we discussed previously. - Call the
tick()method on the borrowedGameinstance, executing our frame logic.
- Call
You have now successfully defined the function that will drive your entire engine. It’s fully wired up to access and mutate the shared game state. It’s currently sitting idle, waiting to be called.
Next Steps
We’ve built the engine, but we haven’t turned the key. Our main_loop closure exists only in the Rust/WASM world. To bring it to life, we need to hand it over to the browser’s JavaScript environment. The next task is to bridge this final gap: we will use wasm_bindgen::closure::Closure to wrap our Rust closure, turning it into a JavaScript object that can be passed to window.request_animation_frame to officially kick off the game loop.
Further Reading
- Closures: Anonymous Functions that Can Capture Their Environment: The official chapter from “The Rust Programming Language” book. A must-read for understanding this powerful feature.
- MDN Web Docs:
window.requestAnimationFrame(): The definitive guide to the browser API we will be using next. Understanding how it works is key to writing smooth, efficient web animations. - Game Programming Patterns: Game Loop: A fantastic, in-depth article about the theory and variations of the game loop pattern.
Wrap a Rust Closure with wasm-bindgen::Closure
Mục tiêu: Use the wasm\_bindgen::closure::Closure wrapper to make a Rust closure callable from JavaScript. This task involves heap-allocating the closure with Box::new and wrapping it to bridge the gap between Rust’s and JavaScript’s function types, preparing it for use with browser APIs like requestAnimationFrame.
You’ve done an excellent job creating the main_loop closure. This anonymous Rust function now encapsulates the logic for a single frame of our game, and it correctly captures the shared Rc<RefCell<Game>> state. However, we have a significant hurdle to overcome: this closure exists entirely within the Rust and WebAssembly world. The browser’s JavaScript environment, which has the requestAnimationFrame function we need to call, has no idea what a “Rust closure” is. They are fundamentally incompatible types from two different language ecosystems.
We need a bridge. A special adapter that can take our Rust closure and present it to the JavaScript world as a standard, callable JavaScript Function object. This is precisely the role of a powerful utility provided by wasm-bindgen: the Closure wrapper.
The Magic Wrapper: wasm_bindgen::closure::Closure
Think of wasm_bindgen::closure::Closure<T> as a translator. It’s a Rust struct that wraps one of your Rust closures (one that satisfies the FnMut trait) and generates all the necessary boilerplate to expose it as a first-class JavaScript function. When JavaScript calls this generated function, the Closure wrapper handles the transition across the WASM boundary, invoking your original Rust code.
This is the final, critical link that allows us to pass Rust logic as a callback to browser APIs like requestAnimationFrame, addEventListener, or setTimeout.
Wrapping Our main_loop
Let’s put this into practice. We will now take the main_loop closure we defined in the previous task and wrap it in Closure, creating a handle that we can later pass to the browser.
First, we need to bring the Closure type into scope. Add the following use statement at the top of your src/lib.rs file.
// In src/lib.rs
use wasm_bindgen::closure::Closure;
Now, let’s modify the end of our run function to perform the wrapping.
// In src/lib.rs, inside the run() function
// ... (previous code defining `main_loop` is unchanged) ...
let main_loop = move || {
game_clone.borrow_mut().tick();
};
// --- NEW CODE STARTS HERE ---
// 1. Wrap the closure.
// `Closure::wrap` takes a `Box<dyn FnMut()>` as input.
// `Box::new()` allocates our closure on the heap, which is a requirement
// for creating a "trait object" like `dyn FnMut()` that `Closure` needs.
// The `'static` lifetime here means the closure lives for the entire
// duration of the program, which is true for our main game loop.
let callback = Closure::<dyn FnMut()>::wrap(Box::new(main_loop));
// The `callback` variable now holds our wrapped closure. It's a Rust object
// that internally manages a reference to a callable JavaScript function.
log("Game loop closure has been successfully wrapped for JavaScript.");
// --- A CRITICAL NOTE ON MEMORY MANAGEMENT ---
// At this point, `callback` is a local variable within the `run` function.
// When `run` finishes, `callback` would normally be dropped. When a `Closure`
// is dropped, it cleans up and invalidates the corresponding JavaScript
// function. If JavaScript tried to call it later, it would throw an error.
//
// In the next step, when we pass this callback to `requestAnimationFrame`,
// we will need a way to tell Rust *not* to deallocate it. We will use
// the `forget()` method for this, intentionally leaking the memory so that
// our callback lives forever.
Ok(())
}
Code Breakdown:
-
Closure::<dyn FnMut()>::wrap(...): This is the key function call.- We are explicitly telling the compiler the type of closure we’re wrapping:
dyn FnMut(). This is a trait object that represents any closure that can be called multiple times, can mutate its captured environment (ourRc), and takes no arguments. This perfectly describes ourmain_loop. - The
<'static>lifetime annotation is often required here, signifying that the closure and its captured data must be valid for the entire program’s lifetime. Since our game loop is meant to run forever, this is a correct and necessary constraint.
- We are explicitly telling the compiler the type of closure we’re wrapping:
-
Box::new(main_loop): This is an important piece of Rust mechanics. To create a trait object likedyn FnMut(), Rust needs to know the size of the object at compile time. Since our closure’s size is determined by the variables it captures and is unknown to the compiler in a generic context, we must place it behind a pointer.Box::new()allocates our closure on the heap (a region of memory for dynamically-sized data) and gives us aBox<T>smart pointer to it. This indirection gives us a value with a known size (the size of a pointer) that can be converted into a trait object.
You have now successfully created a JavaScript-compatible function from your Rust code. The callback variable holds a handle to this bridge. The stage is set to finally start our engine.
Next Steps
Our callback is ready, but it’s still just sitting there. We’ve built the engine and connected the ignition, but we haven’t turned the key yet. In the very next task, we will take our callback, pass it to the browser’s window.request_animation_frame function, and handle the crucial memory management step to ensure our loop lives forever. This is when you’ll see your engine truly come to life.
Further Reading
To understand the powerful concepts we’ve just used, these resources are highly recommended.
wasm_bindgen::closure::ClosureDocumentation: The official API documentation for the wrapper. It’s a must-read for working with callbacks.- The Rust Book: Storing Values on the Heap with
Box<T>: An essential chapter for understanding heap allocation and smart pointers. - The Rust Book: Trait Objects for Values of Different Types: A deep dive into the
dyn Traitsyntax and how it enables dynamic dispatch.
Start the Game Loop with requestAnimationFrame
Mục tiêu: Implement a persistent, recursive game loop in Rust for WebAssembly by scheduling a closure with the browser’s window.requestAnimationFrame API. This involves using the Rc>> pattern to allow the closure to re-schedule itself for the next frame.
The moment has come to ignite the engine. In our previous step, we meticulously crafted our main_loop closure and wrapped it in wasm_bindgen::closure::Closure. This created callback, a Rust object that holds a direct, callable handle to a JavaScript function. Right now, this powerful function is dormant, waiting for its cue. Our task is to give it that cue by passing it to the browser’s native animation scheduler, officially starting the perpetual cycle that is the game loop.
The Browser’s Pacemaker: window.requestAnimationFrame
In web development, the gold standard for running animations, games, or any code that needs to execute on a per-frame basis is the window.requestAnimationFrame() method. You might be tempted to use something like setInterval, but requestAnimationFrame is vastly superior for several key reasons:
- Synchronization: The browser guarantees that it will run your callback function right before it performs its next screen repaint. This synchronization eliminates visual artifacts like screen tearing and results in much smoother animations.
- Efficiency: It’s incredibly smart. If the browser tab is not active or the animation is off-screen, the browser can significantly throttle or even pause the callbacks, saving precious CPU cycles and battery life on mobile devices. A
setIntervalloop would just keep churning away wastefully in the background. - Optimal Timing: It provides the callback with a high-precision timestamp, which is crucial for implementing frame-rate independent physics and movement later on.
There is one crucial behavior to understand: requestAnimationFrame is a one-shot function. It schedules your callback to run once. To create a continuous loop, the callback function itself must, as one of its last actions, call requestAnimationFrame again to schedule its own next execution. This creates a recursive scheduling pattern.
The Recursive Loop Challenge and Solution
This “callback-schedules-itself” pattern presents a classic chicken-and-egg problem in Rust. Our closure needs a reference to itself to pass to the next requestAnimationFrame call. But how can you get a reference to something that is still in the process of being created?
The solution is an elegant but advanced Rust pattern that uses our familiar friends, Rc and RefCell, in a new combination: Rc<RefCell<Option<Closure>>>. It sounds complex, but let’s break it down logically. We’ll essentially create a shared, empty box, define a closure that knows how to find that box, place the closure into the box, and then kick off the first frame.
Let’s modify our run function in src/lib.rs to implement this robust, repeating game loop.
// src/lib.rs
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
// ... (log function and Game struct/impl are unchanged) ...
#[wasm_bindgen]
pub fn run() -> Result<(), JsValue> {
let game = Rc::new(RefCell::new(Game::new()));
log(&format!(
"Game state created successfully. Canvas dimensions: {}x{}\",
game.borrow().width,
game.borrow().height
));
// --- NEW RECURSIVE LOOP CODE ---
// Here, we create a reference-counted pointer to a RefCell containing an Option.
// This looks intimidating, but it's a powerful pattern for self-referential closures.
// `Rc` allows shared ownership of the closure.
// `RefCell` allows mutable access to the Option inside.
// `Option` allows us to create the `Rc` first with `None` and then set the closure later.
let f = Rc::new(RefCell::new(None));
let g = f.clone(); // Create a second handle to the same Rc.
let mut i = 0; // A simple counter to see the loop running in the console.
// Define the main loop closure.
// `*g.borrow_mut() = Some(...)` will move the closure inside the Option in our Rc.
// We use `g` to define the closure and `f` to kick it off.
*g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
// Log a message every 60 frames to show it's running.
if i % 60 == 0 {
log(&format!("Game loop tick: {}", i));
}
i += 1;
// --- Our actual game logic will go here in the future ---
// For example: game.borrow_mut().tick();
// Schedule the next frame.
// This is the recursive part. The closure is calling a function that will
// eventually execute the closure again.
let _ = f.borrow().as_ref().unwrap(); // We need a handle to the closure to request the next frame.
// This is a placeholder for the actual request_animation_frame call
// which we will add in the next step. For now, we're just setting up the structure.
}) as Box<dyn FnMut()>));
// We need to request the first frame to start the loop.
// We'll implement the actual call to the browser API in the next step.
log("Recursive loop structure is set up. Next, we'll start it.");
Ok(())
}
Whoops, the task is to call requestAnimationFrame. Let’s correct the implementation to be complete for this task. I’ll add the web-sys feature and the actual call.
First, ensure the 'Window' feature is enabled in your web-sys dependencies in Cargo.toml. It should already be there from a previous task.
Now, let’s write the final version of the run function in src/lib.rs.
// src/lib.rs
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
pub struct Game {
context: web_sys::CanvasRenderingContext2d,
width: u32,
height: u32,
}
impl Game {
pub fn new() -> Self {
let window = web_sys::window().expect("no global `window` exists");
let document = window.document().expect("should have a document on window");
let canvas = document
.get_element_by_id("game-canvas")
.expect("document should have a canvas element");
let canvas: web_sys::HtmlCanvasElement = canvas
.dyn_into::<web_sys::HtmlCanvasElement>()
.map_err(|_| (()))
.unwrap();
let context = canvas
.get_context("2d")
.unwrap()
.unwrap()
.dyn_into::<web_sys::CanvasRenderingContext2d>()
.unwrap();
Self {
context,
width: canvas.width(),
height: canvas.height(),
}
}
pub fn tick(&mut self) {
// The per-frame logic will go here.
}
}
#[wasm_bindgen]
pub fn run() -> Result<(), JsValue> {
let game = Rc::new(RefCell::new(Game::new()));
log(&format!("Game state created successfully. Canvas dimensions: {}x{}",
game.borrow().width,
game.borrow().height
));
// --- RECURSIVE LOOP SETUP ---
// Create a shared pointer for the closure itself.
let f: Rc<RefCell<Option<Closure<dyn FnMut()>>>> = Rc::new(RefCell::new(None));
let g = Rc::clone(&f); // Create a clone to be used inside the closure.
{ // Create a new scope to manage lifetimes.
// Clone the game state Rc to move it into the closure.
let game_clone = Rc::clone(&game);
// Define the closure that will be our game loop.
*g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
// Call the main tick function to update game state.
game_clone.borrow_mut().tick();
// This is the recursive part: schedule the same closure to be run again.
let window = web_sys::window().expect("no global `window` exists");
window
.request_animation_frame(
f.borrow().as_ref().unwrap().as_ref().unchecked_ref(),
)
.expect("should register `requestAnimationFrame` OK");
}) as Box<dyn FnMut()>));
}
// --- START THE LOOP ---
// Kick off the first frame request.
let window = web_sys::window().expect("no global `window` exists");
window.request_animation_frame(
g.borrow().as_ref().unwrap().as_ref().unchecked_ref(),
)?;
log("Game loop has been started!");
Ok(())
}
Deconstructing the Code
let f = Rc::new(RefCell::new(None));: We create our shared “box”. It’s anRcpointing to aRefCellwhich containsNone.fwill be our handle to this box.let g = Rc::clone(&f);: We clone the handle. Themoveclosure below will capturegame_cloneand thisghandle. The originalfhandle will be used to kick off the first frame.*g.borrow_mut() = Some(...): This is the magic. We mutably borrow the contents of the box (which isNone) and assignSome(our_closure)to it. Now the box contains the closure.- Inside the closure:
game_clone.borrow_mut().tick();: We call our main game logic function.window.request_animation_frame(...): The closure uses its captured handlefto get a reference to itself out of the box and schedules the next frame.
- The final
request_animation_framecall: After the closure has been created and placed in the box, we use our original handlegto kick off the very first frame. as_ref().unwrap().as_ref().unchecked_ref(): This chain of calls looks complex but is a standardwasm-bindgenidiom. It safely navigates through theRefCell,Option, andClosurewrappers to get a raw reference to the underlying JavaScript function that the browser API expects.- No
forget()?: Notice we are no longer calling.forget(). Why? Because we have created a reference cycle. TheClosureis owned by theRc. The closure itself holds a clone of that sameRc. This means the reference count can never drop to zero, so Rust will never attempt to deallocate the memory. This is a “memory leak” by design, which is exactly what we want for a loop that must live for the entire duration of the application.
Next Steps
The engine is running! It’s now silently calling our empty tick method around 60 times every second. You won’t see anything yet, but the heart of our engine is finally beating.
This sets the stage perfectly for our next and final task in this step: to add the first piece of rendering logic inside the tick function. We will implement a “clear screen” function to wipe the canvas with a solid color on each frame. This will give us our first visual confirmation that the game loop is alive and well.
Further Reading
- MDN Web Docs:
window.requestAnimationFrame(): The definitive guide to the browser API we are now using. - The
wasm-bindgenGame of Life Tutorial: This official tutorial walks through the sameRc<RefCell<Option<Closure>>>pattern for creating a persistent game loop. Seeing it in another context can be very helpful. - Common Rust
RcPatterns: A blog post discussing various ways to useRc, including the cycle pattern for callbacks.
Clearing the Screen in a Rust-WASM Game Loop
Mục tiêu: Implement the first rendering step in a Rust and WebAssembly game loop by modifying the tick method to clear the HTML canvas with a solid color on each frame. This serves as the first visual confirmation that the core game engine is running correctly.
The engine is alive! You have successfully wired up the most complex part of the core engine: a persistent, recursive game loop. Right now, your Rust code is being executed by the browser about sixty times every second. The tick method inside your Game struct is being called faithfully on each frame, but because its body is empty, the result is a blank canvas. It’s a silent, invisible heartbeat.
It’s time to make that heartbeat visible. Our final task in this step is to perform the most fundamental of all rendering operations: clearing the screen. In a game, each frame must start with a clean slate before drawing the new scene. If we didn’t clear the screen, every object we drew would remain there permanently, smearing across the canvas as it moved. By filling the entire canvas with a solid color at the beginning of each frame, we create the illusion of animation.
This will be our first visual confirmation that the entire pipeline—from the Rust tick function through the Closure wrapper to the browser’s requestAnimationFrame—is working perfectly.
Adding Logic to the tick
The logic for clearing the screen belongs right inside the Game::tick method. We have everything we need already stored in our Game struct: the context to issue drawing commands and the width and height of the canvas to know how large of a rectangle to draw.
We will use two key methods on the CanvasRenderingContext2d:
set_fill_style(): This sets the color, gradient, or pattern to be used for fill operations.fill_rect(x, y, width, height): This draws a rectangle filled with the currentfill_style.
Let’s modify the tick method in src/lib.rs to implement this.
// In src/lib.rs
// ... (use statements and log function are unchanged) ...
pub struct Game {
context: web_sys::CanvasRenderingContext2d,
width: u32,
height: u32,
}
impl Game {
pub fn new() -> Self {
// ... (this function is unchanged) ...
let window = web_sys::window().expect("no global `window` exists");
let document = window.document().expect("should have a document on window");
let canvas = document
.get_element_by_id("game-canvas")
.expect("document should have a canvas element");
let canvas: web_sys::HtmlCanvasElement = canvas
.dyn_into::<web_sys::HtmlCanvasElement>()
.map_err(|_| (()))
.unwrap();
let context = canvas
.get_context("2d")
.unwrap()
.unwrap()
.dyn_into::<web_sys::CanvasRenderingContext2d>()
.unwrap();
Self {
context,
width: canvas.width(),
height: canvas.height(),
}
}
// --- METHOD MODIFIED HERE ---
pub fn tick(&mut self) {
// Set the fill style to a dark grey color. The Canvas API expects a `JsValue`.
// We use `JsValue::from_str` to convert a Rust string slice into a type
// that JavaScript understands.
self.context.set_fill_style(&JsValue::from_str("#222222"));
// Draw a filled rectangle that covers the entire canvas.
// `fill_rect` takes x, y, width, and height as f64 values.
// We must cast our u32 width and height to f64 to match the API.
self.context.fill_rect(
0.0,
0.0,
self.width as f64,
self.height as f64,
);
}
}
// ... (The run function is unchanged) ...
#[wasm_bindgen]
pub fn run() -> Result<(), JsValue> {
// ...
}
Deconstructing the Code
Let’s break down the two lines you’ve added to tick(), as they are packed with important details about Rust-WASM interoperability.
-
self.context.set_fill_style(&JsValue::from_str("#222222"));- We access the
contextthat is owned by ourGamestruct. - The
set_fill_stylemethod inweb-sysexpects a reference to aJsValue. AJsValueis a special type fromwasm-bindgenthat represents any possible JavaScript value (a string, a number, an object, etc.). This is necessary because in JavaScript, the fill style could be a color string ('#222'), but it could also be aCanvasGradientorCanvasPatternobject. - To create a
JsValuethat represents the string"#222222", we use the constructorJsValue::from_str(). This performs the correct conversion from a Rust string to a JavaScript string behind the scenes.
- We access the
-
self.context.fill_rect(0.0, 0.0, self.width as f64, self.height as f64);- We call
fill_rect, which draws our filled rectangle. - The first two arguments,
0.0, 0.0, specify the x and y coordinates of the top-left corner of the rectangle. In the canvas coordinate system,(0, 0)is the top-left corner. - The next two arguments specify the width and height of the rectangle. We want to cover the whole canvas, so we use
self.widthandself.height. as f64: This is a crucial type cast. Theweb-sysbindings for canvas methods are strongly typed and expectf64(a 64-bit floating-point number, equivalent to JavaScript’sNumbertype) for coordinates and dimensions. Ourwidthandheightfields are stored asu32(unsigned 32-bit integers). Rust is strict and does not perform automatic numeric conversions, so we must explicitly cast these values tof64using theaskeyword.
- We call
Build and Verify
You’ve added the code. Now let’s see the fruit of your labor.
- In your terminal, rebuild the project:
bash wasm-pack build --target web - Make sure your local web server is running (
python3 -m http.server). - Refresh the page at
http://localhost:8000.
Behold! The white canvas is gone, replaced by a solid, dark grey rectangle. You are now seeing the direct visual output of your game loop. Every 1/60th of a second, your Rust code is commanding the browser to paint this rectangle. This is a monumental step forward.
Next Steps
You have successfully built the absolute core of a game engine: a persistent state object and a running game loop capable of rendering to the screen. The foundation is complete and incredibly solid.
Now that we can draw, we need something to draw. The next major step in our journey, Step 3: Designing the Entity-Component-System (ECS) Architecture, will introduce a powerful and modern architectural pattern for organizing game objects. We will move away from a simple, monolithic Game struct and into a flexible system where game objects (called entities) are defined by the data they contain (their components). This will be the framework upon which we build all of our game logic.
Further Reading
To learn more about the Canvas API methods and the wasm-bindgen types you’ve just used, explore these resources:
- MDN Web Docs:
CanvasRenderingContext2D.fillStyle: The definitive documentation for the property we set. - MDN Web Docs:
CanvasRenderingContext2D.fillRect(): The documentation for the drawing method we called. - The
wasm-bindgenBook:JsValue: A guide to the universal type for interacting with JavaScript values. - The Rust Book: Casting: A section on primitive type casting with the
askeyword.