Build a game like Space Invaders using .NET Maui

There have been a lot of exciting new things coming out of .NET Maui lately, especially in the graphics department. To get my hands dirty with some of these new features I built an interpretation of the SpaceInvaders game in Xamarin, then again in Maui. Doing this allowed me learn about new components, understand their differences, and left me with a few key take aways on their capabilities.

.NET Maui still in preview

This sample was created using Microsoft.Maui.Graphics.Skia 6.0.200-preview.12.852. Not everything has been ported over to Microsoft.Maui.Graphics.Skia yet so I worked with what was available.

To get started, head over to Visual Studio releases and download Visual Studio for WIndows 17.1 preview 2 or a later version if one is released.

GraphicsView

Instead of using SKCanvas view there is now a GraphicsView available that is used to manage the canvas. Unlike the SKCanvas, the GraphicsView does not perform the drawing. Instead that work is done in the Drawable of the GraphicsView.

To create the Space Invaders game we need to add a GraphicsView to a UI game control. This view and the GraphicsView need to interact as the view is responsible for detecting when the user slides the x-axis of the slider or if they’re clicking the Fire/Play button. The GraphicsViews Drawable will need to know about these interactions so it can render them graphically.

Configurations:

  • FireCommand: Fires a bullet from the jet
  • XAxisScale: Scale from 0 – 1 of the current position of the Slider. This is used to determine the x-axis of the jet & jet bullets.
  • TimerLoop: Refreshes the game based on frames per second.

In the TimerLoop a call to Invalidate() will redraw the Drawable object onto the GraphicsView canvas. This invalidates the GraphicsView canvas, instead of the canvas itself as it did for SKCanvas.

internal class SpaceInvadersGraphicsView : GraphicsView
{
    public static readonly BindableProperty XAxisScaleProperty = BindableProperty.Create(nameof(XAxisScale),
        typeof(double),
        typeof(SpaceInvadersGraphicsView),
        0.5,
        propertyChanged: (b,o,n) => {
            Drawable.XAxis = (double)n;
        });

    public double XAxisScale
    {
        get => (double)GetValue(XAxisScaleProperty);
        set => SetValue(XAxisScaleProperty, value);
    }

    public static ICommand Fire = new Command(() => Drawable.Fire(true));

   public static SpaceInvadersDrawable Drawable;
   public SpaceInvadersGraphicsView()
   {
        base.Drawable = Drawable = new SpaceInvadersDrawable();

        var ms = 1000.0 / _fps;
        var ts = TimeSpan.FromMilliseconds(ms);
        Device.StartTimer(ts, TimerLoop);
    }

    private bool TimerLoop()
    {
        // get the elapsed time from the stopwatch because the 1/30 timer interval is not accurate and can be off by 2 ms
        var dt = _stopWatch.Elapsed.TotalSeconds;
        _stopWatch.Restart();

        // calculate current fps
        var fps = dt > 0 ? 1.0 / dt : 0;

        // when the fps is too low reduce the load by skipping the frame
        if (fps < _fps / 2)
            return true;

        _fpsCount++;
        _fpsElapsed++;

        if (_fpsCount == 20)
            _fpsCount = 0;

        //Its been a second
        if (_fpsElapsed == _fps)
        {
            _fpsElapsed = 0;
            Drawable.AlienFire();
        }

        Invalidate();

        return true;
    }

    private int _fpsElapsed;
    private int _fpsCount = 0;
    private const double _fps = 30;
    private readonly Stopwatch _stopWatch = new Stopwatch();
}

Create the game UI

Now that we have a GraphicsView that is redrawing based on fps, it’s time to place it inside a game UI control. This UI has three elements; The actual game which is on the left then a button and slider on the right. The button command needs to trigger events on the GraphicsView control so we give it a static binding to the buttons fire command. When the game is over the buttons text needs to change from Fire to Play. To make this happen, the Text attribute is bound to controls ButtonText property. Finally, the slider hooks into the controls XAxisScale BindableProperty to help the user steer the jet.

