24 hours of game dev in Rust

A self imposed game jam trip report

Me

  • Michael Shaw
  • Years of developing games in Scala
  • 24 hours of Rust experience

Why

I started off building big, learning slowly

Most of my ideas are bad
"Require Iteration"

 

Learning out they're bad in 24 hours
is much cheaper than 3 months

Scala

Might be the best game jam language in existence

  • Performance is easily good enough
  • Type safety + terse (fast dev)
  • Fast incremental compiler
  • Functional programming with escape hatches

Painful long term

  • Distribution & garbage collection woes
  • High cost abstractions hard to distuinguish
    from low cost ones
  • Clean code murders the GC
  • GC friendly code is nasty

Rust

  • Expressive (but not terse), fast, no GC
  • Tree based ownership over structural sharing is fine with games
  • Zero cost abstractions (for the machine)

 

Can you make it "Game Jam" friendly though?

Can we create a layer of machine inefficient,
developer productivity based shortcuts?

 

Something you start with,
and phase out when you need speed ...

What

Glium & Glutin overview

Game jam style engine design

A smidge of game design

Building a tiny game

Cheating

 

I read https://doc.rust-lang.org/book/ beforehand

 

I only counted development.
There were a few hours planning with what to build.

24:00

Thievery and triangles

Glium

https://github.com/tomaka/glium

Safe OpenGL without the state machine.

  • Forget about glEnable/glDisable/glBind/glUnbind ...
  • Don't look at the issue count on github, everything is fine
target.draw(
  &vertex_buffer,
  &index_buffer,
  &program,
  &uniforms,
  &Default::default()
).unwrap();

Target

target.draw(
  &vertex_buffer,
  &index_buffer,
  &program,
  &uniforms,
  &Default::default()
).unwrap();

What we're drawing on (window, or off screen)

vertex_buffer

target.draw(
  &vertex_buffer,
  &index_buffer,
  &program,
  &uniforms,
  &Default::default()
).unwrap();

Vertices that we want to draw (usually triangles)

program

target.draw(
  &vertex_buffer,
  &index_buffer,
  &program,
  &uniforms,
  &Default::default()
).unwrap();

A pair of GLSL programs/"shaders"

Vertices + Uniforms in -> Coloured pixels out

void main() {
  gl_Position = matrix * vec4(position, 1.0);
  v_color = color * u_color;
  v_tex_coord = tex_coord;
  v_normal = normal;
}

uniforms

target.draw(
  &vertex_buffer,
  &index_buffer,
  &program,
  &uniforms,
  &Default::default()
).unwrap();

Shader program state
(textures, colors, camera transforms)

let uniforms = uniform! {
  matrix: mvp_raw,
  u_texture_array: texture,
  u_color: color,
  u_alpha_minimum: 0.05_f32,
  u_sun_direction: adjusted_sun_direction_raw,
};

Pretend the (!) isn't there and that it's just a struct.

That ! is saving you from 24 years of OpenGL's legacy

When you see a ! you're exchanging a few hours of runtime trial and error for a runtime error on program start

Uniforms

glGetUniformLocation(id,u.name)

glActiveTexture(GL_TEXTURE0 + n)
glBindTexture(tu.target, 0)

glUniformMatrix4(id, matrix)

Shaders

glBindAttribLocation(id, n, a.name)

glVertexAttribPointer(n, a.components, a.componentType.glCode,
  a.normalized, strideBytes, at)
glEnableVertexAttribArray(n)

glDisableVertexAttribArray(n)

Becomes

thread '<main>' panicked at

'The program attribute `position` is missing in the vertex bindings',

 /Users/michael/.cargo/registry/src/github.com-1ecc6299db9ec823/
      glium-0.15.0/src/vertex_array_object.rs:287

note: Run with `RUST_BACKTRACE=1` for a backtrace.

Mac OS X Notes

Demand a core profile

let display = WindowBuilder::new()
  .with_gl_profile(GlProfile::Core)
  .with_gl(GlRequest::Specific(Api::OpenGl,(4,0)))
  .build_glium().unwrap();

Make your shaders 3.3+

#version 330

Glium examples are really easy to run (steal them)

cargo run --example tutorial-14

Print input events to get familiar

for event in display.poll_events() {
  match event {
    glutin::Event::Closed => return support::Action::Stop,
    e => println!("got {:?}", e)
  }
}

Glutin

https://github.com/tomaka/glutin

Cross platform:

  • Window Creation
  • Input Events
  • OpenGL Context Creation

23:00

An ounce of organisation

Paying the piper

An hour spent learning Rust's module system

You know what I said about stealing everything ...

