Make a game with C# in a Weekend: Saturday


Source code

What and why

In addition of being a great tool for building cloud services and such, C# shines, perhaps surprisingly, in video game development. Unity game engine, for example, uses C# as a scripting language. For my taste, however, Unity is a little bit too magical, too much of mouse dragging and button clicking in order to get something workable. So for this guide we will use Monogame, a wonderful cross-platform C# framework for building games which can run on Windows, Mac, Linux, as well as on a few popular mobile platforms and game consoles. We are going to concentrate on building a desktop game. This will be a Tetris-like game and it will be able to run on any popular desktop platform.

We are going to build something like this: Blocks

Ultra-brief history of Monogame

Around 15 years ago, Microsoft toyed with the idea of making game development accessible to the hobbyists and indie developers alike so they released set of libraries and tools called Microsoft XNA, aimed at the cross-platform game development. Of course, the term “cross-platform” was understood in the traditional Microsoft’s way, i.e. Windows and Xbox. The toolkit was actually pretty well received by the indie games developers community. But, as it often happens with them, Microsoft eventually lost interest and pretty much abandoned the project. The open source community stepped in, and reimplemented the XNA Api as an open source project, which got a name Monogame. Since then the support for many platforms has been added and currently one may use it to develop games for macOS, Linux, Windows, iOS, Android and a few game console platforms, like Nintendo Switch. Monogame powers quite a few popular video games, Stardew Valley being one such example.

What you need to follow along

You’ll need a .net SDK installed on Windows, Mac or Linux. We are going to use the latest version which is 5.0. You will also need a code editor, something like Visual Studio Code although any other editor will work too. Familiarity with the terminal and the basics of C# is assumed. For uniformity, I am going to use bash. On Windows you may use git bash but it is pretty trivial to port commands to the standard Windows cmd.exe.

Initial setup

Create a directory and create a new console project

mkdir csharp_game
cd csharp_game
dotnet new console

It is probably just me, but I dislike, for some reason, the name for the default class to be Program. So let’s rename it

mv Program.cs App.cs

You may also want to open the App.cs file and rename the class name as well. While you are it, make sure that the namespace is called Blocks.

Create the game window

First, we need to add the monogame package to our project.

dotnet add package MonoGame.Framework.DesktopGL

Notice that we are using the OpenGL flavor of Monogame. There are other flavors too, but this one will work on Windows, Mac and Linux.

In Monogame, your window class must derive from the Game class which lives in the Microsoft.Xna.Framework.Graphics namespace, so make your App class to derive from it, and don’t forget to add the appropriate using statement. Create a constructor for the App class which initializes the Monogame GraphicsManager, like so:

using Microsoft.Xna.Framework;

namespace Blocks
{
    class App : Game
    {
        private readonly GraphicsDeviceManager graphics;
        public App()
        {
            graphics = new GraphicsDeviceManager(this);
        }
    }
}

Monogame will call the virtual method Draw in your game class when it is a time to show your game to the screen. For now, we are going to clear the whole window to a nice Gray color

protected override void Draw(GameTime gameTime)
{
    GraphicsDevice.Clear(Color.DimGray);
}

Finally, make change to the Main function so your game actually runs

public static void Main()
{
    using var app = new App();
    app.Run();
}

For convenience, here’s the whole App.cs file as of now:

using Microsoft.Xna.Framework;

namespace Blocks
{
    class App : Game
    {
        private readonly GraphicsDeviceManager graphics;

        public App()
        {
            graphics = new GraphicsDeviceManager(this);
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.DimGray);
        }

        public static void Main()
        {
            using var app = new App();
            app.Run();
        }
    }
}

Give it a spin

dotnet run

You should see a window filled with the gray color.

Draw something

Monogame usually expects you to provide images (sprites etc) to be displayed as graphical assets in your game. There is a quite sophisticated pre-processing pipeline built-in into the framework which convert png or jpg images into the format expected by Monogame. The bad news is that, as at the moment of writing this, the content processing pipeline has not been yet ported to .net 5.0. It works fine with .net core 3.1. The good news though, that all we need to display are pretty much rectangles of various colors.

