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 ‘Renderand
Update` 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#.