Tetra

Build Status Crates.io Minimum Rust Version Documentation License

Tetra is a simple 2D game framework written in Rust. It uses SDL2 for event handling and OpenGL 3.2+ for rendering.

Features

  • XNA/MonoGame-inspired API
  • Efficient 2D rendering, with draw call batching by default
  • Simple input handling
  • Animations/spritesheets
  • TTF font rendering
  • Multiple screen scaling algorithms, including pixel-perfect variants (for those chunky retro pixels)
  • Deterministic game loop, à la Fix Your Timestep

Installation

To add Tetra to your project, add the following line to your Cargo.toml file:

tetra = "0.2"

Tetra currently requires Rust 1.32 or higher.

You will also need to install the SDL2 native libraries - full details are provided in the documentation.

Examples

To get a simple window displayed on screen, the following code can be used:

use tetra::graphics::{self, Color};
use tetra::{Context, ContextBuilder, State};

struct GameState;

impl State for GameState {
    fn draw(&mut self, ctx: &mut Context, _dt: f64) -> tetra::Result {
        // Cornflower blue, as is tradition
        graphics::clear(ctx, Color::rgb(0.392, 0.584, 0.929));
        Ok(())
    }
}

fn main() -> tetra::Result {
    ContextBuilder::new("Hello, world!", 1280, 720)
        .build()?
        .run(&mut GameState)
}

You can see this example in action by running cargo run --example hello_world.

The full list of examples is available here.

Support/Feedback

Tetra is fairly early in development, so you might run into bugs/flaky docs/general weirdness. Please feel free to open an issue/PR if you find something! You can also contact me via Twitter, or find me lurking in the #game-and-graphics-dev channel on the Rust Community Discord.

Tutorial

Installation

Prerequisites

To get started with Tetra, you'll need a couple of things installed:

  • Rust 1.32 or higher
  • The SDL 2.0 development libraries
  • The ALSA development libraries (only required on Linux)

Most of this is one-time setup, so let's get it out of the way!

Installing Rust

Installing Rust is pretty simple - just go to the website and download the Rustup toolchain manager.

Note that if you're developing on Windows with the default toolchain, you'll also need to install the Microsoft Visual C++ Build Tools, as Rust uses the MSVC linker when building.

Installing SDL 2.0

Tetra uses SDL for windowing and input, so you will need to have both the runtime and development libraries installed.

The instructions below are adapted from the README of the sdl2 crate - further information can be found there.

Windows

If you're using the default MSVC Rust toolchain:

  1. Go to the SDL website and download the Visual C++ version of the development libraries.
  2. Copy the .lib files from the SDL2-2.0.x/lib/x64 folder of the zip to the %USERPROFILE/.rustup/toolchains/stable-x86_64-pc-windows-msvc/lib/rustlib/x86_64-pc-windows-msvc/lib folder on your machine. If you are building on a beta/nightly toolchain, adjust the location accordingly.

If you're using the GNU-based Rust toolchain:

  1. Go to the SDL website and download the MinGW version of the development libraries.
  2. Copy the .lib files from the SDL2-2.0.x/x86_64-w64-mingw32/lib folder of the zip to the %USERPROFILE/.rustup/toolchains/stable-x86_64-pc-windows-gnu/lib/rustlib/x86_64-pc-windows-gnu/lib folder on your machine. If you are building on a beta/nightly toolchain, adjust the location accordingly.

Mac

The easiest way to install SDL is via Homebrew:

brew install sdl2

You will also need to add the following to your ~/.bash_profile, if it is not already present.

export LIBRARY_PATH="$LIBRARY_PATH:/usr/local/lib"

Linux

The SDL development libraries are distributed through most Linux package managers - here are a few examples:

# Ubuntu/Debian
sudo apt install libsdl2-dev

# Fedora/CentOS
sudo yum install SDL2-devel

# Arch Linux
sudo pacman -S sdl2

Installing ALSA (Linux only)

On Linux, ALSA is used as the audio backend, so you will also need the ALSA development libraries installed. Similar to SDL, you can find these libraries on most Linux package managers:

# Ubuntu/Debian
sudo apt install libasound2-dev

