Aimlessly Going Forward

Writing a Rust Roguelike for the Desktop and the Web

I want to participate in this
year's 7 Day Roguelike Challenge. If you've looked around this
blog, you know the language is going to be Rust. But for the
7DRL, I'd really love if people could play it in the browser.

For a game jam, you want to as easy as possible for people to play
your game. There's so many to choose from, it's crucial to remove any
obstacles your perspective players can run into. A playable link is
pretty much as close as you can get.

What follows is a little guide to get you to a small playable proof of
concept that can build native Windows, macOS and Linux executables but
also runs in the browser via WebAssembly.

Motivation

Rust is a pretty good language for writing roguelikes: it is fast,
modern (modules, closures, good collections in the standard library,
powerful macros, great enums) and it can build standalone executables
for all the major platforms as well the web!

The last bit is especially cool for game jams -- you don't have to
worry about software packaging and distribution. Just give people a
URL and they can play your game.

We're going to build a skeleton for a traditional ASCII roguelike. You
can take it and turn it into a real game. The same codebase will work
for all three the major platforms as well as the web. And it will
support multiple fonts and text sizes so you can have your square maps
and readable text at the same time!

We're disabling most of the features. You don't have to do this, but
some of these require libraries you might not have installed. The
above should compile pretty much anywhere.

You can always add the sound or gamepad support later if you need it.

Run the program again to build the quicksilver dependency:

$ cargo run --release

This might take a couple of minutes. It should print out the same
hello world message as before.

We'll be always building the optimised version in this guide. You
can drop the --release flag but if you do, you're not allowed to
make any speed measurements. Rust's debug builds are slower than you
think. They're slower than unoptimised C++. They're slower than
Ruby.

Hello, Game!

The first thing we'll do is create a window and print some text on it.
We'll do all our coding in the src/main.rs file in the
game repository. It's less than 300 lines total.

The Settings struct lets us control various engine
settings that we'll get to later. The 800 and 600 numbers
represent the logical size of your window. Depending on your DPI
settings, it might be bigger than that.

And finally, we need to bring all the items we use into scope. Put
this at the top of your file:

Running the game should now produce an empty window filled with black:

$ cargo run --release

Assets

You may have noticed the following message when running the code:

Warning: no asset directory found. Please place all your assets inside
a directory called 'static' so they can be loaded
Execution continuing, but any asset-not-found errors are likely due to
the lack of a 'static' directory.

Quicksilver expects all the game assets to be in the static
directory under the project's root. We don't have one, hence the
warning.

Font rendering takes a lot computational of work. It has to rasterise
the glyphs, handle kerning, etc. Drawing an image is much faster. So
we do all the hard work once, store the results and then just draw a
static image later.

To make both of our images (title and the credits) available to the
draw method, we'll store them in the Game struct:

We're not storing the images directly -- we're storing them
in an Asset<Image>. An Asset wraps a Future that is, a
value that might not actually exist yet (because the font did not
finish loading).

To get to the Asset's inner value, we need to call execute and
pass in a closure that operates on that asset (an Image in
our case). If it's loaded, the closure will be called, if not, nothing
will happen (but the program will keep going).

There's also a [Asset::execute_or] which can call a function if
the loading did not complete yet.

Inside the closure we call window.draw which takes two
parameters: a Drawable (an object that can be drawn: a
Rectangle in our case) and a background.

There's also window.draw_ex with more options such as
transformation to apply or z layer (what's on top of what).

The background can be an image (Background::Img), colour fill
(Background::Col) or a combination of the two
(Background::Blended).

We're drawing images so we use Img(&image).

This might seem backward: we've got an image, so why do we draw a
rectangle and set the image as the background? That's how OpenGL and
similar APIs work: you draw shapes and you either fill them with
colour or a texture (our image).

If your system is configured for a DPI that's 1.3, the window size
(with all its contents) will be scaled up to it. This is a very
important accessibility feature and not handling it properly can make
your program too small for people with bad eyesight or a "Retina
display".

The problem here isn't the DPI itself, but how the image gets
stretched.

By default, Quicksilver uses the Pixelate scale strategy
which tries to preserve the individual pixels. This looks great at 2x,
3x etc. scales, but not so much at a 1.3x. Especially for text
rendering.

If you want to have a full control over your the window and text size,
add this line at the beginning of your main function:

std::env::set_var("WINIT_HIDPI_FACTOR","1.0");

That will force the DPI to be 1.0. Games are more sensitive to scaling
than other application due to their pixel-based visual nature, so this
can be OK. However you ought to provide a way of scaling the UI from
within your game in that case! Ideally, defaulting to the system's DPI value.

We will still keep the Blur scaling strategy. Quicksilver's
coordinates are floating point numbers and things like with_center
can easily result in non-integer values. Again, these tend to look
better with Blur.

Generating the game map

Time to draw the actual game. We need a map and later on, the player,
some items and NPCs.

You might consider defining your own types for position, size, etc.
Vector uses f32 (you may prefer integers) and if you overload
its meaning (e.g. using it for pixel as well as map tile
coordinates), you can end up mixing them by accident.

A proper roguelike would use a procedural / random generation to build
the map. We're just going to create an empty rectangle with # as the
edges:

Yep this meens food and doors would have hit points. That's not as
weird as it might seem (think of fire destroying everything by
lowering HP -- that can apply to any entity not just living things).
If you've got zillions entities however, storing every field for
every entity may be inefficient. Check out
the entity-component-system pattern for an alternative.

We need to add the player (represented, as always, by the @ symbol),
too!

Having all the entities (monsters, items, NPCs, player, etc.) in one
place (the entities Vec) is quite useful, but the player character
is always a little special. We often need to access it directly to
show its health bar, update it's position on key presses, etc.

So let's also store the player's index. That way we can look them up
any time we want.

Ours is going to be built of letters not pictures, but the principle
is the same. And if you want, you can replace it with actual graphics
later.

This is one of the reasons we'll build an atlas rather than calling
Font::render for each character or line on the map. We'd have to
do it for images and this lets us swap them out (or support both)
later. Plus it's more efficient.

Games usually build these atlases during development and then only
ship the composite image. You only need to do it once, after all.

We're going to be a bit lazy and wasteful here, but you can (and
probably should) do that in your build script instead.

One reason we do it in main.rs is that all our code is in one
place. That makes this tutorial easier to follow. Not recommended
for a bigger project.

First, we will list all the characters we're going to render. Put this
in Game::new after our entity code:

letgame_glyphs="#@g.%";

These are the characters we're going to use. A bigger game will have
more of these and you may want to generate them from your map and
entities instead of hardcoding them like we do.

Then we let Quicksilver do its thing and render it into an Image
just like before.

Drawing a part of an image is done via
the subimage method. You give it a Rectangle and
returns a new Image covering that portion and nothing else.

This will not clone the image's contents. Image contains a
reference-counted pointer back to the source, so the operation is
quick and doesn't take up a lot of memory.

We could either call subimage directly in our draw function, or we
could generate a sub-image once for each glyph and then just reference
those when drawing. We're going to do the latter and use
a HashMap to get from a char to the corresponding
Image.

There's a ton of other ways to do this. For example: create an image
of all ASCII characters and then have all the subimages in a
Vec<Image>. Each image's index would be its ASCII value. This
would probably be faster, but it could waste a little more memory
and you'll need to check that your char (a 32-bit Unicode value)
can be converted to the right range. Also, what if you want to add
some good-looking Chinese glyphs? You should measure and decide on
trade-offs that suit your game.

We need to record the size of each tile (so we can pick it out of the
tileset). Our font is twice as tall as it is wide, so 24x12 pixels
should do nicely:

lettile_size_px=Vector::new(12,24);

And build the tileset:

lettileset=Asset::new(Font::load(font_mononoki).and_then(move|text|{lettiles=text.render(game_glyphs,&FontStyle::new(tile_size_px.y,Color::WHITE)).expect("Could not render the font tileset.");letmuttileset=HashMap::new();for(index,glyph)ingame_glyphs.chars().enumerate(){letpos=(indexasi32*tile_size_px.xasi32,0);lettile=tiles.subimage(Rectangle::new(pos,tile_size_px));tileset.insert(glyph,tile);}Ok(tileset)}));

The beginning is the same as our other font-rendering: we load the
font and build the image.

The rest creates a new HashMap and then creates a new sub-image for
every glyph.

This relies on the fact that every glyph has the same width. In
other words, it only works for monospace fonts such as ononoki. If
you want to use a proportional font (say Helvetica), you will need
to build the font-map yourself. You can use the rusttype library
to do it.

If we called self.tileset.excute without the let lines above it,
it would mutably borrow the entire Game struct and we wouldn't be
able to access self.map or self.tile_size_px. So we do a partial
borrow and call execute on that.

Try removing the let lines and use self.map etc. in the draw
function. See what happens!

