DEV Community

Cover image for Deploying your Rust WASM Game to Web with Shuttle & Axum
Rodney Lab
Rodney Lab

Posted on • Originally published at rodneylab.com

Deploying your Rust WASM Game to Web with Shuttle & Axum

🦀 Deploying your Rust WASM Game

In this post, we see you how you can get going on deploying your Rust WASM game. Often, WASM games will just need an HTML page to be embedded in, and a public server to make the page available across the Internet. We saw in a recent post on Rust game engines, that many engines currently offer the option to output your game in WASM, with (Bevy, Fyrox and Macroquad included).

Here, we will use an open-source Macroquad game written to demo Entity Component Systems, using Shipyard, a Rust ECS mentioned in a recent Rust ECS post.

Shuttle Rust App Hosting Service

That leaves us with a web server and deployment service to pick! Axum is currently a popular Rust Web framework choice, and to keep things completely Rust, we shall deploy using Shuttle. Shuttle offer Rust app hosting and have a free tier, which should be just fine for our game.

Now we know the approach, why don’t we get started?

🧱 What we're Building

Deploying your Rust WASM Game to Web: screen capture shows a webpage with a Square Eater title. Below is the game window which reads Click to start.  Further down a title reads How to play.

We will build the Square Eater game, which was originally written to demo ECSs. You should be able to follow along easily enough, with your own Rust game, though, even if it’s not built using Macroquad. Let me know if you run in difficulties with other game engines!

We will create three binaries from the project:

  • one just to run the game locally, natively or build to WASM;
  • another as a local Axum server for development and to test the webpage locally; and
  • a copy of the Axum server binary configured to deploy and run on Shuttle.

Shuttle has excellent Axum support, and the code for running the Axum server locally and on Shuttle is almost identical, so we will put that shared code in a library and reference it from the two server output versions we create.

⚙️ Cargo.toml

To add the three binaries in a single project, we can use the Cargo targets feature. The Cargo.toml file will look like this:

[lib]
path = "src/lib.rs"
name = "shared"

[[bin]]
path = "src/bin/server.rs"
name = "local-server"

[[bin]]
path = "src/bin/shuttle.rs"
name = "square-eater"

[[bin]]
path = "src/bin/main.rs"
name = "game"
Enter fullscreen mode Exit fullscreen mode

Here we have the library and three binaries, mentioned earlier. If you match the name of the Shuttle binary to your Shuttle project name, (line 16), Shuttle will automatically build and deploy that binary. The project name needs to be unique across Shuttle, as it forms the domain Shuttle uses to server the project from.

📂 Project Structure

Here, we are just using some open-source code for the game, and it can fit into a single source file. We place that code in src/bin/main.rs, and can run the game locally using:

cargo run --bin game
Enter fullscreen mode Exit fullscreen mode

With game matching the binary name used in Cargo.toml, above. The code we use is provided as an example of using the Shipyard Rust ECS. Paste the main.rs square_eater code from the repo into src/bin/main.rs in your project.

For this to run, we need to add the following dependencies in Cargo.toml:

[package]
name = "square_eater"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
path = "src/lib.rs"
name = "shared"

# TRUNCATED... 

[[bin]]
path = "src/bin/main.rs"
name = "game"

[build-dependencies]
askama = "0.12.1"

[dependencies]
macroquad = "0.4.4"
miniquad = "0.3.12"
sapp-wasm = "=0.1.26"
shipyard = { version = "0.6.2", default-features = false, features = ["proc", "std"] }
Enter fullscreen mode Exit fullscreen mode

miniquad and sapp-wasm are not, strictly, needed to run the game locally, but used in the next section, when we build the game to WASM.

🎮 Building a Macroquad the Game as WASM

See the macroquad docs for full details on building a WASM game.

If this is the first time you are generating WASM with Rust on your machine, add the WASM output toolchain:

rustup target add wasm32-unknown-unknown
Enter fullscreen mode Exit fullscreen mode

Then, you can run a WASM release build with:

 cargo build --bin game --release --target wasm32-unknown-unknown
Enter fullscreen mode Exit fullscreen mode

This will output the WASM code to target/wasm32-unknown-unknown/release/game.wasm. Copy that file to a new public folder in the project root directory; we will use it when we create the web page next.

Adding a Web Page

We will serve the WASM file and HTML page from the public directory. Add an index.html file there with this content:

<html lang="en-GB">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
    <link
      rel="alternate icon"
      href="/favicon.ico"
      type="image/png"
      sizes="16x16"
    />
    <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="192x192" />
    <link rel="mask-icon" href="/favicon.svg" />
    <title>Square Eater</title>
    <style>
      @font-face{font-display:swap;font-family:Josefin Sans;font-style:normal;font-weight:700;src:url(/fonts/josefin-sans-v32-latin-700.woff2)format("woff2")}@font-face{font-display:swap;font-family:Fira Mono;font-style:normal;font-weight:400;src:url(/fonts/fira-mono-v14-latin-regular.woff2)format("woff2")}@font-face{font-display:swap;font-family:Fira Mono;font-style:normal;font-weight:700;src:url(/fonts/fira-mono-v14-latin-700.woff2)format("woff2")}:root{--font-size-1:1.125rem;--font-size-2:1.406rem;--font-size-3:1.758rem}*,:after,:before{box-sizing:border-box}*{margin:0}html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;scroll-behavior:smooth}body{background-color:oklch(.68,.03,261.69);color:oklch(.3,.04,279.69);font:var(--font-size-1)/1.75 Fira Mono;width:100%;max-width:100%;display:flex}main{width:100%;max-width:640px;margin-block-start:4rem;margin-inline:auto}h1,h2{margin-block:3rem 1.5rem}h1{font:var(--font-size-3)/1.3 Josefin Sans}h2{font:var(--font-size-2)/1.3 Josefin Sans}p{margin-block:0 1rem;margin-inline:0;padding:0}canvas{width:640px;max-width:100%;height:auto;margin-block:3rem 1.5rem;padding:0;overflow:hidden}
    </style>
  </head>
  <body>
    <main>
      <h1>Square Eater</h1>
      <canvas id="glcanvas" tabindex="1"></canvas>
      <h1>How to play</h1>
      <p>
        Eat as many squares as you can before the swarm gets you! Eat yellow
        squares to repel the swarm.
      </p>
      <script src="https://not-fl3.github.io/miniquad-samples/mq_js_bundle.js"></script>
      <script>
        load("game.wasm");
      </script>
    </main>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

I have inlined the CSS here, and you can use Rust-based tooling like Lightning CSS to minify and bundle CSS here. You might also want to create a Rust build script to generate the HTML from a template, using the askama crate (works a little like Jinja).

Remember to add any favicons referenced in the HTML rel tags to the public folder.

🚧 Local Development and Testing Server using Axum

We just need Axum to run as a static file server, and can set this up in a few source files. Create src/bin/server.rs with this content:

use shared::app;

#[tokio::main]
async fn main() {
    let listen_address = "0.0.0.0:8000";
    let listener = tokio::net::TcpListener::bind(listen_address).await.unwrap();
    println!("Local    http://{listen_address}/");
    axum::serve(listener, app()).await.unwrap();
}
Enter fullscreen mode Exit fullscreen mode

This references the shared library, mentioned before. We can create it now, adding a src/lib.rs file:

use cfg_if::cfg_if;

cfg_if! {
    if #[cfg(feature = "game")] { } else {
        mod routes;

        use axum::{routing::get, Router};
        use routes::health_check;
        use tower_http::services::{ServeDir, ServeFile};

        pub fn app() -> Router {
            Router::new()
                .route("/health_check", get(health_check))
                .fallback_service(
                    ServeDir::new("public").not_found_service(ServeFile::new("public/index.html")),
                )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This will serve files in the public folder from the / route, and just serve the index.html file if the requested path is not found. See Auxm docs for more sophisticated setup.

Finally, we can add the mentioned health_check route in src/routes/health_check.rs:

use axum::{http::StatusCode, response::IntoResponse, Json};
use serde::Serialize;

#[derive(Serialize)]
struct Health {
    healthy: bool,
}

pub async fn health_check() -> impl IntoResponse {
    let health = Health { healthy: true };

    (StatusCode::OK, Json(health))
}
Enter fullscreen mode Exit fullscreen mode

And, include that file in the source tree by adding src/routes/mod.rs:

pub mod health_check;

pub use health_check::health_check;
Enter fullscreen mode Exit fullscreen mode

As a last step, here, we need to update Cargo.toml with Axum dependencies. We only use those dependencies when building the local or Shuttle binaries, not the game. For that reason, we make them optional, and will update the game build command to skip them.

Here is the final Cargo.toml:

[package]
name = "square_eater"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
path = "src/lib.rs"
name = "shared"

[[bin]]
path = "src/bin/server.rs"
name = "local-server"

[[bin]]
path = "src/bin/shuttle.rs"
name = "square-eater"

[[bin]]
path = "src/bin/main.rs"
name = "game"

[dependencies]
axum = { version = "0.7.4", optional = true }
cfg-if="1.0.0"
macroquad = "0.4.4"
miniquad = "0.3.12"
sapp-wasm = "=0.1.26"
serde = { version = "1.0.196", features = ["derive"] }
shipyard = { version = "0.6.2", default-features = false, features = ["proc", "std"] }
shuttle-axum = { version = "0.38.0", optional = true }
shuttle-runtime = { version = "0.38.0", optional = true }
tokio = { version = "1.36.0", features = ["macros", "rt-multi-thread"], optional = true }
tower-http = { version = "0.5.1", features = ["fs", "trace"], optional = true }

[features]
default = ["shuttle"]
game = []
local_server = [
  "dep:axum",
  "dep:tokio",
  "dep:tower-http",
]
shuttle = [
  "dep:axum",
  "dep:shuttle-axum",
  "dep:shuttle-runtime",
  "dep:tokio",
  "dep:tower-http",
]
Enter fullscreen mode Exit fullscreen mode

I also added the shuttle dependencies, which we use in the next section.

To build just the game, now, we can run:

cargo build --bin game --release --target wasm32-unknown-unknown --features=game --no-default-features
Enter fullscreen mode Exit fullscreen mode

And to test the local server:

cargo run --bin local-server --features=local_server
Enter fullscreen mode Exit fullscreen mode

Try that command, and jump to http://127.0.0.1:8000 in your browser, and you should see your game, in-browser.

🚀 Shuttle Configuration and Deploy

To get going with Shuttle, you will need the CLI app installed locally. To install, run:

cargo install cargo-shuttle
Enter fullscreen mode Exit fullscreen mode

This will probably take a few minutes to download and compile.

Next, create a Shuttle.toml file in the project root directory, with the following content:

name = "square-eater" # REPLACE with your own project name
Enter fullscreen mode Exit fullscreen mode

Then, we need to add the source code for the shuttle binary file, which Shuttle will run when they deploy your app. This file will is tiny, because we are using the shared Axum-based library, created earlier:

use shared::app;

#[shuttle_runtime::main]
async fn main() -> shuttle_axum::ShuttleAxum {
    Ok(app().into())
}
Enter fullscreen mode Exit fullscreen mode

Shuttle Deploy

To deploy, first create a project, from the Terminal (remember your project name has to be unique for this to work):

cargo shuttle project start
Enter fullscreen mode Exit fullscreen mode

Then deploy:

cargo shuttle deploy
Enter fullscreen mode Exit fullscreen mode

The binary will compile on Shuttle servers, and you will see output as it compiles. Your game site should now be accessible from the URL https://YOUR-PROJECT-NAME.shuttleapp.rs!

🙌🏽 Deploying your Rust WASM Game: Wrapping Up

In this post on deploying your Rust WASM game, we saw how you can deploy a Macroquad WASM game with Shuttle and Axum. In particular, we saw:

  • how to Cargo targets to build multiple binaries;
  • how to serve a static file server with Axum; and
  • the process for deploying a Rust app to Shuttle.

I hope you found this useful. You can find the full source code for the project in a Rodney Lab GitHub repo. Please share links for WASM games you create. Also, let me know if there is anything I could improve in this content to make it easier to follow, or improve your experience.

🙏🏽 Deploying your Rust WASM Game: Feedback

If you have found this post useful, see links below for further related content on this site. Let me know if there are any ways I can improve on it. I hope you will use the code or starter in your own projects. Be sure to share your work on X, giving me a mention, so I can see what you did. Finally, be sure to let me know ideas for other short videos you would like to see. Read on to find ways to get in touch, further below. If you have found this post useful, even though you can only afford even a tiny contribution, please consider supporting me through Buy me a Coffee.

Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via @askRodney on X (previously Twitter) and also, join the #rodney Element Matrix room. Also, see further ways to get in touch with Rodney Lab. I post regularly on Game Dev as well as Rust and C++ (among other topics). Also, subscribe to the newsletter to keep up-to-date with our latest projects.

Top comments (0)