Throw it out and add it back line by line

lib.rs
#![crate_name="gm2"]
#![allow(dead_code)]

#[macro_use]
extern crate glium;

pub mod core {
  pub mod camera;
  pub mod render;
  pub mod shader;
  pub mod game;
}
pub mod game;
pub mod input;

Forget your managed namespaces

module directives form a tree

Named pointers, not namespaces

Halfway between namespaces & include directives

22:00

An ounce of organisation

Piper needs a brand new car

An hour spent battling glium Types

pub fn build_window() ->
  glium::backend::glutin_backend::GlutinFacade

All the examples are one function, so there's no examples of:

pub fn render(display: &glium::Display, rs:&RenderState)

I'm pretty sure I shouldn't be passing around a GlutinFacade.

An hour later I find ...

pub use backend::glutin_backend::GlutinFacade as Display;

Because I'm new at Rust :-/

21:00

Game jam style rendering

Quick & Dirty

2D sprites positioned in 3D (more than Z order)

Quads only, in one vertex format

At most a few batches per frame

Dev performance, not machine performance

"Flash" guys in the middle with per vertex colors

One fat vertex format

#[derive(Copy, Clone)]
pub struct PTCNVertex {
    pub position: [f32; 3],
    pub tex_coord: [f32; 3],
    pub color: [f32; 4],
    pub normal: [f32; 3],
}

implement_vertex!(PTCNVertex, position, tex_coord, color, normal);

color allows us to flash guys white if they get hit
or red if they're hurt

 

normal allows us to add lighting if we have time

glium::VertexBuffer::new(display,
  &[
    PTCNVertex { position: [-0.5, -0.5, 0.0], tex_coord: [1.0, 0.0, 1.0],
        color:    [0.0, 1.0,  0.0,  1.0], normal:    [0.0, 1.0, 0.0] },
    PTCNVertex { position: [0.0,  0.5,  0.0], tex_coord: [1.0, 0.0, 1.0],
        color:    [0.0, 0.0,  1.0,  1.0], normal:    [0.0, 1.0, 0.0] },
    PTCNVertex { position: [0.5,  -0.5, 0.0], tex_coord: [1.0, 0.0, 1.0],
        color:    [1.0, 0.0,  0.0,  1.0], normal:    [0.0, 1.0, 0.0] },
  ]
).unwrap()

(Poorly) recoloured sprites for different teams

Texture Arrays are your friend

Great for sprites

Run out of space?

Add another layer

Different sides/factions?

Add layers with recoloured versions

image

https://github.com/PistonDevelopers/image

The last image library you'll need

let image = image::open(&Path::new("img/tiles.png")).unwrap().to_rgba();
let image_dimensions = image.dimensions();

let image_raw = texture::RawImage2d::from_raw_rgba_reversed(
  image.into_raw(),
  image_dimensions
);

let texture_array = texture::Texture2dArray::new(
  display,
  vec![image_raw]
).unwrap();

Getting Quads back

OpenGL doesn't have quads

We can just duplicate bottom-left and top-right vertices

Efficient* Quads

pub struct QuadTesselator<T> {
  vertices: Vec<T>,
}

impl<T : Copy> QuadTesselator<T> {
  pub fn add_quad(&mut self, ts:[T; 4]) {
    for ele in ts.iter() {
      self.vertices.push(*ele);
    }
    self.vertices.push(ts[0]);
    self.vertices.push(ts[2]);
  }
}

*Developer efficient, embrace #Derive(Copy)

20:00

Generating geometry: Walls and Floors

"Base Anchored Wall"

"Smart" quad tesselator understands desired pixel scale and texture sizes to give consistent pixel density

Rendering becomes

for x in 0..16 {
  for z in 0..16 {
    tesselator.draw_floor_tile(&ground_tile, 0,
          x as f64, 0.0, z as f64, 0.0, false);
  }
}

tesselator.draw_wall_base_anchored_at(&man, 0,
          Vec3::new(1.5, 0.0, 1.5), 0.0, false);
tesselator.draw_floor_centre_anchored_at(&man_shadow, 0,
          Vec3::new(1.5, 0.0, 1.5), 0.01, false);

if let &Some(its) = intersection {
  let x = round_down(its.x);
  let z = round_down(its.z);
  tesselator.draw_floor_tile(&indicator, 0,
          x as f64, 0.0, z as f64, 0.02, false);
}

18:00

Cameras and Tiles

cgmath

https://github.com/brendanzab/cgmath

All the vecs, mats, dots, crosses and inverts you need for a game

Pixel Perfect Camera

