Skip to content

03 Snake

The finished grow-and-avoid game

Make a grow-and-avoid game, like Snake: eat to grow longer, and steer clear of the walls and your own tail.

A game of Snake, played to the end
The original: Snake, a game idea that goes back to Blockade (Gremlin, 1976), and became famous on mobile phones. Image: Ustone07, CC BY-SA 3.0, via Wikimedia Commons.

This time we don’t start from scratch. You get a working codebase, and we look at how it keeps its assets as data. The images, the animations and the room are described in XML files, and the code reads them from there.

The session also covers texture atlases, sprites and animation, movement on a fixed tick, and input as actions, with input buffering.

Source code: gar-games/03-snake

Snake uses the same content builder as Pong and Flappy Bird, with all steps sharing one assets folder, Content/Assets. Next to the images, it contains XML files that describe the assets. They say which part of the image is the snake, how its animations run, and which tile goes where in the room. The builder copies those files as they are, because our own code reads them:

Builder.cs
// Images are built into textures. Magenta pixels become transparent (color keying).
content.Include<WildcardRule>("*.png", new TextureImporter(), new TextureProcessor
{
ColorKeyEnabled = true,
ColorKeyColor = Color.Magenta,
GenerateMipmaps = false,
PremultiplyAlpha = true,
});
// The atlas and tilemap definitions are read by our own code at runtime,
// so they are copied as they are instead of being built.
content.IncludeCopy<WildcardRule>("*.xml");

The benefit is that the data can change while the code stays the same. A new animation frame, a new sprite or a different room is an edit to an XML file. An artist or designer can make it without touching the game’s code, and the code stays smaller and more general.

Steps Snake0 → Snake1

Loading body.png, head1.png, food1.png… as separate textures means the GPU must switch texture between draws, which breaks batching. A texture atlas (sprite sheet) packs many images into one texture.

A texture atlas is one image; a region is a named rectangle inside it.

Snake0 draws parts of the atlas by passing hardcoded source rectangles to SpriteBatch.Draw(), which gets hard to maintain as the number of sprites grows. In Snake1, an XML atlas definition gives each rectangle a name, and a texture region is a named rectangle within the atlas:

<TextureAtlas>
<Texture>images/atlas</Texture>
<Regions>
<Region name="head-1" x="8" y="0" width="7" height="7" />
<Region name="food-1" x="24" y="0" width="7" height="7" />
</Regions>
</TextureAtlas>
TextureAtlas atlas = TextureAtlas.FromFile(Content, "images/atlas-definition.xml");
TextureRegion head = atlas.GetRegion("head-1");
classDiagram
    class TextureAtlas {
        +Texture2D Texture
        +FromFile(content, fileName)$ TextureAtlas
        +GetRegion(name) TextureRegion
        +CreateSprite(regionName) Sprite
        +CreateAnimatedSprite(animationName) AnimatedSprite
    }
    class TextureRegion {
        +Texture2D Texture
        +Rectangle SourceRectangle
        +Draw(spriteBatch, position, color)
    }
    class Sprite {
        +TextureRegion Region
        +Color Color
        +float Rotation
        +Vector2 Scale
        +Vector2 Origin
        +Draw(spriteBatch, position)
    }
    class AnimatedSprite {
        +Animation Animation
        +Update(gameTime)
    }
    class Animation {
        +List~TextureRegion~ Frames
        +TimeSpan Delay
    }
    TextureAtlas o-- TextureRegion
    Sprite --> TextureRegion
    Sprite <|-- AnimatedSprite
    AnimatedSprite --> Animation
    Animation o-- TextureRegion

Try it (Snake1): the food has a second frame in the image, at x = 32. Make the game draw that one by changing only the food-1 region in atlas-definition.xml. Then misspell food-1 in the XML: what happens, and when?

Steps Snake2 and Snake3

A Sprite wraps a texture region together with everything needed to draw it: color mask, rotation, scale, origin, sprite effects and layer depth. Snake2 scales the snake’s head four times and spins it around its centre (CenterOrigin()).

An animation is a list of regions and a frame delay, also defined in the atlas XML:

<Animation name="head-animation" delay="250">
<Frame region="head-1" />
<Frame region="head-1" />
<Frame region="head-1" />
<Frame region="head-1" />
<Frame region="head-1" />
<Frame region="head-2" />
</Animation>

A frame can repeat. Here the head keeps its eyes open for five frames and closes them for one, so it blinks.

An animation is a list of regions and a delay; a frame can repeat.

An AnimatedSprite is a Sprite that accumulates elapsed time in Update() and advances to the next frame when the delay has passed (Snake3).

Try it (Snake2 and Snake3): make the head bigger and spin it the other way, then remove CenterOrigin() and explain what changes. In the XML only, make the food pulse twice as fast and the head blink twice as often.

Step Snake4

The room is data too. A tilemap definition lists which tile of the atlas goes in each cell. The tileset is nine tiles, three by three: the frame’s corners and sides around an empty floor tile in the middle.

<Tilemap>
<Tileset region="0 16 24 24" tileWidth="8" tileHeight="8">images/atlas</Tileset>
<Tiles>
00 01 01 01 01 01 … 01 01 01 01 02
03 04 04 04 04 04 … 04 04 04 04 05
03 04 04 04 04 04 … 04 04 04 04 05
...
06 07 07 07 07 07 … 07 07 07 07 08
</Tiles>
</Tilemap>

GARCore’s Tilemap.FromFile reads it into a flat array of tile numbers, one per cell, row after row. The cell in column x and row y is at index y * Columns + x. To draw the room, the tilemap goes through the array and draws each tile’s region at a position worked out from its index. A sprite keeps its own position, but a tile gets its position from the grid. Every tile comes from the same texture, so the whole room goes to the graphics card in one batch. A big level would only draw the cells on screen.

A tilemap is a grid of tile numbers; each number picks a tile from the tileset.

Here the tilemap is only a picture, and the walls are the cells outside the room’s Rectangle. In Sokoban, the grid becomes the game’s state itself.

Try it (Snake4): rearrange the room in tilemap-definition.xml. Then put a 9 in it, a tile the tileset doesn’t have (its tiles are 0 to 8): what happens, and when?

Step Snake5

A snake doesn’t glide. It jumps one cell at a time, several times per second, however fast the game runs. The Snake class keeps its body as a list of cells (head first), and moves on a fixed tick:

Snake.cs
private static readonly TimeSpan TickDuration = TimeSpan.FromMilliseconds(100);
public void Update(GameTime gameTime)
{
// Collect the time since the last move, and move once for every full tick.
_elapsed += gameTime.ElapsedGameTime;
while (_elapsed >= TickDuration)
{
_elapsed -= TickDuration;
Move();
}
}

This is the fixed timestep from Pong in practice. An accumulator collects real time, and the simulation advances in fixed-size steps. Subtracting (instead of resetting _elapsed to zero) keeps the leftover time, so the ticks stay evenly spaced.

The accumulator collects frame time, and the snake moves once per full tick.

Moving is cheap on a grid. Add a new head in the current direction and remove the tail.

The head has a sprite of its own, and its Rotation turns it to face the direction the snake moves in. It turns around its centre, so it is drawn at its cell’s corner plus its origin:

Snake.cs
_head.Rotation = MathF.Atan2(_direction.Y, _direction.X);
_head.Draw(spriteBatch, CellPosition(Head) + _head.Origin);

Steps Snake5 → Snake6

In Snake5, the game reads the keys directly:

if (Input.Keyboard.WasKeyJustPressed(Keys.W))
{
_snake.Turn(Direction.Up);
}

Physical keys are hardwired to what they do. Supporting the arrow keys too, a gamepad, or letting the player choose their keys means finding every place that reads a key.