# Fedora/CentOS
sudo yum install alsa-lib-devel

# Arch Linux
sudo pacman -S alsa-lib

Creating a New Project

Now that you've got the dependencies set up, we can start making a game!

First, create a new Cargo project:

cargo new --bin my-first-tetra-game

Then, add Tetra as a dependency in Cargo.toml:

[dependencies]
tetra = "0.2"

If you're on Windows, you'll need to place the SDL2 .dll in the root of your project (and alongside your .exe when distributing your game). You can download this from the 'runtime binaries' section of the SDL website.

Next Steps

Once those steps are complete, you're ready to start writing your first game with Tetra!

Getting Started

Once you have installed the native dependencies and set up your project, you're ready to start writing a game!

Creating Some State

The first step is to create a struct to hold your game's state. To begin with, let's create some text, and store a position where we want to render it:

use tetra::graphics::{Text, Font, Vec2};

struct GameState {
    text: Text,
    position: Vec2,
}

impl GameState {
    fn new() -> GameState {
        GameState {
            text: Text::new("Hello, world!", Font::default(), 16.0),
            position: Vec2::new(0.0, 0.0),
        }
    }
}

Adding Some Logic

Now that we have some data, we need a way to manipulate it. In Tetra, you do this by implementing the State trait.

State has two methods - update, which is where you write your game logic, and draw, which is where you draw things to the screen. By default, the former is called 60 times a second, and the latter is called in sync with your monitor's refresh rate.

Let's write some code that draws our text moving across the screen:

use tetra::graphics::{self, Color};
use tetra::{State, Context};

impl State for GameState {
    fn update(&mut self, ctx: &mut Context) -> tetra::Result {
        self.position.x += 1.0;

        Ok(())
    }

    fn draw(&mut self, ctx: &mut Context, _dt: f64) -> tetra::Result {
        graphics::clear(ctx, Color::rgb(0.392, 0.584, 0.929));
        graphics::draw(ctx, &self.text, self.position);

        Ok(())
    }
}

You might have a few questions after reading that code:

  • What's that Context object that we're passing around? Where does it come from?
  • Why do we return Ok(()) from the methods?

To answer these, we'll need to write our program's main function, and actually start our game!

Building a Context

Context is the object that represents all the global state in the framework (the window settings, the rendering engine, etc.). Most functions provided by Tetra will require you to pass the current context, so that they can read from/write to it. As your game grows, you'll probably write your own functions that pass around Context, too.

Let's build a new context with a window size of 1280 by 720, and run an instance of our GameState struct on it:

use tetra::ContextBuilder;

fn main() -> tetra::Result {
    ContextBuilder::new("My First Tetra Game", 1280, 720)
        .build()?
        .run(&mut GameState::new())
}

Note that both our main function and the run method return tetra::Result, just like our update and draw did. If we'd returned an error from update or draw instead of Ok(()), the game would stop running, and run would return that error to be handled or logged out. In our case, we just pass it on as main's return value too - Rust will automatically print errors in this case.

If you run cargo run from the command line, you should now see your text scrolling across the screen!

Next Steps

In the next chapter, we'll try loading a texture to display on the screen.

Here's the full example from this chapter:

use tetra::graphics::{self, Color, Text, Font, Vec2};
use tetra::{State, Context, ContextBuilder};

struct GameState {
    text: Text,
    position: Vec2,
}

impl GameState {
    fn new() -> GameState {
        GameState {
            text: Text::new("Hello, world!", Font::default(), 16.0),
            position: Vec2::new(0.0, 0.0),
        }
    }
}

impl State for GameState {
    fn update(&mut self, ctx: &mut Context) -> tetra::Result {
        self.position.x += 1.0;

        Ok(())
    }

    fn draw(&mut self, ctx: &mut Context, _dt: f64) -> tetra::Result {
        graphics::clear(ctx, Color::rgb(0.392, 0.584, 0.929));
        graphics::draw(ctx, &self.text, self.position);

        Ok(())
    }
}

fn main() -> tetra::Result {
    ContextBuilder::new("My First Tetra Game", 1280, 720)
        .build()?
        .run(&mut GameState::new())
}

Loading a Texture

In the last chapter, we got a basic window on the screen, and displayed some text. Now let's take it one step further, and display an image from a file.

If you didn't follow along with the previous chapter, copy the final code into your main.rs.

Adding a Texture to GameState

For this example, we'll use a sprite that I drew - excuse the programmer art:

A terrible pixel art knight.

Save that image to the root of your project. We can now load it in our GameState constructor:

use tetra::graphics::{Texture, Vec2};
use tetra::Context;

struct GameState {
    texture: Texture,
    position: Vec2,
}

impl GameState {
    fn new(ctx: &mut Context) -> tetra::Result<GameState> {
        Ok(GameState {
            texture: Texture::new(ctx, "./player.png")?,
            position: Vec2::new(0.0, 0.0),
        })
    }
}

Note that:

  • We now need to pass the Context into the constructor so that we can pass it to Texture::new - this is so the texture can be loaded into your GPU memory.
  • Texture::new can fail (e.g. if you enter the wrong path), so we need to return tetra::Result<GameState> instead of just GameState.

The game logic should pretty much stay the same, although you'll need to amend your draw method to replace self.text with self.texture.

Constructing the State

If you try to run your game now, you'll get a couple of compile errors, due to the changes we made to our constructor:

error[E0061]: this function takes 1 parameter but 0 parameters were supplied
  --> main.rs:39:10
   |