pub fn projection(zoom:f64,
                  width:u32,
                  height:u32,
                  pixels_per_unit: f64) -> Mat4 {

  let effective_width = (width as f64) / (zoom * pixels_per_unit);
  let effective_height = (height as f64) / (zoom * pixels_per_unit)
                                         / (2.0_f64).sqrt();
  let half_width = effective_width / 2.0;
  let half_height = effective_height / 2.0;

  cgmath::ortho(-half_width, half_width,
    -half_height, half_height,
    -100.0, 100.0
  )
}

Blending & Depth

let draw_parameters = glium::DrawParameters {
   depth: glium::Depth {
       test: glium::draw_parameters::DepthTest::IfLess,
       write: true,
       .. Default::default()
   },
   blend: glium::Blend::alpha_blending(),
   .. Default::default()
};

16:00

Everything's wrong and I hate the world

Assumed my camera creation was wrong, and spent an hour debugging it

 

Example shader had matrix multiplication reversed

gl_Position = vec4(position, 1.0) * matrix;

gl_Position = matrix * vec4(position, 1.0);

15:00

Interaction

Input Handling

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct KeyState {
  pub down: HashSet<glutin::VirtualKeyCode>,
  pub pushed: HashSet<glutin::VirtualKeyCode>, // pushed this frame
  pub released: HashSet<glutin::VirtualKeyCode>, // released this frame
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InputState {
  pub mouse:MouseState,
  pub keys:KeyState,
}

pub fn produce(input:&InputState, events: &Vec<glutin::Event>)
    -> InputState

We need to remember a few things between frames

Mouse Picking

From windowed mouse coordinates to world objects

pub fn ray_for_mouse_position(&self, x:i32, y:i32)
    -> Option<camera::Line>

#[derive(PartialEq, Debug, Copy, Clone)]
pub struct Line {
  pub from: Vec3,
  pub to: Vec3,
}

impl Line {
  pub fn intersects(&self, plane:Plane) -> Option<Vec3>

In our main loop

let new_input_state = input::produce(&input_state, &evs);
let (mouse_x, mouse_y) = input_state.mouse.at;
let line = render_state.ray_for_mouse_position(mouse_x, mouse_y);
intersection = line.and_then(|l| l.intersects(ground_plane) );

12:00

Hey, you said there'd be games you lying bastard ...

Game in 12 hours anyone?

Set your expectations low

No, lower than that, like real low

Think

Tile laying
&
Mountain climbing

Grom, The Mountain God

Good? Evil? You're the big stone head

Help everyone up?

That's just lemmings

Make sure nobody gets up?

That's just weird tower defense

Get the chosen one to the mountain top

> 1 You Lose ... < 1 You Lose

Who's the chosen one?

Clearly the guy that made it

Climbing is dangerous

11:45

Let's reverse things

9:00

First Steps

#[derive(Clone,Copy,Debug,Eq,PartialEq)]

 

Simple & Inefficient
Why borrow when you can copy? (don't answer that)

 

Disclaimer

This is not idiomatic Rust, these are the fever dreams of a Scala developer who can't even remember what life is like without a garbage collector

Game State

pub type WorldGrid = Vec<Vec<TileId>>;

pub struct World {
    pub tick: Tick,
    pub size: Vec2i,
    pub tiles: WorldGrid,
    pub climbers_by_tile : multimap::MultiMap<Vec2i, u32>,
    pub climbers_by_id: HashMap<u32, Climber>,
}

pub fn advance_world<R : Rng>(world:&World, tiles: &Tiles, rng: &mut R)
  -> World

Tiles

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct Tile {
    pub name: String,
    pub id: TileId,
    pub nodes: Vec<InnerBlockLocation>, // connected
}

World Gen

for x in 0..size.x {
  for y in 0..size.y {
    let tile = &tiles.safe[rng.gen_range(0, tile_count)];
    let tile_id = tile.id;
    placed_tiles[x][y] = place(tile_id);
  }
}
placed_tiles[0][0] = place(spawner.id);
placed_tiles[1][0] = place(flat.id);
placed_tiles[rng.gen_range(0, size.x)][top_row] = place(stone_head.id);

Rendering it

for x in 0..(world_size.x as usize) {
  for y in 0..(world_size.y as usize) {
    let tile_id = game_state.world.tiles[x][y].id as usize;
    let texture_region = &rs.tile_renderers[tile_id];
    tesselator.draw_wall_tile(&texture_region, 0, x, y, 0.0, 0.0, false);
    tesselator.draw_wall_tile(&texture_region, 1, x, y, 0.0, 0.1, false);
    tesselator.draw_wall_tile(&texture_region, 2, x, y, 0.0, 0.2, false);
  }
}

7:00

Placing tiles

// place tile
if let (Some(is), true) = (intersection,
                  new_input_state.mouse.left_pushed())  {
  // can't place on top of climbers
  if game_state.world.can_place_at(&tiles, is) {
    if let Some((tile_id, _)) = game_state.tile_queue.pop_front() {
      game_state.world.tiles[is.x as usize][is.y as usize] =
                                                    place(tile_id);
      tile_placement.play(); // sound
    }
  }
}

5:00

Ears for sound

https://github.com/jhasse/ears

  • Simple cross-platform sound
  • Needs OpenAL & libsndfile at runtime
  • Works cross platform (unlike some others)

let mut tile_placement = Sound::new("snd/place_tile.ogg").unwrap();

tile_placement.play();

4:00

Now with climbers

Climbers

#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub struct Climber {
    pub id: UniqGameId,
    pub prev: TimedLocation,
    pub next: TimedLocation,
}

#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub struct TimedLocation {
    pub loc: Vec2i,
    pub inner_loc: Vec2i,
    pub at: Tick,
}

impl TimedLocation {
  pub fn exact_location(&self, z:f64) -> Vec3

Rendering climbers

impl Climber {
  pub fn exact_location_at(&self, tick:Tick, z:f64) -> Vec3 {
    let time = clamp(tick, self.prev.at, self.next.at);
    let action_duration = self.next.at - self.prev.at;
    let progress = if action_duration == 0 {
        1.0
    } else {
        (time - self.prev.at) as f64 / action_duration as f64
    };
    lerp(self.prev.exact_location(z),
         self.next.exact_location(z),
         progress);

2:00

ClimberS, it's ugly code time

Movement Rules

  1. If you're at the stone head, climb in
  2. Go somewhere new that's not down
  3. Go somewhere new (that's down)
  4. Wait (and reset where you've been)

Travellable Locations

pub fn travellable_locations(&self, from:Vec2i, tiles:&Tiles)
  -> Vec<(Vec2i, Vec2i)> {
  let from_tile = tiles.with_id(self.tile_at(from).id);

  self.adjacent_locations(from).into_iter().filter_map( |tl| {
    let to_tile = tiles.with_id(self.tile_at(tl).id);
    can_travel(from, from_tile, tl, to_tile).map(|il| (tl, il))
  }).collect()
}

Everything would be a stack allocated
lazy iterator if I was better at this

00:00

Everybody loves chiptunes

https://soundcloud.com/eric-skiff/come-and-find-me

Eric Skiff

Springs for smoothness

pub fn smooth_3d(from:Vec3, to:Vec3, velocity:Vec3, smooth_time:f64,
                 time_delta:f64) -> (Vec3, Vec3) {
  let omega = 2.0 / smooth_time;
  let x = omega * time_delta;
  let exp = 1.0 / (1.0 + x + 0.48 * x * x + 0.235 * x * x * x);
  let change = from - to;

  let tmp = (velocity + change * omega) * time_delta;

  let new_velocity = (velocity - omega * tmp) * exp;

  (to + (change + tmp) * exp, new_velocity)
}

Attach them to everything

Parallax clouds

let mut render_pos = Vec3::new(pos.x, pos.y, -8.0 + pos.z);

render_pos -= rs.camera_target.position *
                    pos.z * 0.25; // moved based on camera

tesselator.draw_wall_centre_anchored_at(&cloud_renderer,
    0, render_pos, 0.0, false)

Realistic head bob

let seed = ((climber.id as f64 * 1732.0) % 17.0) / 17.0;

let up = ((climber_state.walk_progress + seed) % 0.05) < 0.025;
if up {
    pos.y += 0.02;
}

#[derive(Debug, Clone, PartialEq)]
pub struct ClimberRenderState {
    pub spring: SpringState3,
    pub walk_progress: f64,
}

"Dynamic" crowd sounds

let mut walk_sound = Sound::new("snd/walk.ogg").unwrap();
walk_sound.set_looping(true);
walk_sound.set_volume(0.0);
walk_sound.play();

loop {
  let mut total_vel = 0.0_f64;
  for (_,css) in &render_state.entity_springs {
      total_vel += css.spring.velocity.magnitude();
  }
  let volume = total_vel.log(2.0) / 4.0;
  walk_sound.set_volume(clamp(volume as f32, 0.0, 0.45));

3D climbing depth

let depth_offset = seed * 0.20 + 0.11;

Thanks for listening

Engine source is up at
https://github.com/MichaelShaw/rust-game-24h

Game is up at
https://github.com/MichaelShaw/grom