In Snake6, a GameController maps keys to the game’s actions. The rest of the game asks whether the player wants to go up, never which key was pressed:

GameController.cs
public static class GameController
{
public static bool Up => WasPressed(Keys.W) || WasPressed(Keys.Up);
public static bool Down => WasPressed(Keys.S) || WasPressed(Keys.Down);
public static bool Left => WasPressed(Keys.A) || WasPressed(Keys.Left);
public static bool Right => WasPressed(Keys.D) || WasPressed(Keys.Right);
private static bool WasPressed(Keys key) => Core.Input.Keyboard.WasKeyJustPressed(key);
}

Now the controls live in one place. In Sokoban, we go one step further and turn each action into an object.

Step Snake7

The snake moves right. Quickly press Up, then Left, before the next tick. In Snake6, the second key press overwrites the first, and the snake turns straight back into its own neck. Input happens every frame, but the snake only acts on a tick, so key presses can be lost between ticks.

Snake7 buffers the turns in a queue, and each tick uses one:

Snake.cs
public void Turn(Point direction)
{
Point current = _turns.Count > 0 ? _turns.Last() : _direction;
if (_turns.Count < MaxBufferedTurns && direction != current && direction != Opposite(current))
{
_turns.Enqueue(direction);
}
}

A new turn is checked against the last buffered direction, so no combination of quick key presses can reverse the snake. The buffer is kept short (two turns), so the snake never acts on presses the player has long forgotten.

Two key presses between ticks: without a buffer the first is lost, with one both are used.

Many games buffer input like this, so that a jump pressed just before landing, or a combo pressed slightly early, still counts.

Try it (Snake7): make the snake twice as fast, and add I, J, K and L as a third set of keys. Which file did each change need? Then press two turns within one tick, in Snake6 and in Snake7. What does the snake do in each?

Step Snake8

Food sits on one cell of the room. When the snake’s head reaches it, the snake eats it, grows, and new food appears on a free cell. Each bite adds a point to the score under the room. Its digits are regions of the atlas too (digit-0 to digit-9), so even the score’s font is data.

To know when the head reaches the food, the game has to check whether two things touch. That is collision detection, and it starts with choosing a shape for each thing:

  • Circles: two circles overlap if the distance between their centres is less than the sum of their radii. Compare squared values (Vector2.DistanceSquared) to avoid a square root.
  • AABB: built into MonoGame as Rectangle.Intersects() and Rectangle.Contains() (see Pong).

Which shape to use is a trade between speed and precision. Circles fit round and rotating things, and rectangles fit boxes and tiles; both are cheap. Polygons follow a sprite’s outline closely, but cost more to check and to write. Use the simplest shape that looks right to the player. A grid lookup, as in Super Mario Bros, is cheaper still, but only works for things that stay in their cell.

MonoGame has no circle type, so GARCore has a Circle struct with Intersects(Circle). Both the snake’s head and the food expose their Bounds as a Circle. The food stays in its cell, so comparing cells would work here too; circles also work for things that move freely, which the later games need.

Two circles overlap when the distance between their centres is less than the sum of their radii.

Collision response is what happens after a hit:

  • Blocking: the two are kept from overlapping, as a wall stops the player in Super Mario Bros.
  • Triggering: something happens. The snake eats the food and grows. On its next move, it keeps its tail.
  • Bouncing: reflect the velocity off the surface, as the ball does in Pong. Vector2.Reflect does it for any angle, given the surface’s normal.

Collision response is what happens after a hit: block, trigger or bounce.

Snake checks one head against one piece of food. With many moving objects, every pair has to be checked: n objects make n × (n − 1) / 2 pairs, so 100 objects are 4,950 checks every frame. Engines split the work in two. A broad phase finds the pairs that are near each other with something cheap and rough, and a narrow phase runs the exact check on those pairs only. Vampire Survivors builds a broad phase from a grid.