Here’s a little trick: the image of 1 pixel wide and 1 pixel tall is, technically, a rectangle, albeit a small one. Monogame has the ability to scale the images arbitrarily, so 1 pixel can be scaled to the rectangle of desired size. Ok, what about the color?. In addition to scaling the image, Monogame can also tint it to any desired color. So, if we would have a 1x1 image of a white pixel, that would be enough to produce a rectangle of any size and any color. Creating such image programmatically is not that hard in Monogame. Create a field in the App class which will represent our pixel. Also, expose it as a property.

private Texture2D pixel;

public Texture2D Pixel
{
    get => pixel;
}

Texture2D is the Monogame object representing images, sprites etc. It lives in the Microsoft.Xna.Framework.Graphics namespace.

The actual creation of our pixel texture can be done in the Initialize call, when all graphics plumbing setup has been completed by the Monogame

protected override void Initialize()
{
    base.Initialize();
    pixel = new Texture2D(GraphicsDevice, 1, 1);
    pixel.SetData<Color>(new[] { Color.White });
}

Ok, we got our pixel. We will want to access this Pixel property from anywhere in the program and, eventually, we’ll want also access from everywhere our class App itself. So let’s turn it into a simple singleton.

//...
class App: Game
{
// ...
//new
private static App instance;

    public static App Instance
    {
        get => instance;
    }

    public App()
    {
        // ...
        // new
        instance = this;

    }
}
//...
class App: Game
{
// ...
//new
private SpriteBatch spriteBatch;
// ...
protected override void Initialize()
{
    protected override void Initialize()
    {
        base.Initialize();
        pixel = new Texture2D(GraphicsDevice, 1, 1);
        pixel.SetData<Color>(new[] { Color.White });
        // new
        spriteBatch = new SpriteBatch(GraphicsDevice);
    }
}

In order to not pollute the App class we are going to create a separate class that holds the main game flow. Create a new file

touch PlayScene.cs

Make it look like this:

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;

namespace Blocks
{

    public class PlayScene
    {
        public void Render(SpriteBatch spriteBatch)
        {
            spriteBatch.Draw(
                App.Instance.Pixel,
                new Rectangle(100, 100, 100, 100),
                Color.Red
            );
        }
    }
}

The Render method of the scene uses the SpriteBatch to draw our pixel. Draw has many overloads, this particular, in addition of having the source texture parameter (our 1x1 white pixel), also takes the destination rectangle and the tint into which to color the texture. So the pixel is scaled to a 100x100 rectangle and colored red.

Ok, now we need to add the PlayScene object to the App and call its Render in the `Draw implementation. Add a new field:

// ...
public class App: Game
{
    //...
    // new
    private readonly PlayScene scene = new();
    // ...
    protected override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.DimGray);
        // new
        spriteBatch.Begin();
        scene.Render(spriteBatch);
        spriteBatch.End();
    }
}

Note that the call to scene’s Render is sandwiched between Begin and End on the SpriteBatch.

Try to run the application, you will see a red square on the gray background.

Windowing

Monogame allows you to run your game in fullscreen mode, or in the window. In our case we are going to run it in the window. We set it to some initial size and let the user resize the window if he/she wants. First create the file Constants.cs where we are going to keep all our constants, so we can find and modify them easily.

namespace Blocks
{
    public static class Constants
    {
        public const int WINDOW_WIDTH = 1024;
        public const int WINDOW_HEIGHT = 768;
    }
}

Next, in your main file, App.cs, inside the Initialize method add the following:

protected override void Initialize()
{
    base.Initialize();
    // new
    graphics.PreferredBackBufferWidth = WINDOW_WIDTH;
    graphics.PreferredBackBufferHeight = WINDOW_HEIGHT;
    graphics.ApplyChanges();
    Window.Title = "Blocks";
    Window.AllowUserResizing = true;
    // as before, create pixel and spriteBatch...
}

In order for these constants to be accessible in the file, make sure you add

using static Blocks.Constants;

on top of it.

We are setting the window dimensions here as well as its title and allowing the user to resize the window. Try to run the application, the red square on the gray background still appears. Now it is possible to change the window size and the content becomes “responsive”, that is it adapts to the window size. This is fine but it is going to be a little hard to position our content, as the user can change the the window dimensions and its aspect ratio. We can make our life a little easier by using virtual dimensions.

Drawing in terms of the Virtual Dimensions.

Let’s say, we want to treat our window as always having a predefined fixed size, say 640x480, position all elements according to this size, and let Monogame scale it appropriately. That is, it might be resized by the user, but we always think that the size is fixed and let Monogame to take care of appropriate scaling Add these two constants to the Constants.cs

public const int VIRTUAL_WIDTH = 640;
public const int VIRTUAL_HEIGHT = 480;

So how we are going to take advantage of the Monogame auto-scaling? The answer is so called RenderTarget. We basically say, hey, do all the drawing to this RenderTarget which happens to be of the size 640x480, and once all drawing done, blast the whole contents of the RenderTarget to whatever current windows size it is now.

Add the following field to the App class

private RenderTarget2D target;

create this objects at the bottom of the Initialize method

target = new RenderTarget2D(
    GraphicsDevice,
    VIRTUAL_WIDTH,
    VIRTUAL_HEIGHT,
    false,
    SurfaceFormat.Color,
    DepthFormat.None, GraphicsDevice.PresentationParameters.MultiSampleCount,
    RenderTargetUsage.DiscardContents
    );

Notice that we are passing the VIRTUAL_WIDTH and VIRTUAL_HEIGHT parameters which set the dimensions of the target. Next, we need to make the the Draw method to look like this

protected override void Draw(GameTime gameTime)
{

    graphics.GraphicsDevice.SetRenderTarget(target);
    GraphicsDevice.Clear(Color.DimGray);
    spriteBatch.Begin();
    scene.Render(spriteBatch);
    spriteBatch.End();
    graphics.GraphicsDevice.SetRenderTarget(null);
    spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.Opaque);
    var dst = new Rectangle(0, 0, Window.ClientBounds.Width, Window.ClientBounds.Height);
    spriteBatch.Draw(target, dst, Color.White);
    spriteBatch.End();
}

This code does what has been described above: we set the render target, do the clearing and drawing to that target, set the target to default, and blast the whole target into the window, which current dimensions are held in Window.ClientBounds. if we run the application again, we’ll see that the red square still shows up, but its dimensions might be slightly different than before, as its coordinates are interpreted in the terms of the virtual coordinates. Ok, by now we can draw rectangles and have adequate control how to position them.

Arrays

I bet you have played a Tetris-like game before, so I won’t bother to specify the rules in detail. You have a rectangular board, and a falling piece which could be also viewed as 4x4 square; with some cells filled and some not. We are going to use two-dimensional arrays to represent both the board and the falling piece. The values equal to 0 in array means that the particular cell is empty, non zero value stands for the occupied cell. In theory, we could use a standard C# array structure for it, but eventually we are going to have bunch of helper methods and properties so, in order to keep the code organized, let’s create the class Arr in the file Arr.cs that is going to represent the array we need.

namespace Blocks
{
    public class Arr
    {
        private readonly int[] buf;
        private readonly int rows;
        private readonly int cols;

         public int Rows
        {
            get => rows;
        }

        public int Cols
        {
            get => cols;
        }
    }
}

the data itself is going to be kept in a simple, one-dimensional array, so we need some helper indexer which gets and sets the value, given the row and the column. Add this indexer to the Arr class.

public int this[int r, int c]
{
    get => buf[cols * r + c];
    set => buf[cols * r + c] = value;
}

We also need to construct somehow instances of the Arr class. Let’s add two constructors, one would initialize an array filled with zeros by simply specifying number or rows and cols. Another constructor will just take the copy of the given array and represents it in a rectangular way. These are the constructors

public Arr(int rows, int cols)
        {
            this.rows = rows;
            this.cols = cols;
            buf = new int[this.rows * this.cols];
        }

        public Arr(int[] source, int rows)
        {
            this.rows = rows;
            cols = source.Length / this.rows;
            buf = new int[this.rows * cols];
            source.CopyTo(buf, 0);
        }

Ok, that is enough for now, we will add extra functionality to this class Later.

Typically, the game board is a 10x20 rectangle, but to make things simpler to manage, we will add extra 2 row at the top, therefore internally the board will be represented as an 10x22 Arr. The 2 extra rows are not going to be visible to the user. Add the following constants to the Constants class.

public const int BOARD_COLS = 10;
public const int BOARD_ROWS = 22;
public const int OVERFLOW = 2;
public const int CELL = 20;

CELL is the size of a simple cell of the board, in (virtual) pixels.

Engine

We could put all game logic into the PlayScene, but that quickly turn into quite unmanageable amount of code. So, in order to keep concerns separate we are going to keep the game engine in a separate class. Start with the Following Engine.cs

using static Blocks.Constants;

namespace Blocks
{

    public class Engine
    {
        private Arr board = new(BOARD_ROWS, BOARD_COLS);
        public Arr Board
        {
            get => board;
        }
    }

Nothing special. the initial board is just an empty 22x10 array.

Let’s instantiate it in the PlayScene and display on screen. Add the following to the PlayScene


private Engine engine;

private void Reset()
{
engine = new();
}

public PlayScene()
{
Reset();
}

We have the Reset method whose responsibility is to ensure the scene into the fresh, ready to play state. For now, it does not do much, as you see. The constructor simply calls the Reset.

Let’s draw the board, empty for now.

Replace the ‘Render` method with

public void Render(SpriteBatch spriteBatch)
{
    var sx = (VIRTUAL_WIDTH - CELL * BOARD_COLS) / 2;
    var sy = (VIRTUAL_HEIGHT - CELL * (BOARD_ROWS - OVERFLOW)) / 2;
    for (int r = OVERFLOW; r < BOARD_ROWS; r++)
    {
        for (int c = 0; c < BOARD_COLS; c++)
        {
            var rc = new Rectangle(
                sx + c * CELL,
                sy + (r - OVERFLOW) * CELL,
                CELL,
                CELL
            );
            spriteBatch.Draw(App.Instance.Pixel, rc, Color.Black);
        }
    }
}

We want our board to be centered, for that we compute where it starts, thus variables sx and sy. Next we go over the whole board, row by row and cell by cell, calculate what is cell’s destination place (variable rc), and draw there a black square. Observe that we are skipping extra two rows (OVERFLOW) If we run the application now, we are going to see an empty board displayed. Clearly we need actual game pieces.

Pieces

We are going to handle falling pieces of 7 classical shapes, As discussed previously, we represent each as an instance of 4x4 Arr, value 0 represents empty cell. If it is not 0, we will interpret it as a color index in some palette. So create a file Pieces.cs with the following.

namespace Blocks
{
    public static class Pieces
    {
        private static readonly Arr pieceO = new(new[] {
            0, 0, 0, 0,
            0, 1, 1, 0,
            0, 1, 1, 0,
            0, 0, 0, 0,
        }, 4);
        private static readonly Arr pieceI = new(new[] {
            0, 0, 0, 0,
            0, 0, 0, 0,
            2, 2, 2, 2,
            0, 0, 0, 0,
        }, 4);
        private static readonly Arr pieceT = new(new[] {
            0, 0, 0, 0,
            0, 3, 0, 0,
            3, 3, 3, 0,
            0, 0, 0, 0,
        }, 4);
        private static readonly Arr pieceL = new(new[] {
            0, 0, 0, 0,
            0, 0, 4, 0,
            4, 4, 4, 0,
            0, 0, 0, 0,
        }, 4);
        private static readonly Arr pieceJ = new(new[] {
            0, 0, 0, 0,
            5, 0, 0, 0,
            5, 5, 5, 0,
            0, 0, 0, 0,
        }, 4);
        private static readonly Arr pieceS = new(new[] {
            0, 0, 0, 0,
            0, 6, 6, 0,
            6, 6, 0, 0,
            0, 0, 0, 0,
        }, 4);
        private static readonly Arr pieceZ = new(new[] {
            0, 0, 0, 0,
            7, 7, 0, 0,
            0, 7, 7, 0,
            0, 0, 0, 0,
        }, 4);

