WebGPU Game Development - Build a Browser Shooter with Rust and TypeScript
WebGPU is finally stable enough to take seriously for real-time games in the browser. Instead of fighting against the limitations of WebGL, you can now write modern, low-level graphics code that feels closer to Vulkan, Metal, or DirectX 12 while still shipping to any modern browser.
In this guide, you will build a simple but complete browser shooter that uses Rust for game logic, compiles to WebAssembly, and uses TypeScript to drive a WebGPU rendering pipeline. By the end, you will understand the full path from input handling to rendering bullets on screen with smooth, GPU-accelerated graphics.
Why Build a Shooter with WebGPU and Rust
Before diving into code, it helps to understand the stack you are building:
- Rust: Safe, fast, and ideal for performance-critical game logic compiled to WebAssembly.
- WebAssembly (Wasm): Lets you run Rust code in the browser at near-native speed.
- TypeScript: Great for orchestration, browser APIs, and ergonomics.
- WebGPU: Modern graphics API that gives you explicit control over buffers, shaders, and pipeline state.
For game developers, this combination gives you:
- Deterministic game logic in Rust.
- Predictable performance thanks to WebGPU’s explicit design.
- Zero-install distribution because everything runs in the browser.
If you are used to engines like Unity, Unreal, or Godot, think of this project as a way to understand the lower-level building blocks those engines sit on.
Project Overview
The shooter you will build is intentionally small but representative:
- A player ship that moves with keyboard input.
- Bullets fired toward the top of the screen.
- Simple enemies that drift down.
- A basic hit detection loop.
- All rendered via WebGPU with a minimal pipeline (no textures, just colored quads).
You can later expand this into:
- Particle effects.
- More complex enemy patterns.
- Upgrades and score tracking.
For a broader look at game performance and architecture, see the performance and networking articles in the rest of the blog.
Setting Up the Toolchain
Install Rust and WebAssembly target
Make sure you have Rust installed, then add the WebAssembly target:
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli
Create a new Rust library crate:
cargo new webgpu_shooter_core --lib
cd webgpu_shooter_core
Update Cargo.toml to use wasm-bindgen:
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2"
Set up the TypeScript/WebGPU frontend
In your project root (outside the Rust crate), initialize a basic frontend:
npm init -y
npm install typescript vite
npm install --save-dev @types/dom-webcodecs
npx tsc --init
Configure Vite to serve a simple index.html that loads your compiled Wasm and TypeScript bundle.
Designing the Game Loop Architecture
You will split responsibilities this way:
- Rust (Wasm):
- Game state (player, bullets, enemies).
- Update loop (movement, spawning, collisions).
- Produces a list of renderable instances (positions, colors).
- TypeScript (WebGPU):
- WebGPU device/queue/swap chain setup.
- Shaders and pipeline.
- Uploads instance data from Rust into GPU buffers.
- Submits draw calls each frame.
This keeps Rust focused on what happens and WebGPU focused on how it is drawn.
Implementing Game State in Rust
Create a minimal game state with a player, bullets, and enemies.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct Vec2 {
pub x: f32,
pub y: f32,
}
#[derive(Clone, Copy)]
struct Player {
pos: Vec2,
}
#[derive(Clone, Copy)]
struct Bullet {
pos: Vec2,
vel: Vec2,
}
#[derive(Clone, Copy)]
struct Enemy {
pos: Vec2,
vel: Vec2,
}
#[wasm_bindgen]
pub struct GameState {
player: Player,
bullets: Vec<Bullet>,
enemies: Vec<Enemy>,
time_since_last_shot: f32,
}
#[wasm_bindgen]
impl GameState {
#[wasm_bindgen(constructor)]
pub fn new() -> GameState {
GameState {
player: Player { pos: Vec2 { x: 0.0, y: -0.8 } },
bullets: Vec::new(),
enemies: Vec::new(),
time_since_last_shot: 0.0,
}
}
}
Expose update and input functions to JavaScript:
#[wasm_bindgen]
impl GameState {
pub fn update(&mut self, dt: f32) {
self.update_player(dt);
self.update_bullets(dt);
self.update_enemies(dt);
self.handle_collisions();
self.time_since_last_shot += dt;
}
pub fn move_player(&mut self, dx: f32) {
self.player.pos.x = (self.player.pos.x + dx).clamp(-0.9, 0.9);
}
pub fn try_shoot(&mut self) {
let cooldown = 0.15;
if self.time_since_last_shot >= cooldown {
self.time_since_last_shot = 0.0;
self.bullets.push(Bullet {
pos: Vec2 { x: self.player.pos.x, y: self.player.pos.y + 0.05 },
vel: Vec2 { x: 0.0, y: 1.5 },
});
}
}
}
To send renderable data to WebGPU, add a function that flattens your entities into a simple instance buffer:
#[repr(C)]
pub struct Instance {
pub position: [f32; 2],
pub size: [f32; 2],
pub color: [f32; 3],
}
#[wasm_bindgen]
impl GameState {
pub fn instance_count(&self) -> usize {
1 + self.bullets.len() + self.enemies.len()
}
pub fn fill_instances(&self, out: &mut [f32]) {
let mut i = 0;
// player
{
let size = [0.06, 0.08];
let color = [0.2, 0.8, 1.0];
out[i..i + 2].copy_from_slice(&[self.player.pos.x, self.player.pos.y]);
out[i + 2..i + 4].copy_from_slice(&size);
out[i + 4..i + 7].copy_from_slice(&color);
i += 7;
}
for b in &self.bullets {
let size = [0.02, 0.06];
let color = [1.0, 0.9, 0.2];
out[i..i + 2].copy_from_slice(&[b.pos.x, b.pos.y]);
out[i + 2..i + 4].copy_from_slice(&size);
out[i + 4..i + 7].copy_from_slice(&color);
i += 7;
}
for e in &self.enemies {
let size = [0.08, 0.08];
let color = [1.0, 0.3, 0.3];
out[i..i + 2].copy_from_slice(&[e.pos.x, e.pos.y]);
out[i + 2..i + 4].copy_from_slice(&size);
out[i + 4..i + 7].copy_from_slice(&color);
i += 7;
}
}
}
On the JavaScript side, you will allocate a Float32Array and pass it into fill_instances.
Wiring Up WebGPU in TypeScript
Create a src/webgpu.ts file for initialization:
export async function initWebGPU(canvas: HTMLCanvasElement) {
if (!navigator.gpu) {
throw new Error("WebGPU not supported in this browser.");
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("Failed to get GPU adapter.");
}
const device = await adapter.requestDevice();
const context = canvas.getContext("webgpu") as GPUCanvasContext;
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device,
format,
alphaMode: "opaque",
});
return { device, context, format };
}
Next, define a basic shader that draws colored rectangles from instance data:
// src/shader.wgsl
struct Instance {
position: vec2<f32>,
size: vec2<f32>,
color: vec3<f32>,
};
@group(0) @binding(0)
var<storage, read> instances: array<Instance>;
struct VSOut {
@builtin(position) position: vec4<f32>,
@location(0) color: vec3<f32>,
};
@vertex
fn vs_main(@builtin(instance_index) instance_index: u32,
@builtin(vertex_index) vertex_index: u32) -> VSOut {
let inst = instances[instance_index];
var local = vec2<f32>(0.0, 0.0);
if (vertex_index == 0u) {
local = vec2<f32>(-0.5, -0.5);
} else if (vertex_index == 1u) {
local = vec2<f32>(0.5, -0.5);
} else if (vertex_index == 2u) {
local = vec2<f32>(-0.5, 0.5);
} else {
local = vec2<f32>(0.5, 0.5);
}
let scaled = local * inst.size + inst.position;
var out: VSOut;
out.position = vec4<f32>(scaled, 0.0, 1.0);
out.color = inst.color;
return out;
}
@fragment
fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
return vec4<f32>(in.color, 1.0);
}
Then set up a pipeline and buffers in TypeScript:
import shaderCode from "./shader.wgsl?raw";
export function createPipeline(device: GPUDevice, format: GPUTextureFormat) {
const module = device.createShaderModule({ code: shaderCode });
const bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: { type: "read-only-storage" },
},
],
});
const pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
vertex: {
module,
entryPoint: "vs_main",
},
fragment: {
module,
entryPoint: "fs_main",
targets: [{ format }],
},
primitive: {
topology: "triangle-strip",
stripIndexFormat: "uint16",
},
});
return { pipeline, bindGroupLayout };
}
Allocate a storage buffer for instances:
export function createInstanceBuffer(device: GPUDevice, maxInstances: number) {
const floatsPerInstance = 7;
const bufferSize = maxInstances * floatsPerInstance * 4;
const buffer = device.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
return buffer;
}
Connecting Rust Game Logic and WebGPU Rendering
Compile your Rust crate to Wasm using wasm-bindgen, then import it in your TypeScript entry file:
import init, { GameState } from "../webgpu_shooter_core/pkg/webgpu_shooter_core.js";
import { initWebGPU, createPipeline, createInstanceBuffer } from "./webgpu";
async function main() {
await init();
const canvas = document.querySelector("canvas") as HTMLCanvasElement;
const { device, context, format } = await initWebGPU(canvas);
const { pipeline, bindGroupLayout } = createPipeline(device, format);
const maxInstances = 512;
const instanceBuffer = createInstanceBuffer(device, maxInstances);
const bindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [
{
binding: 0,
resource: { buffer: instanceBuffer },
},
],
});
const game = new GameState();
const floatsPerInstance = 7;
const instanceData = new Float32Array(maxInstances * floatsPerInstance);
let lastTime = performance.now();
function frame(now: number) {
const dt = (now - lastTime) / 1000;
lastTime = now;
game.update(dt);
const count = game.instance_count();
game.fill_instances(instanceData.subarray(0, count * floatsPerInstance));
device.queue.writeBuffer(
instanceBuffer,
0,
instanceData,
0,
count * floatsPerInstance
);
const encoder = device.createCommandEncoder();
const view = context.getCurrentTexture().createView();
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view,
clearValue: { r: 0.02, g: 0.02, b: 0.05, a: 1 },
loadOp: "clear",
storeOp: "store",
},
],
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(4, count, 0, 0);
pass.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
main().catch(console.error);
Handling Input for the Shooter
Use keyboard events to move the player and fire bullets by calling into the Rust GameState methods:
function setupInput(game: GameState) {
const keys = new Set<string>();
window.addEventListener("keydown", (e) => {
keys.add(e.key);
});
window.addEventListener("keyup", (e) => {
keys.delete(e.key);
});
function applyInput(dt: number) {
let dx = 0;
const speed = 1.6;
if (keys.has("ArrowLeft") || keys.has("a")) dx -= speed * dt;
if (keys.has("ArrowRight") || keys.has("d")) dx += speed * dt;
if (dx !== 0) {
game.move_player(dx);
}
if (keys.has(" ") || keys.has("ArrowUp")) {
game.try_shoot();
}
}
return applyInput;
}
Integrate this into your frame loop by calling applyInput(dt) before game.update(dt).
Performance and Polishing Tips
- Batch updates in Rust so you avoid many small JS↔Wasm calls.
- Avoid reallocations in Rust by reusing vectors or using object pools for bullets.
- Pre-allocate buffers in WebGPU and reuse them; do not recreate pipelines or buffers every frame.
- Profile frame time using your browser’s performance tools and watch for garbage collection spikes.
- Experiment with instance data layout if you add more attributes (rotation, velocity visualization, health).
For a deeper dive into performance trade-offs across engines and platforms, pair this tutorial with the performance and business-focused articles elsewhere on this site.
Where to Go Next
You now have a working pattern for combining Rust, WebAssembly, TypeScript, and WebGPU into a small but complete shooter. From here you can:
- Add enemy patterns and waves.
- Implement simple UI overlays in HTML or canvas.
- Add audio using the Web Audio API.
- Experiment with post-processing and particle effects using additional WebGPU passes.
To keep leveling up your skills, explore the other programming and graphics posts in this blog, as well as the longer-form guides on game performance, networking, and engine-specific workflows.