Skip to content
Comfy Engine

Reference

Small 2D game examples in Rust: twelve techniques in short snippets

Twelve short engine-agnostic Rust snippets for 2D games: shapes, sprites, animation, y-sort, cooldowns, particles, text, sound triggers and a full game loop.

Maintained by Jun Takemura

When I learn a new engine, I do not start with the tutorial game. I start with a list of tiny things: draw a circle, draw a sprite, make it animate, make it sort behind a tree. Each takes ten minutes, and together they tell me how the engine thinks. This page is that list, written so it works whatever you use.

The snippets are plain Rust with a made-up drawing interface called gfx. It has the kind of calls almost every 2D framework offers (rect, circle, sprite, text), so you can translate each one into macroquad, Bevy, Comfy, raylib bindings or your own renderer in a minute. The ideas behind them follow the example list that the Comfy engine used to publish, rewritten from scratch. If you have not picked an engine yet, the engine comparison is a good place to start.

Assume a Vec2 type with x and y, a Color, and world coordinates where one unit is one 16 by 16 pixel tile, y pointing up.

1. Shapes

Shapes are the fastest way to prototype. Before you have any art, a player can be a yellow circle and a wall a grey rectangle.

fn draw_shapes(gfx: &mut Gfx, t: f32) {
    gfx.rect(Vec2::new(0.0, -3.0), Vec2::new(12.0, 1.0), GREY);
    let bob = (t * 3.0).sin() * 0.25;
    gfx.circle(Vec2::new(0.0, bob), 0.5, YELLOW);
    gfx.line(Vec2::new(-4.0, 2.0), Vec2::new(4.0, 2.0), 0.05, WHITE);
}

The circle uses a sine wave on time to bob up and down by a quarter of a tile. That one line is the cheapest "alive" effect you will ever write, so remember it.

2. A single sprite

A sprite is a textured rectangle. The details that matter are which texture, where, how big, and whether it is flipped.

struct Sprite { texture: TextureId, size: Vec2, flip_x: bool }

fn draw_player(gfx: &mut Gfx, p: &Player) {
    let s = Sprite { texture: p.tex, size: Vec2::new(1.0, 1.0), flip_x: p.vel.x < 0.0 };
    gfx.sprite(&s, p.pos, WHITE);
}

Flipping on horizontal velocity means you only need art facing one way. For pixel art, make sure the texture uses nearest-neighbour filtering, or your crisp sprite will turn to soup.

3. Animated sprites

An animation is a row of frames in a sprite sheet and a rule for which frame to show. Say a walk cycle has 6 frames of 16 by 16 pixels in one row, so the sheet is 96 by 16. At 10 frames per second the full cycle lasts 0.6 seconds.

struct Anim { frames: u32, fps: f32, frame_w: u32, frame_h: u32 }

fn source_rect(a: &Anim, time: f32) -> (u32, u32, u32, u32) {
    let frame = (time * a.fps) as u32 % a.frames;
    (frame * a.frame_w, 0, a.frame_w, a.frame_h)
}

At time 0.35 the calculation gives 3.5, truncated to 3, so you show the fourth frame, starting at pixel 48. Keep a per-entity animation timer rather than using global time, otherwise every enemy walks in perfect, creepy unison.

4. Y-sort

In a top-down game, things lower on the screen should draw in front of things higher up, so the hero walks behind a tree trunk and in front of it depending on position. The trick is to sort by the y of each object's feet.

fn draw_world(gfx: &mut Gfx, objects: &mut Vec<Object>) {
    // y up: a lower y means closer to the viewer, so draw it last
    objects.sort_by(|a, b| b.feet_y().partial_cmp(&a.feet_y()).unwrap());
    for o in objects.iter() {
        gfx.sprite(&o.sprite, o.pos, WHITE);
    }
}

If the engine you use has integer layers (z-index), you can instead map feet y to a layer, for example -(feet_y * 100.0) as i32, and let it sort for you. The multiplier matters: with 100, two objects only a hundredth of a tile apart still get different layers, which is plenty for pixel art where one pixel is a sixteenth of a tile. Use the feet, not the centre: a tall tree and a short slime need to compare where they touch the ground.

One more trap. Ground decorations such as grass tufts and puddles should not take part in the sort at all. Put them on a fixed layer below everything, or the hero will suddenly walk underneath a flower.

5. Cooldowns

Dash once every 0.8 seconds, shoot five times per second. Cooldowns are just timers, but keeping them in one place stops them spreading through your code.

use std::collections::HashMap;

struct Cooldowns { left: HashMap<&'static str, f32> }

impl Cooldowns {
    fn tick(&mut self, dt: f32) {
        for v in self.left.values_mut() { *v = (*v - dt).max(0.0); }
    }
    fn ready(&mut self, key: &'static str, duration: f32) -> bool {
        let v = self.left.entry(key).or_insert(0.0);
        if *v <= 0.0 { *v = duration; true } else { false }
    }
}

ready both checks and restarts the timer, so if cds.ready("dash", 0.8) { dash(); } reads exactly like the design. Comfy shipped a helper with this shape, and it is worth copying into any project.

6. Timed drawing

Sometimes you want something to appear for a moment: a "+10" over a coin, a hit flash, a debug marker. Instead of creating an entity, keep a list of draw commands with an expiry time.

struct Timed { pos: Vec2, label: String, until: f32 }

fn draw_timed(gfx: &mut Gfx, list: &mut Vec<Timed>, now: f32) {
    list.retain(|t| t.until > now);
    for t in list.iter() {
        gfx.text(&t.label, t.pos, WHITE);
    }
}

Push a Timed with until: now + 0.5 and forget about it. I use this constantly while debugging collisions: draw a red dot where a hit happened and leave it there for two seconds. For floating score numbers, also move the position up a little each frame based on how much time is left, so the label drifts upwards while it fades. It is a small touch, and players notice when it is missing.

7. One particle

A particle is a position, a velocity, an age and a lifetime. Everything else is decoration.

struct Particle { pos: Vec2, vel: Vec2, age: f32, life: f32 }

fn update(p: &mut Particle, dt: f32) -> bool {
    p.age += dt;
    p.vel.y -= 9.0 * dt;          // gravity in tiles per second squared
    p.pos.x += p.vel.x * dt;
    p.pos.y += p.vel.y * dt;
    p.age < p.life                 // false means remove me
}

When drawing, fade by age: alpha = 1.0 - age / life. A spark that lives 0.4 seconds and fades out linearly already looks decent. Shrinking the size along with the alpha looks even better, and costs one multiplication. For pixel art games, snap the particle's drawn position to the pixel grid (multiply by 16, round, divide by 16) so sparks do not shimmer between pixels while they fall.

8. Particle systems

A system is a spawner plus a pool. Give it a rate, a spread and ranges for speed and life, and it produces particles every frame.

struct Emitter { pos: Vec2, rate: f32, carry: f32, pool: Vec<Particle> }

fn emit(e: &mut Emitter, dt: f32, rng: &mut Rng) {
    e.carry += e.rate * dt;
    while e.carry >= 1.0 {
        e.carry -= 1.0;
        let angle = rng.range(1.2, 1.9);   // radians, mostly upward
        let speed = rng.range(2.0, 4.0);
        e.pool.push(Particle { pos: e.pos, vel: Vec2::new(angle.cos() * speed, angle.sin() * speed), age: 0.0, life: rng.range(0.3, 0.7) });
    }
    e.pool.retain_mut(|p| update(p, dt));
}

The carry value handles fractional spawning. At 40 particles per second and 60 frames per second, each frame adds 0.67, so you spawn one particle on two frames out of three, which averages out correctly. With lives between 0.3 and 0.7 seconds, the pool settles around 20 live particles: rate times average life.

9. Text

For score, timers and labels you need two things: drawing a string at a position, and measuring it so you can centre or right-align it.

fn draw_score(gfx: &mut Gfx, score: u32, screen_w: f32) {
    let label = format!("{score:06}");
    let w = gfx.measure_text(&label, 16.0);
    gfx.text_screen(&label, Vec2::new(screen_w - w - 8.0, 8.0), 16.0, WHITE);
}

Note that this draws in screen space, not world space, so the score stays in the corner when the camera moves. For pixel fonts, pick a size that is a whole multiple of the font's design size.

10. Sound triggers

The cleanest pattern I know is to let game code request sounds by name and let one function play them at the end of the frame. That way ten coins collected on the same frame can become one sound at a slightly higher volume instead of ten stacked copies.

fn flush_sounds(requests: &mut Vec<&'static str>, audio: &mut Audio) {
    requests.sort();
    requests.dedup();
    for name in requests.drain(..) {
        audio.play(name, 0.8);
    }
}

Add a tiny random pitch shift (say, between 0.95 and 1.05) to repeated sounds like footsteps and they stop sounding like a machine gun.

11. A full game loop skeleton

Everything above plugs into one loop. Here is a fixed-timestep version: game logic runs at exactly 60 updates per second, drawing runs as often as the display allows.

const DT: f32 = 1.0 / 60.0;

fn run(game: &mut Game, gfx: &mut Gfx, audio: &mut Audio, clock: &mut Clock) {
    let mut acc = 0.0;
    loop {
        acc += clock.frame_seconds().min(0.25);
        while acc >= DT {
            game.input.poll();
            game.cooldowns.tick(DT);
            game.update(DT);
            acc -= DT;
        }
        gfx.begin();
        game.draw(gfx);
        gfx.end();
        flush_sounds(&mut game.sound_requests, audio);
    }
}

The min(0.25) cap stops the "spiral of death": if a frame takes half a second (you dragged the window, say), the game catches up by at most 15 steps instead of trying to simulate everything it missed.

12. The post-processing idea

Post-processing means drawing the whole scene into an off-screen texture first, then drawing that texture to the screen through a shader. A vignette, a colour grade, a CRT effect and bloom all work this way.

@fragment
fn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
    let c = textureSample(scene_tex, scene_sampler, uv).rgb;
    let d = distance(uv, vec2<f32>(0.5, 0.5));
    let vignette = smoothstep(0.8, 0.3, d);
    return vec4<f32>(c * vignette, 1.0);
}

This darkens the edges smoothly from the centre outwards. If the scene texture is a floating point HDR target, you can add a bloom pass before this one, and that is exactly the area where engines differ most, as the feature checklist explains.

What to do with this list

Pick one engine and work through all twelve in a single evening. Whenever a snippet takes more than fifteen minutes to translate, write down why. That note, more than any feature table, tells you whether the engine fits how you like to work. Then combine sections 3, 4 and 8 into a little scene: a walking character, a tree to hide behind and a campfire throwing sparks.