<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MauiSpaceInvaders.SpaceInvadersView"
             xmlns:spaceInvaders="clr-namespace:MauiSpaceInvaders.SpaceInvaders;assembly=MauiSpaceInvaders">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="0.8*" />
            <ColumnDefinition Width="0.2*" />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="0.8*" />
            <RowDefinition Height="0.2*" />
        </Grid.RowDefinitions>
        <spaceInvaders:SpaceInvadersGraphicsView GridLayout.Row="0"
                                                 Grid.RowSpan="2"
												 WidthRequest="1200"
												 HeightRequest="650"
                                                 x:Name="spaceInvaders" />
        <Button Text="{Binding Source={x:Reference spaceInvaders}, Path=Drawable.ButtonText}"
				Grid.Row="0"
				Grid.Column="1"
				BackgroundColor="Green"
				VerticalOptions="EndAndExpand"
                Command="{Binding ., Source={x:Static spaceInvaders:SpaceInvadersGraphicsView.Fire}}"/>
        <Slider Margin="10"
                Maximum="1"
				Grid.Row="1"
				Grid.Column="1" 
				ThumbColor="Green"
				MaximumTrackColor="Red" 
				MinimumTrackColor="Blue"
                Value="{Binding Source={x:Reference spaceInvaders}, Path=XAxisScale}"/>
    </Grid>
</ContentView>

IDrawable

The Drawable is used to create your graphics and is assigned to the Drawable property of your GraphicsView. The Drawable object has a Draw method which is similar to SKCanvas.OnPaintSurface.

IDrawable

Draw(ICanvas canvas, RectangleF dirtyRect)
  • canvas: The ICanvas to draw the graphic onto
  • dirtyRect: The RectangulF coordinates of the entire GraphicsView

SKCanvas

The SKPaintSurfaceEventArgs parameter contains the SKImageInfo & SKCanvas

OnPaintSurface(SKPaintSurfaceEventArgs e)
  • SKCanvas: The canvas to draw the graphic onto.
  • SKImageInfo: Contains the SKRectl coordinates of the entire SKCanvas. Also responsible for things like AlphaType and BytePerPixel however these attributes seemed to be have been refactored into GraphicsView.

Lets take a look at some of the elements used to create this experience.

  • XAxis: Selected x-axis used for the jet and bullets.
  • IsGameOver: Aliens have all been destroyed or they’ve breached the jet.
  • AlienFireRate: Interval in seconds which aliens fire bullets.
  • ButtonText: Switches from Fire to Play.
  • AlienFire: Takes the number of aliens that fit across the bottom row and randomly selects one to fire a bullet.
  • ParseSVGPathData: At this time the PathF does not have a method to parse an SVG path into a PathF. Previously we used SKPath.ParseSVGPathData to parse an SVG path into an SKPath but I couldn’t find a way to do this yet.
  • Draw: Adds the jet, aliens & bullets to the canvas
  • LoadAliens: Loads aliens to starting positions
  • Fire: Fires bullet or because this is called by the UI may also reset the game
  • Reset: Restarts the game
internal class SpaceInvadersDrawable : View, IDrawable
{
    public double XAxis { get; set; }
    public bool IsGameOver { get; set; }
    public int AlienFireRate { get; set; }

    public string ButtonText
    {
        get => _buttonText;
        set
        {
            _buttonText = value;
            OnPropertyChanged();
        }
    }

    public SpaceInvadersDrawable()
    {
        XAxis = 0.5;
        AlienFireRate = 1;
        ButtonText = Constants.Fire;
    }

    /// <summary>
    /// Fires alien shot
    /// </summary>
    public void AlienFire()
    {
        if (_aliens.Count() == 0)
        {
            return;
        }

        var rdm = new Random();
        var activeShooters = _aliens.TakeLast(_columnCount);
        var shooterIndex = rdm.Next(activeShooters.Count());
        var shooter = activeShooters.ElementAt(shooterIndex);
        var bullet = new Bullet(new PointF(shooter.Bounds.Center.X, shooter.Bounds.Bottom + 20), false);
        Fire(false, bullet);
    }

    public void Draw(ICanvas canvas, RectangleF dirtyRect)
    {
        _info = dirtyRect;

        if (!_aliensLoaded)
            LoadAliens();

        if (_jet != null)
        {
            //Has an alien hit the ships y axis?
            IsGameOver = _aliens
                .Select(x => x.Bounds.Bottom)
                .Any(x => x > _jet.Bounds.Top);

            if (IsGameOver || _aliens.Count == 0)
            {
                PresentEndGame(canvas, _aliens.Count == 0
                    ? Constants.YouWin
                    : Constants.GameOver);
                return;
            }
        }

        canvas.ResetState();

        var jet = SKPath.ParseSvgPathData(Constants.JetSVG);

        _jet = PointsToPath(jet.Points);
       
        canvas.StrokeColor = Colors.Green;
        canvas.FillColor = Colors.Green;

        //Calculate the scaling need to fit to screen
        var scaleX = 100 / _jet.Bounds.Width;

        var jetScaleMatrix = Matrix3x2.CreateScale(Scale);
        _jet.Transform(jetScaleMatrix);

        var jetTranslationMatrix = Matrix3x2.CreateTranslation((float)(XAxis * (_info.Width - _jet.Bounds.Width)),
             _info.Height - _jet.Bounds.Height - BulletDiameter);
            
       _jet.Transform(jetTranslationMatrix);

        _jetMidX = _jet.Bounds.Center.X;

        var jetDown = _bullets.Any(b => _jet.Bounds.Contains(b.Point.X, b.Point.Y));
        if (jetDown)
        {
            PresentEndGame(canvas, Constants.GameOver);
            return;
        }

        //Draw the jet
        canvas.FillPath(_jet);

        //Draw bullets
        for (int i = _bullets.Count - 1; i > -1; i--)
        {
            _bullets[i].Point = new PointF(_bullets[i].Point.X, _bullets[i].Point.Y + (_bullets[i].IsPlayer ? BulletSpeed * -1 : BulletSpeed));
            canvas.FillCircle(_bullets[i].Point, BulletDiameter);

            var alienTarged = _aliens.Any(alien => alien.Bounds.Contains(_bullets[i].Point.X, _bullets[i].Point.Y));
            //Remove any aliens touched by the bullet
            _aliens.RemoveAll(alien => alien.Bounds.Contains(_bullets[i].Point.X, _bullets[i].Point.Y));
            //Remove bullet that touched alien
            if (alienTarged)
                _bullets.RemoveAt(i);
        }

        //Has an alien reached a horizontal edge of game?
        var switched = _aliens.Select(x => x.Bounds)
            .Any(x => x.Left < 0
            || x.Right > _info.Right);

        _aliensSwarmingRight = switched ? !_aliensSwarmingRight : _aliensSwarmingRight;

        //Draw aliens
        for (var i = 0; i < _aliens.Count; i++)
        {
            //Move Aliens
            var alienMatrix = Matrix3x2.CreateTranslation(
            _aliensSwarmingRight ? AlienSpeed : AlienSpeed * -1,
            switched ? 50 : 0);

            _aliens[i].Transform(alienMatrix);

            //TODO There is no way to convert PathF to SKPath for our SVG work around
            var points = _aliens[i].Points.Select(p => new SKPoint(p.X, p.Y)).ToArray();
            var alienPath = PointsToPath(points);
            canvas.FillPath(alienPath);
        }

        //Remove bullets that leave screen
        _bullets.RemoveAll(x => x.Point.Y < 0);
    }

    /// <summary>
    /// TODO Raise issue to add ParseSVGPathData to PathF in Maui.Graphics
    /// </summary>
    /// <param name="pathM">path of SVG</param>
    /// <returns>Path of the points</returns>
    private PathF ParseSVGPathData(string pathM)
    {
        var skPath = SKPath.ParseSvgPathData(pathM);
        return PointsToPath(skPath.Points);
    }

    /// <summary>
    /// Converts SKPoints to PathF
    /// </summary>
    /// <param name="points"></param>
    /// <returns></returns>
    private PathF PointsToPath(SKPoint[] points)
    {
        var path = new PathF();
        for (var i = 0; i < points.Count(); i++)
        {
            var point = new PointF(points[i].X, points[i].Y);

            if (i == 0)
            {
                path.MoveTo(point);
            }
            else if (i == points.Count() - 1)
            {
                path.LineTo(point);
                path.Close();
            }
            else
            {
                path.LineTo(point);
            }
        }
        return path;
   }

    /// <summary>
    /// Loads alien landing coordinates
    /// </summary>
    private void LoadAliens()
    {
        const int AlienCount = 35;
        const int AlienSpacing = 50;

        for (var i = 0; i < AlienCount; i++)
        {
            var alien = ParseSVGPathData(Constants.AlienSVG);

            var alienLength =  30;
            var alienScaleX = alienLength / alien.Bounds.Width;
            var alienScaleY = alienLength / alien.Bounds.Height;

            alien.Transform(Matrix3x2.CreateScale(alienScaleX, alienScaleY));

            //how many aliens fit into legnth
            var scaledAlienLength = (_info.Width - ButtonDiameter) / (alien.Bounds.Width + AlienSpacing);
            _columnCount = Convert.ToInt32(scaledAlienLength - 2);

            var columnIndex = i % _columnCount;
            var rowIndex = Math.Floor(i / (double)_columnCount);

            var x = alien.Bounds.Width * (columnIndex + 1) + (AlienSpacing * (columnIndex + 1));
            var y = (float)(alien.Bounds.Height * (rowIndex + 1) + (AlienSpacing * (rowIndex + 1)));

            var alienTranslateMatrix = Matrix3x2.CreateTranslation(x, y);

            alien.Transform(alienTranslateMatrix);
            _aliens.Add(alien);
        }

        _aliensLoaded = true;
    }

    /// <summary>
    /// Presents end game UI
    /// </summary>
    /// <param name="canvas"></param>
    /// <param name="title"></param>
    private void PresentEndGame(ICanvas canvas, string title)
    {
        IsGameOver = true;
        canvas.ResetState();

        canvas.FontColor = Colors.White;
        canvas.FontSize = 40;
        canvas.DrawString(title, _info.Center.X, _info.Center.Y, HorizontalAlignment.Center);

        ButtonText = Constants.Play;
    }

    /// <summary>
    /// Resets game
    /// </summary>
    private void Reset()
    {
        IsGameOver = false;

        _aliens.Clear();
        _bullets.Clear();

        LoadAliens();
        ButtonText = Constants.Fire;
    }

    /// <summary>
    /// Fires bullet or resets
    /// </summary>
    /// <param name="isPlayer"></param>
    /// <param name="startingPosition"></param>
    public void Fire(bool isPlayer, Bullet startingPosition = null)
    {
        if (IsGameOver && isPlayer)
            Reset();
        else
        {
            if (isPlayer)
            {
                _bullets.Add(new Bullet(new PointF(_jetMidX, _info.Height - _jet.Bounds.Height - BulletDiameter - 20), true));
            }
            else
            {
                _bullets.Add(startingPosition);
            }
        }
    }

    //TODO There is an issue  with Maui Essentials DisplayInfo so we must static assign fake dimensions for now
    private const int Width = 800;
    private const int Height = 1000;

    private const float Scale = 0.4f;
    private const int AlienSpeed = 5;
    private const int BulletSpeed = 10;
    private const int BulletDiameter = 4;
    private const int ButtonDiameter = 100;

    private PathF _jet;
    private float _jetMidX;
    private int _columnCount;
    private RectangleF _info;
    private string _buttonText;
    private bool _aliensLoaded;
    private bool _aliensSwarmingRight;
    private List<PathF> _aliens = new List<PathF>();
    private List<Bullet> _bullets = new List<Bullet>();
}

Create the bullets that will fly across the map. If the bullet is a players bullet than it heads upwards otherwise it travels downwards.

internal class Bullet
{
    public PointF Point { get; set; }
    public bool IsPlayer { get; set; }

    public Bullet(PointF point, bool isPlayer)
    {
        Point = point;
        IsPlayer = isPlayer;
    }
}

IDrawable vs SKCanvas take aways

  • PathF uses Matrix3x2 where as SKPath uses SKMatrix
  • Use PathF.Bounds.Contains instead of SKPath.Contains
  • PathF.Bounds has a Center PointF attribute instead of SKPath.Bounds MidX/MidY.
  • FillPath vs DrawPath. Previously to color in a path we would assign a SKPathFillType to SKPath.FillType but now we choose FillPath to fill a path or DrawPath to draw unfilled.
  • Instead of canvas.Clear we use canvas.ResetState

Convert SVGs to paths

The M path of an SVG can be mapped to a new PathF. This is useful because we can convert a string of coordinates into points on a canvas. From there you can transform and scale the graphic across the canvas using Matricies.

There is not yet a ParseSvgPathData in PathF, although there is an extension for this on SKPath, maybe one is coming soon? To work around this in our example, we took the SKPath extension to get a collection of SKPoints, then iterated through the points using MoveTo, LineTo & Close to draw those points onto a new PathF variable.

public static class Constants
    {
        public static string Play = "Play";
        public static string Fire = "Fire";
        public static string YouWin = "YOU WIN";
        public static string GameOver = "GAME OVER";
        public static string JetSVG = "M170.951,118.192l-22.813-16.94c0.2-3.457,0.345-7.398,0.423-11.922l0.014-0.77c0.032-1.334,0.036-2.517-0.059-3.554    c0.156-0.205,0.297-0.42,0.402-0.654l2.122-4.666c0.406-0.898,0.62-2.153,0.533-3.118l-0.409-4.59  c-0.168-1.867-1.774-3.386-3.58-3.386h-13.883c-1.807,0-3.413,1.512-3.58,3.374l-0.408,4.622c-0.087,0.97,0.126,2.226,0.538,3.139     l2.114,4.618c0.109,0.242,0.256,0.464,0.418,0.675c-0.088,0.882-0.095,1.831-0.082,2.792c0.009,0.686,0.022,1.341,0.035,2.002     l-10.458-7.766c-0.453-4.731-0.967-8.659-1.52-10.646c-1.717-6.171-3.843-8.325-5.551-10.057c-1.67-1.692-2.773-2.81-2.773-9.78     c0-15.618-3.006-28.188-5.528-35.98C104.604,8.476,101.148,0,97.748,0c-4.015,0-8.021,10.789-9.556,15.427     c-2.598,7.847-5.694,20.808-5.694,37.998c0,6.502-0.945,7.352-2.376,8.639c-1.58,1.421-3.744,3.367-5.422,9.45     c-0.516,1.873-0.996,5.447-1.424,9.774l-9.698,7.139c0.03-1.279,0.031-2.418-0.061-3.42c0.155-0.205,0.296-0.42,0.402-0.654     l2.122-4.666c0.406-0.898,0.62-2.153,0.533-3.118l-0.409-4.59c-0.168-1.867-1.774-3.386-3.58-3.386H48.702     c-1.807,0-3.413,1.512-3.58,3.374l-0.408,4.622c-0.087,0.97,0.126,2.226,0.538,3.139l2.114,4.618     c0.109,0.242,0.256,0.464,0.418,0.675c-0.088,0.882-0.095,1.831-0.082,2.792c0.062,4.542,0.193,8.515,0.379,12.022l-24.934,18.355     c-1.416,1.047-2.85,3.169-2.85,5.198v6.913c0,2.438,2.174,4.348,4.746,4.35l56.254-0.058v0.152c1,0,0.809-0.026,1-0.047v30.647     l-14.621,10.536c-1.082,0.771-1.854,2.26-1.878,3.621l-0.173,10.328c-0.018,0.991,0.396,2.015,1.105,2.736     c0.65,0.662,1.494,1.026,2.377,1.026h11.27c1.942,0,3.92-1.962,3.92-3.887v-2.321c0-0.211-0.003-0.634,0.026-0.828     c0.455-1.041,1.49-2.349,2.004-2.349c0.465,0,1.458,1.219,1.928,2.361l0,0.001c0.042,0.219,0.042,0.731,0.042,0.982v2.153     c0,1.761,1.682,3.887,3.771,3.887h10.459c2.088,0,3.771-2.126,3.771-3.887v-2.153c0-0.5,0.05-0.854,0.07-0.924     c0.455-1.057,1.542-2.421,2.073-2.421c0.451,0,1.4,1.189,1.852,2.311c0.018,0.167,0.013,0.508,0.01,0.699l-0.004,2.488     c0,1.925,1.979,3.887,3.92,3.887h11.27c0.889,0,1.737-0.365,2.389-1.029c0.702-0.714,1.11-1.727,1.093-2.706l-0.173-10.213     c-0.021-1.193-0.672-2.853-1.865-3.707l-14.634-10.59v-30.783c0.11,0.007,0.214,0.028,0.326,0.028h0.008l56.418,0.058     c2.421-0.002,4.248-1.785,4.248-4.35v-6.913C173.296,121.791,172.681,119.472,170.951,118.192z M98.141,89.754     c-4.827,0-8.739-6.528-8.739-14.581c0-8.053,3.913-14.581,8.739-14.581s8.739,6.528,8.739,14.581     C106.88,83.226,102.968,89.754,98.141,89.754z";
        public static string AlienSVG = "M469.344,266.664v-85.328h-42.656v-42.672H384v-21.328h42.688v-64h-64v42.656H320v42.672H192V95.992	h-42.656V53.336h-64v64H128v21.328H85.344v42.672H42.688v85.328H0v149.328h64v-85.328h21.344v85.328H128v42.672h106.688v-64h-85.344	v-21.328h213.344v21.328h-85.344v64H384v-42.672h42.688v-85.328H448v85.328h64V266.664H469.344z M192,245.336h-64v-64h64V245.336z	 M384,245.336h-64v-64h64V245.336z";
    }

Consume the control

Now for the easy part, add your game UI control anywhere within the .NET Maui application.

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MauiSpaceInvaders.MainPage"
             BackgroundColor="Black"
	     xmlns:spaceInvaders="clr-namespace:MauiSpaceInvaders;assembly=MauiSpaceInvaders">
	<spaceInvaders:SpaceInvadersView />
</ContentPage>

Summary

I saw many structural differences between using SkiaSharp in a Xamarin.Forms verses Microsoft.Maui.Graphics.Skia in a .NET Maui. These changes made creating a 2D graphical application much easier and cleaner than before. I did not find anything to replace OnTouch yet, I’m hoping that will come soon. I really enjoyed creating this example and learning about new components & I hope you did to.

As Maui updates are published I will continue to update these examples

There is currently an issue with DeviceDisplay so the Maui example does not yet scale to fit. I will update this posting once that’s resolved.

Maui example GitHub

Xamarin example GitHub

4 thoughts on “Build a game like Space Invaders using .NET Maui”

    • Thanks Dave, you should give it a go as there’s so much power in it. The new SDK is certainly a bit different than SkiaSharp was in Xamarin. I like way the way this library was refactored and drawing paths seems to be a bit easier than before.

  1. Damn thanks dude, skiasharp does not working on MAUI for now and I was trying to find how to use the new graphic view for some one who is used to using skiasharp. This is just what i was looking for !

    Thanks a lot

Comments are closed.