Resolution independence is the process of scaling the graphics and input of your game to fit the size of the target screen.

There are a couple of approaches to solving this problem, each one a little more complex than the previous. This post discusses the implementation in the MonoGame.Extended library.

Approach 1: Do nothing

The first question you should ask yourself is, do you really need your game to be resolution independent? It may sound silly, but not all games benefit from resolution independence. This is a design choice, and some games are better if they can be played at a higher resolution.

For example, a tile based game with large maps might be better if the player can see more of the map with a bigger screen. They might still have the ability to zoom in and out with the camera effectively controlling how much of the world they can see.

On the other hand, in a mutliplayer game it's generally considered bad practice to give one player an advantage over another, so allowing a player to see more of the map just because they have a bigger screen may not be the right choice.

Okay, so you didn't really come here to do nothing right? Let's move on to really solving the problem.

Approach 2: Scaling or stretching to fit the screen

The simplest way to solve resolution independence in MonoGame is to create a scaling matrix that can be passed into each SpriteBatch.Begin call. The idea is to pick a virtual resolution and scale everything to fit into that box. When you're coding your game, you'll always work with the virtual resolution.

A simple scaling matrix would look like this:

var scaleX = (float)ActualWidth / VirtualWidth;
var scaleY = (float)ActualHeight / VirtualHeight;
var matrix = Matrix.CreateScale(scaleX, scaleY, 1.0f);

_spriteBatch.Begin(transformMatrix: matrix);

For example, let's say your virtual resolution is 800x480 and the actual resolution of the device is 1024x768. Using the calculations above, the final image would be scaled 1.28 times horizontally and 1.6 times vertically.

The effect is that the images get stretched to fit into the screen. Quite often this looks fine, especially if the aspect ratios of each target device is similar.

A more complete implementation looks like this:

public class ScalingViewportAdapter : ViewportAdapter
{
    public ScalingViewportAdapter(GraphicsDevice graphicsDevice, int virtualWidth, int virtualHeight) 
        : base(graphicsDevice)
    {
        _virtualWidth = virtualWidth;
        _virtualHeight = virtualHeight;
    }

    private readonly int _virtualWidth;
    public override int VirtualWidth
    {
        get { return _virtualWidth; }
    }

    private readonly int _virtualHeight;
    public override int VirtualHeight
    {
        get { return _virtualHeight; }
    }

    public override Matrix GetScaleMatrix()
    {
        var scaleX = (float)ActualWidth / VirtualWidth;
        var scaleY = (float)ActualHeight / VirtualHeight;
        return Matrix.CreateScale(scaleX, scaleY, 1.0f);
    }
}

As you probably guessed, if you're using this viewport adapter without a camera you'll need to pass in the result of GetScaleMatrix() to the SpriteBatch.Begin calls. All talk more about how to use it with a camera later.

_spriteBatch.Begin(transformMatrix: _viewportAdapter.GetScaleMatrix());

Approach 3: Letterboxing and pillarboxing

Of course if you don't like the stretching behaviour of the ScalingViewportAdapter you can use a BoxingViewportAdapter instead. It's a little more complex to setup, but maintains the aspect ratio of the image and puts black bars on each side of the image. Before I go ahead and explain how it's implemented, let's take a look at how it works.

As you can see, the BoxingViewportAdapter keeps the image at the same aspect ratio as the original by putting black bars above and below the image. This is known as letterboxing and is often used in film when a widescreen movie is displayed on a standard screen TV. The BoxingViewportAdapter also does pillarboxing if the target resolution is wider than the virtual resolution.

The implementation of the BoxingViewportAdapter has the same behaviour as the ScalingViewportAdapter with the added complexity that it must be updated when the scren size changes.

public enum BoxingMode
{
    Letterbox, Pillarbox
}

public class BoxingViewportAdapter : ScalingViewportAdapter
{
    public BoxingViewportAdapter(GraphicsDevice graphicsDevice, int virtualWidth, int virtualHeight)
        : base(graphicsDevice, virtualWidth, virtualHeight)
    {
    }

    public BoxingMode BoxingMode { get; private set; }
    
    public override void OnClientSizeChanged()
    {
        var viewport = GraphicsDevice.Viewport;
        var aspectRatio = (float) VirtualWidth / VirtualHeight;
        var width = viewport.Width;
        var height = (int)(width / aspectRatio + 0.5f);

        if (height > viewport.Height)
        {
            BoxingMode = BoxingMode.Pillarbox;
            height = viewport.Height;
            width = (int) (height * aspectRatio + 0.5f);
        }
        else
        {
            BoxingMode = BoxingMode.Letterbox;
        }

        var x = (viewport.Width / 2) - (width / 2);
        var y = (viewport.Height / 2) - (height / 2);
        GraphicsDevice.Viewport = new Viewport(x, y, width, height);
    }
}

To use the BoxingViewportAdapter you'll need to listen to the ClientSizeChanged event of the GameWindow like so:

var viewportAdapter = new BoxingViewportAdapter(GraphicsDevice, 800, 480);
Window.ClientSizeChanged += (s, e) => viewportAdapter.OnClientSizeChanged();

This is important because the BoxingViewportAdapter needs to update the GraphicsDevice.Viewport when the resolution changes to constrain the rendering area.

Not done yet: Handling mouse and touch input

One side effect of using a virtual resolution and scaling the graphics in your game is that mouse and touch coordinates will no longer hit where you expect them too. To get around this you'll need to scale your input coordiantes with the inverse of the scale matrix like this:

public virtual Point PointToScreen(Point point)
{
    var matrix = Matrix.Invert(GetScaleMatrix());
    return Vector2.Transform(point.ToVector2(), matrix).ToPoint();
}

And for the BoxingViewportAdapter you also need to take into account the position of the modified viewport. In other words, subtract the height or width of the black bars.

public override Point PointToScreen(Point point)
{
    var viewport = GraphicsDevice.Viewport;
    return base.PointToScreen(point.X - viewport.X, point.Y - viewport.Y);
}

That's it! Resolution independence can be quite tricky to get right. That's why I've included it in the MonoGame.Extended library.

Happy coding! :)

The graphics used in this post are available for free on GameDevMarket.net