Table of Contents

Chapter 23: Completing the Game

Finalize game mechanics by updating our current demo into a snake-like inspired game.

In Chapter 22 we implemented the core mechanics of a snake-like game by creating the Slime, Bat and GameController classes. While these classes handle the foundational gameplay, a complete game needs additional elements to provide player feedback, manage game states, and create a polished experience.

In this chapter, you will:

  • Create a dedicated UI class to manage the UI for the game scene.
  • Implement pause and game over screens with appropriate controls.
  • Refactor the GameScene class to coordinate all game elements.
  • Add game state management to handle playing, paused, and game over conditions
  • Implement input buffering to improve control responsiveness
  • Connect all elements to create a complete, playable game.

The GameSceneUI Class

Currently, the GameScene class contains the methods for initializing and creating the pause menu.  However, now that we have a defined condition for game over, we need to create a game-over menu as well.  To do this, we will take the opportunity to refactor the current code and pull the UI-specific code into its own class.

In the UI directory of the game project, create a new file named GameSceneUI and add the following initial code:

using System;
using Gum.DataTypes;
using Gum.Managers;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Content;
using MonoGameGum;
using MonoGameGum.Forms.Controls;
using MonoGameGum.GueDeriving;
using MonoGameLibrary;
using MonoGameLibrary.Graphics;

namespace DungeonSlime.UI;

public class GameSceneUI : ContainerRuntime
{

}

This code establishes the foundation for our GameSceneUI class, which inherits from Gum's ContainerRuntime class. This inheritance means our UI class is itself a UI container that can hold and manage other UI elements. We've included all necessary using statements for MonoGame, Gum UI components, and our library references.

Let's build out this class by adding each section in sequence. Follow the order below to create the complete UI management system for our game scene.

Note

You may see compiler errors as you add these sections one by one. This is expected because some parts of the code will reference fields, properties, or methods that we haven't added yet. Once all sections are in place, these errors will resolve.

GameSceneUI Fields

Add the following fields to the GameSceneUI class:

// The string format to use when updating the text for the score display.
private static readonly string s_scoreFormat = "SCORE: {0:D6}";

// The sound effect to play for auditory feedback of the user interface.
private SoundEffect _uiSoundEffect;

// The pause panel
private Panel _pausePanel;

// The resume button on the pause panel. Field is used to track reference so
// focus can be set when the pause panel is shown.
private AnimatedButton _resumeButton;

// The game over panel.
private Panel _gameOverPanel;

// The retry button on the game over panel. Field is used to track reference
// so focus can be set when the game over panel is shown.
private AnimatedButton _retryButton;

// The text runtime used to display the players score on the game screen.
private TextRuntime _scoreText;

Let's break down what each of these fields is responsible for:

  • s_scoreFormat: A string format template used to display the player's score with leading zeros.
  • _uiSoundEffect: Stores the sound effect played for UI interactions like button clicks and focus changes.
  • _pausePanel: The panel containing the UI elements shown when the game is paused.
  • _resumeButton: A reference to the resume button, allowing us to set focus on it when the pause panel is shown.
  • _gameOverPanel: The panel containing the UI elements shown when a game over occurs.
  • _retryButton: A reference to the retry button, allowing us to set focus to it when the game over panel is shown.
  • _scoreText: The text display showing the player's current score.

GameSceneUI Events

After the fields, add the following events to the GameSceneUI class:

/// <summary>
/// Event invoked when the Resume button on the Pause panel is clicked.
/// </summary>
public event EventHandler ResumeButtonClick;

/// <summary>
/// Event invoked when the Quit button on either the Pause panel or the
/// Game Over panel is clicked.
/// </summary>
public event EventHandler QuitButtonClick;

/// <summary>
/// Event invoked when the Retry button on the Game Over panel is clicked.
/// </summary>
public event EventHandler RetryButtonClick;

These events allow the GameSceneUI class to notify the GameScene when important UI actions occur:

  • ResumeButtonClick: Triggered when the player clicks the Resume button on the pause panel.
  • QuitButtonClick: Triggered when the player clicks the Quit button on either panel.
  • RetryButtonClick: Triggered when the player clicks the Retry button on the game over panel.

GameSceneUI Constructor

Add the following constructor to the GameSceneUI class after the events:

public GameSceneUI()
{
    // The game scene UI inherits from ContainerRuntime, so we set its
    // doc to fill so it fills the entire screen.
    Dock(Gum.Wireframe.Dock.Fill);

    // Add it to the root element.
    this.AddToRoot();

    // Get a reference to the content manager that was registered with the
    // GumService when it was original initialized.
    ContentManager content = GumService.Default.ContentLoader.XnaContentManager;

    // Use that content manager to load the sound effect and atlas for the
    // user interface elements
    _uiSoundEffect = content.Load<SoundEffect>("audio/ui");
    TextureAtlas atlas = TextureAtlas.FromFile(content, "images/atlas-definition.xml");

    // Create the text that will display the players score and add it as
    // a child to this container.
    _scoreText = CreateScoreText();
    AddChild(_scoreText);

    // Create the Pause panel that is displayed when the game is paused and
    // add it as a child to this container
    _pausePanel = CreatePausePanel(atlas);
    AddChild(_pausePanel.Visual);

    // Create the Game Over panel that is displayed when a game over occurs
    // and add it as a child to this container
    _gameOverPanel = CreateGameOverPanel(atlas);
    AddChild(_gameOverPanel.Visual);
}

This constructor initializes all UI components:

  1. Set the container to fill the entire screen.
  2. Adds itself to Gum's root element.
  3. Loads necessary assets (sound effect and texture atlas).
  4. Creates and adds child elements in the correct order.

GameSceneUI UI Creation Methods

To keep the code more organized, we will create separate functions to build the individual UI elements that will be managed by the GameSceneUI class.

Creating the Score Text

First, we will add a method to create a TextRuntime element that is used to display the player's score. Add the following method to the GameSceneUI after the constructor:

private TextRuntime CreateScoreText()
{
    TextRuntime text = new TextRuntime();
    text.Anchor(Gum.Wireframe.Anchor.TopLeft);
    text.WidthUnits = DimensionUnitType.RelativeToChildren;
    text.X = 20.0f;
    text.Y = 5.0f;
    text.UseCustomFont = true;
    text.CustomFontFile = @"fonts/04b_30.fnt";
    text.FontScale = 0.25f;
    text.Text = string.Format(s_scoreFormat, 0);

    return text;
}

Creating the Pause Panel

Next, we will add a method to create a Panel element that is shown when the game is paused, including the "Resume" and "Quit" buttons. Add the following method to the GameSceneUI class after the CreateScoreText method:

private Panel CreatePausePanel(TextureAtlas atlas)
{
    Panel panel = new Panel();
    panel.Anchor(Gum.Wireframe.Anchor.Center);
    panel.Visual.WidthUnits = DimensionUnitType.Absolute;
    panel.Visual.HeightUnits = DimensionUnitType.Absolute;
    panel.Visual.Width = 264.0f;
    panel.Visual.Height = 70.0f;
    panel.IsVisible = false;

    TextureRegion backgroundRegion = atlas.GetRegion("panel-background");

    NineSliceRuntime background = new NineSliceRuntime();
    background.Dock(Gum.Wireframe.Dock.Fill);
    background.Texture = backgroundRegion.Texture;
    background.TextureAddress = TextureAddress.Custom;
    background.TextureHeight = backgroundRegion.Height;
    background.TextureWidth = backgroundRegion.Width;
    background.TextureTop = backgroundRegion.SourceRectangle.Top;
    background.TextureLeft = backgroundRegion.SourceRectangle.Left;
    panel.AddChild(background);

    TextRuntime text = new TextRuntime();
    text.Text = "PAUSED";
    text.UseCustomFont = true;
    text.CustomFontFile = "fonts/04b_30.fnt";
    text.FontScale = 0.5f;
    text.X = 10.0f;
    text.Y = 10.0f;
    panel.AddChild(text);

    _resumeButton = new AnimatedButton(atlas);
    _resumeButton.Text = "RESUME";
    _resumeButton.Anchor(Gum.Wireframe.Anchor.BottomLeft);
    _resumeButton.Visual.X = 9.0f;
    _resumeButton.Visual.Y = -9.0f;

    _resumeButton.Click += OnResumeButtonClicked;
    _resumeButton.GotFocus += OnElementGotFocus;

    panel.AddChild(_resumeButton);

    AnimatedButton quitButton = new AnimatedButton(atlas);
    quitButton.Text = "QUIT";
    quitButton.Anchor(Gum.Wireframe.Anchor.BottomRight);
    quitButton.Visual.X = -9.0f;
    quitButton.Visual.Y = -9.0f;

    quitButton.Click += OnQuitButtonClicked;
    quitButton.GotFocus += OnElementGotFocus;

    panel.AddChild(quitButton);

    return panel;
}

Creating the Game Over Panel

Finally, we will add a method to create a Panel element that is shown when a game over occurs, including the "Retry" and "Quit" buttons. Add the following method to the GameSceneUI class after the CreatePausePanel method:

private Panel CreateGameOverPanel(TextureAtlas atlas)
{
    Panel panel = new Panel();
    panel.Anchor(Gum.Wireframe.Anchor.Center);
    panel.Visual.WidthUnits = DimensionUnitType.Absolute;
    panel.Visual.HeightUnits = DimensionUnitType.Absolute;
    panel.Visual.Width = 264.0f;
    panel.Visual.Height = 70.0f;
    panel.IsVisible = false;

    TextureRegion backgroundRegion = atlas.GetRegion("panel-background");

    NineSliceRuntime background = new NineSliceRuntime();
    background.Dock(Gum.Wireframe.Dock.Fill);
    background.Texture = backgroundRegion.Texture;
    background.TextureAddress = TextureAddress.Custom;
    background.TextureHeight = backgroundRegion.Height;
    background.TextureWidth = backgroundRegion.Width;
    background.TextureTop = backgroundRegion.SourceRectangle.Top;
    background.TextureLeft = backgroundRegion.SourceRectangle.Left;
    panel.AddChild(background);

    TextRuntime text = new TextRuntime();
    text.Text = "GAME OVER";
    text.WidthUnits = DimensionUnitType.RelativeToChildren;
    text.UseCustomFont = true;
    text.CustomFontFile = "fonts/04b_30.fnt";
    text.FontScale = 0.5f;
    text.X = 10.0f;
    text.Y = 10.0f;
    panel.AddChild(text);

    _retryButton = new AnimatedButton(atlas);
    _retryButton.Text = "RETRY";
    _retryButton.Anchor(Gum.Wireframe.Anchor.BottomLeft);
    _retryButton.Visual.X = 9.0f;
    _retryButton.Visual.Y = -9.0f;

    _retryButton.Click += OnRetryButtonClicked;
    _retryButton.GotFocus += OnElementGotFocus;

    panel.AddChild(_retryButton);

    AnimatedButton quitButton = new AnimatedButton(atlas);
    quitButton.Text = "QUIT";
    quitButton.Anchor(Gum.Wireframe.Anchor.BottomRight);
    quitButton.Visual.X = -9.0f;
    quitButton.Visual.Y = -9.0f;

    quitButton.Click += OnQuitButtonClicked;
    quitButton.GotFocus += OnElementGotFocus;

    panel.AddChild(quitButton);

    return panel;
}

Both the pause panel and the game over panel use event handlers for their buttons. Let's add those next.

GameSceneUI Event Handlers

After the CreateGameOverPanel method, add the following method to the GameSceneUI class:

private void OnResumeButtonClicked(object sender, EventArgs args)
{
    // Button was clicked, play the ui sound effect for auditory feedback.
    Core.Audio.PlaySoundEffect(_uiSoundEffect);

    // Since the resume button was clicked, we need to hide the pause panel.
    HidePausePanel();

    // Invoke the ResumeButtonClick event
    if(ResumeButtonClick != null)
    {
        ResumeButtonClick(sender, args);
    }
}

private void OnRetryButtonClicked(object sender, EventArgs args)
{
    // Button was clicked, play the ui sound effect for auditory feedback.
    Core.Audio.PlaySoundEffect(_uiSoundEffect);

    // Since the retry button was clicked, we need to hide the game over panel.
    HideGameOverPanel();

    // Invoke the RetryButtonClick event.
    if(RetryButtonClick != null)
    {
        RetryButtonClick(sender, args);
    }
}

private void OnQuitButtonClicked(object sender, EventArgs args)
{
    // Button was clicked, play the ui sound effect for auditory feedback.
    Core.Audio.PlaySoundEffect(_uiSoundEffect);

    // Both panels have a quit button, so hide both panels
    HidePausePanel();
    HideGameOverPanel();

    // Invoke the QuitButtonClick event.
    if(QuitButtonClick != null)
    {
        QuitButtonClick(sender, args);
    }
}

private void OnElementGotFocus(object sender, EventArgs args)
{
    // A ui element that can receive focus has received focus, play the
    // ui sound effect for auditory feedback.
    Core.Audio.PlaySoundEffect(_uiSoundEffect);
}

These event handlers provide audio feedback and appropriate UI updates when buttons are clicked or UI elements receive focus.

GameSceneUI Public Methods

Finally, add the following public methods to the GameSceneUI class after the OnElementGotFocus method:

/// <summary>
/// Updates the text on the score display.
/// </summary>
/// <param name="score">The score to display.</param>
public void UpdateScoreText(int score)
{
    _scoreText.Text = string.Format(s_scoreFormat, score);
}

/// <summary>
/// Tells the game scene ui to show the pause panel.
/// </summary>
public void ShowPausePanel()
{
    _pausePanel.IsVisible = true;

    // Give the resume button focus for keyboard/gamepad input.
    _resumeButton.IsFocused = true;

    // Ensure the game over panel isn't visible.
    _gameOverPanel.IsVisible = false;
}

/// <summary>
/// Tells the game scene ui to hide the pause panel.
/// </summary>
public void HidePausePanel()
{
    _pausePanel.IsVisible = false;
}

/// <summary>
/// Tells the game scene ui to show the game over panel.
/// </summary>
public void ShowGameOverPanel()
{
    _gameOverPanel.IsVisible = true;

    // Give the retry button focus for keyboard/gamepad input.
    _retryButton.IsFocused =true;

    // Ensure the pause panel isn't visible.
    _pausePanel.IsVisible = false;
}

/// <summary>
/// Tells the game scene ui to hide the game over panel.
/// </summary>
public void HideGameOverPanel()
{
    _gameOverPanel.IsVisible = false;
}

/// <summary>
/// Updates the game scene ui.
/// </summary>
/// <param name="gameTime">A snapshot of the timing values for the current update cycle.</param>
public void Update(GameTime gameTime)
{
    GumService.Default.Update(gameTime);
}

/// <summary>
/// Draws the game scene ui.
/// </summary>
public void Draw()
{
    GumService.Default.Draw();
}

These public methods provide the interface for the GameScene to:

  • Update the score display.
  • Show or hide the pause menu.
  • Show or hide the game over menu.
  • Update and draw the UI components.

With the GameSceneUI class complete, we now have a fully encapsulated UI system that can handle displaying game information (score), providing feedback for game states (pause, game over), and processing user interactions (button clicks). This separation of UI logic from game logic will make our codebase much easier to maintain and extend.

Now that we have all our specialized components ready, let's refactor the GameScene class to coordinate between them and manage the overall game flow.

Refactoring The GameScene Class

Now that we have created the encapsulated Slime, Bat, and GameSceneUI classes, we can refactor the GameScene class to leverage these new components.  This will make our code more maintainable and allow us to focus on the game logic within the scene itself.  We will rebuild the GameScene class to coordinate the interactions between the components.

In the Scenes directory of the DungeonSlime project (your main game project), open the GameScene.cs file and replace the code with the following initial code:

using System;
using DungeonSlime.GameObjects;
using DungeonSlime.UI;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Graphics;
using MonoGameGum;
using MonoGameLibrary;
using MonoGameLibrary.Graphics;
using MonoGameLibrary.Scenes;

namespace DungeonSlime.Scenes;

public class GameScene : Scene
{
    private enum GameState
    {
        Playing,
        Paused,
        GameOver
    }

    // Reference to the slime.
    private Slime _slime;

    // Reference to the bat.
    private Bat _bat;

    // Defines the tilemap to draw.
    private Tilemap _tilemap;

    // Defines the bounds of the room that the slime and bat are contained within.
    private Rectangle _roomBounds;

    // The sound effect to play when the slime eats a bat.
    private SoundEffect _collectSoundEffect;

    // Tracks the players score.
    private int _score;

    private GameSceneUI _ui;

    private GameState _state;
}

This code provides the foundation for our refactored GameScene class. We have included all the necessary using statements to reference our new game object classes and UI components. The class will now focus on managing the game state and coordinating between our specialized component classes rather than implementing all the functionality directly.

The GameScene class now contains the following key fields:

  • GameState: An enum that defines the different states that the game can be in (playing, paused, or game over).
  • _slime: A reference to the slime (snake-like player character) instance.
  • _bat: A reference to the bat (food) instance.
  • _tilemap: The tilemap that defines the level layout.
  • _roomBounds: A rectangle defining the playable area within the walls.
  • _collectSoundEffect: The sound effect played when the slime eats a bat.
  • _score: Tracks the player's current score.
  • _ui: A reference to the game scene UI component.
  • _state: The current state of the game represented by the GameState enum.

Now we will add the various methods needed to complete the GameScene class. Add each section in the sequence presented below. This will build up the scene's functionality step by step.

Note

As with previous classes, you might encounter compiler errors until all sections are in place. These errors will be resolved once all components of the class have been added.

GameScene Initialize Method

To set up the scene, add the following Initialize method after the fields in te GameScene class:

public override void Initialize()
{
    // LoadContent is called during base.Initialize().
    base.Initialize();

    // During the game scene, we want to disable exit on escape. Instead,
    // the escape key will be used to return back to the title screen
    Core.ExitOnEscape = false;

    // Create the room bounds by getting the bounds of the screen then
    // using the Inflate method to "Deflate" the bounds by the width and
    // height of a tile so that the bounds only covers the inside room of
    // the dungeon tilemap.
    _roomBounds = Core.GraphicsDevice.PresentationParameters.Bounds;
    _roomBounds.Inflate(-_tilemap.TileWidth, -_tilemap.TileHeight);

    // Subscribe to the slime's BodyCollision event so that a game over
    // can be triggered when this event is raised.
    _slime.BodyCollision += OnSlimeBodyCollision;

    // Create any UI elements from the root element created in previous
    // scenes
    GumService.Default.Root.Children.Clear();

    // Initialize the user interface for the game scene.
    InitializeUI();

    // Initialize a new game to be played.
    InitializeNewGame();
}

This method sets up the initial state of the game scene:

  1. Disables the "exit on escape" behavior so we can use the escape key for pausing.
  2. Calculate the playable area within the tilemap walls.
  3. Subscribes to the slime's body collision event to detect when the player collides with itself triggering a game over state.
  4. Initialize the UI components.
  5. Set up a new game.

GameScene InitializeUI Method

The Initialize method we just added calls a method to initialize the user interface for the scene. Let's add that method now. Add the following method after the Initialize method in the GameScene class:

private void InitializeUI()
{
    // Clear out any previous UI element incase we came here
    // from a different scene.
    GumService.Default.Root.Children.Clear();

    // Create the game scene ui instance.
    _ui = new GameSceneUI();

    // Subscribe to the events from the game scene ui.
    _ui.ResumeButtonClick += OnResumeButtonClicked;
    _ui.RetryButtonClick += OnRetryButtonClicked;
    _ui.QuitButtonClick += OnQuitButtonClicked;
}

This method creates the UI components and subscribes to its events to respond to button clicks.

GameScene UI Event Handlers

In the InitializeUI method we just added, we subscribe to the events from the GameSceneUI class that are triggered when buttons are clicked. Now we need to add those methods that would be called when the events are triggered. Add the following methods to the GameScene class after the InitializeUI method:

private void OnResumeButtonClicked(object sender, EventArgs args)
{
    // Change the game state back to playing
    _state = GameState.Playing;
}

private void OnRetryButtonClicked(object sender, EventArgs args)
{
    // Player has chosen to retry, so initialize a new game
    InitializeNewGame();
}

private void OnQuitButtonClicked(object sender, EventArgs args)
{
    // Player has chosen to quit, so return back to the title scene
    Core.ChangeScene(new TitleScene());
}

These methods respond to the UI events:

  • OnResumeButtonClicked: Resumes the game from a paused state.
  • OnRetryButtonClicked: Restarts the game after a game over.
  • OnQuitButtonClicked: Quits the game by returning to the title scene.

GameScene InitializeNewGame Method

In the Initialize method we added above, it also makes a call to an InitializeNewGame method. Let's add this now. Add the following method to the GameScene class after the OnQuitButtonClicked method:

private void InitializeNewGame()
{
    // Calculate the position for the slime, which will be at the center
    // tile of the tile map.
    Vector2 slimePos = new Vector2();
    slimePos.X = (_tilemap.Columns / 2) * _tilemap.TileWidth;
    slimePos.Y = (_tilemap.Rows / 2) * _tilemap.TileHeight;

    // Initialize the slime
    _slime.Initialize(slimePos, _tilemap.TileWidth);

    // Initialize the bat
    _bat.RandomizeVelocity();
    PositionBatAwayFromSlime();

    // Reset the score
    _score = 0;

    // Set the game state to playing
    _state = GameState.Playing;
}

This method will:

  1. Position the slime in the center of the map.
  2. Initialize the slime with its starting position and movement stride.
  3. Randomize the bat's velocity and position it away from the slime.
  4. Reset the player's score.
  5. Set the game state to "Playing".

GameScene LoadContent Method

Next, we need to add the method to load game assets for the scene. Add the following method to the GameScene class after the InitializeNewGame method:

public override void LoadContent()
{
    // Create the texture atlas from the XML configuration file
    TextureAtlas atlas = TextureAtlas.FromFile(Core.Content, "images/atlas-definition.xml");

    // Create the tilemap from the XML configuration file.
    _tilemap = Tilemap.FromFile(Content, "images/tilemap-definition.xml");
    _tilemap.Scale = new Vector2(4.0f, 4.0f);

    // Create the animated sprite for the slime from the atlas.
    AnimatedSprite slimeAnimation = atlas.CreateAnimatedSprite("slime-animation");
    slimeAnimation.Scale = new Vector2(4.0f, 4.0f);

    // Create the slime
    _slime = new Slime(slimeAnimation);

    // Create the animated sprite for the bat from the atlas.
    AnimatedSprite batAnimation = atlas.CreateAnimatedSprite("bat-animation");
    batAnimation.Scale = new Vector2(4.0f, 4.0f);

    // Load the bounce sound effect for the bat
    SoundEffect bounceSoundEffect = Content.Load<SoundEffect>("audio/bounce");

    // Create the bat
    _bat = new Bat(batAnimation, bounceSoundEffect);

    // Load the collect sound effect
    _collectSoundEffect = Content.Load<SoundEffect>("audio/collect");
}

This method loads all necessary assets for the game scene:

  1. The texture atlas containing the sprite graphics
  2. The tilemap that defines the level layout.
  3. The animated sprites for the slime and bat.
  4. Sound effects for the bat bouncing and collecting.

GameScene Update Method

Next, to update the scene add the following method to the GameScene class after the LoadContent method:

public override void Update(GameTime gameTime)
{
    // Ensure the UI is always updated
    _ui.Update(gameTime);

    // If the game is in a game over state, immediately return back
    // here
    if (_state == GameState.GameOver)
    {
        return;
    }

    // If the pause button is pressed, toggle the pause state
    if (GameController.Pause())
    {
        TogglePause();
    }

    // At this point, if the game is paused, just return back early
    if (_state == GameState.Paused)
    {
        return;
    }

    // Update the slime;
    _slime.Update(gameTime);

    // Update the bat;
    _bat.Update(gameTime);

    // Perform collision checks
    CollisionChecks();
}

This method updates the scene in each frame to:

  1. Always update the UI, regardless of game state.
  2. Return early if the game is over.
  3. Check for pause input and toggle the pause state if needed.
  4. Return early if the game is paused.
  5. Update the slime and bat.
  6. Check for collisions between the game objects.

GameScene CollisionChecks Method

In the Update method we just added, it makes a call to a CollisionChecks method to handle the collision detection and response. Let's add that method now. Add the following method to the GameScene class after the Update method:

private void CollisionChecks()
{
    // Capture the current bounds of the slime and bat
    Circle slimeBounds = _slime.GetBounds();
    Circle batBounds = _bat.GetBounds();

    // FIrst perform a collision check to see if the slime is colliding with
    // the bat, which means the slime eats the bat.
    if (slimeBounds.Intersects(batBounds))
    {
        // Move the bat to a new position away from the slime.
        PositionBatAwayFromSlime();

        // Randomize the velocity of the bat.
        _bat.RandomizeVelocity();

        // Tell the slime to grow.
        _slime.Grow();

        // Increment the score.
        _score += 100;

        // Update the score display on the UI.
        _ui.UpdateScoreText(_score);

        // Play the collect sound effect
        Core.Audio.PlaySoundEffect(_collectSoundEffect);
    }

    // Next check if the slime is colliding with the wall by validating if
    // it is within the bounds of the room.  If it is outside the room
    // bounds, then it collided with a wall which triggers a game over.
    if (slimeBounds.Top < _roomBounds.Top ||
       slimeBounds.Bottom > _roomBounds.Bottom ||
       slimeBounds.Left < _roomBounds.Left ||
       slimeBounds.Right > _roomBounds.Right)
    {
        GameOver();
        return;
    }

    // Finally, check if the bat is colliding with a wall by validating if
    // it is within the bounds of the room.  If it is outside the room
    // bounds, then it collided with a wall, and the bat should bounce
    // off of that wall.
    if (batBounds.Top < _roomBounds.Top)
    {
        _bat.Bounce(Vector2.UnitY);
    }
    else if (batBounds.Bottom > _roomBounds.Bottom)
    {
        _bat.Bounce(-Vector2.UnitY);
    }

    if (batBounds.Left < _roomBounds.Left)
    {
        _bat.Bounce(Vector2.UnitX);
    }
    else if (batBounds.Right > _roomBounds.Right)
    {
        _bat.Bounce(-Vector2.UnitX);
    }
}

This method checks for three types of collisions:

  1. Slime-Bat collision: The slime "eats" the bat, gains points, grows, and the bat respawns.
  2. Slime-Wall collision: Triggers a game over if the slime hits a wall.
  3. Bat-Wall collision: Causes the bat to bounce off the walls.

GameScene PositionBatAwayFromSlime Method

The CollisionCheck method makes a call to PositionBatAwayFromSlime. Previously, when we needed to set the position of the bat when it respawns, we simply chose a random tile within the tilemap to move it to. By choosing a completely random location, it could be on top fo the head segment of the slime, forcing an instant collision, or it could spawn very close to the head segment, which adds not challenge for the player.

To ensure the bat appears in a random, but strategic location, we can instead set it to position away from the slime on the opposite side of the room. Add the following method to the GameScene class after the CollisionCheck method:

private void PositionBatAwayFromSlime()
{
    // Calculate the position that is in the center of the bounds
    // of the room.
    float roomCenterX = _roomBounds.X + _roomBounds.Width * 0.5f;
    float roomCenterY = _roomBounds.Y + _roomBounds.Height * 0.5f;
    Vector2 roomCenter = new Vector2(roomCenterX, roomCenterY);

    // Get the bounds of the slime and calculate the center position
    Circle slimeBounds = _slime.GetBounds();
    Vector2 slimeCenter = new Vector2(slimeBounds.X, slimeBounds.Y);

    // Calculate the distance vector from the center of the room to the
    // center of the slime.
    Vector2 centerToSlime = slimeCenter - roomCenter;

    // Get the bounds of the bat
    Circle batBounds =_bat.GetBounds();

    // Calculate the amount of padding we will add to the new position of
    // the bat to ensure it is not sticking to walls
    int padding = batBounds.Radius * 2;

    // Calculate the new position of the bat by finding which component of
    // the center to slime vector (X or Y) is larger and in which direction.
    Vector2 newBatPosition = Vector2.Zero;
    if (Math.Abs(centerToSlime.X) > Math.Abs(centerToSlime.Y))
    {
        // The slime is closer to either the left or right wall, so the Y
        // position will be a random position between the top and bottom
        // walls.
        newBatPosition.Y = Random.Shared.Next(
            _roomBounds.Top + padding,
            _roomBounds.Bottom - padding
        );

        if (centerToSlime.X > 0)
        {
            // The slime is closer to the right side wall, so place the
            // bat on the left side wall
            newBatPosition.X = _roomBounds.Left + padding;
        }
        else
        {
            // The slime is closer ot the left side wall, so place the
            // bat on the right side wall.
            newBatPosition.X = _roomBounds.Right - padding * 2;
        }
    }
    else
    {
        // The slime is closer to either the top or bottom wall, so the X
        // position will be a random position between the left and right
        // walls.
        newBatPosition.X = Random.Shared.Next(
            _roomBounds.Left + padding,
            _roomBounds.Right - padding
        );

        if (centerToSlime.Y > 0)
        {
            // The slime is closer to the top wall, so place the bat on the
            // bottom wall
            newBatPosition.Y = _roomBounds.Top + padding;
        }
        else
        {
            // The slime is closer to the bottom wall, so place the bat on
            // the top wall.
            newBatPosition.Y = _roomBounds.Bottom - padding * 2;
        }
    }

    // Assign the new bat position
    _bat.Position = newBatPosition;
}

This method positions the bat after it's been eaten:

  1. Determines which wall (top, bottom, left, or right) is furthest from the slime.
  2. Places the bat near that wall, making it more challenging for the player to reach.

GameScene Event Handler and Game State Methods

Next, we will add some of the missing methods being called from above that handle game events and state changes. Add the following methods to the GameScene class after the PositionBatAwayFromSlime method:

private void OnSlimeBodyCollision(object sender, EventArgs args)
{
    GameOver();
}

private void TogglePause()
{
    if (_state == GameState.Paused)
    {
        // We're now unpausing the game, so hide the pause panel
        _ui.HidePausePanel();

        // And set the state back to playing
        _state = GameState.Playing;
    }
    else
    {
        // We're now pausing the game, so show the pause panel
        _ui.ShowPausePanel();

        // And set the state to paused
        _state = GameState.Paused;
    }
}

private void GameOver()
{
    // Show the game over panel
    _ui.ShowGameOverPanel();

    // Set the game state to game over
    _state = GameState.GameOver;
}

These methods handle specific game events:

  • OnSlimeBodyCollision: Called when the slime collides with itself, triggering a game over.
  • TogglePause: Switches between paused and playing states.
  • GameOver: Called when a game over condition is met, showing the game over UI.

GameScene Draw Method

Finally, we need a method to draw the scene. Add the following method to the GameScene class after the GameOver method.

public override void Draw(GameTime gameTime)
{
    // Clear the back buffer.
    Core.GraphicsDevice.Clear(Color.CornflowerBlue);

    // Begin the sprite batch to prepare for rendering.
    Core.SpriteBatch.Begin(samplerState: SamplerState.PointClamp);

    // Draw the tilemap
    _tilemap.Draw(Core.SpriteBatch);

    // Draw the slime.
    _slime.Draw();

    // Draw the bat.
    _bat.Draw();

    // Always end the sprite batch when finished.
    Core.SpriteBatch.End();

    // Draw the UI
    _ui.Draw();
}

This method handles drawing the scene by:

  1. Clearing the screen.
  2. Drawing the tilemap as the background.
  3. Drawing the slime and bat sprites.
  4. Drawing the UI elements on top.

By refactoring our game into these encapsulated components, we have created a more maintainable codebase with a clear separation of concerns:

  • The Slime class handles snake-like movement and growth.
  • The Bat class manages its movement and bouncing.
  • The GameSceneUI class manages all UI components.
  • The GameScene class coordinates between these components and manages the game state.

This architecture makes it easier to add new features or fix bugs, as changes to one component are less likely to affect others.

Adding Input Buffering to the Slime Class

The game at this point is now playable. If you test it out though, you may notice a small issue with inputs. As we discussed in Chapter 10, in games where movement updates happen at fixed intervals, inputs can sometimes feel unresponsive, especially when trying to make multiple inputs in succession.

For instance, if a player wants to navigate a tight corner by pressing up and then immediately left, pressing these keys in rapid succession often results in only the second input being registered. When this happens, the slime will only turn left without first moving upward, missing the intended two-part movement completely. This occurs because the second input overwrites the first one before the game has a chance to process it, leading to frustrating gameplay.

Let's implement the input buffering technique we introduced in Chapter 10 to solve this problem in our Slime class.

Implementing Input Buffering in the Slime Class

First, we will add the necessary fields to store our input queue. In the GameObjects directory of the DungeonSlime project (your main game project), open the Slime.cs file and add the following fields after the _sprite field:

// Buffer to queue inputs input by player during input polling.
private Queue<Vector2> _inputBuffer;

// The maximum size of the buffer queue.
private const int MAX_BUFFER_SIZE = 2;

This queue will store the directional vectors (up, down, left, right) that we will apply to the slime's movement in the order they were received.

Next, we need to initialize this queue. In the Slime class, locate the Initialize method and and update it to the following:

/// <summary>
/// Initializes the slime, can be used to reset it back to an initial state.
/// </summary>
/// <param name="startingPosition">The position the slime should start at.</param>
/// <param name="stride">The total number of pixels to move the head segment during each movement cycle.</param>
public void Initialize(Vector2 startingPosition, float stride)
{
    // Initialize the segment collection.
    _segments = new List<SlimeSegment>();

    // Set the stride
    _stride = stride;

    // Create the initial head of the slime chain.
    SlimeSegment head = new SlimeSegment();
    head.At = startingPosition;
    head.To = startingPosition + new Vector2(_stride, 0);
    head.Direction = Vector2.UnitX;

    // Add it to the segment collection.
    _segments.Add(head);

    // Set the initial next direction as the same direction the head is
    // moving.
    _nextDirection = head.Direction;

    // Zero out the movement timer.
    _movementTimer = TimeSpan.Zero;

    // initialize the input buffer.
    _inputBuffer = new Queue<Vector2>(MAX_BUFFER_SIZE);
}

Next, we need to update the input handling method to store the inputs in the queue instead of immediately overwriting the _nextDirection field. In the Slime class, locate the HandleInput method and update it to the following

private void HandleInput()
{
    Vector2 potentialNextDirection = Vector2.Zero;

    if (GameController.MoveUp())
    {
        potentialNextDirection = -Vector2.UnitY;
    }
    else if (GameController.MoveDown())
    {
        potentialNextDirection = Vector2.UnitY;
    }
    else if (GameController.MoveLeft())
    {
        potentialNextDirection = -Vector2.UnitX;
    }
    else if (GameController.MoveRight())
    {
        potentialNextDirection = Vector2.UnitX;
    }

    // If a new direction was input, consider adding it to the buffer
    if (potentialNextDirection != Vector2.Zero && _inputBuffer.Count < MAX_BUFFER_SIZE)
    {
        // If the buffer is empty, validate against the current direction;
        // otherwise, validate against the last buffered direction
        Vector2 validateAgainst = _inputBuffer.Count > 0 ?
                                  _inputBuffer.Last() :
                                  _segments[0].Direction;

        // Only allow direction change if it is not reversing the current
        // direction.  This prevents th slime from backing into itself
        float dot = Vector2.Dot(potentialNextDirection, validateAgainst);
        if (dot >= 0)
        {
            _inputBuffer.Enqueue(potentialNextDirection);
        }
    }
}
  1. The potentialNewDirection is now given the initial value of Vector2.Zero.
  2. A check is made to see if the player has pressed a direction key and if the input buffer is not already at maximum capacity.
  3. If a new direction key is pressed and the buffer has space:
    1. The validation is made using Vector2.Dot just like before to ensure it is a valid direction
    2. If it is a valid direciton, then it is added to the queue.

Finally, we need to modifiy how we apply the movement direction during the movement update cycle. In the Slime class, locate the Move method and update it to the following:

private void Move()
{
    // Get the next direction from the input buffer if one is available
    if (_inputBuffer.Count > 0)
    {
        _nextDirection = _inputBuffer.Dequeue();
    }

    // Capture the value of the head segment
    SlimeSegment head = _segments[0];

    // Update the direction the head is supposed to move in to the
    // next direction cached.
    head.Direction = _nextDirection;

    // Update the head's "at" position to be where it was moving "to"
    head.At = head.To;

    // Update the head's "to" position to the next tile in the direction
    // it is moving.
    head.To = head.At + head.Direction * _stride;

    // Insert the new adjusted value for the head at the front of the
    // segments and remove the tail segment. This effectively moves
    // the entire chain forward without needing to loop through every
    // segment and update its "at" and "to" positions.
    _segments.Insert(0, head);
    _segments.RemoveAt(_segments.Count - 1);

    // Iterate through all of the segments except the head and check
    // if they are at the same position as the head. If they are, then
    // the head is colliding with a body segment and a body collision
    // has occurred.
    for (int i = 1; i < _segments.Count; i++)
    {
        SlimeSegment segment = _segments[i];

        if (head.At == segment.At)
        {
            if (BodyCollision != null)
            {
                BodyCollision.Invoke(this, EventArgs.Empty);
            }

            return;
        }
    }
}

The key change here is that we now dequeue a direction from the input buffer rather than directly using the _nextDirection value. This ensures we process inputs in the order they were received, preserving the player's intent.

With these changes in place, our game now supports input buffering. This small enhancement improves how the game feels to play, particularly when making rapid directional changes.

Players will notice:

  • When navigating a corner, they can quickly press up followed by left (or any other valid combination), and both inputs will be respected
  • The game feels more responsive since it remembers inputs between movement update cycles
  • Complex maneuvers are easier to execute since timing is more forgiving

The difference might seem subtle, but it significantly reduces frustration during gameplay.

Putting It All Together

With all of these components now in place, our Dungeon Slime game has transformed from a simple demo built on learning MonoGame concepts into a complete snake-like game experience.  The player controls the slime that moves through the dungeon, consuming bats to grow longer.  If the slime collides with the wall or its own body, the game ends.

Let's see how it all looks and plays:

Figure 23-1: Gameplay demonstration of the completed Dungeon Slime game showing the snake-like slime growing as it eats bats and a game over when colliding with the wall
  1. The game starts with a single slime segment in the center of the room.
  2. The player controls the direction of the slime by using the keyboard (arrow keys or WASD) or by using a game pad (DPad or left thumbstick).
  3. The slime moves at regular intervals, creating a grid-based movement pattern.
  4. When the slime eats a bat, it grows longer by adding a new segment to its tail.
  5. The bat respawns at a strategic location after being eaten.
  6. The player's score increases with each bat consumed.
  7. If the slime collides with a wall or its own body, the game over panel appears.
  8. On the game over panel, the player can choose to retry or return to the title scene.

With these mechanics implemented, Dungeon Slime is now a complete game with clear objectives, escalating difficulty, and a game feedback loop.

Conclusion

In this chapter, we have transformed our technical demo into a complete game by integrating UI systems with game mechanics. We have accomplished several important goals:

  • Created a dedicated GameSceneUI class to manage the game's user interface.
  • Implemented pause and game over screens that provide clear feedback to the player.
  • Refactored the GameScene class to coordinate all game components.
  • Added game state management to handle different gameplay conditions.
  • Enhanced player control through input buffering for more responsive gameplay
  • Connected all of the elements to create a complete playable game.

The refactoring process we undertook demonstrates an important game development principle: separating concerns into specialized components makes code more maintainable and easier to extend. The Slime class manages snake-like behavior, the Bat class handles movement and collision response, and the GameSceneUI class encapsulates all UI-related functionality.

Test Your Knowledge

  1. How does the game handle different states (playing, paused, game over), and why is this state management important?

    The game uses an enum (GameState) to track its current state and implements different behavior based on that state:

    • During the Playing state, the game updates all objects and checks for collisions
    • During the Paused state, the game shows the pause menu and stops updating game objects
    • During the GameOver state, the game shows the game over menu and prevents further gameplay

    This state management is important because it:

    • Prevents inappropriate updates during non-gameplay states
    • Creates a clear flow between different game conditions
    • Simplifies conditional logic by using explicit states rather than multiple boolean flags
    • Makes the game's behavior more predictable and easier to debug
  2. Why is it important to position the bat away from the slime after it's been eaten rather than at a completely random location?

    Positioning the bat away from the slime after it's been eaten rather than at a completely random location is important because:

    • It prevents unfair situations where the bat might spawn right on top of the slime causing an immediate collision
    • It creates a more strategic gameplay experience by forcing the player to navigate toward the bat
    • It ensures the player faces an appropriate level of challenge that increases as the slime grows longer
    • It prevents potential frustration from random spawns that might be either too easy or too difficult to reach
    • It creates a more balanced and predictable game experience while still maintaining variety
  3. What problem does input buffering solve and how does our implementation address it?

    Input buffering solves the timing disconnect between when players press buttons and when the game can actually process those inputs in games with fixed movement cycles. Without buffering, inputs that occur between movement cycles are lost, especially when players make rapid sequential inputs like navigating corners.

    Our implementation addresses this by:

    • Using a queue data structure to store up to two directional inputs
    • Processing inputs in First-In-First-Out order to preserve the player's intended sequence
    • Validating each input against the previous one to prevent impossible movements