10 |     fn new(ctx: &mut Context) -> tetra::Result<GameState> {
   |     ----------------------------------------------------- defined here
...
39 |         .run(&mut GameState::new())
   |                   ^^^^^^^^^^^^^^^^ expected 1 parameter

error[E0277]: the trait bound `std::result::Result<GameState, tetra::error::TetraError>: tetra::State` is not satisfied
  --> main.rs:39:10
   |
39 |         .run(&mut GameState::new())
   |          ^^^ the trait `tetra::State` is not implemented for `std::result::Result<GameState, tetra::error::TetraError>`

The obvious solution would be to write something like this:

fn main() -> tetra::Result {
    let mut ctx = ContextBuilder::new("My First Tetra Game", 1280, 720)
        .build()?;

    let mut state = GameState::new(&mut ctx)?;

    ctx.run(&mut state)
}

But wait, there's a simpler way! Context provides a method called run_with that will use a closure to initialize your state before running the game:

fn main() -> tetra::Result {
    ContextBuilder::new("My First Tetra Game", 1280, 720)
        .build()?
        .run_with(|ctx| GameState::new(ctx))
}

And since our constructor's function signature is the same as that of the closure that run_with expects, we can simplify this even further:

fn main() -> tetra::Result {
    ContextBuilder::new("My First Tetra Game", 1280, 720)
        .build()?
        .run_with(GameState::new)
}

Much nicer! If you run the game now, you should see our little knight running across the screen!

Next Steps

This is currently the last chapter of the tutorial (although more will be added in the future). To learn more about using Tetra, check out the API documentation, or look at the examples on GitHub.

Here's the full example from this chapter:

use tetra::graphics::{self, Color, Texture, Vec2};
use tetra::{State, Context, ContextBuilder};

struct GameState {
    texture: Texture,
    position: Vec2,
}

impl GameState {
    fn new(ctx: &mut Context) -> tetra::Result<GameState> {
        Ok(GameState {
            texture: Texture::new(ctx, "./player.png")?,
            position: Vec2::new(0.0, 0.0),
        })
    }
}

impl State for GameState {
    fn update(&mut self, ctx: &mut Context) -> tetra::Result {
        self.position.x += 1.0;

        Ok(())
    }

    fn draw(&mut self, ctx: &mut Context, _dt: f64) -> tetra::Result {
        graphics::clear(ctx, Color::rgb(0.392, 0.584, 0.929));
        graphics::draw(ctx, &self.texture, self.position);

        Ok(())
    }
}

fn main() -> tetra::Result {
    ContextBuilder::new("My First Tetra Game", 1280, 720)
        .build()?
        .run_with(GameState::new)
}

Examples

Tetra has a fairly large suite of examples - to try them out, clone the repository and run:

cargo run --example example_name

You can also click on the name of the example below to view the source code.

Name Category Description
hello_world Basic Opens a window and clears it with a solid color.
texture Graphics Loads and displays a texture.
animation Graphics Displays an animation, made up of regions from a texture.
text Graphics Displays text using a TTF font.
nineslice Graphics Slices a texture into nine segments to display a dialog box.
scaling Graphics Demonstrates the different screen scaling algorithms.
shaders Graphics Uses a custom shader to render a texture.
canvas Graphics Uses a custom render target to apply post-processing effects.
interpolation Graphics Demonstrates how to interpolate between ticks.
audio Audio Plays back an audio file.
keyboard Input Moves a texture around based on keyboard input.
animation_controller Input Moves a sprite around, with the animation changing based on keyboard input.
mouse Input Moves a texture around based on mouse input.
gamepad Input Displays the input from a connected gamepad.
text_input Input Displays text as it is typed in by the player.
bunnymark Benchmark Benchmarks rendering performance by rendering lots of bunnies.
tetras Game A full example game (which is entirely legally distinct from a certain other block-based puzzle game cough).

Showcase

This page is a showcase for projects that are using Tetra.

Please feel free to add your own!

Games

Name Author Details
Mankojai puppetmaster A puzzle game, created for the Nokia 3310 Jam.
Shoot Out Your Life puppetmaster An arcade shooter where your ammo is your lives. Made for Ludum Dare 44.
rl 17cupsofcoffee A tech demo, showing how a roguelike can be built with Tetra and Specs.
Tetras 17cupsofcoffee A Tetris clone, built to demonstrate what a full Tetra game might look like.

FAQ

General

Will Tetra be written in pure Rust eventually?

Probably not - SDL2 is a stable and well-tested foundation for building games, and it runs on basically every platform under the sun, so I'm hesitant to replace it. That's likely to remain the only non-Rust dependency, however.

If you're looking for a similar engine that is working towards being pure Rust, GGEZ and Quicksilver are excellent options.

Why is it called Tetra?

I'm terrible at naming projects, and this happened to be playing when I was typing cargo new. I wish there was a better origin story than that :D

Do I have to install SDL manually?

It's possible to have your project automatically compile SDL2 from source as part of the build process. To do so, specify your dependency on Tetra like this:

[dependencies.tetra]
version = "0.2"
features = ["sdl2_bundled"]

This is more convienent, but does however require you to have various build tools installed on your machine (e.g. a C compiler, CMake, etc). In particular, this can be a pain on Windows - hence why it's not the default!

Can I static link SDL?

If you want to avoid your users having to install SDL2 themselves (or you having to distribute it as a DLL), you can specify for it to be statically linked:

[dependencies.tetra]
version = "0.2"
features = ["sdl2_static_link"]

This comes with some trade-offs, however - make sure you read this document in the SDL2 repository so that you understand what you're doing!

Compatibility

Why am I getting a black screen?

First, check the debug output on the console to see which OpenGL version your drivers are using - Tetra primarily aims to support 3.2 and above, and explicitly will not work with anything lower than 3.0.

If your OpenGL version is higher than 3.0 and you're still getting a black screen, that may indicate a bug - I currently only have access to a Windows machine with a reasonably modern graphics card, so it's not outside the realms of possibility that something that works for me might be broken for others! Please submit an issue, and I'll try to fix it and release a patch version.

Performance

Why is my game running slow?

Rust's performance isn't great in debug mode by default, so if you haven't tweaked your optimization settings, Tetra can get CPU-bound quite quickly. Other Rust game engines run into this issue too.

If your framerate is starting to drop, either run the game in release mode (cargo run --release) or set the debug opt-level to something higher than zero in your Cargo.toml:

[profile.dev]
opt-level = 1

Benchmarks

The impact of this can be observed by running the bunnymark example both with and without the --release flag. This example adds 100 new sprites to the screen every time the user clicks, until rendering conistently drops below 60fps.

These were the results when I ran it against Tetra 0.2.9 on my local machine:

Configuration Bunnies Rendered
Debug 3200
Release 230000

For reference, my system specs are:

  • CPU: AMD Ryzen 5 1600 3.2GHz
  • GPU: NVidia GeForce GTX 1050 Ti
  • RAM: 8GB DDR4