A broad phase cheaply finds the pairs that are near each other; a narrow phase runs the exact check on those only.

Step Snake9

Until now, the snake wrapped around to the other side of the room. In Snake9, the walls and the snake’s own body are deadly. Both are grid checks, without any shapes:

// Hitting a wall or its own body ends the game, and a new one starts.
if (!_room.Contains(_snake.Head) || _snake.IsBitingItself)
{
_snake.Reset(_room.Center);
_score = 0;
_food.MoveToFreeCell(_snake);
}

Step Snake9

Snake9 is the finished game. Game1 holds the room, the snake and the food, and applies the rules between them, which are eating, scoring and game over. The snake and the food draw themselves with sprites from the one atlas.

classDiagram
    Core <|-- Game1
    Game1 --> Tilemap : the room
    Game1 --> Snake
    Game1 --> Food
    Game1 ..> GameController : asks for actions
    Snake --> AnimatedSprite : head
    Snake --> Sprite : body
    Food --> AnimatedSprite
    TextureAtlas ..> Sprite : creates
    TextureAtlas ..> AnimatedSprite : creates

Tilemap, TextureAtlas, Sprite and AnimatedSprite are GARCore classes, and the XML files fill them. The other classes in the diagram belong to this game.

Concern Answer
What the art and the room look like XML files that the code reads
Many images in few draw calls A texture atlas with named regions
Movement in steps A fixed tick, kept by an accumulator
Which key does what Actions, in one GameController
Key presses between two ticks An input buffer
What happens after a hit Blocking, triggering or bouncing

Start from Snake9.

  1. A bug from data: the atlas image also holds a bug (an insect, as another thing to eat) that the game doesn’t use yet, with two 7 × 7 frames at (40, 0) and (48, 0). Describe it in atlas-definition.xml (two regions and a bug-animation), and make the snake eat a bug instead of the food. How much C# did you need to change? And for a bug next to the food: what in Game1 would have to change, and what does that say about where the food’s rules live?
  2. Speed up: make the tick shorter each time the snake eats, down to a minimum. Where does that rule belong?
  3. New input: add gamepad support for the D-pad. A press needs last frame’s state, so first add a GamePadInfo to GARCore’s InputManager, modelled on KeyboardInfo (Core already updates the InputManager every frame). Then map the D-pad in GameController. Which files in the game itself did you change?
  4. Pause: add a pause action. While paused, the snake doesn’t move and turns aren’t buffered. Which class knows the key, and which one decides that the snake stands still?
  5. Refactor (stretch): the tick timer lives in Snake. Move it into a reusable FixedTimer class in GARCore that calls back on every tick, and use it for the snake.
  • Which of your game’s assets and settings are hardcoded, and could live in data files?
  • What are the actions in your game? List them separately from the keys and buttons that trigger them.
  • Does anything in your game happen on a fixed tick?
Why prefer a texture atlas over many individual image files?

Drawing from one texture lets SpriteBatch batch many sprites into few draw calls. Switching textures between draws breaks the batch and costs performance.

Why subtract the tick duration from the accumulator instead of resetting it to zero?

The leftover time carries over to the next tick. Resetting it would throw away a bit of time every tick, so the ticks would drift and depend on the frame rate.

What problems come from reading the keyboard directly inside gameplay code?

Keys are hardcoded throughout the game, so rebinding, supporting a gamepad or letting an AI drive the same entity means editing gameplay code in many places.

Why check a buffered turn against the last buffered direction?

The snake only turns on the next tick. Checking against its current direction would allow two quick turns that together reverse it into its own neck.

What do you gain by describing assets in data files?

A new sprite, animation frame or room is an edit to a file, made without touching the code, and the code stays smaller and more general. The builder copies the XML files as they are, because our own code reads them when the game runs.

Related exam questions: 1, 4, 6, 7, 8.