        public static readonly Arr[] PIECES = new[]
        {
            pieceI,
            pieceO,
            pieceT,
            pieceL,
            pieceJ,
            pieceS,
            pieceZ
        };

    }
}

The cell set to zero means an empty cell, otherwise it indicates the color of the particular cell. Obviously it is one of these occasions where it is just fine to copy paste the code snippet as there is little learning value in typing this in. We give each piece a name which refers to a letter that vaguely resembles its shape. Finally, the PIECES holds all of them in a single array.

The current piece

Ok, what it takes to put one piece on the board? First we need to check if there is space for it in the desired position, then just blast the non-0 entries into the board. For that we will add two helpers CanPlace and Place to the Arr class. The former is supposed to return a boolean indicating if it is possible to put the piece into the board at desired position.

 public bool CanPlace(Arr piece, int r, int c)
{
    for (int y = 0; y < piece.Rows; y++)
    {
        for (int x = 0; x < piece.Cols; x++)
        {
            int p = piece[y, x];
            if (p == 0)
            {
                continue;
            }
            int br = r + y;
            int bc = c + x;
            if (br < 0 || br >= rows)
            {
                return false;
            }
            if (bc < 0 || bc >= cols)
            {
                return false;
            }
            if (this[br, bc] != 0)
            {
                return false;
            }
        }
    }
    return true;
}

We make sure that any non-zero value of the piece is within the board bounds and the corresponding value in the board is not occupied.

The latter method simply places the piece on the board, assuming that it has been verified by now that it is possible.

public void Place(Arr piece, int r, int c)
{
    for (int y = 0; y < piece.Rows; y++)
    {
        for (int x = 0; x < piece.Cols; x++)
        {

            int p = piece[y, x];
            if (p != 0)
            {
                this[r + y, c + x] = p;
            }

        }
    }
}

Finally, we need to be able to create a deep clones of the Arr instances. Add this:

public Arr Cloned
{
    get => new(buf, rows);
}

Ok, now we are prepared start putting pieces of the board. Let’s add the following fields into the Engine class which represent what is the current piece in play and what is its position.

public class Engine
{
    //...
    private int curRow;
    private int curCol;
    private Arr curPiece;
    private readonly Random rnd = new();
}

We will need also the random number generator, thus the rnd field. Random lives in the System namespace, so make sure is in your usings. Now add the Spawn method to our Engine which will responsible for initializing the current piece:

public void Spawn()
{
    int which = rnd.Next(pieces.Length);
    curCol = 4;
    curRow = 0;
    curPiece = PIECES[which].Cloned;
}

In order to access the PIECES array, make sure you have using static Blocks.Pieces; at the top.

Now head back to the PlayScene and add the call to engine.Spawn() inside the Reset method.

