How to Make a Game in MonoGame

Dive into the world of game development with MonoGame – a sleek solution to making games with the C# language!

Both free and open-source, MonoGame is a fantastic game framework designed to give C# programmers the necessary tools to build their games. Not only does the game framework come with helpful architecture to make structuring your game easy, but it also comes with the ability to export to multiple platforms – meaning more players to play your game.

In this tutorial, we’ll be going through the foundations of MonoGame by building a spaceship game from the ground up. You’ll master everything from how to add sprites and audio, to dealing with collisions and health logic. And don’t worry if you’ve never used MonoGame – this tutorial is designed for those who are diving into MonoGame for the first time!

Let’s get started and find out why MonoGame is ready to bring our game ideas to life!

Table of contents

Project Assets & Tutorial Requirements

While no MonoGame experience is needed for this course, you will need experience with C#, including key concepts such as loops, methods, and classes. If you’re familiar with Unity or Microsoft .NET, you’ll be well-prepared for this experience (though neither of these are needed to follow along. We’ll guide you through everything you need to know!

We will also provide instructions on downloading and installing MonoGame and Visual Studio.

If you want to use the same assets used in this tutorial or review the project’s full source code, you can find them in the download link here: MonoGame Tutorial Files

However, if you find you need more assistance, Zenva’s courses on MonoGame are a fantastic resource. They not only cover the material featured here with more depth, but also provide other tools and guided learning pathways that can help you achieve success.

CTA Small Image
FREE COURSES AT ZENVA
LEARN GAME DEVELOPMENT, PYTHON AND MORE
ACCESS FOR FREE
AVAILABLE FOR A LIMITED TIME ONLY

Download and Set Up Visual Studio & MonoGame

To begin, we first need to set everything up to develop with MonoGame. We will be using Visual Studio as our code editor (note this is different from Visual Studio Code). Please ensure it is installed on your system. If not, you can either visit the Monogame documentation or follow these steps:

  • Visit the official Visual Studio website.
  • Scroll down to the ‘Visual Studio’ section.
  • Download the Community Edition.

Download Button for Visual Studio

Launch Visual Studio and choose the option to ‘Continue without code’. Then, head over to the ‘Extensions’ tab within the Visual Studio interface and click on ‘Manage Extensions’. In the provided search box, type ‘MonoGame’ and proceed to install the extension that appears in the search results.

Download Button for Visual Studio

Once you’ve successfully installed the MonoGame extension, you’re ready to create a new project. Begin by opening Visual Studio and navigating to the ‘Create a new project’ option. Within the project templates, locate ‘MonoGame’ and select the ‘Cross-Platform Desktop Application’ template. Assign a name to your project and proceed by clicking on the ‘Next’ button.

Importing Game Files

Let’s now import the game files essential for this tutorial. Begin by navigating to the ‘Content’ folder in your project directory and opening ‘Content.mgcb’. This will launch the Content Manager window, where you can efficiently manage your assets for MonoGame projects.

Within the Content Manager, establish organized folders for sprites and audio files, and use the ‘Add’ icon at the top of the window to import all the game files.

MGCB Editor for MonoGame (Monogame Content Manager)

After importing your assets, click on the ‘Build’ button to compile them. Remember to save your progress before closing the window. Now we’re ready to dive into our MonoGame project.

Custom Game Manager

Let’s talk about making our own Game Manager. Essentially, we’re swapping out the default MonoGame Game1.cs file for our custom GameManager.cs.

Create a folder named ‘Scripts’ in your project, then add a new script called ‘GameManager’ to that folder. Once the GameManager script is created, change the access modifier from ‘internal’ to ‘public’.

public class GameManager
{
    // GameManager code goes here...
}

Alright, now we need to transfer all the code from the ‘Game1.cs’ file to our GameManager script. The Game1.cs file contains properties, an initializer for the game, and a handful of methods like Initialize, LoadContent, Update, and Draw.

Simply copy everything from the default MonoGame ‘Game1.cs’ class and paste it into our GameManager script. Then, rename the Game1 method to the GameManager method. This method name must match your class name.

public class GameManager : Game
{
    private GraphicsDeviceManager _graphics;
    private SpriteBatch _spriteBatch;
    public GameManager()
    {
        _graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsMouseVisible = true;
    }
    protected override void Initialize()
    {
        base.Initialize();
    }
    protected override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);
    }
    protected override void Update(GameTime gameTime)
    {
        if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
            Exit();
        base.Update(gameTime);
    }
    protected override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.Black);
 
        base.Draw(gameTime);
    }
}

Info: The Update and Draw methods are called every frame. So, if your game runs at 60 frames per second, these methods are called 60 times a second.

Monogame Window

Player Character

We’ll now tackle the first part of our MonoGame tutorial: the player character.

Creating Player Class

Let’s dive into creating our player script for a Space Shooter game. This script will handle tasks such as instantiating the player, drawing the player’s sprite, and managing player interactions within the game. Let’s get started!

To begin, add a new item to the script folder named ‘Player.cs’. Change its accessibility from ‘internal’ to ‘public’:

public class Player
{
    // Player properties and methods will go here...
}

Ensure that ‘Player.cs’ is correctly added to your project and its accessibility is set to public, allowing it to interact seamlessly with other parts of your game.

Instantiate Player

Now that our player script is prepared, we’ll instantiate it within the GameManager. Start by adding a private variable of type ‘Player’ to the GameManager class. Then, in the ‘LoadContent’ method, initialize and spawn the player instance to integrate it into the MonoGame game environment.

public class GameManager : Game
{
    ...
    private SpriteBatch _spriteBatch;
    ...
    protected override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);
        _player = new Player();
    }
    ...
}

At this point, the game should execute without any errors. However, the player sprite isn’t visible yet because we haven’t rendered it.

Player Sprite – Draw

To define the player sprite’s texture, let’s create a property of type ‘Texture2D’ within the player script. This property will store the visual representation of our player character

private Texture2D sprite;

Following that, we’ll create a new method titled ‘LoadContent’ within the player script. This method’s purpose is to load the player sprite, ensuring its graphical representation is properly initialized within MonoGame.

public void LoadContent()
{
    sprite = GameManager.Instance.Content.Load<Texture2D>("Sprite/player_ship");
}

With the player sprite successfully loaded, it’s time to render it on the screen. To accomplish this, let’s create a new method named ‘Draw’ within the player script, which accepts a ‘SpriteBatch’ parameter. This method’s responsibility is to draw the player’s sprite onto the screen using the provided ‘SpriteBatch’ object.

public void Draw(SpriteBatch spriteBatch)
{
    spriteBatch.Draw(sprite, Vector2.Zero, Color.White);
}

To ensure the player is initialized correctly and its sprite is loaded, we’ll create an initializer method within the player class. This method will call ‘LoadContent’ internally. The player class will be structured accordingly:

    public class Player
    {
        private Texture2D sprite;

        public Player()
        {
            LoadContent();
        }
        private void LoadContent()
        {
            sprite = GameManager.Instance.Content.Load("Sprite/player_ship");
        }

        public void Draw(SpriteBatch spriteBatch)
        {
            spriteBatch.Draw(sprite, Vector2.Zero, Color.White);
        }
    }

To call the ‘Draw’ method, return to the ‘GameManager‘ class. Within the ‘Draw’ method of the GameManager class, add the following line of code to render the player sprite:

_spriteBatch.Begin();
_player.Draw(_spriteBatch);
_spriteBatch.End();

Now, when you run the game, the player sprite should appear on the screen as expected! Of course, our player doesn’t do anything yet (which we’ll cover next in our MonoGame tutorial), but we’ve made progress.

MonoGame Window Displaying the Player Spaceship

Player Movement – Update

Now, we’re going to tackle player movement in MonoGame by reading keyboard input to control a spaceship using the WASD keys. This involves defining a new method, keeping an eye on keyboard input, and tweaking the spaceship’s position accordingly.

Open the player.cs class. Here, define a new method called “Update” that takes the game time as a parameter. This method will handle reading keyboard input and controlling the spaceship’s movement.

void Update(GameTime gameTime)
{
    //Method body goes here.
}

In the ‘Update’ method of your Player class, you’ll set up a 2D vector to represent the player’s movement direction. This vector will adjust based on WASD key presses. ‘W’ will move the player up, ‘S’ down, ‘A’ left, and ‘D’ right. This ensures the player moves in the desired direction as per the user’s input.

public void Update(GameTime gameTime)
{
    Vector2 direction = Vector2.Zero;
    KeyboardState keyboardState = Keyboard.GetState();

    if (keyboardState.IsKeyDown(Keys.W))
    {
        direction.Y = -1;
    }
    if (keyboardState.IsKeyDown(Keys.S))
    {
        direction.Y = 1;
    }
    if (keyboardState.IsKeyDown(Keys.A))
    {
        direction.X = -1;
    }
    if (keyboardState.IsKeyDown(Keys.D))
    {
        direction.X = 1;
    }
}

Note: To see if the direction value changes when you press the WASD keys, print the direction value using Debug.WriteLine, passing the direction vector as a parameter. Debug.WriteLine(direction);

To enable player movement, you’ll define a speed parameter and a position variable in the player script. The speed parameter determines how fast the player moves, while the position variable stores the player’s current location in the game world. These elements are essential for updating the player’s position in response to user input.

float speed = 300f; //Any value
Vector2 position;

In the ‘Update’ method, incorporate the player’s movement logic by adding the direction multiplied by speed to the position value. To ensure consistent movement across different frame rates, adjust the movement calculation by multiplying it with gameTime.ElapsedGameTime.TotalSeconds.

position += direction * speed * (float)gameTime.ElapsedGameTime.TotalSeconds;

Info: Accounting for varying GPU and CPU speeds among players’ devices is crucial. For instance, a player running the game at 120 FPS will have their spaceship move significantly faster than one with a PC running at 60 FPS. To address this, we adjust the spaceship’s movement by taking smaller steps on 120 FPS devices and larger steps on 60 FPS devices. This ensures that both move at the same speed regardless of what device they use for the MonoGame project.

Design Featuring 60fps and 120fps Screen

In the player script’s ‘Draw’ method, replace ‘Vector2.Zero’ with the ‘position’ variable. This adjustment ensures that the player sprite is rendered at its current position on the screen.

public class Player
{
    private Texture2D sprite;

    //Movement
    private float speed = 300f;
    public Vector2 position;

    public Player()
    {
        LoadContent();
    }
    private void LoadContent()
    {
        sprite = GameManager.Instance
            .Content
            .Load("Sprite/player_ship");
    }

    public void Update(GameTime gameTime)
    {
        Vector2 direction = Vector2.Zero;
        KeyboardState keyboardState = Keyboard.GetState();

        if (keyboardState.IsKeyDown(Keys.W))
        {
            direction.Y = -1;
        }
        if (keyboardState.IsKeyDown(Keys.S))
        {
            direction.Y = 1;
        }
        if (keyboardState.IsKeyDown(Keys.A))
        {
            direction.X = -1;
        }
        if (keyboardState.IsKeyDown(Keys.D))
        {
            direction.X = 1;
        }

        position += direction * speed * (float)gameTime.ElapsedGameTime.TotalSeconds;
    }
    public void Draw(SpriteBatch spriteBatch)
    {
        spriteBatch.Draw(sprite, position, Color.White);
    }

}

Obstacles

Let’s jump into adding our first obstacle to the MonoGame game. We’ll begin by creating a class called Obstacle.cs, which will be the parent class for all our obstacles. Then, we’ll create another class for our Meteor obstacle, called ObstacleMeteor.cs.

Creating the Parent Obstacle Class

We’ll start by creating the parent obstacle class as seen below:

public abstract class Obstacle
{
    public Vector2 position;
    protected float speed = 200f;
    protected Texture2D sprite;
    public abstract void Update(GameTime gameTime);
    public abstract void Draw(GameTime spriteBatch, GameTime gameTime);
}

The Obstacle class encapsulates key attributes such as the obstacle’s position, speed, and a sprite for visual representation. Additionally, it defines two abstract methods – Update and Draw.

The Update method adjusts the obstacle’s state based on the game time, allowing for dynamic behavior such as movement or interaction with other game elements. On the other hand, the Draw method is responsible for rendering the obstacle sprite on the screen.

This structure enables the Obstacle class to manage its behavior and appearance independently.

Creating the Meteor Class

Next, we’ll make a script for our Meteor using the work we just did above.

public class ObstacleMeteor : Obstacle
{
    public ObstacleMeteor(Vector2 position)
    {
        this.position = position;
        this.sprite = GameManager.Instance.Content.Load<Texture2D>("Sprite/meteor_small");
    }

    public override void Update(GameTime gameTime)
    {
        
    }

    public override void Draw(GameTime gameTime, SpriteBatch spriteBatch)
    {
        
    }
}

The Meteor class extends the functionality of the Obstacle class, inheriting its attributes and behaviors while introducing specific details unique to meteors. In the constructor of the Meteor class, we initialize the position of the meteor and load its corresponding sprite, ensuring it’s ready for rendering in the game environment. We also make sure our methods are added so there is no error with the script – but we’ll be overriding them in the next section.

Updating and Drawing the Meteor

In the Update method of the Meteor class, we modify the position.y attribute by multiplying the speed with the total elapsed time. This calculation causes the meteor to descend vertically, simulating its falling motion.

public override void Update(GameTime gameTime)
{
    position.Y += speed * (float)gameTime.ElapsedGameTime.TotalSeconds;
}

In the Draw method of the Meteor class, we render the meteor by invoking the spriteBatch.Draw() method. This method requires parameters such as the meteor’s texture, position, and color. By providing these parameters, we ensure that the meteor sprite is correctly displayed on the screen.

public override void Draw(GameTime gameTime, SpriteBatch spriteBatch)
{
    spriteBatch.Draw(sprite, position, Color.White);
}

The meteor.cs class should look like this:

public class ObstacleMeteor : Obstacle
{
    public ObstacleMeteor(Vector2 position)
    {
        this.position = position;
        this.sprite = GameManager.Instance.Content.Load&lt;Texture2D&gt;("Sprite/meteor_small");
    }

    public override void Update(GameTime gameTime)
    {
        position.Y += speed * (float)gameTime.ElapsedGameTime.TotalSeconds;
    }

    public override void Draw(GameTime gameTime, SpriteBatch spriteBatch)
    {
        spriteBatch.Draw(sprite, position, Color.White);
    }
}

Obstacle Spawner

We have our obstacles in our MonoGame project, but we’re not actually adding them to the game. So, to solve this, we’re going to want a spawner. To implement the Spawner class, which handles the spawning of enemies and obstacles at random intervals on the screen, we’ll follow these steps:

  • Define a Spawner class responsible for managing the spawning behavior.
  • Implement a timer mechanism to execute spawning logic at regular intervals.
  • Within the timer’s block of code, randomly generate spawn points for enemies and obstacles.
  • Instantiate and spawn enemy and obstacle objects at the generated spawn points.
  • Repeat the spawning process at specified intervals to ensure continuous gameplay challenges.

Creating the Spawner Class

Create a public class named Spawner in a file named Spawner.cs. Add an Update method to the Spawner class, taking gameTime as a parameter to handle game updates. Include a Draw method in the Spawner class, accepting gameTime and spriteBatch parameters for rendering.

public class Spawner
{
    public void Update(GameTime gameTime) 
    {
        // Update logic goes here
    }
    public void Draw(GameTime gameTime, SpriteBatch spriteBatch) 
    {
        // Draw logic goes here
    }
}

Integrating the Spawner Class with the Game Manager

To integrate the Spawner’s update and draw methods into the GameManager, create a reference property to the Spawner class, initialize it within LoadContent, and call its Update and Draw methods in Update and Draw, respectively.

public class GameManager : Game
{
    private Spawner _spawner;
    protected override void LoadContent()
    {
        _spawner = new Spawner();
        // Other initialization code
    }
    protected override void Update(GameTime gameTime)
    {
        _spawner.Update(gameTime);
        // Other update code
    }
    protected override void Draw(GameTime gameTime)
    {
        _spawner.Draw(gameTime, _spriteBatch);
        // Other draw code
    }
}

Creating a Timer in the Spawner Class

Next, go back to the Spawner class. To run code every few seconds, we need to create two TimeSpan variables: one for spawn rate and another for timer. In the update method, we will keep adding the elapsed game time to the timer. When the timer is equal to or greater than the spawn rate, we will run our code to spawn the obstacle and reset the timer.

public class Spawner
{
    private TimeSpan obstacleSpawnRate;
    private TimeSpan obstacleTimer;
    public Spawner()
    {
        obstacleSpawnRate = TimeSpan.FromSeconds(3);
        obstacleTimer = TimeSpan.Zero;
    }
    public void Update(GameTime gameTime)
    {
        obstacleTimer += gameTime.ElapsedGameTime;
        if (obstacleTimer >= obstacleSpawnRate)
        {
            // Code to spawn obstacle goes here (Check the Next Step)
            obstacleTimer = TimeSpan.Zero;
        }
    }
    // Other code
}

Spawning the Obstacles

To implement obstacle spawning, begin by randomly selecting a position on either the left or right side of the screen for the X coordinate, and above the screen viewport for the Y coordinate. Then, instantiate a new obstacle at this randomly determined position. This approach ensures that obstacles appear at unpredictable positions while still maintaining control over their spawning within the game environment.

public void Update(GameTime gameTime)
{
    obstacleTimer += gameTime.ElapsedGameTime;
    if (obstacleTimer >= obstacleSpawnRate)
    {
        Random random = new Random();
        int randomX = random.Next(0, GameManager.Instance.GraphicsDevice.Viewport.Width);
        Vector2 position = new Vector2(randomX, -100);
        new ObstacleMeteor(position);
        obstacleTimer = TimeSpan.Zero;
    }
}

Updating and Drawing the Obstacles

Now, our objective is to update and draw the meteor on the screen, as well as manage the deletion of meteors that cross a certain boundary below the screen. This involves creating a list to hold all the obstacles and iterating through this list during the spawner’s update process. We will also similarly manage the drawing process. Let’s get started.

Within the Spawner class, declare an obstacle list and initialize it to store spawned obstacles. This list will facilitate the management and tracking of obstacles within the game environment.

List<Obstacle> obstacles = new List<Obstacle>();

Before we instantiate our Meteor in the spawner update, let’s store the Meteor into a variable of type Obstacle, which we will name ‘currentObstacle’. After this line, we’ll add the current obstacle to our list. This step is crucial for ensuring that every new obstacle is included in the list.

Obstacle currentObstacle = new ObstacleMeteor(position); 
obstacles.Add(currentObstacle);

In the Update and Draw methods of the Spawner class, iterate through all the obstacles stored in the obstacle list. For each obstacle encountered, invoke its Update method within the Update method of the Spawner, and its Draw method within the Draw method of the Spawner. This ensures that all obstacles are properly updated and rendered during gameplay.

public class Spawner
{
    private TimeSpan obstacleSpawnRate;
    private TimeSpan obstacleTimer;
    List<Obstacle> obstacles = new List<Obstacle>();
    
    public Spawner()
    {
        obstacleSpawnRate = TimeSpan.FromSeconds(3);
        obstacleTimer = TimeSpan.Zero;
    }
    
    public void Update(GameTime gameTime)
    {
        obstacleTimer += gameTime.ElapsedGameTime;
        if (obstacleTimer >= obstacleSpawnRate)
        {
            Random random = new Random();
            int randomX = random.Next(0, GameManager.Instance.GraphicsDevice.Viewport.Width);
            Vector2 position = new Vector2(randomX, -100);
            Obstacle currentObstacle = new ObstacleMeteor(position); 
            obstacles.Add(currentObstacle);
            obstacleTimer = TimeSpan.Zero;
        }
        
        for (int i = 0; i < obstacles.Count; i++)
        {
            obstacles[i].Update(gameTime);
        }
    }
    
    public void Draw(GameTime gameTime, SpriteBatch spriteBatch)
    {
        for (int i = 0; i < obstacles.Count; i++)
        {
            obstacles[i].Draw(gameTime, spriteBatch);
        }
    }

}

Spawner Test

Once you run the game, you should observe meteors spawning every few seconds.

MonoGame Window Displaying Spaceship and Meteor Obstacle

Removing Obstacles

To address the inefficiency of looping through meteors that have moved beyond our viewport, we’ll instruct our spawner to remove any obstacle passing a certain boundary below the screen from the obstacle list. to that go to the spawner’s update method, after updating a meteor, check if its position on the y-axis is equal to or greater than the screen height. If so, remove the obstacle from the list. Don’t forget to decrease the loop index by 1 to adjust for the change in list indexing.

Note that this is something you should consider doing for any MonoGame project for the sake of memory efficiency.

for (int i = 0; i < obstacles.Count; i++)
{
    obstacles[i].Update(gameTime);
    if (obstacles[i].position.Y > GameManager.Instance.GraphicsDevice.Viewport.Height + 100f)
    {
        obstacles.RemoveAt(i);
        i--;
    }
}

Spawner.cs Overview

Here’s how your Spawner class should look after implementing all the code above.

public class Spawner
{
    private TimeSpan obstacleSpawnRate;
    private TimeSpan obstacleTimer;
    List<Obstacle> obstacles = new List<Obstacle>();
    
    public Spawner()
    {
        obstacleSpawnRate = TimeSpan.FromSeconds(3);
        obstacleTimer = TimeSpan.Zero;
    }
    
    public void Update(GameTime gameTime)
    {
        obstacleTimer += gameTime.ElapsedGameTime;
        if (obstacleTimer >= obstacleSpawnRate)
        {
            Random random = new Random();
            int randomX = random.Next(0, GameManager.Instance.GraphicsDevice.Viewport.Width);
            Vector2 position = new Vector2(randomX, -100);
            Obstacle currentObstacle = new ObstacleMeteor(position); 
            obstacles.Add(currentObstacle);
            obstacleTimer = TimeSpan.Zero;
        }
        
        for (int i = 0; i &lt; obstacles.Count; i++)
        {
            obstacles[i].Update(gameTime);
            if (obstacles[i].position.Y > GameManager.Instance.GraphicsDevice.Viewport.Height + 100f)
            {
                obstacles.RemoveAt(i);
                i--;
            }
        }
    }
    
    public void Draw(GameTime gameTime, SpriteBatch spriteBatch)
    {
        for (int i = 0; i < obstacles.Count; i++)
        {
            obstacles[i].Draw(gameTime, spriteBatch);
        }
    }

}

Collision System

We’ll next want to deal with the collision system of our MonoGame project.

Concept

We’ll explore the concepts of boundaries and collisions in MonoGame. Boundaries are shapes that encompass a player or an obstacle, and they can take various forms like squares, circles, or more intricate shapes. We’ll concentrate on square shapes for our boundaries, which is the easiest to work with.

When two boundaries intersect, this indicates a collision. The area of interest is when the player’s boundary intersects with that of an obstacle. Instead of writing boundary logic for each individual script – which would quickly become difficult to track, especially as the number of scripts grows – we will employ a more streamlined approach.

Concept of Collisions for games

We will introduce a collision component to our MonoGame project. This component can be attached to any object that requires a boundary and needs to check for collisions. This strategy helps to avoid redundancy and keeps the codebase clean and manageable.

We’ll also establish a collision manager alongside the collision component to maintain a list of all game boundaries and check for intersections between them. When a collision is detected, the collision manager will notify the owner class of the involved boundary. For example, if a player’s boundary intersects with an obstacle’s boundary, both objects will be notified.

This system ensures both parties know about the interaction, allowing adjustments to the player’s health and possibly destroying the obstacle, following the game rules.

Flowchart showing relationship between Collision Manager and different objects

Collision Component

We will start building our collision component, a crucial aspect of our MonoGame project. This component can also be referred to as a boundary component. It will help us manage collisions between different game entities.

Creating the Collision Component

Create a new class and name it ‘CollisionComponent.cs’, Define the necessary properties, including two integers for ‘layer’ and ‘check layer’. These integers filter out unimportant collisions, For example, if we have numerous obstacles on the screen, we’re primarily interested in collisions between the player and obstacles rather than between two obstacles. that’s why we need to have a layer and check layer.

You can also have a tag property for additional filtering in the future.

public class CollisionComponent
{
    public int layer
    public int checkLayer;
    public string tag = "";
}

To enable collision detection notifications between game objects, define a delegate called ‘OnCollisionDelegate’. Delegates in C# serve as a mechanism for other scripts to listen to events and respond accordingly. This ‘OnCollisionDelegate’ will facilitate communication between objects when collisions occur.

public class CollisionComponent
{
    public int layer
    public int checkLayer;
    public string tag = "";
    
    //This is how you define a delegate
    public delegate void OnCollisionDelegate(CollisionComponent other);
    public OnCollisionDelegate OnCollision;

}

Consider what information we need when a collision happens. We will require the other collision component. This means that if the player collides with an obstacle, both the player and the obstacle need to be notified along with a reference to each other.

The last parameter, which is crucial, is the boundary itself.

public Rectangle boundary;

Defining Methods

Within the class, create an initializer method that requests parameters for position, width, and height (as integers), layer, check layer (also integers), and tag (as a string). In this method, populate the class properties with the provided parameters and define a new rectangle to represent the boundary of the collision component. This ensures that each collision component is properly initialized with its position, size, layer information, and tag for identification within the game environment.

public CollisionComponent(Vector2 position, int width, int height, int layer, int checkLayer, string tag)
{
    //this is how you create a rectangle boundary
    boundary = new Rectangle((int)position.X, (int)position.Y, width, height);
    this.layer = layer;
    this.checkLayer = checkLayer;
    this.tag = tag;
}

To update the component for moving game entities, define an ‘UpdatePosition’ method taking a Vector2 parameter, Inside, create a new rectangle using the new position while retaining the old boundary’s width and height.

public void UpdatePosition(Vector2 position)
{
    boundary = new Rectangle((int)position.X, (int)position.Y, boundary.Width, boundary.Height);
}

Your component should look like this once all the above is added:

public class CollisionComponent
{
    public int layer
    public int checkLayer;
    public string tag = "";
    
    public delegate void OnCollisionDelegate(CollisionComponent other);
    public OnCollisionDelegate OnCollision;

    public CollisionComponent(Vector2 position, int width, int height, int layer, int checkLayer, string tag)
    {
        boundary = new Rectangle((int)position.X, (int)position.Y, width, height);
        this.layer = layer;
        this.checkLayer = checkLayer;
        this.tag = tag;
    }
    
    public void UpdatePosition(Vector2 position)
    {
        boundary = new Rectangle((int)position.X, (int)position.Y, boundary.Width, boundary.Height);
    }
}

Attaching a Collision Component to the Player

To attach a collision component to the player, navigate to the player script. Define a private property named ‘CollisionComponent’. In the ‘LoadContent’ method, instantiate ‘CollisionComponent’ as a new instance, passing required parameters like position, using sprite width and height for width and height. Set the layer to 0 for the player and 1 for the ‘check layer’, which will be assigned to the obstacle later. then Use ‘player’ as the tag.

Also, since our player is always in motion, don’t forget to update the collision position by calling ‘collisionComponent.updatePosition’ in the player Update.

private CollisionComponent CollisionComponent;
private void LoadContent()
{
    ...
    collisionComponent= new CollisionComponent(position, sprite.Width, sprite.Height, 0, 1, "player");
    ...
}

public void Update(GameTime gameTime)
{
    collisionComponent.UpdatePosition(position);
}

To handle collisions, subscribe to the ‘onCollisionDelegate’ event in the ‘LoadContent’ method, linking it to a method named ‘OnCollision’. This method will accept a parameter representing the colliding object, such as a meteor.

private void LoadContent()
{
    ...
    collisionComponent.OnCollision += OnCollision;
    ...
}
private void OnCollision(CollisionComponent Other)
{
    ...
}

It’s good practice to always unsubscribe when the player dies or is deleted. One easy way to achieve this is to implement the ‘IDisposable’ interface. Implement the ‘Dispose’ method. Inside this method, unsubscribe from the event. Here’s how the player script should look now:

public class Player : IDisposable
{
    private Texture2D sprite;

    //Movement
    private float speed = 300f;
    public Vector2 position;

    //Collision
    private CollisionComponent collisionComponent;
    
    public Player()
    {
        LoadContent();
    }
    private void LoadContent()
    {
        sprite = GameManager.Instance
            .Content
            .Load("Sprite/player_ship");
        
        collisionComponent= new CollisionComponent(position, sprite.Width, sprite.Height, 0, 1, "player");
        collisionComponent.OnCollision += OnCollision;
    }

    public void Update(GameTime gameTime)
    {
        Vector2 direction = Vector2.Zero;
        KeyboardState keyboardState = Keyboard.GetState();

        if (keyboardState.IsKeyDown(Keys.W))
        {
            direction.Y = -1;
        }
        if (keyboardState.IsKeyDown(Keys.S))
        {
            direction.Y = 1;
        }
        if (keyboardState.IsKeyDown(Keys.A))
        {
            direction.X = -1;
        }
        if (keyboardState.IsKeyDown(Keys.D))
        {
            direction.X = 1;
        }

        position += direction * speed * (float)gameTime.ElapsedGameTime.TotalSeconds;
        
        //Update collision position 
        collisionComponent.UpdatePosition(position);
    }
    
    public void Draw(SpriteBatch spriteBatch)
    {
        spriteBatch.Draw(sprite, position, Color.White);
    }
    
    public void Dispose()
    {
        collisionComponent.OnCollision -= OnCollision;
    }

}

With that, we’ve covered everything related to the collision component for the player.

Attaching a Collision Component to the Obstacle

Of course, our obstacles need to have this same collision component. We’ll replicate the process used to attach collision to the player, but this time for the obstacle. This involves creating a new CollisionComponent for the obstacle, updating its position, and handling collision events.

public class ObstacleMeteor : Obstacle, IDisposable
{
    private CollisionComponent collisionComponent;



    public ObstacleMeteor(Vector2 position)
    {
        this.position = position;
        sprite = GameManager.Instance.Content.Load<Texture2D>("Sprite/meteor_small");

        collisionComponent = new CollisionComponent(position, sprite.Width, sprite.Height, 1, 0, "obstacle");
        collisionComponent.OnCollision += OnCollision;
    }
    
    public override void Draw(GameTime gameTime, SpriteBatch spriteBatch)
    {
        spriteBatch.Draw(sprite, position, Color.White);
    }

    public override void Update(GameTime gameTime)
    {
        position.Y += speed * (float)gameTime.ElapsedGameTime.TotalSeconds * GameManager.Instance.difficulty;
        collisionComponent.UpdatePosition(position);
    }

    private void OnCollision(CollisionComponent other)
    {
       
    }

    public void Dispose()
    {
        collisionComponent.OnCollision -= OnCollision;
    }
}

Collision Manager

We will learn how to create the CollisionManager, a class responsible for managing collisions in our game. The CollisionManager loops through all collision components in the game and checks if they intersect with any other collision component.

Begin by creating a new CollisionManager.cs. In this class, define a list of collision components. This list will hold all the active collision components in our game. Also, create an update method that accepts a GameTime parameter.

public class CollisionManager
{
    public List<CollisionComponent> collisionComponents = new List<CollisionComponent>();

    public void Update(GameTime gameTime)
    {
        
    }
}

We need to hold a reference to the collision manager class from the GameManager. This allows us to call the CollisionManager.Update method at each game update cycle.

public class GameManager : Game
{
    ...
    public CollisionManager _collisionManager;
    ...
    protected override void LoadContent()
    {
        ...
        _collisionManager = new CollisionManager();
        ...
    }
    protected override void Update(GameTime gameTime)
    {
        ...
        _collisionManager.Update(gameTime);
        ...
    }
}

Back in the CollisionManager class, our list of collision components is still empty. We need to fill it with all collision components in the game. To do this, go to the CollisionComponent class. At the end of the initializer method, add the current collision component to the CollisionManager’s list.

public class CollisionComponent
{
    ...
    public CollisionComponent(Vector2 position, int width, int height, int layer, int checkLayer, string tag)
    {
        ...
        GameManager.Instance._collisionManager.collisionComponents.Add(this);
        ...
    }
}

Now let’s write the real logic for managing collisions. We will loop through all of the collision components in our game. For each one, we will do another loop for the components that come next to it. If the components are in the same layer, we will ignore them. Then, we will check for intersections between the components.

CollisionManager
{
    public List&lt;CollisionComponent&gt; collisionComponents = new List&lt;CollisionComponent&gt;();

    public void Update(GameTime gameTime)
    {
        for (int i = 0; i < collisionComponents.Count; i++)
        {
            CollisionComponent currentComponent = collisionComponents[i];
            for (int j = i + 1; j < collisionComponents.Count; j++)
            {
                CollisionComponent otherComponent = collisionComponents[j];
                if (currentComponent.checkLayer != otherComponent.layer) continue;
                if (currentComponent.boundary.Intersects(otherComponent.boundary))
                {
                    currentComponent.OnCollision.Invoke(otherComponent);
                    otherComponent.OnCollision.Invoke(currentComponent);
                }
            }
        }
    }
}

Testing Collisions

Finally, let’s test if our collisions are working. Go to the player class and in the onCollision method, print a message using Debug.WriteLine. Run the game, and whenever the player touches an obstacle, you should see the message.

private void OnCollision(CollisionComponent other)
{
    Debug.WriteLine("Collision Detected");
}

Health Component

We’ll learn about implementing health management systems for the player and meteors in a MonoGame project. Using a component-based approach, we’ll create a health class, define attributes like current and maximum health, and implement methods to manage health and damage.

Creating the HealthComponent Class

Let’s start by creating a new class named HealthComponent.cs. This class will manage the health-related attributes and methods for our game entities, such as the player and meteors. It will track current and maximum health, and include methods to apply damage.

public class HealthComponent
public class HealthComponent
{
    public int maxHealth;
    public int currentHealth;
    
    
    public HealthComponent(int maxHealth) 
    {
        this.maxHealth = maxHealth;
        this.currentHealth = maxHealth;
    }
   
}

Additionally, we’ll need to create a delegate called OnDeathDelegate. This event will be triggered when the current health drops to zero, signaling that the player or the meteor has died. By using this delegate, we can execute custom actions, such as playing a death animation or removing the entity from the game, whenever a death event occurs.

public delegate void OnDeathDelegate();
public OnDeathDelegate OnDeath;

Now, let’s define a few methods to help us manage the health of our game entities.

The first method is TakeDamage, which accepts an integer value representing the damage. Inside this method, we’ll decrease the current health by the damage amount. If the current health falls to zero or below, we will trigger the OnDeathDelegate to handle the entity’s death.

public void TakeDamage(int damage)
{
    this.currentHealth -= damage;
}

Let’s also define the IsAlive method. This method will return a boolean value indicating whether the game entity is alive or not. Inside it, if the current health is equal to or less than zero, it will return false; otherwise, it will return true. This check helps us determine if the entity should continue participating in the game or if it needs to be removed or marked as inactive.

public bool IsAlive()
{
    return this.currentHealth > 0;
}

Let’s now revisit the TakeDamage method. First, we’ll check if the entity is not alive. If it isn’t, we’ll return early to avoid executing the rest of the code. We’ll update the current health by subtracting the damage amount. Then, we’ll verify if the current health is less than or equal to zero. If it is, we’ll trigger the OnDeathDelegate event to handle the death of the entity.

This ensures that any necessary actions, such as playing death animations or removing the entity from the game, are properly executed.

public void TakeDamage(int damage)
{
    if (!IsAlive()) return;
    this.currentHealth -= damage;
    if  (this.currentHealth <= 0)
    {
        OnDeath.Invoke();
    }
}

Now, Your health component should look like this:

public class HealthComponent
{
    public int maxHealth;
    public int currentHealth;
    public delegate void OnDeathDelegate();
    public OnDeathDelegate OnDeath;
    
    public HealthComponent(int maxHealth) 
    {
        this.maxHealth = maxHealth;
        this.currentHealth = maxHealth;
    }
    
    public void TakeDamage(int damage)
    {
        if (!IsAlive()) return;
        this.currentHealth -= damage;
        if  (this.currentHealth <= 0)
        {
            OnDeath.Invoke();
        }
    }
    
    public bool IsAlive()
    {
        return this.currentHealth > 0;
    }
}

Using the HealthComponent Class

Now that we’ve defined our HealthComponent, let’s see how we can use it for the Meteor class.

First, go to the ObstacleMeteor class and define a property of type HealthComponent. In the initializer, create a new HealthComponent instance, passing the health amount specific to this meteor. Additionally, set up an event listener for the OnDeath event to handle what should happen when the meteor’s health reaches zero.

public class ObstacleMeteor : Obstacle, IDisposable
{
    ...
    private HealthComponent healthComponent;

    public ObstacleMeteor(Vector2 position)
    {
        ...
        // Health Component
        healthComponent = new HealthComponent(10);
        healthComponent.OnDeath += OnDeath;
    }

    private void OnDeath()
    {
        // Code to handle death of the meteor...
    }

    public void Dispose()
    {
        ...
        healthComponent.OnDeath -= OnDeath;
    }
    ...
}

Now, let’s consider what should happen when the meteor dies. We need to inform the spawner that this meteor is no longer alive and should be removed from the obstacle list. To achieve this, we can create a method in the spawner class that removes an obstacle from the obstacle list. This method will be called whenever the OnDeath event of the meteor is triggered, ensuring that the game state is updated accordingly.

public void Remove(Obstacle obstacle)
{
    this.obstacles.Remove(obstacle);
}

Back in the Meteor class, we need to handle what happens when the meteor dies. Inside the OnDeath method, call the newly created Remove method in the spawner and pass the keyword this as a parameter. This will remove the meteor from the spawner’s obstacle list, ensuring that the Update and Draw methods for this meteor are no longer called. This effectively removes the meteor from the game.

private void OnDeath()
{
    GameManager.Instance._spawner.Remove(this);
}

When a meteor dies, it’s also a good idea to call the Dispose method and unsubscribe from all events. This ensures that any resources used by the meteor are properly cleaned up, and it prevents potential memory leaks caused by lingering event subscriptions.

private void OnDeath()
{
    GameManager.Instance._spawner.Remove(this);
    Dispose();
}

Lastly, we need to inform the CollisionManager that we no longer need a collision check for this dead obstacle. To accomplish this, we will define a new method named Remove in the CollisionManager class. This method will accept a CollisionComponent as a parameter and remove the corresponding item from the collision list. By doing so, we ensure that the dead meteor is no longer considered in collision detections, optimizing the game’s performance.

public void Remove(CollisionComponent collisionComponent)
{
    this.collisionComponents.Remove(collisionComponent);
}

Then, we can call this new method in our OnDeath method.

private void OnDeath()
{
    GameManager.Instance._spawner.Remove(this);
    Dispose();
    GameManager.Instance._collisionManager.Remove(collisionComponent);
}

Now that we’ve addressed everything related to removing the meteor when it dies, let’s focus on applying damage to the meteor when it collides with the player. Go to the OnCollision method and call HealthComponent.TakeDamage, passing the appropriate amount of damage. Use a value of 1000 if the collision is with the player, and a value of 2 if the collision is with any other object. This will ensure that the meteor takes significant damage upon colliding with the player, leading to its destruction immediately.

private void OnCollision(CollisionComponent other)
{
    if (other.tag == "player")
    {
        healthComponent.TakeDamage(1000);
    } else
    {
        healthComponent.TakeDamage(2);
    }

    SoundManager.PlaySound(damageSFX.CreateInstance(), 1f, -0.4f, 0.4f);
            
}

Now we are ready to test our implementation. When you touch a meteor, it should disappear, indicating that our health management system is working as expected.

Using the HealthComponent for the Player

To implement the HealthComponent for the player, repeat the process above and make the necessary script changes to include a health property. Initialize the HealthComponent with a maximum health value, and set up an event listener for the OnDeath event to handle the player’s death. Ensure the player can take damage and respond accordingly:

public class Player : IDisposable
{
    ...
    private HealthComponent healthComponent;


    private void LoadContent()
    {
        ...
        healthComponent = new HealthComponent(10);
        healthComponent.OnDeath += OnDeath;
    }

    public void Update(GameTime gameTime)
    {
        if (!healthComponent.IsAlive()) return;
        ...
    }

    public void Draw(SpriteBatch spriteBatch)
    {
        if (!healthComponent.IsAlive()) return;
        spriteBatch.Draw(sprite, position, Color.White);
    }

    private void OnCollision(CollisionComponent other)
    {
        healthComponent.TakeDamage(2);
    }

    private void OnDeath()
    {
        Dispose();
        GameManager.Instance._collisionManager.Remove(collisionComponent);
    }

    public void Dispose()
    {
        collisionComponent.OnCollision -= OnCollision;
        healthComponent.OnDeath -= OnDeath;
    }
}

Notice that in the Draw and Update methods, we check if the player is not alive. If the player is dead, we skip updating and drawing the player. This ensures that once the player’s health reaches zero, the player is effectively removed from the game, and no further actions are taken for that entity.

Space Background

To enhance the visual appeal of our game’s background, we’ll change its color to black and add layers of stars that move at different speeds. This parallax effect will create a sense of depth and movement, making the background more dynamic and visually interesting. By varying the speeds of the star layers, we can simulate a more realistic space environment that enhances the overall gaming experience.

Changing the Background Color

The first step is to change the background color of the game. To do this, we need to modify the GameManager class. Update the background color to black.

protected override void Draw(GameTime gameTime)
{
    GraphicsDevice.Clear(Color.Black);
    _spriteBatch.Begin();
    _player.Draw(_spriteBatch);
    _spawner.Draw(gameTime, _spriteBatch);   
    _spriteBatch.End();
    base.Draw(gameTime);
}

MonoGame C# Space Shooter Game

Adding Stars to the Background

Although we’ve changed the background color to black, it still looks somewhat empty. To make it more interesting, we’re going to fill it with stars. We’ll achieve this by creating two new scripts:

  1. Star.cs – This script will contain information about each star, including its position, size, and speed.
  2. StarManager.cs – This script will handle the creation, updating, and deletion of stars, ensuring that they move at different speeds to create a dynamic and engaging background.

Creating the Star.cs

Each star will have properties such as position, texture, and speed. These properties define where the star is located, how it looks, and how fast it moves. The star will also have an Update method to move it downward, creating the illusion of a moving background, and a Draw method to render it on the screen.

public class Star
{
    public Vector2 position;
    private Texture2D sprite;
    private float speed;
    private float scale;

    public Star(Vector2 position, Texture2D sprite, float speed, float scale)
    {
        this.position = position;
        this.sprite = sprite;
        this.speed = speed;
        this.scale = scale;

    }

    public void Update(GameTime gameTime)
    {
        position.Y += speed * (float)gameTime.ElapsedGameTime.TotalSeconds;
    }

    public void Draw(SpriteBatch spriteBatch)
    {
        spriteBatch.Draw(sprite, position, null, Color.White, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
    }
}

Creating the Star Manager

Now that we have our Star class, we can create a StarManager class to manage the creation and deletion of stars. This class will hold a reference to all the stars and will control their spawn rate.

public class StarManager
{
    private List<Star> stars = new List<Star>();
    private List<Texture2D> sprites = new List<Texture2D>();

    private TimeSpan spawnRate = TimeSpan.FromSeconds(0.05f);
    private TimeSpan spawnTimer = TimeSpan.Zero;
}

We’ll also define an initializer in the StarManager class that will load star textures and add them to the list of sprites. This initializer will be responsible for preparing all necessary star images and ensuring they are ready to be used in the game. By organizing and preloading these textures, we can efficiently manage and render the stars.

public StarManager() 
{
    sprites.Add(GameManager.Instance.Content.Load<Texture2D>("Sprite/star_tiny"));
    sprites.Add(GameManager.Instance.Content.Load<Texture2D>("Sprite/star_small"));

}

We’ll also define Draw and Update methods in the StarManager class. These methods will loop through all the stars, calling each star’s Draw and Update methods. The Update method will handle moving the stars, while the Draw method will render each star on the screen. This ensures that all stars are properly animated and displayed.

public void Draw(SpriteBatch spriteBatch)
{
    for (int i = 0; i < stars.Count; i++)
    {
        stars[i].Draw(spriteBatch);
    }
}

public void Update(GameTime gameTime)
{
    for (int i = 0; i < stars.Count; i++)
    {
        stars[i].Update(gameTime);
    }
}

Let’s also adjust the Update method to ensure we remove any stars that have moved off the screen. As stars move downward, we need to check their position and remove them from the list once they pass the bottom edge of the screen. This prevents unnecessary processing of off-screen stars and keeps the background clean and efficient.

public void Update(GameTime gameTime)
{
    for (int i = 0; i < stars.Count; i++)
    {
        stars[i].Update(gameTime);

        if (stars[i].position.Y > GameManager.Instance.GraphicsDevice.Viewport.Height)
        {
            stars.RemoveAt(i);
            i--;
        }
    }
}

After that, we’ll handle spawning new stars based on timers. This involves generating a random x-position and speed for each new star, selecting a random sprite from the available textures, and adjusting the scale to create varying sizes. By doing this, we can ensure a more natural and varied star field. Once the new star’s properties are set, we’ll add it to our list so the StarManager can process it, updating its position and rendering it on the screen.

public void Update(GameTime gameTime)
{
    ...
    spawnTimer += gameTime.ElapsedGameTime;
    if (spawnTimer > spawnRate)
    {
        //Reset Spawn timer to 0
        spawnTimer = TimeSpan.Zero;
        
        Random random = new Random();

        //Random Speed
        float speed = random.Next(50, 300);

        //Random Position
        int y = -100;
        int x = random.Next(0, GameManager.Instance.GraphicsDevice.Viewport.Width);
        Vector2 position = new Vector2(x, y);

        //Random Sprite Index
        int index = random.Next(0, sprites.Count);

        //Scale
        float scale = 1;
        if (index != 0)
        {
            scale = 0.15f;
        }

        Star currentStar = new Star(position, sprites[index], speed, scale);
        stars.Add(currentStar);
    }
}

Finally, we need to integrate the StarManager into the GameManager class. We’ll define a reference of type StarManager in the GameManager class and instantiate it in the LoadContent method. Then, in the Update and Draw methods of the GameManager, we’ll call the corresponding methods of the StarManager. This ensures that the stars are continuously updated and rendered.

public class GameManager : Game {
  ...
  StarManager starManager;

  protected override void LoadContent() {
    ...
    starManager = new StarManager();
  }

  protected override void Update(GameTime gameTime) {
    ...
    starManager.Update(gameTime);
  }

  protected override void Draw(GameTime gameTime) {
    ...
    starManager.Draw(spriteBatch);
    ...
  }
}

Weapon System

Next we will handle the weapon for our MonoGame space shooter. This involves creating a new class, implementing abstract methods, and setting up the firing mechanism. The weapon class we will develop will be an abstract one, meaning it will serve as a blueprint for all other weapon classes in the game.

Weapon & Projectile In Theory

Before we dive in, let’s talk about how weapons and projectiles will be structured for our MonoGame project.

The conventional method of spawning bullets directly within the player class might seem appealing due to its simplicity, but it falls short in terms of scalability and ease of adding new content. We will instead explore a more robust and flexible approach.

Our first step is to create an abstract base class for the weapon. This is a similar approach to the one we took when creating obstacles for this MonoGame project. This base class allows us to generate multiple weapons that are derived from it, providing a variety of weapons for our player. The player class will maintain a reference to this base weapon class, meaning the player can hold any weapon we create.

Weapon structure for MonoGame project

Just like the weapon, our projectiles (or bullets) will also have an abstract base class, which we will call ‘projectile’ (again, as we’ve done previously in this MonoGame tutorial). This is because we may want to introduce different types of bullets in the future. By having an abstract class for projectiles, we can easily create new types of bullets as needed.

Projectile structure for MonoGame project

Recall that when we created meteors and obstacles, we also needed an obstacle manager. Similarly, we will need a ‘projectile manager’ for our projectiles. This manager will handle the deletion, updating, and drawing of bullets.

Projectile Manager flowchart for managing projectiles

The player class listens for keyboard input, and when a key is pressed, the ‘fire’ method is called on the weapon reference. The weapon then checks the fire rate and spawns a projectile if appropriate. The projectile adds itself to the projectile manager and continues moving forward. The projectile manager loops through all projectiles, calling the update and draw methods, while also checking for any far projectiles that need to be deleted.

Flowchart showing how the Player works with our weapons system

Creating the Weapon Class

We need to think about the parameters that all weapons will need. In this case, we want to have a fire rate and a fire timer. we also need to define two abstract methods: `Update` and `Fire`. The `Update` method will take `gameTime` as a parameter. The `Fire` method will take `position`, `direction`, `layer`, and `checkLayer` as parameters. These parameters are used to determine the spawn point of the projectile, the direction it should travel, and which layer it should collide with.

public abstract class Weapon
{
    protected TimeSpan fireRate;
    protected TimeSpan fireTimer = TimeSpan.Zero;

    public abstract void Update(GameTime gameTime);

    public abstract void Fire(Vector2 position, Vector2 direction, int layer, int checkLayer);

}

Creating a Simple Weapon

Now that we have our abstract Weapon class in our MonoGame project, let’s create a simple weapon that extends from it. To do this, create a new class named SimpleWeapon. In this class, implement the Weapon methods and define the fireRate for this specific weapon. By setting up the fireRate, we can control how frequently the weapon can fire, providing a foundation for its functionality in the game.

public class SimpleWeapon : Weapon
{
    public SimpleWeapon()
    {
        fireRate = TimeSpan.FromSeconds(0.1f);
    }
}

In the Update method, we need to add the elapsed game time to the fire timer. This will allow us to keep track of the time passed and control when the next bullet can be fired based on the weapon’s fire rate. By continuously updating the fire timer, we can ensure that the weapon fires at the correct intervals, maintaining a consistent rate of fire.

public override void Update(GameTime gameTime)
{
    fireTimer += gameTime.ElapsedGameTime;
}

In the Fire method, we need to check if the fire timer is greater than the fire rate. If it is, we reset the timer to zero. This ensures that the weapon adheres to its designated fire rate. For now, instead of spawning a bullet, we can debug a message called “fire” to confirm that the method is working correctly. This step will help us verify that the firing logic is functioning as intended before we implement the bullet spawning.

public override void Fire(Vector2 position, Vector2 direction, int layer, int checkLayer)
{
    if (fireTimer >= fireRate)
    {
        fireTimer = TimeSpan.Zero;
        Debug.WriteLine("Fire");
    }
}

Using the Weapon

Now that we have defined our simple weapon, how can we use it? As we mentioned in the Theory section, we need to define a property of type Weapon for the player and name it activeWeapon. We then instantiate a new SimpleWeapon in the LoadContent. In the Update method, we call the active Weapon.Update` method, passing in the game time.

public class Player
{
    ...
    private Weapon activeWeapon;
    private void LoadContent()
    {
        ...
        activeWeapon = new SimpleWeapon();
    }
    public void Update(GameTime gameTime)
    {
        activeWeapon.Update(gameTime);
        if (Keyboard.GetState().IsKeyDown(Keys.Space))
        {
            activeWeapon.Fire(this.position, new Vector2(0f, -1f), 0, 1);
        }
    }
}

Notice how we listen for the spacebar key to be pressed. If it is, we call the activeWeapon.Fire method.

Projectile & Projectile Manager

We are going to create a projectile and a projectile manager for our MonoGame space shooter. The projectile in our game is the bullet that the player shoots, and the projectile manager manages these bullets.

Creating the Projectile Class

Create a new class named Projectile.cs. It’s useful to implement IDisposable, as projectiles will be removed when they are outside the viewport. To satisfy the IDisposable interface, we need to include a Dispose method. We will make this method virtual, allowing it to be overridden by any child classes. This approach ensures that resources used by the projectiles are properly cleaned up, preventing memory leaks and maintaining game performance.

public abstract class Projectile : IDisposable
{
    // Dispose method
    public virtual void Dispose()
    {
        // Implementation here
    }
}

Let’s define the properties that we will need for any projectile. First, we’ll need a Texture2D property, as each bullet must have a sprite. We’ll also need properties for Position and Direction to determine where the projectile is and the direction it should travel. Additionally, a float for Speed will be necessary to control how fast the projectile moves. Finally, we’ll need a CollisionComponent to handle collisions between the projectile and other objects in the game.

public abstract class Projectile : IDisposable
{
    protected Texture2D sprite;
    public Vector2 position;
    protected Vector2 direction;
    protected float speed = 100f;
    protected CollisionComponent collisionComponent;
    
    // Dispose method
    public virtual void Dispose()
    {
        // Implementation here
    }
}

Then, we will have an initializer method, which asks for a bunch of things like position, direction, layer, and check layer. Inside it, we will set the property values to the parameter values. Then, we will load the texture and store it in the sprite property. We will also set the collision component value by creating a new collision component object passing the position of sprite width and sprite height as well as the layer and check layer. Lastly, for the tag let’s write projectile. Then, let’s listen to the on collision event.

public Projectile(Vector2 position, Vector2 direction, int layer, int checkLayer)
{
    this.sprite = GameManager.Instance.Content.Load<Texture2D>("Sprite/laser_green");
    this.position = position;
    this.direction = direction;
    this.collisionComponent = new CollisionComponent(this.position, sprite.Width, sprite.Height, layer, checkLayer, "projectile");
    collisionComponent.OnCollision += OnCollision;
}

We will define an Update method that takes a gameTime parameter. Inside this method, we will update the projectile’s position by multiplying the direction vector by the speed and the elapsed game time’s total seconds. This calculation ensures that the projectile moves consistently in the defined direction at the correct speed, providing smooth and accurate movement.

public virtual void Update(GameTime gameTime)
{
    position += direction * speed * (float)gameTime.ElapsedGameTime.TotalSeconds;
    collisionComponent.UpdatePosition(position);
}

Next, we will define a Draw method that takes a SpriteBatch parameter. Inside this method, we will draw the projectile’s sprite using SpriteBatch.Draw. By passing the projectile’s texture, position, and other necessary drawing parameters to SpriteBatch.Draw, we ensure that the projectile is rendered correctly on the screen.

public virtual void Draw(SpriteBatch spriteBatch)
{
    spriteBatch.Draw(sprite, position, Color.White);
}

For the OnCollision method, we’ll call the Dispose method to handle the projectile’s removal when it collides with another object. In the Dispose method, we will unsubscribe from the collision event to prevent any further collision handling and remove the collision component from the CollisionManager. This ensures that the projectile is properly cleaned up and no longer participates in collision detection.

public virtual void OnCollision(CollisionComponent otherComponent)
{
    Dispose();
}
public virtual void Dispose()
{
    collisionComponent.OnCollision -= OnCollision;
    GameManager.Instance._collisionManager.Remove(collisionComponent);
}

Creating a Projectile Example

Let’s create one projectile example for our MonoGame project. Create a new class, name it SimpleProjectile. implement the projectile abstract class. Then, create an initializer that asks for all the required information. Inside this initializer, we can define the speed of this projectile. For the rest of the virtual methods, let’s override them one by one but actually, we won’t add any custom logic for now. We will just call the base methods.

public class SimpleProjectile : Projectile
{
    public SimpleProjectile(Vector2 position, Vector2 direction, int layer, int checkLayer) : base(position, direction, layer, checkLayer)
    {
        this.speed = 500f;
    }
    public override void Update(GameTime gameTime)
    {
        base.Update(gameTime);
    }
    public override void Draw(SpriteBatch spriteBatch)
    {
        base.Draw(spriteBatch);
    }
    public override void Dispose()
    {
        base.Dispose();
    }
    public override void OnCollision(CollisionComponent otherComponent)
    {
        base.OnCollision(otherComponent);
    }
}

Creating the Projectile Manager

Let’s not create the Projectile Manager for our MonoGame project.

Since the Update and Draw methods of the projectile need to be called every frame, we should create a ProjectileManager to manage all active projectiles. This manager will contain a list of projectiles. We’ll also define an Add method that takes a projectile and adds it to the list, and a Remove method that takes a projectile and removes it from the list.

public class ProjectileManager
{
    private List<Projectile> projectiles = new List<Projectile>();
    public void Add(Projectile projectile)
    {
        projectiles.Add(projectile);
    }
    public void Remove(Projectile projectile)
    {
        projectiles.Remove(projectile);
    }
}

Then, we will define an update method that takes a game time as a parameter. Inside it, we will loop through all projectiles and call Update. Inside the same loop, we will check if the projectile position Y value is more than the screen height, which means the bullet is below the screen or if it’s less than minus 100, it means it’s above the screen by 100 units. If that’s the case, we will call the dispose method on the bullet and then remove it from the list. We have to decrease the index by one so we don’t skip any iteration.

public void Update(GameTime gameTime)
{
    for (int i = 0; i < projectiles.Count; i++)
    {
        projectiles[i].Update(gameTime);
        if (projectiles[i].position.Y > GameManager.Instance.GraphicsDevice.Viewport.Height
            || projectiles[i].position.Y < -100)
        {
            projectiles[i].Dispose();
            projectiles.RemoveAt(i);
            i--;
        }
    }
}

Next, let’s define a Draw method in the ProjectileManager class that takes a SpriteBatch as a parameter. Inside this method, we’ll loop through all the projectiles in the list and call their Draw methods. This ensures that each projectile is rendered on the screen during each frame

public void Draw(SpriteBatch spriteBatch)
{
    for (int i = 0; i < projectiles.Count; i++)
    {
        projectiles[i].Draw(spriteBatch);
    }
}

To call these draw and update methods, we need to define a reference to the projectile manager inside the game manager. And call these methods from there. So go to GameManager. Define a new property of type projectile manager. In the load content method set the value of the property to a new projectile manager. Then in the game manager update method call the ProjectileManager.Update and in the game manager draw method call the underscore ProjectileManager.Draw.

public class GameManager : Game
{
    public ProjectileManager _projectileManager;
    protected override void LoadContent()
    {
        _projectileManager = new ProjectileManager();
    }
    protected override void Update(GameTime gameTime)
    {
        _projectileManager.Update(gameTime);
    }
    protected override void Draw(GameTime gameTime)
    {
        _projectileManager.Draw(_spriteBatch);
    }
}

Finally, to spawn a simple projectile, we need to adjust our simple weapon and tell it to spawn a simple projectile. So go to simple weapon and in the fire method remove the debug message and instead spawn a real simple projectile passing the required parameters. and don’t forget to add this projectile to the projectile manager and remove it when necessary. To do that go to the projectile and in the initializer method add the projectile to the projectile manager and inside the onCollision method, let’s remove the projectile from the projectileManager list.

public class SimpleProjectile : Projectile
{
    public SimpleProjectile(Vector2 position, Vector2 direction, int layer, int checkLayer) : base(position, direction, layer, checkLayer)
    {
        GameManager.Instance._projectileManager.Add(this);
    }
    public override void OnCollision(CollisionComponent otherComponent)
    {
        base.OnCollision(otherComponent);
        GameManager.Instance._projectileManager.Remove(this);
    }
}

MonoGame C# Spaceship Shooting Bullets

Adjusting Projectile Starting Position

We need to adjust the starting position of the bullet. Instead of starting at the player’s position, we want it to start at the center of the player’s spaceship. To achieve this, we have to add half the width of the player’s sprite to the X value of the player’s position. This will move the starting position of the bullet from the top left of the player’s sprite to the center.

We also need to consider the width of the projectile sprite. To ensure that the center of the bullet matches the center of the player’s spaceship, we need to push the position back by half of the projectile’s width.

...
if (keyboardState.IsKeyDown(Keys.Space))
{
    activeWeapon.Fire(new Vector2(position.X + (sprite.Width / 2), position.Y), new Vector2(0f, -1f), 0, 1);
}
...
...
public Projectile(Vector2 position, Vector2 direction, int layer, int checkLayer)
{
    this.position = new Vector2(position.X - (sprite.Width/2), position.Y);
}
...

Sound Effects

In this section, we will explore how to enhance the gaming experience by adding sound effects (SFX) and ensuring they don’t become repetitive. Sound effects play a crucial role in creating an immersive environment, and their effective management is key to the overall feel of the game. They’re also very easy to work with in MonoGame.

Creating Sound Effects for the Projectile

To add sound effects to projectiles, we start by defining new properties in the projectile parent class. First, introduce a property of type SoundEffect and name it shootSFX. This will store the sound file that plays when a projectile is fired. Next, create a property named shootSFXInstance of type SoundEffectInstance. This allows for individual control over the playback of the shootSFX, such as starting and stopping the sound, which is particularly useful for managing multiple instances of the sound effect to prevent them from overlapping or sounding too repetitive.

//Shoot SFX
protected SoundEffect shootSFX;
protected SoundEffectInstance shootSFXInstance;

In the initializer method of the ProjectileManager, load the sound effects (SFX) needed for the projectiles:

shootSFX = gameManager.instance.content.load<SoundEffect>("player_laser");
shootSFXInstance = shootSFX.createInstance();

Now that we have an instance of the audio file, we can play it within the initializer method. This ensures that the sound effect is triggered when the projectile is created.

shootSFXInstance.play

Give the game a try and observe the outcome.

The SFX works but it might feel very repetitive over time, which is not desirable. To solve this issue, we can adjust the pitch of the sound effect instance. Increasing or decreasing this value changes the audio pitch, which can help to avoid the sound feeling repetitive. As each audio file is different, it is important to find a good lowest pitch and a good highest pitch. You will use these values to get a random value between them.

Creating a Sound Manager

As we will have a lot of sound effects in our game, it’s more efficient to create a sound manager. This will handle the playing of sound effects, including adjusting the volume and pitch. To do this, create a new file named ‘SoundManager’ and define a private static property of type ‘random’. Then, create an initializer method and set the value of the random property to a new random.

public class SoundManager
{
    private static Random random;
    public SoundManager()
    {
        random = new Random();
    }
}

Next, create a public static method named ‘PlaySound’. This method will ask which sound we want to play, the volume, and the lowest and highest pitch. Inside this method, it first checks if the sound effect instance is already playing. If it is, it returns early. Then, it gets a random pitch value and sets the volume and pitch of the sound effect. Finally, it plays the sound effect.

public static void PlaySound(SoundEffectInstance sfx, float volume, float minPitch, float maxPitch)
{
    if (sfx.State == SoundState.Playing) return;
    float pitch = (float)(random.NextDouble() * (maxPitch - minPitch) + minPitch);
    sfx.Volume = volume;
    sfx.Pitch = pitch;
    sfx.Play();
}

Then, in the Projectile class, instead of playing the audio directly, change it to SoundManager.PlaySound and pass the required parameters.

SoundManager.PlaySound(shootSFXInstance, 0.3f, -0.2f, 0.2f);

Creating Sound Effects for the Obstacle

Let’s add a sound effect to enhance the interaction when an obstacle, specifically a meteor, is hit by a projectile. In the Meteor Obstacle class, create a new property of the type SoundEffect and name it DamageSFX. This sound effect differs from the projectile’s as it isn’t played during initialization.

Instead, play it at the moment of collision, which makes the OnCollision method the ideal location for this action. However, remember to load the audio file into DamageSFX within the initializer to ensure it’s ready to be played when needed. This setup will effectively augment the audio feedback during gameplay interactions.

public class ObstacleMeteor : Obstacle, IDisposable
{
    ...
    //Damage SFX
    private SoundEffect damageSFX;
    public ObstacleMeteor(Vector2 position)
    {
        ...
        //Damage SFX
        damageSFX = GameManager.Instance.Content.Load<SoundEffect>("Audio/obstacle_damage");
    }
    private void OnCollision(CollisionComponent other)
    {
        ...
        SoundManager.PlaySound(damageSFX.CreateInstance(), 1f, -0.4f, 0.4f);
        
    }
}

Creating the Meteor Sound Effect

For the scenario where a meteor is destroyed, you’ll need to integrate a sound effect to enhance this event’s impact. The appropriate place to initiate this sound is within the Meteor Obstacle class, specifically inside the OnDeath method. By placing the sound trigger here, you ensure that the audio plays precisely when the meteor’s destruction occurs, thereby improving the game’s auditory feedback and enhancing the player’s immersion.

public class ObstacleMeteor : Obstacle, IDisposable
{
    //...
    //Destroy SFX
    private SoundEffect destroySFX;
    public ObstacleMeteor(Vector2 position)
    {
        //...
        //Destroy SFX
        destroySFX = GameManager.Instance.Content.Load<SoundEffect>("Audio/obstacle_destroy");
        //...
    }
    
    private void OnDeath()
    {
        //...
        SoundManager.PlaySound(destroySFX.CreateInstance(), 0.7f, -0.2f, 0.2f);
        //...
    }
    //...
}

Creating the Player Death Sound Effect

Creating the player death sound effect follows a similar process to the meteor sound effect. Start by going to the Player class and adding a new property for the sound effect, which you could name DeathSFX. During the class initialization, load the designated audio file into this property. Then, within the OnDeath method, trigger this sound effect to play. This approach ensures that the sound is played at the exact moment of the player’s death

public class Player : IDisposable
{
    //...
    //Death SFX
    private SoundEffect deathSFX;
    private void LoadContent()
    {
        //...
        //DeathSFX
        deathSFX = GameManager.Instance.Content.Load<SoundEffect>("Audio/player_death");
        //...
    }
    private void OnDeath()
    {
        //...
        SoundManager.PlaySound(deathSFX.CreateInstance(), 1f, -0.2f, 0.2f);
        //...
    }
    //...
}

Now, we can run the game to test our new sound effects. If everything is set up correctly, you should hear the respective sound effects when the meteor is destroyed and when the player dies.

Ambient Sound

We will last learn how to play background music in our MonoGame project using the SoundManager. This will involve defining a new property of type ‘Song’, loading the song, setting the music to repeat, adjusting the volume, and finally, playing the song.

First, we need to define a new property of type ‘Song’ within our SoundManager. We will name this property ‘backgroundMusic’. In the initializer method, we will load the song by writing:

backgroundMusic = gameManager.instance.content.load<Song>("background_music");

Before playing the music in your game, set it to loop by enabling MediaPlayer.IsRepeating to true and adjust the volume to 0.5 for balanced background audio. This setup ensures the music continuously enhances the gameplay without being disruptive.

MediaPlayer.IsRepeating = true;
MediaPlayer.Volume = 0.5f;

To play the background music in your game, simply use the ‘MediaPlayer.Play()’ method and pass in the ‘backgroundMusic’ variable. This will start the music, allowing it to continuously enhance the game environment as it loops in the background.

MediaPlayer.Play(backgroundMusic);

Now, if you run the game, you should hear your background music playing continuously. This adds a new layer of immersion to your game, enhancing the player’s experience.

MonoGame Tutorial Wrap-Up

Congratulations on completing this comprehensive tutorial on game development with MonoGame! You’ve not only learned core game mechanics that will be useful for other kinds of MonoGame projects, but also have your first game already done and ready for your profolio.

Where do you go from here, though? These systems equip you with versatile skills that can be applied to any game project, making you a more adaptable and proficient game developer. So start exploring other genres and games, whether that’s platformers, RPGs, or something else. You could even try making other retro-style games like Pong. The sky is the limit!

If you’re looking for more guided help with MonoGame, we also suggest checking out Zenva’s MonoGame courses, which come with source code, video tutorials, and more to ensure you get the hands-on-training you need to become an expert developer!

We wish you the best of luck with your next MonoGame project! Keep learning, keep developing, and keep creating!

Did you come across any errors in this tutorial? Please let us know by completing this form and we’ll look into it!

FREE COURSES
Python Blog Image

FINAL DAYS: Unlock coding courses in Unity, Godot, Unreal, Python and more.