Make a game with C# in a Weekend: Sunday


Previous part

Source code

Fonts

In our game, we will be wanting to display some text, for example current level and score. The way Monogame deals with assets like fonts, images or sounds is through so called “content pipeline”. The idea was that optimized binary formats for such assets may be different for different target platforms Monogame supports. This sounds good in theory, the bad news though is that the development of content pipeline tools somewhat lags the development of the actual library. At the time of the writing, the .net 5.0 support for the content pipeline tools is not yet available in the main branch. There are workarounds, but for our game we are not going to use the content pipeline at all, and use nice library called FontStashSharp for our font rendering needs.

Let’s add it to the project

dotnet add package FontStashSharp.MonoGame

Next, we’ll need a font. I am going to use Fixedsys Excelsior.

Create the directory

mkdir assets

and move the file FSEX300.ttf into it. Next, we need to ensure that the contents of the assets directory gets copied upon build, to the target directory. In your projects file, add the following

<ItemGroup>
    <Content Include="assets/*">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </Content>
</ItemGroup>

This will do the trick.

Head over your App class and add a new field

private readonly FontSystem fontSystem = new();

The FontSystem class resides in the FontStashSharp namespace. Monogame app can override the LoadContent method which is the best place to actually load the font into the font system.

protected override void LoadContent()
{
    fontSystem.AddFont(File.ReadAllBytes("assets/FSEX300.ttf"));
}

make sure you import System.IO to get access to the File class. For now, we are going to use the font of size 18, so add the field and expose it with the property

// ...
// new
private SpriteFontBase font18;
public SpriteFontBase Font18
{
    get => font18;
}

Initializing this field is as simple as adding, after the font bytes are loaded

protected override void LoadContent()
{
    fontSystem.AddFont(File.ReadAllBytes("assets/FSEX300.ttf"));
    // new
    font18 = fontSystem.GetFont(18);
}

Levels and score

To make the game more challenging we’ll implement a simple leveling system. Increasing the level will make the pieces fall faster, thus controlling the piece more challenging. The score should depend on how many full lines were completed, by taking the account the current level: higher level - more points. in the PlayScene class let’s add two fields

// new
int score;
int level;

First things first, we want to display the level and the score on the screen. At the end of your Render method, add these lines:

var font = App.Instance.Font18;
font.DrawText(
    spriteBatch,
    $"Level: {level + 1}",
    new Vector2(16, 16),
    Color.White
    );
font.DrawText(
    spriteBatch,
    $"Score: {score}",
    new Vector2(16, 32),
    Color.White
    );

Notice that the level is translated from ‘programese’ to the human language. The above snippet displays the level and the score at the left upper corner. Tweak the position to your liking.

Increasing the level

we are going to increase the level every 20 seconds and shorten the delay 1.1 times. Add these constants:

public const double LEVEL_SECS = 20;
public const double SPEED_BUMP = 1.1;

We need to track how much time the player has spent in the level. Add this field to the PlayScene:

private double? timeInLevel;

At the end of the Update function implement the logic which increases the level once it player stays in level longer than LEVEL_SECS. It could be as simple as

public void Update(double dt)
{
    // ...
    // new
    if (!timeInLevel.HasValue)
    {
        timeInLevel = dt;
    }
    else
    {
        timeInLevel += dt;
    }
    if (timeInLevel > LEVEL_SECS)
    {
        timeInLevel = null;
        level++;
        baseDelay /= SPEED_BUMP;
    }
}

Updating the score

We are going to update the score once the full line/lines are removed. Since the ‘Engine’ takes care of that we need to communicate these events up to the ‘PlayScene’. Here’s one way of doing this: In the Engine class add

private Action<int> linesRemoved = (rows) => { };
public Action<int> LinesRemoved
{
    set => linesRemoved = value;
}

Make sure that you are calling this action once the lines are removed, in the Down method, replace the line

board.RemoveFullRows();

with the following

var rows = board.RemoveFullRows();
linesRemoved(rows);

In the PlayScene add the following helper method

private void UpdateScore(int removed)
{
    var scoreMultipliers = new[] { 0, 40, 100, 300, 1200 };
    score += scoreMultipliers[removed] *  (level + 1);

}

Note that the score increase depends on the number of lines removed and the current level. Make sure this is called when the lines are removed. At the end of the Reset method

private void Reset()
{
    // ...
    // new
    engine.LinesRemoved = UpdateScore;
    engine.Down();
}

Scenes

The game developers often call separate game screens as “scenes”. For now, everything is happening in the PlayScene, but it is not really hard to add another scene which handles, say, game over screen. We’ll also need a class to manage scenes. Create a new file ScreenManager with the code

namespace Blocks
{
    public class SceneManager
    {

    }
}

We will add content to it shortly. Let’s abstract the scene into an interface. The file IScene.cs:

using Microsoft.Xna.Framework.Graphics;

namespace Blocks
{
    public interface IScene
    {
        public void Render(SpriteBatch spriteBatch);
        public void Update(double dt);
        public void Enter(SceneManager manager, object state);
        public void Leave();
    }
}

The scene will know how to Render and Update itself. The SceneManager is going to maintain a “current scene” and allow to switch between them.

Implementation is pretty straightforward

using System.Collections.Generic;

namespace Blocks
{
    public class SceneManager
    {
        private readonly Dictionary<string, IScene> scenes;
        private IScene currentScene;

        public SceneManager(Dictionary<string, IScene> scenes, string initial)
        {
            this.scenes = scenes;
            currentScene = this.scenes[initial];
            currentScene.Enter(this, null);
        }

        public void SwitchScene(string newScene, object state)
        {
            currentScene.Leave();
            currentScene = scenes[newScene];
            currentScene.Enter(this, state);
        }
    }

}

The state parameter int the SwitchScene allows passing information between the scenes.

For simplicity we’ll allow refer to scenes by name, therefore SceneManager holds a dictionary of scenes with strings as keys. For the Render and Update logic, the SceneManager should delegate to the current scene. Add this

public void Render(SpriteBatch spriteBatch)
{
    currentScene.Render(spriteBatch);
}

public void Update(double dt)
{
    currentScene.Update(dt);
}

Wiring up the SceneManager

Head to the App class and replace the line

private readonly PlayScene scene = new();

with

private readonly SceneManager sceneManager;

Create this object in the constructor

public App()
{
    // new
    sceneManager = new SceneManager(
        new Dictionary<string, IScene>{
            {"play", new PlayScene()}
        }, "play"
    );
    graphics = new GraphicsDeviceManager(this);
    instance = this;
}

Replace the calls to ‘RenderandUpdate` to refer to the sceneManager instead of the single scene.

protected override void Draw(GameTime gameTime)
{

    // ...
    // replace
    sceneManager.Render(spriteBatch);
    // ...
}

protected override void Update(GameTime gameTime)
{
    // ...
    // replace
    sceneManager.Update(dt);
    // ...
    }
}

Run the game. It should work as before. If it doesn’t, retrace your steps and find what you missed.

So what’s the point?, you may ask. If the game works as before, why we went through the trouble to creating the SceneManager and such? With the single scene, the benefit indeed is negligible, but once you have multiple scenes, managing and switching between them becomes much easier.

GameOverScene

Once the PlayScene detects that the game is done, we want to switch to a new scene which displays the game summary (score and level reached) and ask if the player wants to play again. To share the summary let’s create a new file GameSummary.cs with the following

namespace Blocks
{
    public record GameSummary(int Score, int Level);
}

Create the new class GameOverScene which implements IScene. Let’s start with the placeholder

using Microsoft.Xna.Framework.Graphics;

namespace Blocks
{
    class GameOverScene : IScene
    {
        public void Enter(SceneManager manager, object state)
        {
            throw new System.NotImplementedException();
        }

        public void Leave()
        {
        }

        public void Render(SpriteBatch spriteBatch)
        {
            throw new System.NotImplementedException();
        }

        public void Update(double dt)
        {
            throw new System.NotImplementedException();
        }
    }
}

We’ll remember the SceneManager and the state passed in the Enter call.

class GameOveScene : IScene
{
    // new
    private SceneManager sceneManager;
    private GameSummary gameSummary;
    // ...
    // change
    public void Enter(SceneManager manager, object state)
    {
        sceneManager = manager;
        gameSummary = state as GameSummary;
    }
}

We’ll also take advantage of the InputManager class which we have already used in the PlayScene. Add the field

private readonly InputManager inputManager = new();

Once the user hits Enter in the GameOverScene, we want to start a new game, to exit the app, user would press Escape. Wire this logic at the end of the Enter implementation

public void Enter(SceneManager manager, object state)
{
    sceneManager = manager;
    gameSummary = state as GameSummary;
    // new
    inputManager.Handle(Keys.Enter, () =>
    {
        sceneManager.SwitchScene("play", null);
    });
    inputManager.Handle(Keys.Escape, () =>
    {
        App.Instance.Exit();
    });
}

Here, we don’t want to pass anything back to the PlayScene, therefore the state argument value is null;

And, as you may recall, in order for the InputManager to work, its Update has to be called.

public void Update(double dt)
{
    inputManager.Update(dt);
}

In order to display the info screen, implement Render

public void Render(SpriteBatch spriteBatch)
{
    App.Instance.Font18.DrawText(
        spriteBatch,
        "GAME OVER",
        new Vector2(128, 100),
        Color.White
    );
    App.Instance.Font18.DrawText(
        spriteBatch,
        $"Final Level: {gameSummary.Level + 1}",
        new Vector2(128, 132),
        Color.White
    );
    App.Instance.Font18.DrawText(
        spriteBatch,
        $"Final score: {gameSummary.Score}",
        new Vector2(128, 148),
        Color.White
    );
    App.Instance.Font18.DrawText(
        spriteBatch,
        "[enter] to play again",
        new Vector2(128, 164),
        Color.White
    );
    App.Instance.Font18.DrawText(
        spriteBatch,
        "[escape] to exit",
        new Vector2(128, 180),
        Color.White
    );
}

Nothing fancy. Feel free to tweak to make it look better.

Wiring up the GameOverScene

When the game is over? The simplest way to think about it is when the Engine tries to Spawn a new piece and there is no place to put it (or, more, precisely, one cell down), that is the game over condition. Replace the Spawn with

public void Spawn()
{
    int which = rnd.Next(PIECES.Length);
    curCol = 4;
    curRow = 0;
    curPiece = PIECES[which].Cloned;
    if (!board.CanPlace(curPiece, curRow + 1, curCol))
    {
        gameOver();
    }
}

The last thing left to do is to switch to the GameOverScene once the game is actually over. Head back to the PlayScene and add make it remember the SceneManager

public class PlayScene : IScene
{
    // ...
    // new
    private SceneManager sceneManager;
    // ...
    public void Enter(SceneManager manager, object _)
    {
        // new
        sceneManager = manager;
        score = 0;
        level = 0;
        Reset();
    }
}

Switch handling the game over condition in the Reset method to this:

private void Reset()
{
    // ...
    // change
    engine.GameOver = () =>
    {
        sceneManager.SwitchScene("gameOver", new GameSummary(score, level));
    };
    // ...
}

The last thing is to add the GameOverScene to the dictionary of available scenes. In the App constructor make the following change

public App()
        {
            sceneManager = new SceneManager(
                // changed
                new Dictionary<string, IScene>{
                    {"play", new PlayScene()},
                    {"gameOver", new GameOverScene()}
                }, "play"
            );
            // ...
        }

You can run the game now. Everything should work as expected.

Summary

Here you go. We implemented a cross-platform game in C# from scratch to finish using C#.


See also