22

Tutorial: Writing a Tiny Rust Game Engine for Web

 3 months ago
source link: https://ianjk.com/game-engine-in-rust/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is damaged, please click the button below to view the snapshot at that time.

Tutorial: Writing a Tiny Rust Game Engine for Web

January 12, 2022

In this tutorial we'll use the Rust programming language to code a tiny game engine. Our game engine will respond to key presses, draw rectangles, and define a structure that could accomodate a larger engine.

Our engine will use no code other than Rust's standard library and the APIs the browser provides us. It will compile near-instantly (less than a second on my computer) and be about 130 lines of code total.

If you aren't familiar with Rust it's a relatively new programming language that runs fast and helps you write better code.

This tutorial includes some Javascript and web code, but the general ideas apply to non-web as well.

To follow along you should already know the basics of programming. This tutorial is written to be legible for a Rust beginner and skimmable for a Rust expert.

Let's begin!


If ever you get lost or just want to skip ahead the final code is here: https://github.com/kettle11/sprinkles

This tutorial has lots of steps but they're individually simple. If ever you get stuck consult the GitHub repository to figure out what's different.

Setup

First off install Rust if you don't already have it installed. https://www.rust-lang.org/learn/get-started.

Next pick a fun name for your engine. I'm calling my engine "sprinkles" because it's the first thing that popped into my head. It might be easiest if you call yours sprinkles too, but if you don't just replace the word sprinkles everywhere with your engine's name.

Create a folder with your engine name, open that folder with a command line, and run cargo init --lib to initialize a Rust library project. We're creating a library (or a crate in Rust terminology) so we can use our game engine easily with multiple projects.

Let's also create an examples folder for our library. As we develop our engine we'll repeatedly run this example game as a test.

In your game engine folder create a folder named examples and add an example file named game.rs. In game.rs let's write the classic "Hello World" code:

fn main() {
    println!("Hello world!");
}

From your game engine folder run cargo run --example game and you should see "Hello world!" printed to the command line.

Our engine code will be in the src/lib.rs and example game that uses our engine will be in examples/game.rs. Delete any example code Rust added to src/lib.rs to start with a clean slate.

Running in the browser

To make life easier our engine will run in the web browser. We're only going to use a few of the browser's features and the features we'll use are ones that exist on non-web platforms as well. So if you wanted to later port our engine to Windows, Mac, or Linux you could.

Let's create a web-page called index.html in our engine folder. Copy-paste the following code into index.html:

<html>
    <body style="margin:0; overflow-y: hidden;overflow-x: hidden;">
    <canvas id="my_canvas" style="width:100vw; height: 100vh;"></canvas>
    <script></script>
    </body>
</html>

What we've pasted there is a fullscreen canvas (which we'll use to draw stuff to) and <script> tags we're we will soon put Javascript code into that connects to our Rust code.


We also need a way to view our website on our computer. For this we'll use a tool called devserver.

Install devserver by running cargo install devserver.

Open a new command line terminal and run the command devserver within your engine folder.

Then open a browser and go to localhost:8080 to view your website.

The reason to open a new command line window is because we can leave devserver running while we work on the engine instead of stopping and starting it repeatedly to run other commands.

To run Rust code in the browser we will compile Rust to WebAssembly. WebAssembly is a simple assembly language that browsers have adopted as a way to safely and quickly run non-Javascript programming languages in the browser. WebAssembly can call Javascript to access the browser's APIs.

Fortunately compiling Rust to WebAssembly is easy peasy.

First install the WebAssembly target with rustup target add wasm32-unknown-unknown.

Next you can build our example for WebAssembly with:

cargo build --example game --target wasm32-unknown-unknown

Rust will compile and place a WebAssembly file in target/wasm32-unknown-unknown/debug/examples/game.wasm.

If you're feeling ultra-lazy you can use cargo watch to rebuild your project whenever you edit a source file.

First cargo install cargo-watch.

Then run:

cargo watch -x 'build --example game --target wasm32-unknown-unknown'

Now your example game will automatically rebuild whenever you edit the engine or game files!


Next we need to add some Javascript between our <script> tags to load and run our WebAssembly file:

