In the previous chapter, we created a window and gave it a background color. Next, let's draw some paddles and make them move!
First up, let's update our imports with the new types/modules that we'll be using:
For our game, we'll be using some public domain sprites by Kenney.
Create a folder called
resources in your project directory, and save this image as
player1.png inside it:
The naming of this folder isn't something that's enforced by Tetra - structure your projects however you'd like!
To add this image to our game, we can use our first new type of the chapter:
Texture. This represents a piece of image data that has been loaded into graphics memory.
Since we want our texture to stay loaded until the game closes, let's add it as a field in our
We can then use
Texture::new to load the sprite and populate that field:
Notice that we're now using the previously unnamed parameter that's passed to the
run closure - it's a mutable reference to our
Context, allowing us to access it in the initialization code for our state.
Try running the game now - if all is well, it should start up just like it did last chapter. If you get an error message, check that you've entered the image's path correctly!
Texture is effectively just an ID number under the hood. This means that they are very lightweight and cheap to clone - don't tie yourself in knots trying to pass references to them around your application!
The same is true for quite a few other types in Tetra - check the API documentation for more info.
We've got our texture loaded in, but our
main function is starting to look a little cluttered. Before we move on, let's clean things up a little by introducing a proper constructor function for our game state:
We could call this inside of the
run closure, like so:
That's already a big improvement! However, there's a useful trick that we can apply here to make this even shorter. Because our constructor function's signature and the
run closure's signature are the same, we can just pass the constructor function in directly:
This is the conventional style for a Tetra
main function, and is what you'll see in most of the examples.
While we're here, let's pull our window width and height out into constants, so that we'll be able to use them in our game logic:
i32 casts look a bit silly, but for most of the places we'll be using the constants, it'll be easier to have them as floating point numbers.
With that bit of housekeeping out of the way, let's finally draw something!
To draw our texture, we just need to call the
draw method on the
This will draw the texture to the screen at position
If you look at the docs for
Texture::draw, you'll notice that the type of the second parameter is actually
When you pass in a
Vec2, it is automatically converted into a
DrawParams struct with the
position parameter set. If you want to change other parameters, such as the rotation, color or scale, you can construct your own
DrawParams instead, using
A static Pong paddle is no fun, though - let's make it so the player can control it with the W and S keys.
In order to do this, we'll first need to store the paddle's position as a field on the state struct, so that it persists from frame to frame. While we're at it, we'll also offset the Y co-ordinate so that the paddle is vertically centered at startup:
We can then plug this field into our existing rendering code, so that the texture will be drawn at the stored position:
We'll also need to add another constant for our paddle's movement speed:
Now we're ready to write some game logic!
While we could do this in our
draw method, this is a bad idea for several reasons:
- Mixing up our game logic and our rendering logic isn't great seperation of concerns.
drawmethod does not get called at a consistent rate - the timing can fluctuate depending on the speed of the system the game is being run on, leading to subtle differences in behaviour. This is fine for drawing, but definitely not for physics!
Instead, it's time for us to add another method to our
State implementation. The
update method is called 60 times a second, regardless of how fast the game as a whole is running. This means that even if rendering slows to a crawl, you can still be confident that the code in that method is deterministic.
This 'fixed-rate update, variable-rate rendering' style of game loop is best explained by Glenn Fiedler's classic 'Fix Your Timestep' blog post. If you've used the
FixedUpdate method in Unity, this should feel pretty familiar!
If you want to change the rate at which updates happen, or switch to a more traditional 'lockstep' game loop, you can do this via the
timestep parameter on
update method, we can use the functions exposed by the
input module in order to check the state of the keyboard:
Your paddle should now move up when you press W, and down when you press S.
At this point, we've seen all of the Tetra functionality required to complete this chapter - all that remains is to add player two's paddle, and wire it up to the Up and Down keys.
First, save this image as
player2.png in your
We could just duplicate all of the fields in
GameState to add another object to the screen, but that feels like a bit of a messy solution. Instead, let's create a new struct to hold the common state of a game entity. We'll add some helper methods to this in the next chapter, but for now, it just needs a constructor:
It's worth mentioning at this point: this isn't the only way of structuring a game in Rust!
The language lends itself very well to 'data-driven' design patterns, such as entity component systems, and you'll definitely want to investigate these concepts if you start writing a bigger game. For now though, let's keep things as simple as possible!
Now for the final stretch - let's refactor our existing code to use the new
Entity struct, and finally add in our second player!
And with that, we're done!
In this chapter, we learned how to draw textures and read keyboard input, and put that knowledge to good use by creating some Pong paddles. Next, we'll add the last piece of the puzzle - the ball.
Here's the code from this chapter in full: