WebGPU notes / 47
Build a Geometry Dash-Style WebGPU Game in Rust
Ryan, my 8-year-old son, asked me to build this game with him. He was the game designer, inspired by Geometry Dash, while I was the technical developer. Together, we built an original one-button platformer level featuring deterministic 120 Hz physics, generated triangle geometry, keyboard, mouse, and touch controls, all rendered in a single streamed WebGPU draw call.
- Physics rate
- 120 Hz
- Level objects
- 30
- Scene draws
- 1
Design a one-button platform game around a WebGPU canvas
The previous fixed-camera article combines a skinned 3D actor with bundled backgrounds and directional controls. Geometry Dash returns to a compact 2D problem: the player moves automatically, and one action starts, jumps, retries, or replays according to the current state. Space, Arrow Up, a left mouse press, and a new touch all perform that same action.
This original demo was introduced as a
complete example with its Rust source, five-line WGSL shader, screenshots, gallery entry, and build
registration. A later update
credits Ryan Eimandar in the HUD and README: Ryan designed the concept and gameplay at his request, inspired by
the original Geometry Dash game. The current implementation runs
through sib::render.
Run platform physics at a fixed 120 Hz
Rendering follows the display, but gameplay advances in exact 1/120-second ticks. Each update caps the incoming frame delta at 0.1 second, adds it to an accumulator capped at 0.25 second, and consumes as many fixed steps as fit. This keeps jump arcs and collision thresholds stable across ordinary refresh rates while preventing an unbounded catch-up loop after a long pause.
let dt = self.stats.delta_seconds().min(0.1);
self.accumulator = (self.accumulator + dt).min(0.25);
while self.accumulator >= STEP {
self.tick();
self.accumulator -= STEP;
}
During play, the level scrolls 5.15 units per second. Gravity subtracts 36 units per second squared from vertical velocity, a normal jump starts at 11.5 units per second, and a pad launches at 14. Ignoring discrete collision checks, a normal jump rises about 1.84 units above takeoff and remains airborne for about 0.64 seconds; a pad raises those figures to 2.72 units and 0.78 seconds.
Scroll sideways to see all table columns.
| Quantity | Value | Applied in | Effect |
|---|---|---|---|
| Fixed step | 1/120 s | Accumulator loop | Stable physics and collision sampling |
| Scroll speed | 5.15 units/s | Every playing tick | Moves the level past a fixed player X |
| Gravity | 36 units/s² | Airborne velocity | Creates the jump arc |
| Jump velocity | 11.5 units/s | Ground or block top | Manual jump |
| Pad velocity | 14 units/s | Ground-level pad overlap | Automatic higher jump |
| Level end | 120 units | Scroll progress | Completes in about 23.3 s without a crash |
Build one sorted level from spikes, blocks, and jump pads
The level() function creates 18 spikes, eight blocks, and four pads from authored X arrays. Every
spike is 0.78 units wide and 0.92 high; blocks are 1.4 by 1.15; pads are 1.0 wide and render as a 0.14-high strip.
Rust sorts all 30 records, then inserts 0.35 units before each new object except spikes that form a close
double-spike group.
That spacing pass shifts the last spike from its authored X of 105 to 113.75 while preserving the paired spike gaps. Completion remains at scroll 120, leaving room after the final obstacle. The level is deterministic, stored on the CPU, and small enough for a direct scan on every physics tick.
Scroll sideways to see all table columns.
| Kind | Count | Collision rule | Visible geometry | Color |
|---|---|---|---|---|
| Spike | 18 | Crash below 72% of spike height | One triangle | Pink |
| Block | 8 | Land from above; crash into the side or underside | Filled quad + four-piece frame | Blue with cyan edge |
| Jump pad | 4 | Launch when the player is within 0.12 of ground | One thin quad | Yellow |
Resolve a compact set of platform collision rules
The player remains at screen X 3.1 while scroll advances through level space. Collision uses a
narrower horizontal interval than the 0.72-unit visible square: half-width is SIZE * 0.34, about
0.245 units. The forgiving hitbox lets a close visual edge pass without immediately feeling unfair.
Only overlapping obstacles enter the kind-specific branch. A descending player whose previous Y was at the block top lands and resets vertical velocity and rotation. Entering a block from the side or below crashes. The player may jump again from ground or from a block whose top lies within 0.04 units of the current Y. A spike crashes below 72% of its height, while a ground-level pad replaces vertical velocity with the stronger launch value.
The square rotates clockwise at 3.9 radians per second while airborne and snaps upright on a landing. There is no continuous swept collision, so fixed 120 Hz sampling and the capped catch-up interval are part of the gameplay behavior, not just timing details.
Generate every visible shape as colored triangles in Rust
Each rendered frame rebuilds a CPU Vec<Vertex>. Helper functions expand triangles, quads,
rectangles, and frames into a triangle list. The scene starts with a dark background, 30 deterministic star
points, a gridded ground strip, and a sine-pulsed horizon. It adds only obstacles inside the current horizontal
view, the rotated player quad, and two rectangles for the progress bar.
The virtual scene stays 10 units high. Its width follows the physical surface aspect ratio as
max(10 * width / height, 7), then positions convert directly to normalized device coordinates. Wider
canvases reveal more level to the right instead of stretching the geometry. There is no camera matrix, depth
attachment, texture, sampler, bind group, or index buffer.
Stream one dynamic vertex buffer and issue one scene draw
A vertex stores a two-component f32 position and RGBA color for a 24-byte stride. Initialization
allocates capacity for 16,384 vertices, or 393,216 bytes. After scene generation, Rust clamps the submitted count
to that capacity and writes the active prefix with queue.write_buffer. One non-indexed triangle-list
draw renders the complete game scene.
The WGSL shader only forwards position and color. Standard alpha blending is enabled, which preserves the translucent stars and ground grid. A second color-load pass renders the shared Vazirmatn text overlay, so the HUD is separate from the one scene draw.
@vertex
fn vs_main(@location(0) pos: vec2<f32>,
@location(1) color: vec4<f32>) -> Out {
var out: Out;
out.pos = vec4<f32>(pos, 0.0, 1.0);
out.color = color;
return out;
}
Handle one action across four game states
Ready, Playing, Dead, and Complete define the state machine.
An action in Ready starts the level with a jump. During Playing it jumps only when grounded. In Dead or Complete
it resets scroll, Y, velocity, angle, and state, then increments the attempt counter; the next action begins the
new attempt. The R key resets from any state and also increments attempts.
The HUD displays “Designed by Ryan Eimandar,” the attempt count, integer progress from 000% to 100%, and a state-specific prompt. Its text updates when sampled frame statistics change and on every non-playing frame. The progress bar itself is scene geometry and therefore updates every rendered frame.
Run and extend the game
From a local checkout with Rust installed, run the native example:
cargo run --example geometrydash
For WebAssembly, install the wasm32-unknown-unknown target and the wasm-bindgen CLI
version matching Cargo.lock, then build and serve:
scripts/build-wasm.sh --release geometrydash
cargo run --bin serve
Open http://127.0.0.1:8080/geometrydash/ in a browser with WebGPU support. Press Space or Arrow Up,
click, or touch to act; press R to reset. Because the canvas consumes gameplay input, this article intentionally
does not use the static-demo scrolling flag.
The game has no audio, beat map, interpolation between physics states, saved progress, level editor, variable game modes, camera effects, particles, textures, or GPU-side simulation. Fixed-step state is drawn at the last completed tick, so very low or uneven frame rates can show temporal stepping. Input uses a boolean action flag: multiple presses between updates collapse into one action.
Collision scans all 30 objects and uses discrete overlap tests. That is ample for this level but would need spatial partitioning or a moving index for thousands of obstacles. The dynamic scene is regenerated and uploaded every frame even when Ready, Dead, or Complete; static background and level geometry could be cached or instanced.
Useful changes to try:
- Add audio-driven beat markers and keep the fixed-step simulation synchronized with a monotonic song clock.
- Interpolate visual position and rotation between fixed physics states for smoother high-refresh rendering.
- Move repeated blocks, spikes, and stars into instance buffers while keeping collision data on the CPU.
- Create a small level format and editor, then validate that authored jumps are possible at the current physics constants.