The Vector::times method multiplies the corresponding Vector
elements. So v1.times(v2) is the same as: Vector::new(v1.x * v2.x,
v1.y * v2.y). This gets us from the tile position (from 0 to 20)
to the pixel position on the screen (from 0 to 240).

There are a few different ways to multiply vectors in Maths (cross
product, dot product, per-element), so they're all available as
separate methods with their own names instead the v1 * v2 operator
you might expect.

And finally, the Blended background option allows us to apply a
colour to the pixels on the picture. Since our glyphs are white, this
turns them into whatever colour we set.

We need to add it to our use statement:

quicksilver::graphics::Background::Blended

And that should do it:

Looking good, but the map is in the top-left corner, obscured by the
title text! Let's fix that.

We'd like to move the whole map out of the title's way. That means
shifting each tile that we draw. Let's say 50 pixels to the right
and 120 down.

letoffset_px=Vector::new(50,120);

And then in window.draw we'll add offset_px to pos_px in the
Rectangle::new call:

Adding a square font

This starts to look like a roguelike, but we can improve upon it.
Why are the tiles not square? Personal preference aside (whatever
floats your boat), in our case it's just an artefact of the font we're
using.

We've picked mononoki, because we like it! It looks great, has
visually distinct characters and even normal text looks decent in it
(though when it comes to reading a block of actual text, nothing beats
proportional fonts).

"we" == Tomas Sedovic. I like mononoki. It's awesome. If you
disagree, pick a different font!

But it's not a square font.

If we were writing a terminal game or using a library that emulates
one (such as libtcod), everything would be the same font and you'd
have to choose between a square font (good for the map, bad for text)
or a non-square one (good for text, bad for the map).

Neither is a great option, but all old-school roguelikes were that
way.

We can do (arguably) better, however!

Let's just pick a second font with square proportions and use that for
the map (and keep doing text with mononoki).

For a font with square proportions, you clearly can't do better than
Square:

It's licensed under CC BY 3.0. Download it and put square.ttf in the
static folder.

You can also just keep using a non-square font and simply center
each glyph into a square tile. I did that in my first game. It's
fine.

We'll need to tweak a few things in Game::new. We'll add the new
font file name and then replace font_mononoki in the tileset's
Font::load with font_square:

letfont_square="square.ttf";letgame_glyphs="#@g.%";lettile_size_px=Vector::new(12,24);lettileset=Asset::new(Font::load(font_mononoki).and_then(move|text|{lettiles=text.render(game_glyphs,&FontStyle::new(tile_size_px.y,Color::WHITE)).expect("Could not render the font tileset.");letmuttileset=HashMap::new();for(index,glyph)ingame_glyphs.chars().enumerate(){letpos=(indexasi32*tile_size_px.xasi32,0);lettile=tiles.subimage(Rectangle::new(pos,tile_size_px));tileset.insert(glyph,tile);}Ok(tileset)}));

You might wonder whether we should also update tile_size_px. We
should! Look what happens if we don't:

Glitches like these are one of gamedev's lesser-known pleasures.

Make the tile size a proper square:

lettile_size_px=Vector::new(24,24);

Take that, 1950s terminals!

Z3, one of the first computers with a textual terminal had 1408
bits of data memory. Our tileset image alone has 11,520
bytes.

Square credit

Since we've added another font, let's show our appreciation to its
author too!

And finally draw it. First we draw the full width in a somewhat
transparent colour and then the current value in full red:

// Full healthwindow.draw(&Rectangle::new(health_bar_pos_px,(full_health_width_px,tile_size_px.y)),Col(Color::RED.with_alpha(0.5)),);// Current healthwindow.draw(&Rectangle::new(health_bar_pos_px,(current_health_width_px,tile_size_px.y)),Col(Color::RED),);

And we need to usequicksilver::graphics::Background::Col. That's
the final Background value -- representing the whole area filled
with the given colour.

Move the player around

Games have to be interactive. Let's move our player if any of the
arrow keys are pressed:

Please make sure you don't ship your game with this left in! Someone
will press Esc unintentionally and lose their progress (or at
least be annoyed they have to restart the game). That someone will
be me. Please add a confirmation step before closing the window.

We'll need to add quicksilver::input::Key to our use declarations.

As you can see, the player can walk through everything. The goblins,
food, even the walls! This is fine if you're making a roguelike where
you're a ghost, but probably not in most other circumstances.

We're not going to fix that here either! Your second homework.

Web Version

One last thing.

Running the game with cargo run builds the desktop version. But we
promised that Quicksilver can do a web version too.