<script>
    let imports = {};
    WebAssembly.instantiateStreaming(fetch('target/wasm32-unknown-unknown/debug/examples/game.wasm'), imports).then(function (result) {
        result.instance.exports.main();
    })
</script>

This Javascipt code loads our WebAssembly file and then calls our WebAssembly's main function.

If you look at our webpage now you'll see nothing has changed. Where does our "Hello World!" message go? Ideally we'd like the messsage to go the browser's console. So let's open up your browser's Javascript console and see if "Hello World!" is there.

Is it?

WebAssembly is very minimalist and Rust can't immediately communicate with Javascript to log messages.

To verify that our WebAssembly is actually running let's do the one thing we can do: crash.

In examples/game.rs update the main function to look like this:

fn main() {
    println!("Hello world!");
    panic!();
}

That panic!() will immediately crash our WebAssembly instance.

Rebuild the example and look at the browser console. You should see something scary like game.wasm:0x4760 Uncaught (in promise) RuntimeError: unreachable.

Yay! We crashed! So our Rust code works!


From here on out we'll be editing these three files:

examples/game.rs for our example game.

src/lib.rs for our engine code.

index.html for our Javascript glue code.

Now let's make something more interesting.

Talking to Javascript

Rust and Javascript need a way to talk.

Let's start by sending Javascript a number to log.

Something I should call out: Most Rust projects should consider the wasm-bindgen project for Rust <=> Javascript communication. In this tutorial we will skip using wasm-bindgen in exchange for learning how things work, instant build times, and 0-dependencies.

Ok! Let's edit the <script> to look like this:

 <script>
        let imports = {
            env: {
                log_number: function (number) { console.log("Number from Rust: ", number); }
            }
        };
        WebAssembly.instantiateStreaming(fetch('target/wasm32-unknown-unknown/debug/examples/game.wasm'), imports).then(function (result) {
            result.instance.exports.main();
        })
</script>

What we're doing here is adding a function that Javascript makes available to Rust via the imports object.

Edit examples/game.rs to look like this:

extern "C" {
    pub fn log_number(number: usize);
}

fn main() {
    unsafe {
        log_number(4);
    }
}

Check the browser and you should see the message Number from Rust: 4.

A few things are going on here.

extern "C" {
    pub fn log_number(number: usize);
}

This code declares to Rust there will be a function defined outside of our code with the name "log_number". The name of the function corresponds with the name of the Javascript function on the Javascript side. usize is Rust's default type for whole numbers.

In the main function we must surround the use of log_number with unsafe {} because Rust rightfully makes us acknowledge that we're doing something unsafe. Rust is very cautious that way and if you stick to Rust without unsafe you'll create fewer weird bugs.

Sadly communicating with Javascript is inherently unsafe. We'll just be careful.

Coloring the Screen

Let's do something more interesting. Let's add our engine's first feature and make the screen a different color!

First let's just make the screen red on the Javascript side. After we will figure out how to connect it to Rust.

To render to our canvas we'll use WebGL. WebGL is basically OpenGL and both WebGL and OpenGL can feel a bit antiquated and finicky if you aren't familar with them.

We're using WebGL instead of simpler web-specific APIs because our WebGL code is similar to OpenGL code we could write on non-web platforms. This tutorial won't focus too heavily on Open/WebGL as there are already great tutorials that teach them.

Add the following code above the imports in your index.html file:

<script>
let canvas = document.getElementById("my_canvas");
let gl = canvas.getContext("webgl");
gl.canvas.width = canvas.clientWidth;
gl.canvas.height = canvas.clientHeight;
gl.viewport(0, 0, canvas.clientWidth, canvas.clientHeight);

gl.clearColor(1.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
//...

What we're doing here is getting the canvas and taking the gl handle that lets us use WebGL functions for drawing to the canvas.

We immediately resize the canvas to have the same number of pixels as the space it takes up within the HTML. The gl.viewport tells WebGL to render to the entire canvas.

gl.clearColor sets the color to fill the screen. Each number in the arguments represents the amount of red, green, blue, and alpha (also known as transparency) respectively. 1.0 is 100%.

gl.clear actually clears the screen.

gl.COLOR_BUFFER_BIT says that we want to clear only the screen's color. The screen can also store other properties we might want to clear, but that's not relevant here.

If you visit localhost:8080 you'll now see a pure red page.


Let's add the ability to customize the color from our Rust code.

First update our Javascript imports and move our clear code within it:

<script>
let canvas = document.getElementById("my_canvas");
let gl = canvas.getContext("webgl");
let imports = {
    env: {
        js_clear_screen_to_color: function (red, green, blue, alpha) {
            gl.clearColor(red, green, blue, alpha);
            gl.clear(gl.COLOR_BUFFER_BIT);
        }
    }
};
// ...

Now on the Rust side let's add the imports to our engine code in src/lib.rs:

extern "C" {
    fn js_clear_screen_to_color(red: f32, green: f32, blue: f32, alpha: f32);
}

pub fn clear_screen_to_color(red: f32, green: f32, blue: f32, alpha: f32) {
    unsafe {
        js_clear_screen_to_color(red, green, blue, alpha)
    }
}

f32 is a Rust number type for numbers with a decimal like 3.14.

Update examples/game.rs to look like this:

fn main() {
    // Clear screen to blue
    sprinkles::clear_screen_to_color(0.0, 0.0, 1.0, 1.0);
}

Note that my engine is named sprinkles, so change the name if you named yours something different.

Run the code and you should see a blue screen! We've made our first useful engine API! 🎉🎉🎉


A few subtle things have happened here that need to be called out.

First you'll note that we created a new function pub fn clear_screen_to_color. The pub says that this function can be used from outside our library's code. Without pub our example game would not have been able to call clear_screen_to_color.

It would have been possible to let games directly call js_clear_screen_to_color but we did not label it pub. Why?

By wrapping js_clear_screen_to_color we hide the internal use of unsafe. It is important to be a responsible Rust developer and not misleadingly make something seem safe when it is not, but in this case we are confident there is little that can go wrong.

By wrapping js_clear_screen_to_color in clear_screen_to_color we make it clear that the function is safe and predictable.

User Input

Now let's make the screen change color based on our key presses!

First we add a function to src/lib.rs like this:

#[no_mangle]
pub extern "C" fn key_pressed() {
    clear_screen_to_color(0.0, 1.0, 0.0, 1.0);
}

extern "C" declares that we want this function to be available for Javascript to call. #[no_mangle] tells Rust not to try to change key_pressed's name, which is important because we need to know its name to call it from the Javascript side.

For now key_pressed immediately calls clear_screen_to_color with green so that we can verify the event is received.

On the Javascript side we need to hook up an event handler just after our Rust WebAssembly is loaded. That looks like this:

<script>
// ...
  WebAssembly.instantiateStreaming(fetch('target/wasm32-unknown-unknown/debug/examples/game.wasm'), imports).then(function (result) {
    result.instance.exports.main();

    document.onkeydown = function (event) {
        result.instance.exports.key_pressed();
    };
});
</script>

As you can see we can call Rust's key_pressed function from the Javascript side within our onkeydown handler.

Give it a try! When you press any key the screen should change to green.


Now we need to alert our game about the key event. To do that we're going to let our game pass an event handler function to our engine. Our engine will then hold onto that function and call it whenever an event is received.

First let's go over how our games will pass this function to our engine.

Let's replace our examples/game.rs code with this:

fn main() {
    let mut blue_amount = 0.0;

    sprinkles::set_event_handler(move || {
        blue_amount += 0.1;
        sprinkles::clear_screen_to_color(0.0, 0.0, blue_amount, 1.0);
    });
}

Here we declare a variable called blue_amount. We add mut so that the blue_amount can be edited after it is declared.

Then we pass a function to our engine via set_event_handler. Notably the function uses move || { } syntax. This syntax declares an unnamed function and the keyword move says the function should take control of and store all variables it accesses. This concept is referred to as a 'closure'. In this case our function takes control of blue_amount.

Because this function will be called whenever we press a key blue_amount will keep being added to.

Now that we understand how we want this code to look on the game's side let's implement the engine side!


Add this code to src/lib.rs:

thread_local! {
    pub static EVENT_HANDLER: std::cell::RefCell<Box<dyn FnMut()>> 
        = std::cell::RefCell::new(Box::new(||{}));
}

pub fn set_event_handler(function: impl FnMut() + 'static) {
    EVENT_HANDLER.with(|event_handler| {
        *event_handler.borrow_mut() = Box::new(function);
    });
}

There's a lot going on here. This is probably the single weirdest code we're going to write in this tutorial. Most Rust doesn't look this strange!

This small snippet uses a bunch of Rust features so I'll go through what each does. If you don't follow completely or want to skim over this part that's fine. You can Google these individual terms later to learn more. Rust has great docs!


thread_local! is a special Rust macro that we use to store our event handler in global memory so that any code on the thread can access it. This engine is only going to be single-threaded so we don't need to think about threads anyways.

std::cell::RefCell is a class in Rust's standard library that ensures that whatever the RustCell stores cannot be edited from multiple places at a time.

The Box</*...*/> within the RefCell is used to store a variable sized object. Our move || {/* ... */} is variable-sized because it may store values to edit later.

dyn FnMut() within the Box says our box will store any function that accepts no parameters. Our move || {/* ... */} fits that description.

We initialize our RefCell<Box<dyn FnMut()>> by setting it to std::cell::RefCell::new(Box::new(||{})). The ||{} is a function that takes no arguments and does nothing.

Lastly within set_event_handler we use impl FnMut() + 'static to specify that the function will accept any function that implements FnMut() and is 'static. 'static is a Rust lifetime that denotes that the function passed in must exist for the entire time the program runs. Within the body of the function we use thread_local's somewhat weird interface EVENT_HANDLER.with(|event_handler| {/*...*/}) to get access to EVENT_HANDLER's contents.

Once we have the contents of EVENT_HANDLER we use *event_handler.borrow_mut() = Some(Box::new(function)); to assign to the RefCell's interior.

The borrow_mut call is a way to access the contents of the RefCell but it also checks that we aren't already accessing the RefCell somewhere else in our code.


Phew, that was a lot. Moving on!

Update key_handler to look like this:

#[no_mangle]
pub extern "C" fn key_pressed() {
    EVENT_HANDLER.with(|event_handler| (event_handler.borrow_mut())())
}

Here we access our EVENT_HANDLER and then call its stored event_handler.

This snippet: (event_handler.borrow_mut())() may look a bit weird but the parentheses at the end make this a call on our stored event handler.


Now when we go to our page and click any key the page will progressively change from black to blue. Pretty cool!

This is where the pace picks up and we can make something more interesting.

Let's add the ability to detect which key is pressed! For now we'll only support the left/right/up/down arrows and spacebar.

In our src/lib.rs file let's create a Key enum like so:

pub enum Key {
    Left,
    Right,
    Up,
    Down,
    Space
}

Now let's modify our event_handler to accept the a Key value as an argument:

thread_local! {
    pub static EVENT_HANDLER: std::cell::RefCell<Box<dyn FnMut(Key)>> = std::cell::RefCell::new(Box::new(|_|{}));
}

pub fn set_event_handler(function: impl FnMut(Key) + 'static) {
/*...*/

Pay attention to the Box::new(|_|{}). The _ denotes that the temporary placeholder function won't use the argument passed in.

And in the examples/game.rs file:

/*...*/
 sprinkles::set_event_handler(move |key| {
/*...*/

Now we need to pass the key info from Javascript to Rust. Unfortunately we can't just pass text between Javascript and WebAssembly / Rust so we'll settle for numbers.

On the Javascript side in index.html let's make this change to our onkeydown handler:

/* ... */
document.onkeydown = function (event) {
    let code = 0;
    switch (event.code) {
        case "ArrowLeft":
            code = 1;
            break;
        case "ArrowRight":
            code = 2;
            break;
        case "ArrowUp":
            code = 3;
            break;
        case "ArrowDown":
            code = 4;
            break;
        case "Space":
            code = 5;
            break;
    }

    result.instance.exports.key_pressed(code);
};
/* ... */

And on the Rust side in src/lib.rs update key_pressed:

#[no_mangle]
pub extern "C" fn key_pressed(value: usize) {
    let key = match value {
        1 => Key::Left,
        2 => Key::Right,
        3 => Key::Up,
        4 => Key::Down,
        5 => Key::Space,
        _ => return,
    };

    EVENT_HANDLER.with(|event_handler| (event_handler.borrow_mut())(key))
}

In our examples/game.rs lets use the key inputs to clear the screen to a different color depending on the direction pressed:

fn main() {
    sprinkles::set_event_handler(move |key| match key {
        sprinkles::Key::Left => sprinkles::clear_screen_to_color(1.0, 0.0, 0.0, 1.0),
        sprinkles::Key::Right => sprinkles::clear_screen_to_color(0.0, 1.0, 0.0, 1.0),
        sprinkles::Key::Up => sprinkles::clear_screen_to_color(0.0, 0.0, 1.0, 1.0),
        sprinkles::Key::Down => sprinkles::clear_screen_to_color(0.0, 1.0, 1.0, 1.0),
        sprinkles::Key::Space => sprinkles::clear_screen_to_color(1.0, 1.0, 0.0, 1.0),
    });
}

Nice! It works! 🎊🎊🎊🎊🎊

If you find sprinkles:: ugly you can add use sprinkles::*; to the top of examples/game.rs and remove all the sprinkles::.

Drawing a Rectangle

Wouldn't it be nice to do more than just clear the screen? Let's draw rectangles!

This section will use WebGL. While the code we're writing is Javascript the techniques can be adapted to non-web Rust OpenGL libraries.

First let's draw a square with just Javascript. Once we get it working in Javascript we'll make it callable from Rust.


We need to initialize some WebGL state and then create a js_draw_rectangle function that uses WebGL to draw a rectangle.

First we need to create a program to tell the GPU how to render a bunch of triangles. This involves two "shaders" (which are more like functions): the vertex and fragment shaders. Then we connect those two parts together into a full OpenGL program.

Our vertex shader will take in a list of points that define the corners of triangles and it will pass those points to the next step unchanged.

The GPU then draws the triangles and calls our fragment shader per pixel within each triangle. The fragment shader produces a color that will set the color of that pixel on the screen.

For now we will code our fragment shader to always produce the same color.

Put this code beneath let gl = canvas.getContext("webgl"); in index.html.

let vertex_shader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertex_shader, `
    attribute vec3 vertex_position;
    void main(void) {
        gl_Position = vec4(vertex_position, 1.0);
    }
`);
gl.compileShader(vertex_shader);

let fragment_shader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragment_shader, `
    void main() {
        gl_FragColor = vec4(1.0, 0.5, 0.313, 1.0);
    }
`);
gl.compileShader(fragment_shader);

let program = gl.createProgram();
gl.attachShader(program, vertex_shader);
gl.attachShader(program, fragment_shader);
gl.linkProgram(program);

// We'll need to know this "location" later to let WebGL know where our rectangle corner data should go.
let position_attribute_location = gl.getAttribLocation(program, "vertex_position");
// For some reason these "locations" are disabled by default. It's unclear to me why that's useful.
gl.enableVertexAttribArray(position_attribute_location);

I won't dive into all the details about how WebGL/OpenGL works in this tutorial, but there are lots of great resources out there! I learned by following the tutorials on this website: https://learnopengl.com.


Now let's create a draw_rectangle function that uses our WebGL program to draw to the screen.

Add this code to the Javascript imports in our index.html file:

js_draw_rectangle: function (x, y, width, height) {
    let data_buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, data_buffer);

    function adjust_pos(size, pos) {
        return (pos / size) * 2.0 - 1.0;
    }
    gl.bufferData(
        gl.ARRAY_BUFFER,
        new Float32Array([
            adjust_pos(gl.canvas.width, x), adjust_pos(gl.canvas.height, y),
            adjust_pos(gl.canvas.width, x + width), adjust_pos(gl.canvas.height, y),
            adjust_pos(gl.canvas.width, x + width), adjust_pos(gl.canvas.height, y + height),
            adjust_pos(gl.canvas.width, x), adjust_pos(gl.canvas.height, y + height)
        ]),
        gl.STATIC_DRAW);

    gl.vertexAttribPointer(
        position_attribute_location,
        2,          // How many numbers are in each value of our data. In our case it's 2 because we're passing in 2D coordinates as vec2.
        gl.FLOAT,   // What type of numbers are used our data
        false, 0, 0 // These aren't important to understand now.
    );

    gl.useProgram(program);
    gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
    gl.deleteBuffer(data_buffer);
}

This code creates a WebGL buffer, uploads our rectangle corner positions to the buffer, draws the buffer with our program, and then deletes the buffer.

WebGL expects coordinates to range from (-1.0, -1.0) to (1.0, 1.0) but I find it easier to work in pixel coordinates so I wrote adjust_pos to make the conversation.

Note that this code isn't very efficient. A speedier implementation would not create and delete a WebGL buffer for every rectangle drawn, but this approach will still be plenty fast for our purposes.


Let's expose this function on the Rust side. This part is easy!

Change our extern "C" js functions in src/lib.rs to look like this:

extern "C" {
    fn js_clear_screen_to_color(red: f32, green: f32, blue: f32, alpha: f32);
    fn js_draw_rectangle(x: f32, y: f32, width: f32, height: f32);
}

Now we wrap the unsafe js_draw_rectangle call in a nicer public function:

pub fn draw_rectangle(x: f32, y: f32, width: f32, height: f32) {
    unsafe {
        js_draw_rectangle(x, y, width, height);
    }
}

Now let's give our new function a whirl!

Update examples/game.rs to look like this:

fn main() {
    let mut x_position = 200.0;
    let mut y_position = 30.0;

    sprinkles::set_event_handler(move |key| {
        let move_amount = 20.0;
        match key {
            sprinkles::Key::Left => x_position -= move_amount,
            sprinkles::Key::Right => x_position += move_amount,
            sprinkles::Key::Up => y_position += move_amount,
            sprinkles::Key::Down => y_position -= move_amount,
            sprinkles::Key::Space => {}
        }

        sprinkles::clear_screen_to_color(0.0, 0.0, 0.3, 1.0);
        sprinkles::draw_rectangle(x_position, y_position, 100., 100.);
    })
}

The above code stores an x (horizontal) and y (vertical) position and when the arrow keys are pressed the position is updated and the rectangle is drawn at the new position. This is almost a game! Note that nothing is actually drawn until a key is pressed.

Amazing! 🥳🥳🥳


Pause and Reflect

Let's pause and reflect. Our engine isn't quite perfect. What are its flaws?

There are a few obvious problems:

  • We can't draw continuously to represent something like a moving rectangle.
  • draw_rectangle always draws the same color.

And there are some more subtle flaws:

  • The way we're directly calling global functions to draw things isn't great Rust code. Just like WebGL's clunky API we're manipulating global state behind the scenes. We can make our code more clear by creating a Context data structure for our functions.

Let's fix these problems!

Animation

First up let's do animation. What we want is for the browser to tell us periodically to redraw our scene. Then we can redraw it even without any user input!

Let's hook this up!

In index.html beneath the document.onkeydown function add this code:

 function animate() {
    result.instance.exports.animate();
    requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

requestAnimationFrame tells the browser to call our function when the browser is ready to draw. Our function, animate, repeatedly calls requestAnimationFrame with itself so it will keep requesting new animation frames until we close the page.

result.instance.exports.animate(); calls our Rust code.

In the engine code in /src/lib.rs add this:

#[no_mangle]
pub extern "C" fn animate() {
    // Todo
}

Great! So now our engine is told when to redraw, but how should we tell our game about these events?

Let's add an enum Event we'll use to encompass all information our engine passes to the game. We'll change our current Key events to be part of this new enum Event.

In src/lib.rs beneath the pub enum Key declaration add

pub enum Event {
    KeyDown(Key),
    Draw,
}

We also need to update our event handler code to replace Key with Event:

thread_local! {
    pub static EVENT_HANDLER: std::cell::RefCell<Box<dyn FnMut(Event)>> = std::cell::RefCell::new(Box::new(|_|{}));
}

pub fn set_event_handler(function: impl FnMut(Event) + 'static) 
/* ... */

And at the end of our key_pressed function we need to need to update it to send the new Event type:

#[no_mangle]
pub extern "C" fn key_pressed(value: usize) {
    /* ... */
    EVENT_HANDLER.with(|event_handler| (event_handler.borrow_mut())(Event::KeyDown(key)))
}

Finally let's send our new Draw event:

#[no_mangle]
pub extern "C" fn animate() {
    EVENT_HANDLER.with(|event_handler| (event_handler.borrow_mut())(Event::Draw))
}

Now let's write a small test program to test out our animations:

Update the game in examples/game.rs to look like this:

fn main() {
    let mut x_position = 200.0;
    let mut y_position = 30.0;

    let mut x_direction = 1.0;
    let mut y_direction = 1.0;

    let speed = 5.0;

    sprinkles::set_event_handler(move |event| match event {
        sprinkles::Event::Draw => {
            x_position += x_direction * speed;
            y_position += y_direction * speed;
            // Change the horizontal direction if the cube's too far to the left or right.
            if x_position <= 0.0 || x_position >= 500.0 {
                x_direction *= -1.0;
            }
            // Change the vertical direction if the cube's too far to the top or bottom.
            if y_position <= 0.0 || y_position >= 500.0 {
                y_direction *= -1.0;
            }
            sprinkles::clear_screen_to_color(0.0, 0.0, 0.3, 1.0);
            sprinkles::draw_rectangle(x_position, y_position, 100., 100.);
        }
        _ => {}
    })
}

This small program will draw a rectangle that bounces around the screen.

Fantastic! 🙌🙌🙌🙌🙌

Context

Let's make it so our games call functions on a Context struct instead of mysterious global functions.

In our engine code in src/lib.rs declare a Context struct like so:

pub struct Context {}

For now it doesn't have anything in it but for a full engine you'd definitely want to store something there.

Now we'll move our clear_screen_to_color and draw_rectangle functions to be member functions on Context:

impl Context {
    pub fn clear_screen_to_color(&mut self, red: f32, green: f32, blue: f32, alpha: f32) {
        unsafe { js_clear_screen_to_color(red, green, blue, alpha) }
    }

    pub fn draw_rectangle(&mut self, x: f32, y: f32, width: f32, height: f32) {
        unsafe {
            js_draw_rectangle(x, y, width, height);
        }
    }
}

Note that we added &mut self at the front of the function calls. This lets us call functions on a context instance like context.draw_rectangle(/*..*/) and the mut means we need exclusive access to the Context to call its functions. While this doesn't matter yet it sets useful expectations about how Context will be used.

Next we need to update our callback code to accept a Context as well and add a Context to our global thread_local! data:

thread_local! {
    pub static EVENT_HANDLER_AND_CONTEXT: std::cell::RefCell<(Box<dyn FnMut(&mut Context, Event)>, Context)>
     = std::cell::RefCell::new((Box::new(|_, _|{}), Context {}));
}

pub fn set_event_handler(function: impl FnMut(&mut Context, Event) + 'static) {
    EVENT_HANDLER_AND_CONTEXT.with(|event_handler| {
        // Note we're storing our `EVENT_HANDLER_AND_CONTEXT`'s internal data as a tuple of two elements.
        // To access the first item in the tuple we use the `.0` syntax.
        event_handler.borrow_mut().0 = Box::new(function);
    });
}
/* ... */

&mut Context is saying that the game's function will temporarily have exclusive access to the Context.

Now we need to update our event handling functions to pass in Context as well.

To make our code simpler let's create a send_event helper:

fn send_event(event: Event) {
    EVENT_HANDLER_AND_CONTEXT.with(|event_handler_and_context| {
        let mut borrow = event_handler_and_context.borrow_mut();
        let (event_handler, context) = &mut *borrow;
        (event_handler)(context, event)
    })
}

And now we can update key_pressed and animate to use our new helper:

#[no_mangle]
pub extern "C" fn key_pressed(value: usize) {
    let key = match value {
        1 => Key::Left,
        2 => Key::Right,
        3 => Key::Up,
        4 => Key::Down,
        5 => Key::Space,
        _ => return,
    };

    send_event(Event::KeyDown(key));
}

#[no_mangle]
pub extern "C" fn animate() {
    send_event(Event::Draw);
}

Much cleaner!


Finally change the function in examples/game.rs to look like this:

sprinkles::set_event_handler(move |context, event| match event {
    sprinkles::Event::Draw => {
        x_position += x_direction * speed;
        y_position += y_direction * speed;
        if x_position <= 0.0 || x_position >= 500.0 {
            x_direction *= -1.0;
        }
        if y_position <= 0.0 || y_position >= 500.0 {
            y_direction *= -1.0;
        }
        context.clear_screen_to_color(0.0, 0.0, 0.3, 1.0);
        context.draw_rectangle(x_position, y_position, 100., 100.);
    }
    _ => {}
})

This change helps us in a few ways:

  • It's more clear that we're modifying state when we call the draw_rectangle and clear_screen_to_color functions.
  • We have an obvious place to put future engine-specific state or settings.
  • And it's more Rusty than hidden global state!

More Rectangle Colors

One last thing! Let's make it so we can draw rectangles of any color.

In our Javascript in index.html edit our fragment shader to have a new "uniform" that will be used to define the rectangle color.

precision mediump float;
let fragment_shader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragment_shader, `
    uniform vec4 color;
    void main() {
        gl_FragColor = color;
    }
`);

Uniforms are properties that can be passed into shaders.

Then after this line ...

gl.enableVertexAttribArray(position_attribute_location);

... add this line:

let color_uniform_location = gl.getUniformLocation(program, "color");

Later update the js_draw_rectangle function to look like this:

js_draw_rectangle: function (x, y, width, height, red, green, blue, alpha) {
    /*...*/
}

Then after the call to gl.useProgram add a call to set our color uniform. Like so:

gl.useProgram(program);
gl.uniform4f(color_uniform_location, red, green, blue, alpha);

Last we'll update the Rust side.

Update js_draw_rectangle to look like this:

fn js_draw_rectangle(
        x: f32,
        y: f32,
        width: f32,
        height: f32,
        red: f32,
        green: f32,
        blue: f32,
        alpha: f32,
    );

And update Context::draw_rectangle to this:

pub fn draw_rectangle(
        &mut self,
        x: f32,
        y: f32,
        width: f32,
        height: f32,
        red: f32,
        green: f32,
        blue: f32,
        alpha: f32,
    ) {
        unsafe {
            js_draw_rectangle(x, y, width, height, red, green, blue, alpha);
        }
    }

Finally in the examples/game.rs file edit the call to draw_rectangle to this:

context.draw_rectangle(x_position, y_position, 100., 100., 1.0, 0.0, 0.0, 1.0);

Now our rectangle is red! But more importantly we can choose the color!

Closing Thoughts

Wow, that was a lot. But we've made something pretty cool here. It may not look like much but this is actually a solid start for a game framework!

We've made a 0-dependency library with input, graphics, and some organization.

Our engine has two key phases for game code:

fn main() {
    /* 1. General setup */
    sprinkles::set_event_handler(move |context, event|  {
        /* 2. Respond to events */
    })
}

This is actually a great model for quick prototyping and yet can scale to more serious programs. You can quickly toss some variables in the setup area and later you can refactor it be part of a cleaner data structure.

Another cool thing is that we can already start using other great Rust libraries with our little engine.

Want an ECS? Add hecs as a dependency and go to town.

From here there are all sorts of things we can add:

  • More input events
  • More shapes to draw
  • Proper fixed timestep updates
  • Drawing images
  • Sounds
  • A non-web backend perhaps using winit
  • Fix the canvas blurring when the window resizes

Congratulations on making it to end and thanks for reading!

Let me know your what you think of this tutorial over on twitter.

Or send me an email with thoughts, corrections, or whatever.


If you're feeling adventurous and like this sort of thing consider checking out my (very) work-in-progress game engine koi.

I haven't publicized it much at all yet but it's a major effort to make my ideal Rust game engine from scratch. koi uses the same setup / run split structure sprinkles does, but also integrates an ECS and a bunch of features.

Also make sure to check out other popular Rust game engines:

For more cool Rust Gamedev stuff give the newsletter a browse: https://gamedev.rs/news/

Lastly consider joining the Rust Gamedev Discord. The folks in there are remarkably kind and helpful.


Edit: 01/15/2022 Thank you to /u/tungtn on reddit for submitting important code corrections!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK