2-Player Online 3D Doom (Difficulty: 6)
-
Overview Podcast
Here is an interesting podcast of 2 AI hosts discussing this tutorial to unpack some of the most challenging concepts:
Introduction
In this tutorial, you will learn how to build a multiplayer shooter game in 3D similar to the classic game “Doom”. Two players (on two separate computers) can play this game against each other.
Since this project is fairly complicated, the completed project is shared below:play.creaticode.com/projects/66e823b5a634f8ee7d92cc88
This tutorial will go through all the key components of this project step by step so that you can understand it thoroughly.
Note that our multiplayer game server only knows about 2D game worlds, but we can use it to support our 3D game, so long as all the objects are on the same ground level. A simple way to think about it is this: if we look down from above the 3D world, then the game world looks like 2D, and a 3D box in the 3D world will look like a 2D square when we look at it from above.
1 - Overview of Sprites
This project contains six sprites:
- Start: This sprite contains code to display the game’s starting screen so the host player can create new games for the guest player to join. It also initializes the 3D world.
- Player: This sprite allows the players (users) to control the two avatars in the 3D world, including handling key events and managing the health/firepower of the players. This is the most complex sprite in this project.
- Bullet: This sprite represents the bullets fired by the players.
- Wall: This sprite is used to create the walls in the 3D world. Players or bullets can not pass through these walls.
- Powerup: This sprite is used to generate new powerup items for “health” or “firepower”. They will appear randomly in the game world periodically, and they will disappear after a while if not collected by any player.
- Winner: This sprite displays the ending screen of the game when either player wins.
2 - “Start” Sprite: Initial Screen
When the user clicks the green flag, we will show an initial screen, which contains a label, an input textbox, and two buttons:
Here is the code for creating this screen in the “Start” sprite:
Note that each game can only be played between 2 users (host and guest), and it is uniquely identified by its name. The default name is “3D Shooter”. If a name is taken, then the host needs to pick a different game name when they create the game, and the guest needs to use the same name to join that game.
3 - “Start” Sprite: Create New Game
After the host user specifies the game name, he/she will click the “Create New Game” button. We will read the game name, and set the ID of this user to “A”, which represents the host player. We will show an error message if the game name is empty, otherwise we will try to create the game.
4 - “Start” Sprite: Create Game
To create a game, we do the following:
- Initialize the 3D scene on this computer;
- Ask the game server to create a new game with the given game name.
- To keep it simple, we will use the default password of “123” and assume the host user is “Player A”.
- The game capacity is 2, which means it will not accept any join request after one host and one guest are in it.
- The game world is set to be 2000 by 2000. Note that this is a 2D game world, so it only has width and height. We assume every object is at the ground level, so we don’t need to specify the world size in the Z dimension.
- If the game world is created successfully, this computer will be connected to the game. Then we can add the host player to the game (will be discussed later), and then wait for the other player (the guest) to join.
- If we are not connected to the game after a short wait, it means we have failed, and the most likely reason is the game name is already taken, or the game server is too busy. Either way, we can ask the user to change the game name and try again.
5 - “Start” Sprite: Initialize 3D Scene
To set up the 3D world, we create a simple “Grass Land” scene, then add a “rectangle cube” with no cap on top, so it becomes the walls on the 4 borders of the world. The size of the world is 2000 by 2000. We set the height of the border wall to 100, so that half of that (50) is above the ground. The avatars will be 100 tall, so the wall will not block our camera that follows the avatar. After the world is created, we also add the health bar and firepower bar on top using the “add bars” block.
6 - “Start” Sprite: Add Health and Firepower Bars
Each player starts with 3 lives, so he/she will lose when hit by bullets 3 times, but can gain lives by collecting health rewards. Each player starts with firepower of 1, and can increase it up to 5 by collecting firepower rewards. The firepower controls the minimum interval between every 2 bullets fired: interval = 2 seconds / firepower. So when firepower is 1, the player can fire every 2 seconds and no sonner; but when firepower is 5, the player can fire every 0.4 seconds.
The current health and firepower values for both players are displayed on top of the stage like this:
The code to add the player name and the health bars is the following. The health bar is represented by 3 square labels in green, and their names start with “health”, then followed by the player’s ID “A” or “B”, and lastly end with a sequence number of 1/2/3.
The code to add the firepower labels is very similar:
7 - “Start” Sprite: Wait for the guest player to join
After the host player creates the game, he/she needs to wait until the guest player has successfully joined the game. We can repeatedly fetch the player information into a table, then check if the number of players is 2. If that’s true, then we show a “Start Game” button, which allows the host player to start the game.
8 - “Start” Sprite: Join Existing Game
Now, let’s look at the guest player side. The guest player needs to input the same game name as the host player, then click the “Join Existing Game” button.
When that button is clicked, we do the following:
- Save the local player to be “Player B”, which represents the guest player.
- Make sure the game name specified by user is not empty
- Join the game
9 - “Start” Sprite: Join the game
To join the game, we will first create the 3D scene at the guest player’s computer, then send a request to the game server to join the game with the given name. This player will be the “Player B”, which represents the guest player.
After waiting some time, we check if we are connected to the game. If so, we create the gust player (to be discussed later); otherwise, we show an error message.
10 - “Start” Sprite: Start the Game
After the guest player joins the game, the “Start Game” button will appear on the host computer. When the host user clicks it, we will send a new message “start game”:
Note that this message is sent to both computers, but only “Original” sprites can receive it. For example, the Player A sprite on the host computer will receive it, but the clone of it on the guest computer will not. This way, the “start game” message is only received and handled for one time on each computer.
11 - “Player” Sprite: Add Players
Now, let’s switch to the “Player” sprite. This sprite represents the player’s avatar in the game world.
To add the host or guest player, we use simply create a clone of the “Player” sprite with different clone IDs: “A” for the host and “B” for the guest. This way, we reuse most of the code to add a player.
When the clone is created, we do the following:
- We initiate a variable “last fire time” to keep track of the last time a fire is shot, which helps us determine when the player can take another shot
- We use two variables, “health” and “firepower”, to keep track of the number of lives and firepower level of each player, which start at 3 and 1. Note that these variables are “private” to each clone of the Player sprite, so the two players can have their own “health” and “firepower” values.
- We will place the two players at two opposite sides of the game world: x of 0, and y of 800 or -800. Note that we use their clone ID to tell whether this is the host or the guest player.
- We add the player to the game, which means we register this player at the game server. The player is “Dynamic”, which means its movement will be tracked and replicated across the two computers.
- We use a “Circle” shape to represent each player. Although each player is a 3D avatar in the 3D world, in the eyes of our game server, they are both represented by a 2D circle with a diameter of 30.
- We will stop the player avatar from going through the Wall objects
- We will allow the player to collect powerup items, which will trigger a message “collect powerup”.
Behind the Scenes
When we register a player to the game server, what happens behind the scenes? The game server simulates what happens in the game world as we play it. At the beginning of the game, when we register a new player, the game server adds a new circle shape to its simulated game world.
In addition, a copy of that player is created on the other user’s computer. For example, when the host user creates a game, his/her code will create the host player “A”, then a clone of player “A” will be created in the guest user’s computer. Similarly, when the guest user joins the game and creates player “B”, it will be replicated in host user’s computer. As a result, on each computer, there are two clones of the “Player” sprite running.
12 - “Player” Sprite: When a player is added to the game
After we run the “add this sprite to game” block, if it is successful, another block will be triggered: “when added to game”. This is an important “hidden” event, because it gives us a chance to fully initialize the player.
This block is called two times on each computer. On the host user’s computer, after the host player is created, this block is triggered for this player (Player A). Later, when the guest user joins the game, a clone of player B is created on the host user’s computer, and this block is triggered again.
When this block is triggered, we do the following:
- Create a 3D avatar to represent this player. Currently it is Superman for the host and Batman for the guest. The avatars will be moved to the initial positions we have specified earlier: x of 0, and y of 800 or -800.
- Although this block is triggered two times on each computer, we will only add a “follow camera” once, only for the avatar that represents the local player. For example, for the host user, his player ID is “A”, so we will only add the follow camera if the clone ID is also “A”. Note that the follow camera’s direction lock is “Free”, which means the user can turn the camera to point at any direction using the mouse. If you are debugging the game, you can skipping adding the follow camera, so you can use the default camera to look at the entire game world easily.
- Lastly, we will add the “Run” animation to the 3D avatar so it knows how to play that animation later.
13 - “Player” Sprite: Handle Game Start
As discussed above, the “original” player sprite on each computer will receive the “start game” message. Here is what we do when we receive that message:
- Remove the “Start Game” button
- If this is the host player (player “A”), then we also need to trigger 2 events: add wall and add powerup. The wall and powerup objects will be replicated on the guest computer, so we don’t need to create them separately on the guest computer. Also, the “add powerup” message is handled by a forever loop that adds a new powerup periodically, so we are not using “broadcast and wait” for that message.
- We set the current animation of the avatar to “Idle”, so when it changes later, we need to tell the other computer.
- Lastly, we start the “handle keys” block, which is a forever loop that handles key inputs. Note that only at this step can the user start to play the game with the keyboard.
14 - “Player” Sprite: Handle Keys
This is the main loop of the game logic, where we continuously check the keys the user is pressing, and update the player avatars accordingly. Specifically, we do the following inside a forever loop:
- Check if we have disconnected from the game server, and show an error message if so. The player gets automatically disconnected from the server if no updates are received from any player in the last 5 minutes. This will help the game server reclaim the resources allocated to a game in case the players have left the game.
- If we are still in the game, we check the SPACE key and fire a shot if enough time has passed.
- We also check the movement keys to determine the moving speed, direction and animation.
- Lastly, we wait a very short time before entering the loop again. This helps reduce the number of messages we send out on the network and avoid too much “network traffic” that will slow down the game server.
15 - “Player” Sprite: Handle Firing Shot
When we detect the SPACE key is pressed down, we check if the current time is more than the last time we fired a shot plus a waiting period. This waiting period is calculated as (2 / firepower). For example, if firepower is 2, then the minimum wait time is 2/2 = 1 seconds.
If we are allowed to fire a shot, we send the “fire” message, which will be handled by the “Bullet” sprite (discussed later). In this message, we need to specify the clone ID of this sprite (“A” or “B”), the x/y positions and the facing direction of this player, so that the Bullet sprite can create a bullet at the same position and direction.
16 - “Player” Sprite: Get Intended Speed and Animation
To create a fun experience that gives the users full control of the avatar’s movement, we will allow the user to use both the mouse and the keyboard at the same time. The mouse will be used to control the camera, and all movements will be relative to the direction of the camera. For example, if we drag the camera to look to the right, the avatar will immediately turn to face the same direction. So when we press “W” to move forward, the avatar will move in the same direction as the camera:
To achieve this effect and keep our code short and clean, we will divide it into 3 cases:
- When the “a” key is pressed, it means the user wants to move left.
- When the “d” key is pressed, it means the user wants to move right
- When neither the “a” or “d” key is pressed, the user wants to move forward/backward or stay still.
The default assumption is that the intended speed is 300 and the intended animation is “Run”, and we can change them as we check the keys.
17 - “Player” Sprite: Handle Move Left or Right
When only the “a” key is pressed, the user wants to move left horizontally. If the “w” or “s” key is pressed at the same time, then the user intends to move diagonally to the left front or left back. Note that we are just setting the “intended dir” and “intended speed” for now, then we will use them to update the avatar later.
Also, all directions are relative to the camera’s “H-Angle”, which is the direction the follow camera is facing. In addition, when the user presses “a” and “s” at the same time, we set the direction to be forward right, and set the speed to negative.
For the “d” key, the logic is exactly the same, though the angles are reversed:
18 - “Player” Sprite: Handle Move Forward or Backward
If neither the “a” or “d” key is pressed, the avatar will run forward under the “w” key, run backward under the “s” key, and stay still when neither key is pressed.
19 - “Player” Sprite: Update player speed/animation/direction
By this point, we have calculated the intended moving speed, moving direction and animation, we need to update it for 3 entities: the player’s avatar on his/her own computer, its 2D representation at the game server, and its copy on the other user’s computer.
This is done in 3 steps:
-
We first compare the local camera’s facing direction and the avatar’s facing direction. If they are not enough (at least different by 0.1 degrees), then we need to update the avatar’s facing direction. For example, this might happen when the user rotate the camera to a different direction. We are using the “synchronously set direction” block, which not only update the direction of this player’s avatar on his/her own computer, but also update the direction on the game server and the other user’s computer.
-
If the intended moving speed or direction has changed, we will use the “synchronously set speed and direction” block to update these values for all 3 representations as well. Note that the “intended dir” is where the avatar is moving, and it may be different from the camera’s direction. For example, when the user presses “a”, the camera may still facing forward, but the intended moving direction is to the left.
-
Lastly, if the intended animation has changed, we need to update the animation for this player’s avatar on this computer and on the other user’s computer. This can be done by broadcasting a message “update animation” to all sprites, and attaching the clone ID and intended animation with that message. We will discuss how that message is handled next.
20 - “Player” Sprite: Handle the “update animation” message
When any sprite receives this message, we will split the attached information and extract the clone ID. If that ID matches the ID of this clone, then we update this sprite’s animation. Recall from #19 that the “animation_info” attached to the message has two parts joined by “_”: “clone ID” and “intended animation”, such as “A_Run”.
21 - “Wall” Sprite: Add 4 Clones
The “Wall” sprite is fairly simple. You can modify it to design any map inside the borders. As an example, we can have four walls on the four sides of the world like this:
To keep the code simple, all the wall objects are the same shape, except that two of them are rotated 90 degrees. They are all clones of the “Wall” sprite with clone IDs of 1, 2, 3 and 4:
When each clone is created, we need to place it at the desired position and direction based on its clone ID. Then, we need to add it to the game so that the game server can create a rectangle of 400 by 50 to represent it in the 2D simulated world at the server. The same clone will also be created on the guest user’s computer.
22 - “Wall” Sprite: Create the 3D Wall Object
When we add the Wall sprite to the game, it has not yet been added to the 3D scene. As discussed earlier, after a sprite is added, the game server will trigger a new event “when added to game”, and this is where we should add the 3D box that represents the wall in the 3D scene. The reason for this is that this event is triggered on both the host computer and the guest computer, which ensures both players can see the walls in their 3D scenes.
The x and y sizes of the 3D box should match the rectangle shape we specified earlier: 400 by 50. The height of the wall does not matter, since the game server only thinks of the wall as a 2D rectangle. However, to make sure the player can not see above the wall, we should make the wall taller than the player’s height of 100. Therefore, we can set z size to 300, so the top half is 150. Depending on the clone ID, we place each box at the desired position and direction.
That is all that we need to do for the walls. We already specified that when a Player touches any Wall sprite, it will be stopped.You can also add 3D cylinders as walls, and you just need to make sure they are added as circle shapes like this:
23 - “Bullet” Sprite: Handle the “fire” message
Next, let’s look at the Bullet sprite. It is slightly more complicated than the Wall sprite. A bullet is created when the “fire” message is received. Note that the Wall is only added at the host computer, but the Bullet can be fired at both computers.
When the “fire” message is received, we first need to make sure only the original Bullet sprite handles it. We check the clone ID to make sure it is “originalsprite”. Otherwise, a clone of the Bullet sprite may receive the “fire” message as well, which may generate a new duplicate clone.
The “shot info” attached to the “fire” message contains four pieces of information about the Player that has fired the shot: Player ID (“A” or “B”), x position, y position and direction. We store this information in four variables, then move the sprite to the given position/direction. Then, we make a clone of this sprite. Each clone is given a unique ID of two parts: the player ID (“A” or “B”), and a bullet ID that keeps increasing by 1.
24 - “Bullet” Sprite: Add clone to game
When a clone of the Bullet sprite is created, we first need to add it to the game. It will be dynamic since it will move forward in a straight line. It will be added as a circle with a diameter of 40, so the game server will use a 2D circle to represent it in the simulated game world.
After that, we need to make the bullet only hit the opponent player. For example, if the clone ID starts with “A”, then we set the bullet to collide only with Player sprites that has a clone ID of “B”. And when that happens, the bullet will destroy itself, and also send out a new message “bullet hit player”. This message will be handled by the Player sprite to reduce its health (to be discussed below).
Lastly, when the bullet hits a wall or the edge of the world, it will simply destroy itself.
25 - “Bullet” Sprite: When added to game
When we get a notification from the game server that a new Bullet clone has been added to the game, we add the 3D sphere to represent the Bullet in the 3D scene. There are a few steps we need to take:
- We will play an explosion sound.
- Depending on the clone ID starts with “A” or “B”, we add a sphere of red or black color to the scene. Its diameter should be 40, which matches the size we used to represent it at the game server. Again, you can think about it this way: if we look down from the top, the sphere looks like a circle on the ground with a diameter of 40.
- We move the sphere to the starting position, which is the position of the Player that fired the shot.
- We set a speed of 600 for this bullet to move along the starting direction.
26 - “Player” Sprite: Handle “bullet hit player”
When a bullet from the opponent hits a player, that player will receive a “bullet hit player” message. Only the main Player sprite on the user’s own computer will receive this message, not its replicate on the other computer. For example, when Player A got hit, only the Player on the host computer will receive this message. This avoid handling the message two times.
If the Player’s health is still greater than 0, we will reduce that player’s health by 1. Then we broadcast a “update health” message, and attach the clone ID and health of that player.
27 - “Player” Sprite: Handle “update health”
When the Player receives the “update health” message, we will make sure only the original sprite will handle it, not the clones. We do the following steps:
- Extract the player ID and the updated health value
- Use a repeat loop to go through the 3 rectangle labels that represent the health of that player, and set them to either green or black based on the health value
- If the health is already 0, then we send a message of “game over”, with the parameter being the ID of the opponent player. Note that this is the only way to end the game.
28 - “Winner” Sprite: Handle “game over”
When the “game over” message is received, we do the following in the “Winner” sprite:
- Hide the 3D scene, since we will use a 2D costume to show the end screen;
- Play a sound of “Success 7”
- Switch to the costume that shows the name of the winner: A or B
- After 3 seconds, we stop the entire program.
29 - “Powerup” Sprite: Handle the “add powerup” message
When the Powerup sprite receives the “add powerup” message, it will first make sure only the original sprite with clone ID of “originalsprite” will handle this message. Note that this is only done on the host computer, and whenever a new powerup sprite is added, it will be replicated on the guest computer.
The Powerup items can be either for gaining one health point or increasing the firepower by 1. You can fine-tune the parameters that control the type and frequency of the new powerup items based on your preference. Obviously, if there are too many “health” powerup items, then the game may never end, since both players can recover from hits quickly.
Here is how Powerup works currently:
- We wait a random time period between 30 to 60 seconds before adding each new powerup. You can make this time longer to reduce the number of powerups available.
- We pick a random position within the world
- We pick a random type between “health” or “firepower” with 50% probability for each. You can change the probabilities to generate more “health” or more “firepower” rewards.
- We move the sprite to the given position, then create a clone.
- Each clone has a unique ID composed of 2 parts: the powerup type and a sequence number.
30 - “Powerup” Sprite: When a clone is created at the host
When a new clone is created, we add it to the game as a static circle (diameter of 40) at the game server. After a random timeout period between 10 to 20 seconds, we remove this item from the game. This way, if the two players do not try to collect a powerup item quickly, it will disappear soon, making the game more exciting. Of course, if you change this time out period longer, it will become easier to gain power for both players.
30 - “Powerup” Sprite: When added to game
Similar to other sprites, we add the 3D object representing the powerup items under the block “when added to game”. This block is triggered on both computers when the host computer adds a new powerup item to the game.
We do the following to add the powerup item when it is added to the game:
- We play a sound to notify the users a new item has been added;
- We store the position of the item in 2 variables “my x” and “my y”. We need this because when we run the “add model” block, it will reset the sprite’s x and y positions to 0.
- We add the model for a medical box or a thunder icon depending on the Powerup type. Note that it is added as hidden, because otherwise the model will show up at the origin point before we move it;
- We will set a z-rotation to make it spin continuously forever.
- We will move the model to the saved position;
- Lastly, we show the object.
31 - “Player” Sprite: Handle “collect powerup”
When the Player sprite touches a Powerup object, it will trigger the “collect powerup” message. Only the Player clone that is controlled by the user will receive this message. To handle it, we first play a sound to confirm the collection of a Powerup. If it is a “health” type, and this player’s health is still less than 3, we would increase the health by 1, and broadcast the “update health” message, which updates the health value display at the top. Similarly, if this player’s firepower is less than 5, we increase it by 1, and send out the “update firepower” message.
32 - “Player” Sprite: Handle “update firepower”
The way we handle “update firepower” is very similar to how we handle “update health”. We extract the update firepower value from the message’s parameter, then we go through all 5 labels for that player, and set each label to red or black based on the firepower value.
Summary
We have explained all the blocks in this project. Feel free to try to remix and modify the game. Here are some ideas:
-
Adjust game parameters: There are many simple changes you can make to the game, such as player avatars, 3D models/textures used, maximum health/firepower values, time interval before each new Powerup is generated and how long it lasts, movement speed of the players and bullets, keyboard controls of the player movements, size/count/positon of the walls, size of the world, etc. Feel free to change them based on your preference.
-
New Powerup types: You can extend the game by adding new Powerup types, such as speed-up for player or bullet, new types of bullets (double or triple bullets), powerup to allow players to run through walls or see through the walls, etc.
-
2 vs 2 games: You can try to allow four users to play together. You will need to increase the game capacity when you create the game, and add new player IDs like “C” and “D”, etc.
-
AI Players: You can create an AI bot to control one player, so that one user can play against the AI instead of looking for another player, or you can have 2 human players team up to play against 2 AI players.
-