 private void Reset()
{
    engine = new();
    // new
    engine.Spawn();
}

In order to display non-empty cells we need to assign colors other than black to all values but 0. Add the following field

    private readonly Color[] palette = new Color[]
    {
        Color.Black,
        Color.Cyan,
        Color.Yellow,
        Color.Purple,
        Color.Orange,
        Color.Blue,
        Color.Green,
        Color.Red,
    };

And tweak the rendering so it grabs the color from the palette instead of always using black.

public void Render(SpriteBatch spriteBatch)
{
    var sx = (VIRTUAL_WIDTH - CELL * BOARD_COLS) / 2;
    var sy = (VIRTUAL_HEIGHT - CELL * (BOARD_ROWS - OVERFLOW)) / 2;
    for (int r = OVERFLOW; r < BOARD_ROWS; r++)
    {
        for (int c = 0; c < BOARD_COLS; c++)
        {
            var rc = new Rectangle(
                sx + c * CELL,
                sy + (r - OVERFLOW) * CELL,
                CELL,
                CELL
            );
            var v = engine.Board[r, c];
            spriteBatch.Draw(App.Instance.Pixel, rc, palette[v]);
        }
    }
}

Ok, this looks like everything we need in order to display at list one piece. But if you run the the app now, the board still seems to be black. How come? Well we spawned a new piece in the engine but haven’t actually placed it on board. Moving the piece one position down will accomplish the goal.

Piece Down

So, what do we need to move the piece down? In the simplest terms, we remove the piece from the current position increase the current row by one and place it again. So firstly, we need a new method in the Arr class which removes the piece in the give position:

public void Remove(Arr piece, int r, int c)
{
    for (int y = 0; y < piece.Rows; y++)
    {
        for (int x = 0; x < piece.Cols; x++)
        {
            if (piece[y, x] != 0)
            {
                this[r + y, c + x] = 0;
            }
        }
    }
}

Good. Now let’s think for a moment. We remove the piece at the current position, increase the row by one and try to put it again. But what to do if there’s no space anymore? obviously the Down operation was not possible in this situation at all! But we removed the piece already, so shall we put it back? looks like a mess. So instead of trying to move down on a real board and do this operation on its clone. If it succeeds we can either copy the whole clone back to the original board or, simpler, just replace the board with the clone. On the other hand, if the operation would fail we could just notify the application that the move is not legit so it can indicate that the game is over or something like that. Let’s add a set only property GameOver which will hold the action to be executed when it is impossible to move.

public class Engine
{
    //...
    // new
    private Action gameOver = () => { };
    public Action GameOver {
         set => gameOver = value;
    }
}

Now we can implement Down as discussed

public void Down()
{
    var clone = board.Cloned;
    clone.Remove(curPiece, curRow, curCol);
    if (clone.CanPlace(curPiece, curRow + 1, curCol))
    {
        curRow++;
        clone.Place(curPiece, curRow, curCol);
        board = clone;
    }
    else
    {
        gameOver();
    }
}

Again,

