How to Program a Primitive Twin-Stick Shooter in Monogame 3.4 This is a tutorial for making a basic twin-stick style shooter in C# using Monogame 3.4 and Microsoft Visual Studio. This guide will demonstrate a rudimentary system that can handle moving a player character, aiming and shooting projectiles, and rudimentary enemy behavior. It is assumed that the reader has moderate experience with both C# and some version of Microsoft Visual Studio, preferably MSVS2015. Materials: 1. A Windows PC, preferably Windows 7 or later 2. Physical mouse and keyboard compatible with PC 3. A stable internet connection 0. Setting Up the Project 1. Download and install the free version of Microsoft Visual Studio 2015 by clicking the Download Community Free button on this link. 2. Download and install Monogame 3.4 from this link by clicking Monogame 3.4 for Visual Studio. 3. Download the C3.XNA.Primitives2D zip file by following this link. Once it is downloaded, unzip the directory into your work space. 4. Open Visual Studio 2015. Open a new Monogame project by clicking File->New->Project under the navigation bar at the top. Once the new project pop-up appears, navigate to Templates->Visual C#->Monogame->Monogame Windows Project. 5. Name the project as you desire and select OK. Note: From now on, whenever you see [YOUR_NAME], replace it with whatever you named your project in Step 5. 6. Right click on the newly created solution in the Solution Explorer. Select Add->Existing Item. In the file selection pop-up that appears, navigate to your work space and select Primitives2D.cs from the folder we unzipped in Step 3. 7. Open the file [YOUR_NAME].cs by double clicking on it in the solution explorer. 8. At the top of the file, add the code using C3.XNA; using System; using System.Collections.Generic; to the existing include statements.
1. Initializing the Window Caution: Compile code regularly to ensure that everything is working properly. 1. Declare the following class-scope constants: const int screenwidth = 1280; const int screenheight = 768; const bool fullscreen = false; 2. Find the [YOUR_NAME]() constructor. Underneath the line graphics = new GraphicsDeviceManager(this);, insert the following lines of code: graphics.isfullscreen = fullscreen; graphics.preferredbackbufferwidth = screenwidth; graphics.preferredbackbufferheight = screenheight; 3. Find the Draw(GameTime gametime) method. Change the method call GraphicsDevice.Clear(Color.Black); Note: Every frame will begin with the window set to a solid rectangle of this color. You can choose a different color if you wish. At this time, if you compile and run the program you should see a window with a completely black background (or whichever color you chose in Step 3.)
2. Player and Cursor Movement 2.1 Movement Trails This section will allow us to display faded circles behind the player, bullets, and enemies as they move to add visual flair. 1. Declare a void method UpdateMovementTrail(ref Vector2[] trail, int trail_count). 2. Implement the above method. It should contain a for loop that iterates backwards from the index trail_count and set the i th element equal to the i-1 th element. Declare the class-scope constant const double MOVEMENT_UPDATE = 0.025;. 3. Declare a void method DrawMovementTrail(SpriteBatch spritebatch, Vector2[] trail, int trail_count, float radius, float sides, Color color). Note: The arguments are used as follows: 1. spritebatch is a native Monogame entity used for drawing. 2. trail is the list of positions at which we will draw a circle. 3. trail_count is the number of circles we will draw 4. radius is the radius in pixels of each circle 5. sides is the number of sides we will use to approximate a circle. 6. color is the color that we will make each circle, ignoring the alpha channel. 4. Implement a for loop in the drawing method that iterates backwards from trail_count-1 to 0. In each step it should check if both the X and Y coordinate of the point is greater than 0, as our convention is that empty positions in the array start out at (-1,-1). Inside the if statement, place the following method call: Primitives2D.DrawArc( spritebatch, trail[i], radius, sides, 0, MathHelper.TwoPi, new Color( (float)color.r / 255, (float)color.g / 255, (float)color.b / 255, (1 - ((float)i / trail_count)) / (float)(i+1)
), radius); Note: This method calls a function defined in the Primitives2D file we included earlier in Section 0. As of version 3.4, Monogame does not support native primitve rendering in 2D, so we are utilizing 3 rd party code. Our code does not include a method for filling circles, so we get around this by drawing a circular arc with the thickness set to be the thickness of our radius. At each position in the movement trail, we draw a circle with slightly more opacity. This is what the fourth computation in the color constructor represents. 2.2 Player Movement 1. Declare the following class-scope constants: Color PLAYER_COLOR = new Color(40, 255, 27); Color CURSOR_COLOR = new Color(40, 128, 255); const int CURSOR_SIDES = 100; const int CURSOR_RADIUS = 10; const int MOVE_SPEED = 500; const float SHOOT_DELAY = 0.25f; const int PLAYER_RADIUS = 25; const int PLAYER_SIDES = 100; const int PLAYER_MOVEMENT_TRAIL = 25; Note: The Monogame Color datatype cannot be declared as constant. We use captialization conventions to communicate that this value should not be changed. 2. Declare the following class-scope variables: Vector2[] pos = new Vector2[PLAYER_MOVEMENT_TRAIL];
double lasttime = 0.0f; double lastshot = 0.0f; Vector2 cursorpos; 3. In the Initialize() method, set the first element of the pos array with the constructor call new Vector2(screenWidth / 2, screenheight / 2). Set the rest of the elements of the array using the constructor call new Vector2(-1, -1). 4. Declare the void method UpdatePlayer(GameTime gametime). 5. In the Update(GameTime gametime) method, add a call to the newly created UpdatePlayer method with gametime as the argument. 6. Place the following code in the UpdatePlayer(GameTime gametime) method: float deltatime = (float)gametime.elapsedgametime.totalseconds; KeyboardState keystate = Keyboard.GetState(); cursorpos = new Vector2(Mouse.GetState().Position.X, Mouse.GetState().Position.Y); if(gametime.totalgametime.totalseconds - lasttime > MOVEMENT_UPDATE) lasttime = gametime.totalgametime.totalseconds; UpdateMovementTrail(ref pos, PLAYER_MOVEMENT_TRAIL); Here we update the movement trail after a set interval. if(keystate.iskeydown(keys.a)) pos[0].x -= MOVE_SPEED * deltatime; if (keystate.iskeydown(keys.d)) These conditionals check to see if the movement key is down. If so, update the player's position accordingly. pos[0].x += MOVE_SPEED * deltatime; if (keystate.iskeydown(keys.s)) pos[0].y += MOVE_SPEED * deltatime;
if (keystate.iskeydown(keys.w)) pos[0].y -= MOVE_SPEED * deltatime; if(pos[0].x < PLAYER_RADIUS) pos[0].x = PLAYER_RADIUS; Monogame's coordinate system starts with (0,0) in the upper left corner. X increases to the right and Y towards the bottom. Thus if we want the player to move up, the Y coordinate needs to shrink. else if(pos[0].x > screenwidth - PLAYER_RADIUS) pos[0].x = screenwidth - PLAYER_RADIUS; if(pos[0].y < PLAYER_RADIUS) These conditionals make sure that the player cannot exit the screen. pos[0].y = PLAYER_RADIUS; else if(pos[0].y > screenheight - PLAYER_RADIUS) pos[0].y = screenheight - PLAYER_RADIUS; 7. Place the following code in the Draw(GameTime gametime) method after the call to the clear method: spritebatch.begin(spritesortmode.deferred, BlendState.Additive); Primitives2D.DrawArc(spriteBatch, cursorpos, CURSOR_RADIUS, CURSOR_SIDES, 0, MathHelper.TwoPi, CURSOR_COLOR); Primitives2D.DrawArc(spriteBatch, cursorpos, 0.5f * CURSOR_RADIUS, CURSOR_SIDES, 0, MathHelper.TwoPi, new Color(0.5f * (float)cursor_color.r / 255, 0.5f * (float)cursor_color.g / 255, 0.5f * (float)cursor_color.b / 255, CURSOR_COLOR.A)); DrawMovementTrail(spriteBatch, pos, PLAYER_MOVEMENT_TRAIL, PLAYER_RADIUS, PLAYER_SIDES, PLAYER_COLOR); All drawing calls in spritebatch.end(); Monogame need to be between calls to Begin and End
If you compile and run the program now, you should see a green circle and two blue circles on the screen. The blue circles should move with your mouse, and the green circle should move in response to the WASD keys. 3. Shooting 1. Declare the following class-scope constants: Color BULLET_COLOR = new Color(255, 40, 200); const float BULLET_SPEED = 1500; const int BULLET_MOVEMENT_TRAIL = 5; const int BULLET_RADIUS = 12; const int BULLET_SIDES = 20; 2. Declare a Bullet class. Give it the following fields and constructor: public Vector2[] positions = new Vector2[BULLET_MOVEMENT_TRAIL]; public Vector2 direction; public double lasttime = 0.0f; public bool remove = false; public Bullet(Vector2 pos, Vector2 dir) direction = dir; We will not draw elements of the movement trail outside of the bounds of the window, so setting the initial value outside ensures nothing is drawn until necessary. positions[0] = pos; for (int i = 1; i < BULLET_MOVEMENT_TRAIL; i++) positions[i] = new Vector2(-1, -1);
3. Declare the following class-scope variable: List<Bullet> bullets = new List<Bullet>(); Note: List<Type> is a C# template found in the System.Collections.Generic namespace. It behaves like a sophisticated linked list. 4. Declare the void method UpdateBullets(GameTime gametime). 5. Inside the new method, compute deltatime as done in the UpdatePlayer(GameTime gametime) method. 6. Create a foreach loop that iterates over the bullets list. Inside the for loop, use an if statement and the lasttime property of the Bullet class to update the object's movement trail as done in the UpdatePlayer(GameTime gametime) method. Add the following line of code outside the if statement but inside the foreach loop: bullet.positions[0] += BULLET_SPEED * deltatime * bullet.direction; This moves the bullet in the Stored direction at the constant Speed defined earlier. The deltatime Multiplier is used for frame rate Independent movement. 7. Add a call to the UpdateBullets(GameTime gametime) method beneath the call to UpdatePlayer(gameTime) in the Update(GameTime gametime) method. 8. Add the following code to the bottom of the UpdatePlayer(GameTime gametime) method: if (Mouse.GetState().LeftButton == ButtonState.Pressed) if (gametime.totalgametime.totalseconds - lastshot > SHOOT_DELAY) lastshot = gametime.totalgametime.totalseconds;
Vector2 dir = cursorpos - pos[0]; dir.normalize(); bullets.add(new Bullet(pos[0], dir)); 9. Finally, add the following lines of code to the Draw(GameTime gametime) beneath the code used to render the player and above the call to spritebatch.end(). foreach(bullet bullet in bullets) DrawMovementTrail(spriteBatch, bullet.positions, BULLET_MOVEMENT_TRAIL, BULLET_RADIUS, BULLET_SIDES, BULLET_COLOR); If you compile and run the program now, try shooting by clicking the left mouse button. A pink circle should move from the player (or green circle) and travel towards the cursor (blue circle.) 1. Declare the following class-scope constants: 4. Enemies and Collisions Color ENEMY_COLOR = new Color(128, 10, 0); const float ENEMY_SPEED = 250; const int ENEMY_MOVEMENT_TRAIL = 7; const int ENEMY_RADIUS = 20;
const int ENEMY_SIDES = 25; const double SPAWN_DELAY = 1.5; const double POINT_MULTIPLIER = 0.25; 2. Declare an Enemy class. It should be almost identical to the Bullet class, the only difference being that positions uses the ENEMY_MOVEMENT_TRAIL constant instead of BULLET_MOVEMENT_TRAIL and the direction field is computed instead of passed in in the constructor: direction = new Vector2(screenWidth / 2 - pos.x, screenheight / 2 - pos.y); direction.normalize(); 3. Create a list (using the above template) of Enemy objects. Declare a class-scope integer variable points, a class-scope double variable lastspawn and set both to 0. 4. Declare the float method PointMultiplier(), which should contain the single line return 1 + ((float)point_multiplier * points);. 5. Declare the void method Spawn() and implement it as follows: Random rand = new Random(); int border = (rand.next() % 4); float x = ENEMY_RADIUS; float y = ENEMY_RADIUS; Randomly pick a border to spawn along. if(border == 0 border == 2) if(border == 2) y = screenheight - ENEMY_RADIUS; Place the new enemy somewhere random along the length of the selected border. x = ENEMY_RADIUS + (rand.next() % (screenwidth - 2 * ENEMY_RADIUS)); else if(border == 3) x = screenwidth - ENEMY_RADIUS;
y = ENEMY_RADIUS + (rand.next() % (screenheight - 2 * ENEMY_RADIUS)); enemies.add(new Enemy(new Vector2(x, y))); 6. Declare the void method UpdateEnemies(GameTime gametime) and implement it as follows: float deltatime = (float)gametime.elapsedgametime.totalseconds; if(gametime.totalgametime.totalseconds - lastspawn > SPAWN_DELAY / PointMultiplier()) lastspawn = gametime.totalgametime.totalseconds; Spawn(); foreach(enemy enemy in enemies) The point multiplier is used to speed the game up as the player scores more points. if(gametime.totalgametime.totalseconds - enemy.lasttime > MOVEMENT_UPDATE) enemy.lasttime = gametime.totalgametime.totalseconds; UpdateMovementTrail(ref enemy.positions, ENEMY_MOVEMENT_TRAIL); enemy.positions[0] += PointMultiplier() * ENEMY_SPEED * deltatime * enemy.direction; 7. Add a call to the UpdateEnemies(GameTime gametime) method beneath the call to UpdateBullets(gameTime) in the Update(GameTime gametime) method. 8. Add the following code to the top of the Update(GameTime gametime) method: foreach (Enemy enemy in enemies) if ((enemy.positions[0] - pos[0]).lengthsquared() < (PLAYER_RADIUS * PLAYER_RADIUS) + (ENEMY_RADIUS * ENEMY_RADIUS) + 2 * PLAYER_RADIUS * ENEMY_RADIUS) Exit(); If the player intersects an enemy, it is game over. foreach(bullet bullet in bullets)
if ((enemy.positions[0] - bullet.positions[0]).lengthsquared() < (BULLET_RADIUS * BULLET_RADIUS) + (ENEMY_RADIUS * ENEMY_RADIUS) + 2 * BULLET_RADIUS * ENEMY_RADIUS) points += 1; bullet.remove = true; enemy.remove = true; for(int i = bullets.count - 1; i > -1; i--) if(bullets[i].remove) bullets.removeat(i); Remove all bullets and enemies that have collided. for(int i = enemies.count - 1; i > -1; i--) if(enemies[i].remove) enemies.removeat(i); 9. Add code to draw the enemies on-screen between the render code for the bullets and player. It should mirror the draw code for bullets using corresponding Enemy fields and constants. That's it! You should now compile and run, and you should see red circles coming from the outside edges of the screen, passing through the middle, and leaving the other side. Red circles that come into contact with the green one will end the game, and shooting them will make them spawn more often and move more quickly.
To recap, we installed and prepared a fresh Monogame project. We customized the window size and background color. We used the keyboard and mouse to manipulate on screen objects, making a player character that could move about the world. We added the ability to shoot while clicking the left mouse button. Finally, we added primitive enemies that fly from the edge of the screen towards the player. Think about extending this example. Try first changing the constants to make the game faster and slower paced. Think about making the enemies fly in a semi-random direction once they spawn instead of straight at the center. Can you think of a way to remove bullets and enemies that are offscreen but have not collided with anything?