  • make the clone of the board
  • remove the piece at the current position in the clone
  • increment current row
  • if it is possible to place the piece in the new position, place it
  • otherwise, indicate the application that the move is not legit, by calling the GameOver action.

Let see if it works. Back to the PlayScene, set the engine’s GameOver to something and call Down immediately after Spawn.

private void Reset()
{
    engine = new();
    engine.Spawn();
    engine.GameOver = () =>
    {
        Console.WriteLine("Game Over");
    };
    engine.Down();
}

When you run the application now, one piece should show up. Since we are using the random number generator, every subsequent run will likely yield another piece to show.

Falling Piece

In order to make the piece to fall we need to call Down periodically. Add the following constant to the Constants.cs

public const double NORMAL_DELAY = 0.5;

That indicates how long we normally wait (in seconds) until we need to call Down again.

Add the following fields to the PlayScene

private double baseDelay;
private double curDelay;
private double elapsed;

baseDelay holds the base delay for the current difficulty level. curDelay is the actual delay. At the moment these two will be set to the same value. elapsed tracks how much time passed since the last Down. Initialize delays in the Reset

private void Reset()
{
    // new
    baseDelay = NORMAL_CLOCK;
    curDelay = baseDelay;
    // ...
}

create the method Update which will be called periodically. It has a single argument indicating how much time, in seconds, since the last call.

public void Update(double dt)
{

    elapsed += dt;
    if (elapsed >= curDelay)
    {
        elapsed = 0;
        engine.Down();
    }
}

Monogame asks the Game to update its state by calling the Update method, unsurprisingly. Let’s create the new field in the App class

private double? clock;

That is the current value of the clock, in seconds. Override the Update method

protected override void Update(GameTime gameTime)
{
    if (clock.HasValue)
    {
        var updated = gameTime.ElapsedGameTime.TotalSeconds;
        var dt = updated - clock.Value;
        scene.Update(dt);
    }
    else
    {
        clock = gameTime.ElapsedGameTime.TotalSeconds;
    }
}

Easy stuff, we calculate the time difference between the current time and the previous one, and pass that to scene’s Update call.

Run the application again, the piece will fall until it stops at the the bottom of the board. At this point it is not able to move anymore, so the GameOver action will be invoked which, for now, just prints to the console.

Moving the Piece Right and Left

We will want to move the current piece right and left once the right and left keys are pressed, correspondingly. Monogame allows to query the state of the particular key and it will report if the key is pressed or not. So, we can check the state of the left and right keys and if they are pressed, do the corresponding action.

Well, almost.

If you implement it naively this way you will notice that one press will cause usually multiple movements in the corresponding direction. Since the Update is called many times per second it is humanely impossible to ensure that the key press lasted only one cycle. The way we are going to deal with that is that we are going to invoke the appropriate action only if the key state switched from “not pressed” to “pressed” if both current and previous values are “pressed”, we do nothing. Let’s create a helper class which will handle that. Name it InputManager

using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework.Input;
namespace Blocks
{
    public class InputManager
    {
        private KeyboardState oldKeys;
        private readonly Dictionary<Keys, Action> discretePresses = new();

        public void Handle(Keys keys, Action action)
        {
            discretePresses[keys] = action;
        }

        public void Reset()
        {
            oldKeys = Keyboard.GetState();
        }

        public void Update(double _)
        {
            var keys = Keyboard.GetState();
            foreach (var key in discretePresses.Keys)
            {
                if (keys.IsKeyDown(key) && !oldKeys.IsKeyDown(key))
                {
                    discretePresses[key]();
                }
            }
            oldKeys = keys;
        }
    }
}

As you see, we essentially maintain the dictionary of keys and corresponding actions to invoke when the key state switched from “not pressed” to “pressed”. To do its job, the InputManager has to be called on every Update. It does not care of how much time elapsed since last call, but for uniformity it will still accept it as a parameter.

Ok, time to implement Left and Right methods in the Engine class

public void Right()
{
    var clone = board.Cloned;
    clone.Remove(curPiece, curRow, curCol);
    if (clone.CanPlace(curPiece, curRow, curCol + 1))
    {
        curCol++;
        clone.Place(curPiece, curRow, curCol);
        board = clone;
    }
}

public void Left()
{
    var clone = board.Cloned;
    clone.Remove(curPiece, curRow, curCol);
    if (clone.CanPlace(curPiece, curRow, curCol - 1))
    {
        curCol--;
        clone.Place(curPiece, curRow, curCol);
        board = clone;
    }
}

Conceptually their implementation is very similar to the Down implementation, modulo that we are moving in horizontal direction, rather than vertical direction. Plus, we don’t bother to notifying the application if it is not possible to move the desired direction.

Now, make sure that we create an instance of the InputManager in the PlayScene and call Update on it.

class PlayScene
{
    // ...
    // new
    private readonly InputManager input = new();
    // ...
    public void Update(double dt)
    {
        // new
        input.Update(dt);
        // ...
    }
}

In the Reset implementation handle the Left and Right keys and delegate to the engine’s Left and Right correspondingly.

private void Reset()
{
    engine = new();
    input.Handle(Keys.Left, () =>
    {
        engine.Left();
    });
    input.Handle(Keys.Right, () =>
    {
        engine.Right();
    });
    engine.Spawn();
    engine.GameOver = () =>
    {
        Console.WriteLine("Game Over");
    };
    engine.Down();
}

Run the app again, you’ll be able to move the piece left and right.

Rotating the Piece

Next we want to rotate the current piece. Let’s say we want to rotate the piece 90 degrees clockwise. The first row of our original piece becomes the last column, the second row becomes the second last column and so on. You see the pattern. Rotation counter-clockwise is pretty much the same, but instead of the first row becoming the last column, it becomes the the column, and so on…

We are going to implement both rotation flavors. Add the following to the Arr implementation

public Arr RotatedClockwise
{
    get
    {
        var dst = new Arr(cols, rows);
        for (int r = 0; r < rows; r++)
        {
            for (int c = 0; c < cols; c++)
            {
                int v = this[r, c];
                int dr = c;
                int dc = rows - 1 - r;
                dst[dr, dc] = v;
            }
        }
        return dst;
    }
}

public Arr RotatedCounterClockwise
{
    get
    {
        var dst = new Arr(cols, rows);
        for (int r = 0; r < rows; r++)
        {
            for (int c = 0; c < cols; c++)
            {
                int v = this[r, c];
                int dr = cols - 1 - c;
                int dc = r;
                dst[dr, dc] = v;
            }
        }
        return dst;
    }
}

Note that we create the new, rotated array. Granted, it is possible to do rotation in place, but we won’t bother here.

Next, let’s implement the Rotate method in the Engine class. Our application could support both clockwise and counter-clockwise rotations by handling different keys, but I decided here to support only counter-clockwise flavor as it seems, subjectively, more natural to me. In the Engine class add

public void Rotate()
{
    var clone = board.Cloned;
    var rotated = curPiece.RotatedCounterClockwise;
    clone.Remove(curPiece, curRow, curCol);
    if (clone.CanPlace(rotated, curRow, curCol))
    {
        curPiece = rotated;
        clone.Place(curPiece, curRow, curCol);
        board = clone;
    }

}

Let’s bind the Rotate to the Up key. in the PlayScene, right below handling Left and Right keys, add

input.Handle(Keys.Up, () =>
{
    engine.Rotate();
});

Dropping the Piece

As the next part of our game we will enable the ability to “Drop” the piece, once the user presses the Space key. We could calculate that position and place the piece there immediately. But that would not be very friendly to the user. So instead we are going to temporarily shorten the delay so it takes way less time for the next Down to be forced. Add the new constant to the Constants.cs

public const double FAST_DELAY = 0.01;

We want the Space key to make the scene to switch to the FAST_DELAY. Add the corresponding handler to the Reset method.

private void Reset
{
    // ...
    // new
    input.Handle(Keys.Space, () =>
    {
        curDelay = FAST_DELAY;
    });
    // ...
}

That should do it. However, once the piece is dropped, we want to switch back to the “normal” delay, that is to the baseDelay. add the new action to the Engine class

public class Engine
{
    // ...
    // new
    private Action dropped = () => { };
    public Action Dropped
    {
        set => dropped = value;
    }
}

When the Down does not have place to move, switch from calling the gameOver to dropped. We will handle the game over condition later.

public void Down()
{
    var clone = board.Cloned;
    clone.Remove(curPiece, curRow, curCol);
    if (clone.CanPlace(curPiece, curRow + 1, curCol))
    {
        curRow++;
        clone.Place(curPiece, curRow, curCol);
        board = clone;
    }
    else
    {
        // changed
        dropped();
        Spawn();
    }
}

Naturally, once the piece is dropped we want to spawn a new one.

the last thing is to subscribe to that action and switch the delay back. In the Reset method add

engine.Dropped = () =>
{
   curDelay = baseDelay;
};

Try to run the game. you are able to move the piece left and right, rotate it and drop. The board though fills up as, at the moment we don’t have a way to remove complete lines. So we need to address that next.

Removing Full Lines

First, let’s add a helper function to the Arr.cs which checks if the row is full

public class Arr
{
    //...
    // new
    private bool IsRowFull(int row)
    {
        for (int c = 0; c < cols; ++c)
        {
            if (this[row, c] == 0)
            {
                return false;
            }
        }
        return true;
    }
    //...
}

Next, what it takes to remove the row, in case it is full? We can simply shift all rows above it down and, eventually, fully clear the 0th row. Let’s implement these primitives:

public class Arr
{
    //...
    private void ShiftRow(int row)
    {
        for (int c = 0; c < cols; ++c)
        {
            this[row + 1, c] = this[row, c];
        }
    }

    private void ClearRow(int row)
    {
        for (int c = 0; c < cols; ++c)
        {
            this[row, c] = 0;
        }
    }
}

The last primitive is just a composition of these two

private void RemoveRow(int row)
{
    for (int r = row; r > 0; r--)
    {
        ShiftRow(r - 1);
    }
    ClearRow(0);
}
public int RemoveFullRows()
{
    int removed = 0;
    for (int row = rows - 1; row > 0; row--)
    {
        if (IsRowFull(row))
        {
            RemoveRow(row);
            row++; // need to check the row again
            removed++;
        }
    }

    return removed;
}

We are returning the number of rows actually removed.

Everything is ready to hook this code into the Engine.cs. Modify the Down method so it removes the full rows once it is not able to move the piece down. Like this

public void Down()
{
    var clone = board.Cloned;
    clone.Remove(curPiece, curRow, curCol);
    if (clone.CanPlace(curPiece, curRow + 1, curCol))
    {
        curRow++;
        clone.Place(curPiece, curRow, curCol);
        board = clone;
    }
    else
    {
        dropped();
        board.RemoveFullRows();
        Spawn();
    }
}

If you run the game now, you will notice that the game mechanics is pretty much implemented completely.


See also