By Ken Burchfiel
Tutorial and code released under the MIT license. (See License.md file for more details)
Note: Both the code and the documentation were created without the use of generative-AI tools.
This guide demonstrates how to create a 3D multiplayer game in Godot using C++ and GDExtension. It has a number of advantages for newcomers to GDExtension:
-
It explains code and editor tasks step-by-step, thus making it easier to learn how to put a game together.
-
It also provides references for various code and editor tasks, thus (hopefully) demystifying the process of finding relevant C++ code for your own projects.
-
It notes potential pitfalls you might encounter along the way, along with tips for resolving them.
The tutorial is based on my Cube Combat demo. I created it for Godot 4.6, but it will (hopefully) still work well for subsequent editions of Godot.
This project is dedicated to my wife, Allie. I am very grateful for her patience as I put this code and documentation together!
Disclaimer: I am still quite new to GDExtension, and my C++ skills are still developing as well--so this project and its corresponding source code likely have lots of room for improvement. However, they should still help both new and experienced developers become acquainted with C++ development in Godot. After all, an imperfect example is (generally) better than no example at all!
(A look at the finished game)
(Note: Many of these steps will resemble those in the excellent 'Getting started' section of the official GDExtension documentation at https://docs.godotengine.org/en/4.6/tutorials/scripting/cpp/gdextension_cpp_example.html . Certain code blocks within this section derive from that document as well.)
-
Create a new folder that will store your Godot project and its corresponding code. I'll call mine
godot_cpp_3d_tutorial, but the name you choose is of course up to you. -
First, you'll want to download the latest stable version of godot-cpp from https://github.com/godotengine/godot-cpp . (As of 2026-04-17, a stable version of version 10.x hasn't yet been released, so I went ahead and downloaded the beta version with a commit ID of 4862a9d (https://github.com/godotengine/godot-cpp/tree/4862a9dcf1471c9ea19680b9faadb5b6a9432092 .) Whether you download and unzip or simply clone it, make sure that exists within your project folder within a folder named 'godot-cpp'.
-
Next, open up this godot-cpp folder within your terminal and run
scons platform=linux(replacinglinuxwith your own OS if needed). This will compile all of the source code needed to apply this library. (It will also generate additional code files that aren't visible in an uncompiled version of the repository, such as the one on GitHub). -
Go ahead and create a 'src' folder within your root project folder. This folder will store your source C++ code and its compiled variants.
-
Create a 'project' folder within this root folder also. Next, open up Godot, which you can download from https://godotengine.org/ if you haven't already. (I'm using Godot 4.6 for this project, but this tutorial should be applicable for newer releases also for a decent while.) Within the loading screen, hit the Create button at the top left. I chose 'Cpp 3D Tutorial' as my project name and the 'project' folder I just created as my path. Once you've filled in these items, hit Create on the bottom right.
-
Close back out of the editor for now. Before we create a scene, we should first create a GDExtension class within C++ that can be used as the basis for that scene. (This is a different approach than what you might be used to with GDScript.)
-
Before we start writing our own C++ code, let's get a few crucial setup tasks out of the way. First, within your project folder, which will now have a number of Godot-generated items (including a project.godot) file, go ahead and create a new folder called 'bin.' Within this folder, create a new file called gdexample.gdextension, then paste the following material into it:
[configuration] entry_symbol = "example_library_init" compatibility_minimum = "4.1" reloadable = true [libraries] macos.debug = "./libgdexample.macos.template_debug.dylib" macos.release = "./libgdexample.macos.template_release.dylib" windows.debug.x86_32 = "./gdexample.windows.template_debug.x86_32.dll" windows.release.x86_32 = "./gdexample.windows.template_release.x86_32.dll" windows.debug.x86_64 = "./gdexample.windows.template_debug.x86_64.dll" windows.release.x86_64 = "./gdexample.windows.template_release.x86_64.dll" linux.debug.x86_64 = "./libgdexample.linux.template_debug.x86_64.so" linux.release.x86_64 = "./libgdexample.linux.template_release.x86_64.so" linux.debug.arm64 = "./libgdexample.linux.template_debug.arm64.so" linux.release.arm64 = "./libgdexample.linux.template_release.arm64.so" linux.debug.rv64 = "./libgdexample.linux.template_debug.rv64.so" linux.release.rv64 = "./libgdexample.linux.template_release.rv64.so"(This is an exact copy of the corresponding code within Godot's official GDExtension documentation (Reference 1)).
(I could have changed 'gdexample' and 'example_libary_init' to more meaningful entries, but for boilerplate like this, it's often safest to stick with the existing version.)
-
Next, within your src folder, create a new file called 'register_types.h.' Copy and paste the following code into that file:
#pragma once #include <godot_cpp/core/class_db.hpp> using namespace godot; void initialize_example_module(ModuleInitializationLevel p_level); void uninitialize_example_module(ModuleInitializationLevel p_level);(Source: Reference 1)
-
These two files won't need to be modified further. The same is not the case for the following code, which we'll update over time to include the classes that we'll create within this tutorial. Go ahead and paste it into a new 'register_types.cpp' file within your src folder:
#include "register_types.h" #include <gdextension_interface.h> #include <godot_cpp/core/defs.hpp> #include <godot_cpp/godot.hpp> using namespace godot; void initialize_example_module(ModuleInitializationLevel p_level) { if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) { return; } } void uninitialize_example_module(ModuleInitializationLevel p_level) { if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) { return; } } extern "C" { // Initialization. GDExtensionBool GDE_EXPORT example_library_init(GDExtensionInterfaceGetProcAddress p_get_proc_address, const GDExtensionClassLibraryPtr p_library, GDExtensionInitialization *r_initialization) { godot::GDExtensionBinding::InitObject init_obj(p_get_proc_address, p_library, r_initialization); init_obj.register_initializer(initialize_example_module); init_obj.register_terminator(uninitialize_example_module); init_obj.set_minimum_library_initialization_level(MODULE_INITIALIZATION_LEVEL_SCENE); return init_obj.init(); } }(Source: Reference 1, with a few lines removed so that we can add in our own versions later)
-
We'll also need to add a SConstruct file to our root folder in order to successfully compile our GDExtension classes. Visit https://docs.godotengine.org/en/4.6/_downloads/8df537a8866633825684515bce40faa6/SConstruct , which should prompt you to save an SConstruct file to your computer. Go ahead and save it into your root folder as 'SConstruct' (without any extension). (You can also access this download by visiting https://docs.godotengine.org/en/4.6/tutorials/scripting/cpp/gdextension_cpp_example.html , then searching for a link with the text "the SConstruct file we prepared.")
Here's what your root folder should look like at this point (excluding certain Godot-created project files):
godot_cpp_3d_tutorial/ [my root folder name; your name may vary] --godot-cpp/ [contains your compiled godot-cpp repository] --project/ ----bin/ ------gdexample.gdextension --src/ ----register_types.cpp/ ----register_types.h --SConstruct -
Although we haven't created any classes yet, this will be a good time to compile the code we've added in so far. Navigate to your root folder and then run
scons platform=[your_os](e.g.scons platform=linuxin my case). This is the same command you used to compile godot-cpp--just in your project folder rather than the godot-cpp folder. You should seescons: done building targets.appear in the terminal after all files have been compiled and linked.
Now that we've gotten those setup tasks out of the way, we can begin programming our own GDExtension classes. Let's start with the Main class, which will govern the game area and some fundamental gameplay logic.
-
Within your src folder, create a new file called 'main.h'. Copy the following code into this file:
#pragma once #include <godot_cpp/classes/node.hpp> #include <godot_cpp/variant/utility_functions.hpp> using namespace godot; class Main: public Node {GDCLASS(Main, Node) protected: static void _bind_methods(); public: Main(); ~Main(); void _ready(); };(Source: Reference 8)
Here, we're declaring a GDExtension class called 'Main' that will inherit from Node. We're also adding some fundamental functions (e.g. constructors, destructors, _bind_methods(), and _ready()). Lots more will be added to this file later on, but I wanted to focus on the items we'll need at this moment.
-
Next, create a file within src/ called 'main.cpp' and paste the following code into it:
#include "main.h" void Main::_bind_methods() {} Main::Main() {} Main::~Main() {} void Main::_ready() { UtilityFunctions::print("Main::_ready() just got called."); }(Source: Reference 8)
This is all pretty barebones so far, but we'll expand this file quite a bit later in the tutorial.
We don't need to add anything just yet to
_bind_methods(), but since that function is a core part of many GDExtension classes, I figured I would add it here. -
Go back to register_types.cpp. Directly under
#include "register_types.h", add:#include "main.h" -
Next, within the
initialize_example_module()function, add the following text right before the final closing bracket:GDREGISTER_RUNTIME_CLASS(Main);These two updates allow Godot to learn about our Main GDExtension class. We'll repeat these simple updates for all other classes that we define.
Note: I'm using GDREGISTER_RUNTIME_CLASS here rather than GDREGISTER_CLASS so that this code will only run when I'm actually running my project. With GDREGISTER_CLASS, code can also run (or at least attempt to run) in the editor, which can cause some irritating crashes. (For instance, suppose your code for a class references a Pivot object that you haven't yet created. If you then attempt to launch the editor after compiling this code, the editor will attempt to find this object, fail, and potentially crash. To avoid the need to comment out that code, add the object into your editor, and then recompile it, you can simply make that class a runtime class.)
(Sources: References 8, 9, and 10)
-
Go ahead and run
scons platform=[your_os]again. (If you haven't closed your terminal since the last time you ran this command, you may be able to access this line by pressing your Up Arrow key.) You should again seescons: done building targets.once the compilation process completes.
Now that we have a class (albeit a very simple one), we can create a scene based on it.
-
Reopen Godot and open your project. Next, within the 'Create Root Node:' menu, select 'Other Node' and enter 'Main' in the search bar. Hopefully, you will see your newly-created Main node within the list of available nodes:
If you don't see this node (a situation I've faced quite a few times), this likely means that you forgot one of the earlier steps (such as adding references to this node to your register_types.cpp file).
-
Select this node, then save your scene as main.tscn. Next, click the play button near the top right of the editor in order to get the debug message we defined within
Main::_ready()to display. This will bring up a scene-selection dialog box; you can hit 'Select Current', since we'll indeed want this to be our main scene. -
A gameplay window with a gray box will open. Nothing will appear inside it, which is to be expected. However, you should see
Main::_ready() just got called.appear within your Output tab in the lower half of the main editor window: -
While we're inside the editor, let's go ahead and create a game area. (Many of the following steps were based on the 'Setting up the game area' section of the Your First 3D Game Tutorial (Reference 3).) Right click on Main and select Add Child Node, then search for (and select) StaticBody3D. Rename it Ground within your scene tree on the left side of the editor. Next, add a CollisionShape3D and a MeshInstance3D as children of Ground.
-
Select the MeshInstance3D item within the scene tree if you haven't already. Click the 'empty' box to the right of Mesh in the Inspector and select a new BoxMesh. Next, click on CollisionShape3D within the scene tree; within the Shape row of the Inspector, choose a BoxShape3D.
-
Next, click on the Ground object within the scene tree, then click on Transform within the Inspector. Change the y entry within the Position (not Scale!) menu to -0.5 so that the top of the Ground, which is 1 meter thick, has a y position of 0 rather than 0.5.
-
Click the CollisionShape resource, then select the BoxShape. Within the Size menu that appears, change the x and z values to 60; keep the y value untouched at 1. Next, click on the MeshInstance, then click on the chain to the right of 'Scale' within the Transform section such that it appears broken. (This way, changes to one dimension won't affect the others.) As you did with the BoxShape, change the x and z scales to 60.0 and leave the y scale untouched at 1.0. Your Ground object should now look like a thin square.
-
Let's change the drab, white color of the Ground to something more interesting. Select your MeshInstance3D in the scene tree, then click the downwards-facing arrow to the right of the Mesh (and its corresponding gray cube) in the Inspector and select 'Edit.' Within the Material section, create a new StandardMaterial3D, then click the downwards arrow to the right of the white sphere that appears and select Edit. Click on the Albedo section, then select a color of your choice.
Here's what the Ground should look like at this point:
-
Before we can get to the 'action' part of this scene, we'll need lights and a camera. Add a DirectionalLight3D as a child of Main, then set its y transform to 20.0. (The x and z transforms can stay at 0.0.) Change its x rotation to -90, or whatever allows the light to point directly down at the ground. (You'll know it's working when you see the ground brighten up.) Check the Shadow box within the Light3D section of the Inspector as well.
-
Next, add a Marker3D as a child of Main and set its y and z transforms to 28.0 and -40.0, respectively. Finally, add a Camera3D as a child of the Marker3D. Set its x and y rotations (accessible within the Transform section of the Inspector) to -45 and 180, respectively. (You can scroll up to the top of the Inspector to get a preview of what the camera will display.)
-
Try running the scene again. You should now see your game area within the window that appears:
-
Now that we have a game scene in place, this will be a good time to begin work on our Mnchar (main character) class. There's plenty more C++ code that will get added to main.cpp and main.h, but those additions will be easier to implement and debug once we have actual characters and projectiles to manage.
Our Mnchar class, which players will be able to control via game controllers, will fire projectiles and (potentially) get hit by other projectiles. We'll eventually configure our game such that anywhere from 2-8 Mnchars can get added to the game scene at the start of each game; however, that configuration will involve a Hud class that we won't be setting up for a little while.
-
To begin the setup process, create both a 'mnchar.h' and a 'mnchar.cpp' file within your src folder. Enter the following code into mnchar.h:
#pragma once #include <godot_cpp/classes/character_body3d.hpp> #include <godot_cpp/variant/utility_functions.hpp> using namespace godot; class Mnchar : public CharacterBody3D { GDCLASS(Mnchar, CharacterBody3D) private: double movement_speed = 14; double rotation_speed = 0.15; protected: static void _bind_methods(); public: Mnchar(); ~Mnchar(); void set_movement_speed(const double movement_speed); double get_movement_speed() const; void set_rotation_speed(const double rotation_speed); double get_rotation_speed() const; };(Source: References 1, 2, and 4)
This code is very similar to that found within main.h. Two new additions of note are
movement_speedandrotation_speedalong with their corresponding setter and getter functions. We'll make it possible to update both of these values within the editor.Also note that, because Mnchar will extend CharacterBody3D, we need to include its header file within this source file. (I chose to use CharacterBody3D as my player's class because the Your First 3D Game tutorial uses this same class; see Reference 5. Code for the CharacterBody3D class itself can be found in References 6 and 7.
-
Next, within mnchar.cpp, enter the following:
#include "mnchar.h" #include <godot_cpp/core/class_db.hpp> using namespace godot; void Mnchar::_bind_methods() { ClassDB::bind_method(D_METHOD("get_movement_speed"), &Mnchar::get_movement_speed); ClassDB::bind_method(D_METHOD("set_movement_speed", "p_movement_speed"), &Mnchar::set_movement_speed); ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "movement_speed"), "set_movement_speed", "get_movement_speed"); ClassDB::bind_method(D_METHOD("get_rotation_speed"), &Mnchar::get_rotation_speed); ClassDB::bind_method(D_METHOD("set_rotation_speed", "p_rotation_speed"), &Mnchar::set_rotation_speed); ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "rotation_speed"), "set_rotation_speed", "get_rotation_speed"); } Mnchar::Mnchar() {} Mnchar::~Mnchar() {} void Mnchar::set_movement_speed(const double p_movement_speed) { movement_speed = p_movement_speed; } double Mnchar::get_movement_speed() const { return movement_speed; } void Mnchar::set_rotation_speed(const double p_rotation_speed) { rotation_speed = p_rotation_speed; } double Mnchar::get_rotation_speed() const { return rotation_speed; }(Source: References 1 and 2)
This code, like that in main.h, is quite barebones; our priority is simply to add in a class that the Godot editor will recognize. Once we create and configure a Mnchar scene within the editor, we'll then come back and extend this code.
However, I did also add in code that will allow us to modify the movement and rotation speeds within the editor. This isn't a crucial part of this particular game, but it will allow you to test out different movement and rotation values without having to recompile the code--not that that's a huge hurdle.
The speed-adjustment code consists of two functions (
set_movement_speed()andget_movement_speed()) that let us specify and retrieve, respectively, the player's speed. Both of these functions (including the former'sp_movement_speedargument) have corresponding bind_method() calls within_bind_methods(). In addition, we have an ADD_PROPERTY() call that tells the editor about themovement_speedvariable along with its setter and getter functions. (These are all based on theamplitudeproperty within the GDExample class in Reference 1.)The rotation-adjustment code is very similar. I simply copied and pasted my relevant movement-adjustment code, then replaced all cases of 'movement' with 'rotation.'
(It is not necessary to add in
bind_method()andADD_PROPERTY()calls like this for every attribute of a class. However, they're very important for certain items, such as signals--which we'll get to later.) -
This new code should compile at this point; however, if we tried to do so, we most likely wouldn't be able to locate a Mnchar class within our editor. That's because we also need to update register_types.cpp with information about this class. Fortunately, this is easy to do so. Right under
#include "main.h", add#include "mnchar.h". Next, right underGDREGISTER_RUNTIME_CLASS(Main);, addGDREGISTER_RUNTIME_CLASS(Mnchar);. -
Now run
scons platform=[your_os]to compile this new Mnchar-related code. In my case, the editor still didn't show the Mnchar class within the 'Create New Node' menu after this step, but it did present it once I closed and relaunched my editor. Thus, I'd recommend that you do the same at this point.
-
Back in the editor, create a new scene. Click the 'Other Node' button under the 'Create Root Node:' prompt; search for 'Mnchar'; then double-click it. Next, save this scene as mnchar.tscn.
You should see the custom Movement Speed property that we configured near the top of the Inspector menu, along with our default value (14). Changing this value in the editor will also change the Mnchar's behavior within our game.
(I have found, however, that this property will sometimes disappear from the editor after compiling my code. This might be caused by an issue with my current setup, but it might also be a glitch within the editor itself. Closing, then relaunching the editor always seems to resolve this issue, thankfully.)
-
Add a Node3D as a child of Mnchar and rename it 'Pivot'. In many cases, we'll apply movement and rotation actions to this node rather than to Mnchar itself. Next, add a MeshInstance as a child of Pivot; name it 'Body'; click the 'empty' text within its Mesh section in the Inspector; and select a BoxMesh. Next, click the downwards-pointing arrow to the right of the gray cube in the Inspector and select Edit. (Reference 5)
-
Within the Size section of the edit menu, change the x, y, and z values from 1.0 to 2.0. Next, go down to the Material section and select a new StandardMaterial 3D. Unlike with the game area, we won't choose a color for this material just yet--as we'll actually use C++ to update this color instead. (This will make it easier to assign different colors to different Mnchar instances.)
-
We'll also want to create a turret for our player. Create a new MeshInstance3D child of Pivot; name it 'Turret'; assign it a new BoxMesh; go into that mesh's edit menu; change the x, y, and z sizes to 0.5, 0.5, and 0.5; and give it a new StandardMaterial3D.
-
Next, navigate back to the Turret's main Inspector menu. (You can do so a few different ways; one of which is to select the Body, then the Turret again.) Within the Node3D section, set the z transform to 1.25 meters. That way, the turret will be adjacent to the forward face of the Pivot.
-
Add a CollisionShape3D as a child of Mnchar (not Pivot). Click on the 'empty' text within the Shape section of the Inspector; add in a BoxShape3D; click the downwards arrow to the right of 'BoxShape3D'; and select 'Edit.' Change the x, y, and z Size values to 2.0 in order to make it the exact same size as the Body component.
(You could also update these Size values, along with the transform of the CollisionShape, to allow it to fit over both the Body and Turret objects; alternatively, you could create a separate CollisionShape3D for the Turret. That would prevent the Turret from being able to enter into other objects. But this simpler approach will suffice for this tutorial.) (Reference 5)
Once you're finished with these updates, go ahead and save the scene.
-
In order to move the Mnchar with a controller (or, for development purposes, a keyboard), we'll need to add input actions to our game's Input Map. Navigate to this map by clicking Project in the top left of the Godot editor window, then selecting Project Settings; the Input Map should be the second tab from the left. (Reference 5)
-
For now, we'll just add in keyboard entries; once we're further in the development process, we'll add in controller entries also. Click on the 'Add New Action' text within the Input Map menu; type move_left_0; and hit the '+ Add' button to the right of this window. Next, click the + sign to the right of the new 'move_left_0' entry that has appeared; and hit your J key (or, if you're using a different layout like I am, where the J key would be on a QWERTY keyboard).
(You're welcome to use a key other than J, such as Left Arrow, if you'd like. The use of 'J' will make more sense in the context of all the keys we'll be adding in.)
-
Perform the same steps for the following action names and keys:
- move_right_0 (L key)
- move_forward_0 (I key)
- move_back_0 (K key)
- rotate_left_0 (S key)
- rotate_right_0 (F key)
- fire_0 (Space Bar)
- reset_0 (O key)
-
Once you've finished this process, your input map should look like the following:
By the way, the reason for adding '_0' to the end of these actions is to allow different sets of controls to be distinguished for different players later on.
Close out of the input map and save your mnchar.tscn file.
We're almost ready to add in code that will let us move our Mnchar around the game area. First, though, we need to add the Mnchar to the game area.
One option would be to instantiate a Mnchar as a child scene of main.tscn (Reference 4). However, since we're creating a multiplayer game whose player count might change from round to round, it will be more ideal to add Mnchars to this scene via code. (That way, a specific number of Mnchars can be placed within the game area depending on how many players choose to enter a given game.) The following steps will allow us to add a Mnchar to the scene using C++.
-
Within main.h, add the following code right above
protected:private: Ref<PackedScene> mnchar_scene;Next, add the following right below
~Main();Ref<PackedScene> get_mnchar_scene() const; void set_mnchar_scene(Ref<PackedScene>);(Reference 8)
Finally, after
#include <godot_cpp/variant/utility_functions.hpp>, add:#include <godot_cpp/classes/packed_scene.hpp> #include "mnchar.h" -
Next, within main.cpp, add the following code within
Main::_bind_methods():ClassDB::bind_method(D_METHOD("get_mnchar_scene"), &Main::get_mnchar_scene); ClassDB::bind_method(D_METHOD("set_mnchar_scene", "mnchar_scene"), &Main::set_mnchar_scene); ADD_PROPERTY(PropertyInfo(Variant::OBJECT, "packed_scene", PROPERTY_HINT_RESOURCE_TYPE, "PackedScene"), "set_mnchar_scene", "get_mnchar_scene");(Reference 8)
-
In addition, add the following code below
Main::~Main() {}:Ref<PackedScene> Main::get_mnchar_scene() const { return mnchar_scene; } void Main::set_mnchar_scene(Ref<PackedScene> packed_scene) { mnchar_scene = packed_scene; }(Reference 8)
We're defining a scene (
mnchar_scene) that we'll be able to access within main.cpp. Adding it as a property (like we did with movement_speed) will allow us to specify, within the editor, the exact scene (in this case, mnchar.tscn) that we want Godot to interpret asmnchar_scene. -
Next, add the following to the bottom of
Main::_ready():auto new_mnchar = reinterpret_cast<Mnchar *>(get_mnchar_scene()->instantiate()); add_child(new_mnchar);(Reference 8)
This code retrieves our mnchar_scene; reinterprets it as a Mnchar object; and then adds it to our scene tree.
-
Note: When I was putting this code together, I initially forgot to define
get_mnchar_scene()andset_mnchar_scene()within main.cpp. Although my code compiled successfully, these omissions caused Godot to fail to locate both my Main and Mnchar classes within the editor:(I knew this was the cause because I've made similar mistakes more often than I'd like to admit!)
Adding these functions in, then closing and relaunching the editor resolved this issue.
I actually managed to make a similar mistake later on for
get_rotation_speed()andset_rotation_speed(). Error messages within the console helped me figure out the issue. (Note theget_rotation_speedreference within the third-to-last error.) -
Go ahead and compile your code, then relaunch the editor. After you open main.tscn and click on the Main node within your scene tree, you should now see a new Packed Scene entry right below Main in the inspector. Click the 'empty' text; select 'Load'; and then choose your mnchar.tscn scene.
-
When you try playing your project, you should now see a little gray box (your Mnchar) in the middle of your game area:
-
You'll notice, though, that the main character is partially sunk in the ground. We could fix this by changing its transform within Mnchar.tscn; however, because we'll need to be able to move Mnchars around later on when setting up multiplayer games, we may as well add some initial movement code now.
Declare a new
start()function for the Mnchar class by adding the following code right before the final closing bracket of mnchar.h:void start(const Vector3 mnchar_translate_arg);Next, add the following code to the bottom of mnchar.cpp:
void Mnchar::start(const Vector3 mnchar_translate_arg) {translate(mnchar_translate_arg);}This function will ultimately allow us to configure each player's starting color, location, rotation, and ID. For now, though, we'll just use it to move our player to a better starting location.
(Resource 8)
-
Within Main::_ready(), add the following code right above
add_child(new_mnchar):new_mnchar -> start(Vector3(15, 1, -20));This will move the Mnchar 15 meters to the left, one meter up (to make its bottom level with the ground), and 20 meters closer to the camera. We'll add in code later to give other Mnchars different starting locations, but all of them will also get moved up one meter.
(Resource 8)
Note: I had originally tried this code:
get_node<Node3D>("Pivot") -> translate(translate_val);However, this caused issues when multiple characters were added to the scene--possibly because the transforms of the actual Mnchar class weren't getting changed and were thus overlapping with one another. (This caused them to shoot up in the sky, which was both frustrating and hilarious!)
-
Compile your code, then rerun your main scene. You should now see the full Mnchar closer to the bottom left of the window:
Because we're creating a multiplayer game, we'll want to set up our movement code in a way that allows each player to move his or her own Mnchar (and no one else's). The approach we'll take for this task will be as follows:
-
We'll first create a new String variable (
mnchar_id) that will store a unique ID for each Mnchar. (These IDs will range from "0" to "7" in order to support up to eight different players.) This variable will also be useful for setting character-specific locations, rotations, and colors. -
We'll also add each of these same IDs to the end of all action names within one particular group of movement commands that we'll create. Thus, one group's action names will end in "0", others will end in "1", and so forth. (This is why we added "_0" to our first set of movement names.) We'll also specify that each set of commands will only work for one particular device. (The device IDs recognized by Godot range from 0 through 7, thus matching our own list of possible IDs.)
-
In our movement code, we'll check for movements that match the given Mnchar's ID. For instance, here's what our left/right movement code will look like:
x_direction = input->get_axis("move_left_"+mnchar_id, "move_right_"+mnchar_id); -
In summary, by having Mnchar IDs match movement-name suffixes, and by having all movements for a particular suffix match only one particular device, we can create a functioning multiplayer control setup without too much extra work. (This approach was based on references 11 through 15.)
-
The first step here will be to add a
mnchar_idvalue to our Mnchar class. Underdouble movement_speed = 14;within theprivatesection of Mnchar.h, add:String mnchar_id = "";Next, in the
publicsection, add the following setter and getter function declarations under yourget_movement_speed()function declaration:void set_mnchar_id(const String mnchar_id); String get_mnchar_id() const;Finally, add a
mnchar_id_argargument before yourmnchar_translate_argwithinstart()so that the declaration matches the following line:void start(const String mnchar_id_arg, const Vector3 mnchar_translate_arg); -
Within mnchar.cpp, add the following below your
get_movement_speed()function definition:void Mnchar::set_mnchar_id(const String p_mnchar_id) { mnchar_id = p_mnchar_id; } String Mnchar::get_mnchar_id() const { return mnchar_id;}Next, add
String mnchar_id_argbeforeVector3 mnchar_translate_argwithin the arguments in this file'sMnchar::start()function definition. In addition, right beforetranslate(mnchar_translate_arg);in the function body, add:set_mnchar_id(mnchar_id_arg);. -
Finally, within main.cpp, update your
new_mnchar -> start()command withinMain::_ready()so that it reads as follows:new_mnchar -> start("0", Vector3(15, 1, -20));(This will assign
new_mncharan ID of 0, thus allowing all movement commands ending in "0" to get registered by this Mnchar.)If you want to confirm that this change has taken effect, you can also add the following code following
set_mnchar_id(mnchar_id_arg)withinMnchar::start():UtilityFunctions::print("Mnchar's ID is ", get_mnchar_id(), ".");(Printing out values is incredibly helpful for debugging work.)
We could also have added
bind_method()andADD_PROPERTY()calls for ourmnchar_idvalue and its corresponding setter and getter functions (as we did with themovement_speedvariable). However, that won't be necessary in this case, as we won't need to access or modify those values directly within the editor. -
Now that we have code in place for assigning
mnchar_ids, we can utilize that ID within our input code. First, add the following code after yourstart()function declaration within mnchar.h:void _physics_process(double delta) override;(See Reference 4 for the use of
_physics_processhere rather than_process.)In addition, add the following three include statements after
#include <godot_cpp/variant/utility_functions.hpp>:#include <godot_cpp/classes/input.hpp> #include <godot_cpp/classes/input_event.hpp> #include <godot_cpp/classes/input_map.hpp> -
Next, add the following code to the end of mnchar.cpp. We'll start each
_physics_processcall by retrieving input data that we can then parse. (The lack of a closing bracket is intentional, since we'll be filling out the rest of this function below.)void Mnchar::_physics_process(double delta) { auto input = Input::get_singleton();(Reference 4)
-
Extend this function by adding the following code, which uses our left, right, forward, and back movements to determine the player's movement along the x (left/right) and z (forward/back) axes. This approach will allow different movement speeds depending on exactly how far a controller joystick is moved down, but it also works fine for keyboard input.
(Note: I've found that I sometimes need to put 'move_right' before 'move_left', and 'move_forward' before 'move_back', in order to get my movement code to work correctly.)
float x_direction = input->get_axis("move_left_" + mnchar_id, "move_right_" + mnchar_id); float z_direction = input->get_axis("move_forward_" + mnchar_id, "move_back_" + mnchar_id);Also note the inclusion of mnchar_id, which I discussed at length earlier.
(References 16 and 17)
-
Next, we'll rotate the player in response to any rotation commands sent by the player's controller (or, if
mnchar_idis 0, the keyboard). Add the following code to the end of the function:get_node<Node3D>("Pivot")->rotate_object_local( Vector3(0, 1, 0), rotation_speed * input->get_axis("rotate_right_" + mnchar_id, "rotate_left_" + mnchar_id));(References 16, 18, and 19)
-
We'll now retrieve information about the Mnchar's basis that we'll use to update its velocity. Add the following code to the end of
_physics_process():Note: I'm not sure why, but multiplying the x and z components of the x and z bases, respectively, by -1 was critical for getting the movement code to work. This may be due to an issue with my setup. I imagine that there's a way to update my code such that at least one of these multiplication commands won't be necessary.
auto player_transform_basis_z = get_node<Node3D>("Pivot")->get_transform().get_basis()[2]; auto player_transform_basis_x = get_node<Node3D>("Pivot")->get_transform().get_basis()[0]; player_transform_basis_x.x *= -1; player_transform_basis_z.z *= -1; -
Extend the function by adding the following code, which initializes, then updates, the player's target velocity.
Vector3 target_velocity = Vector3(0, 0, 0); float abs_z_direction = std::abs(z_direction); float abs_x_direction = std::abs(x_direction); if (abs_z_direction >= abs_x_direction) { target_velocity += -1 * player_transform_basis_z * z_direction * movement_speed; } else { target_velocity += -1 * player_transform_basis_x * x_direction * movement_speed; }I would like Mnchar to be able to move only forward, back, left, or right relative to its current position (i.e. not diagonally). Therefore, the code above finds the absolute value of the x and z directions, then moves the player along the axis with the greatest absolute value. This isn't strictly necessary, but I find it makes the Mnchar's movement somewhat more intuitive.
(Note that I'm using the standard library's abs() function rather than Godot's, as the latter appeared to truncate values down to the nearest int.)
(References 4, 17, 20, and 21)
-
Finally, close out this function with the following code:
set_velocity(target_velocity); move_and_slide(); }(References 4 and 21)
-
Reopen your editor, launch the scene, and test out the controls. Make sure that no directions are the inverse of what you'd expect--and that the player cannot move diagonally.
By the way: if the game crashes right when you launch it, make sure that your mnchar.tscn scene is still present within Main's Packed Scene attribute. (It sometimes disappears on my end, but thankfully, it's easy to add back in.)
It's neat to move our Mnchar around with code, but the game won't be too much fun without anything for it to fire (or be hit by). Therefore, let's go ahead and add a Projectile class to our game. This class will have many similarities to the Mnchar class (on which its code will be based).
-
Within your src/ folder, create two new files, 'projectile.h' and 'projectile.cpp'. Add the following text to projectile.h:
#pragma once #include <godot_cpp/classes/character_body3d.hpp> #include <godot_cpp/core/class_db.hpp> #include <godot_cpp/variant/utility_functions.hpp> using namespace godot; class Projectile : public CharacterBody3D { GDCLASS(Projectile, CharacterBody3D) private: double projectile_speed = 64; double active_time = 0; String firing_mnchar_id = ""; Vector3 projectile_velocity = Vector3(0, 0, 0); protected: static void _bind_methods(); public: Projectile(); ~Projectile(); void set_projectile_speed(const double movement_speed); double get_projectile_speed() const; void set_firing_mnchar_id(const String firing_mnchar_id_arg); String get_firing_mnchar_id() const; void start(const Transform3D transform, const String firing_mnchar_id); void _physics_process(double delta) override; };active_timewill store how long a projectile has been active, thus allowing us to remove it from the scene after a certain amount of time has passed. This prevents projectiles from existing in the game's memory forever, which would prove to be inefficient. (Reference 22)firing_mnchar_idwill let us determine which Mnchar fired a projectile--and, thus, which Mnchar should be credited for a hit on another Mnchar. We won't use this variable for a little while, but it doesn't hurt to add it in now. -
Next, add the following code to projectile.cpp:
#include "projectile.h" using namespace godot; void Projectile::_bind_methods() { ClassDB::bind_method(D_METHOD("get_projectile_speed"), &Projectile::get_projectile_speed); ClassDB::bind_method(D_METHOD("set_projectile_speed", "p_projectile_speed"), &Projectile::set_projectile_speed); ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "projectile_speed"), "set_projectile_speed", "get_projectile_speed"); } Projectile::Projectile() {} Projectile::~Projectile() {} void Projectile::set_projectile_speed(const double p_projectile_speed) { projectile_speed = p_projectile_speed; } double Projectile::get_projectile_speed() const { return projectile_speed; } void Projectile::set_firing_mnchar_id(const String firing_mnchar_id_arg) { firing_mnchar_id = firing_mnchar_id_arg; } String Projectile::get_firing_mnchar_id() const { return firing_mnchar_id; }This is all fairly similar to what we've added within mnchar.cpp.
-
Add the following code right below your existing projectile.cpp code:
void Projectile::start(const Transform3D transform, const String firing_mnchar_id) { set_firing_mnchar_id(firing_mnchar_id); set_transform(transform); auto projectile_basis_z = Projectile::get_transform().get_basis()[2]; projectile_basis_z.z *= -1; projectile_velocity = -1 * projectile_basis_z * projectile_speed; }This code will allow us to configure a new Projectile that a Mnchar has just fired. In conjunction with an upcoming update to mnchar.cpp, it will set the Projectile's
firing_mnchar_idwith that Mnchar'smnchar_id; set the Projectile's transform based on that Mnchar's transform; and initialize the projectile's velocity. (This velocity code is quite similar to that found withinMnchar::_process(). One difference is that we don't need to calculate and then incorporate x_direction and z_direction values; the z direction will always be 1, and the x direction will always be 0.(Based on references 18, 23, 24, 27, and 28. References 25 and 26 are also helpful for projectile-related code.)
-
Finally, add the following code to the bottom of projectile.cpp:
void Projectile::_physics_process(double delta) { auto collision = move_and_collide(projectile_velocity * delta); if (active_time >= 2) { queue_free(); } active_time += delta; }The queue_free() command removes this particular projectile from the game, which we'll want to do after a certain amount of time (in this case, two seconds) has elapsed. It would be ideal to make this time contingent on the size of the game area (which probably won't change very much) and the projectile's movement speed, but this simpler apporach will work OK for now.
(Based on references 8 and 23)
-
As always, before we can incorporate this class into our game, we'll need to add references for it within register_types.cpp. Within that script, add
#include "projectile.h"below#include "mnchar.h", andGDREGISTER_RUNTIME_CLASS(Projectile);belowGDREGISTER_RUNTIME_CLASS(Mnchar);. -
We'l have more to add to our Projectile class's code later on, but what we have so far will at least let us create a Projectile scene within our editor. Go ahead and compile your code, then restart your editor.
-
Select Scene --> New Scene; click 'Other Node' under the 'Create Root Node:' text; and search for your new Projectile class. Once you've found it, select it and hit 'Create.' Go ahead and save this near-empty scene as projectile.tscn.
-
Just as our Projectile code was based on our Mnchar code, our Projectile scene will have many similarities to our Mnchar scene. As you did within mnchar.tscn, add a Node3D as a child of your Projectile and rename it 'Pivot.' Then add a MeshInstance3D as a child of this Pivot; rename it 'Body'; and assign it a BoxMesh within the MeshInstance3D section of the Inspector. Within the edit menu for this BoxMesh, create a new StandardMaterial3D for it, then set both its x and y Size values equal to 0.25. (Leave the z value unchanged at 1.) (Refer to the 'Adding a Mnchar to the game area' section of the tutorial if you've forgotten how to perform any of these steps.)
-
Finally, add a CollisionShape3D as a child of Projectile; assign it a BoxShape3D; and set this shape's x, y, and z values to 0.25, 0.25, and 1, respectively. (Again, refer to the 'Adding a Mnchar to the game area' section if needed.)
Next, we'll need to add code for firing projectiles to our Mnchar class.
-
Within mnchar.h, add the following two lines to the end of your list of
#includestatements:#include <godot_cpp/classes/packed_scene.hpp> #include "projectile.h" -
Next, add
void shoot_projectile();right abovevoid _physics_process(double delta) override;. Then addRef<PackedScene> projectile_scene;at the end of yourprivatesection (right afterString mnchar_id = "";). Finally, add the following code rigth beforeshoot_projectile()within thepublicsection of this file:Ref<PackedScene> get_projectile_scene(); void set_projectile_scene(Ref<PackedScene>);This code will ultimately allow us to access the Projectile scene within our Mnchar class. It's very similar to the code we're using to access Mnchars themselves within the Main class.
-
Within mnchar.cpp, add the following code to the end of
Mnchar::_bind_methods()so that we can access thisprojectile_scenevariable within the editor (and thus link projectile.tscn to it):ClassDB::bind_method(D_METHOD("get_projectile_scene"), &Mnchar::get_projectile_scene); ClassDB::bind_method(D_METHOD("set_projectile_scene", "projectile_scene"), &Mnchar::set_projectile_scene); ADD_PROPERTY(PropertyInfo(Variant::OBJECT, "packed_scene", PROPERTY_HINT_RESOURCE_TYPE, "PackedScene"), "set_projectile_scene", "get_projectile_scene"); -
Next, right below
Mnchar::~Mnchar() {}, defineprojectile_scene's setter and getter functions as follows:Ref<PackedScene> Mnchar::get_projectile_scene() { return projectile_scene; } void Mnchar::set_projectile_scene(Ref<PackedScene> packed_scene) { projectile_scene = packed_scene; }(Based on reference 8)
-
Next, within mnchar.cpp, add the following definition of this function right after your
Mnchar::start()function:{ auto projectile = reinterpret_cast<Projectile *>(projectile_scene->instantiate()); Transform3D projectile_transform = get_node<Node3D>("Pivot")->get_global_transform(); projectile_transform = projectile_transform.translated_local(Vector3(0, 0, 3)); projectile->start(projectile_transform, mnchar_id); get_parent()->add_child(projectile); }We need to initialize the projectile's transform as that of the main character's Pivot node because it's this node, not Mnchar itself, whose basis we adjust when moving the player.
The
translated_local()call creates some distance in between the projectile and the firer. This prevents the firer from getting hit by its own bullet immediately after firing! (We're usingtranslated_local()rather thantranslate()here because we want to move the projectile in front of the Mnchar's field of view rather than the game area itself.)Adding
get_parent()beforeadd_child()ensures that these projectiles are children of the parent scene (e.g. main.tscn) rather than the character. Without this line, projectiles might rotate whenever the character rotates--which, while a potentially-cool mechanic, isn't what we're looking for here.(Based on references 8, 18, 24, 29, 30, and 31)
-
Now that we have a function for firing projectiles, we also need to allow the player to trigger (pun intended?) it. We can do so by adding the following code right after
auto input = Input::get_singleton();withinMnchar::_physics_process:if ((input->is_action_just_pressed("fire_" + mnchar_id)) && (input->is_action_pressed("reset_" + mnchar_id) == false)) { shoot_projectile(); }Later in this tutorial, we'll allow players to start a new game by pressing both their 'fire' and 'reset' buttons. Therefore, I added code to this
ifstatement that will only allow a projectile to get fired when reset is not being pressed. (Otherwise,the game would always start with a projectile being fired, which could give the firing player an advantage and/or distort any accuracy statistics that we might choose to collect.) -
Compile your code, then restart your editor. When you click on mnchar.tscn, you should see a new 'Packed Scene' attribute below 'Movement Speed' and 'Rotation Speed' near the top of the editor. (It's neat how these variables get formatted automatically to look nicer within the editor, by the way.) Click the folder icon to the right of 'empty', then select projectile.tscn.
-
Launch your game. Confirm that pressing space bar causes a projectile to fire in front of the player's turret. Also confirm that this remains the case regardless of which direction the player is facing, and that the projectile's velocity isn't affected in any way by the player's actions following the fire command. (It took quite a bit of debugging on my part when I was initially coding this section to get things working. I hope that you won't need to do the same, but don't get too frustrated if things don't work right away.)
Now that we've added code for firing a projectile, we'll also need to create code that specifies how the game should react when a Mnchar gets hit by a projectile. But in order to test out that code, we'll need to add a second Mnchar to the scene.
Since having two identical Mnchars within a scene can get confusing, this will also be a good time to create code that assigns different colors to different players. And in order to assign those colors, we may as well set up a typed dictionary that will store colors for all the players who will might eventually join our game.
Finally, since we're adding a typed dictionary for player colors, we may as well add ones for starting locations and rotation values as well, as these will also play an important role in initializing new Mnchars.
(This is a bit like If You Give a Mouse a Cookie, but in game-development form! It's funny quickly one programming task can lead to five others.)
-
With that rambling introduction out of the way, let's go ahead and create typed dictionaries that will store colors, rotation values, and starting locations for all of our players. Within main.h, add
#include <godot_cpp/variant/typed_dictionary.hpp>to the bottom of your list of#include statements. Next, enter the following code right belowRef<PackedScene> mnchar_scenewithin theprivatesection:const TypedDictionary<String, Vector3> mnchar_id_location_dict{ {String("0"), Vector3(15, 0, -20)}, {String("1"), Vector3(-15, 0, 20)}, {String("2"), Vector3(-20, 0, -15)}, {String("3"), Vector3(20, 0, 15)}, {String("4"), Vector3(-5, 0, -20)}, {String("5"), Vector3(5, 0, 20)}, {String("6"), Vector3(-20, 0, 5)}, {String("7"), Vector3(20, 0, -5)}}; const TypedDictionary<String, double> mnchar_id_rotation_dict{ {String("0"), 0}, {String("1"), Math_PI}, {String("2"), Math_PI / 2}, {String("3"), Math_PI / -2}, {String("4"), 0}, {String("5"), Math_PI}, {String("6"), Math_PI / 2}, {String("7"), Math_PI / -2}}; const TypedDictionary<String, Color> mnchar_id_color_dict{ {String("0"), Color(0, 0, 1, 1)}, {String("1"), Color(0, 1, 0, 1)}, {String("2"), Color(0, 1, 1, 1)}, {String("3"), Color(1, 0, 0, 1)}, {String("4"), Color(1, 0, 1, 1)}, {String("5"), Color(1, 1, 0, 1)}, {String("6"), Color(1, 1, 1, 1)}, {String("7"), Color(0, 0, 0, 1)}};This code is based on references 32 (an overview of Godot's own core types) and 33 (the example.cpp file within the godot-cpp repository). Both of these resources proved very helpful in working with Godot types like TypedDictionary, so I highly recommend checking them out if you aren't already familiar with them. The syntax is also similar to that for
std::map(see reference 35).Each of these dictionaries uses string-based IDs as keys and various initialization settings (namely, starting locations, starting rotations, and colors) as values. The transforms and rotations allow for an adequate amount of space between players while keeping them all facing towards the game area. (I also needed to make sure that no player would begin in another's direct line of fire.)
The rotation values are in radians; they use the MATH_PI variable found within reference 34. For the rotation dictionary, the fourth value within each 'Color' represents that color's opacity.
-
Next, we need to update Mnchar::start() in order to utilize these rotation and color variables. Update your
void start()function within mnchar.h so that it reads:void start(const String mnchar_id_arg, const Vector3 mnchar_translate_arg, const double mnchar_rotation_arg, const Color mnchar_color_arg); -
We'll also need to declare and define a function that can modify a Mnchar's color. Right above
void shoot_projectile();within thepublicsection of mnchar.h, add:void set_mnchar_color(const Color mnchar_color_arg); -
Switch over to mnchar.cpp. Add the following two statements to the end of your
#includelist:#include <godot_cpp/classes/base_material3d.hpp> #include <godot_cpp/classes/mesh_instance3d.hpp> -
Then, after your
Mnchar::get_mnchar_id()definition within mnchar.cpp, define this function as follows:void Mnchar::set_mnchar_color(const Color mnchar_color_arg) { UtilityFunctions::print("Mnchar::set_mnchar_color() checkpoint 1."); Ref<BaseMaterial3D> mncharbody_mesh_material_3d = ( get_node<Node3D>("Pivot") ->get_node<MeshInstance3D>("Body") ->get_mesh() ->surface_get_material(0)); mncharbody_mesh_material_3d->set_albedo(mnchar_color_arg); get_node<Node3D>("Pivot") ->get_node<MeshInstance3D>("Body") ->get_mesh() ->surface_set_material(0, Ref<Material>(mncharbody_mesh_material_3d)); }This code (Based on references 36, 37, 38, and 39--special thanks to RamblingStranger and pescador in the Godot discord for their help here) first retrieves the current material of the Mnchar's Pivot. It then uses
Ref<>to create aBaseMaterial3Dcopy of this material, as this class has aset_albedomethod that we can use to convert the existing material to our new color. Finally, it sets the pivot's existing material to aMaterialcopy of this BaseMaterial3D.(pescador noted in Discord that "Ref wrappers automatically verify and give you the right type to you", but also clarified that this approach "only works for RefCounted objects." For an alternative approach that may be more flexible, see the
Mnchar::set_character_color()method within mnchar.cpp in Reference 2.)Also note that, for this code to work, the "Pivot" and "Body" names must also be present within your scene tree within Mnchar.tscn. If you're using a different name for one of these items, your game will most likely crash before either Mnchar appears (which I know from experience!).
-
Replace the
Mnchar::start()function definition within mnchar.cpp with the following code:void Mnchar::start( const String mnchar_id_arg, const Vector3 mnchar_translate_arg, const double mnchar_rotation_arg, const Color mnchar_color_arg) { set_mnchar_id(mnchar_id_arg); UtilityFunctions::print("Mnchar's ID is ", get_mnchar_id(), "."); translate(mnchar_translate_arg); set_mnchar_color(mnchar_color_arg); get_node<Node3D>("Pivot")->rotate_object_local(Vector3(0, 1, 0), mnchar_rotation_arg); }This function will now not only set the Mnchar's ID and starting location, but also update its color and rotation.
-
Next, replace your existing Main::_ready() {} function definition with the following text:
void Main::_ready() { UtilityFunctions::print("Main::_ready() just got called."); Array mnchars_to_include {"0", "1"}; for (int index = 0; index < mnchars_to_include.size(); index++) { String mnchar_id_arg = mnchars_to_include[index]; Color mnchar_color_arg = mnchar_id_color_dict[mnchar_id_arg]; Vector3 mnchar_translate_arg = mnchar_id_location_dict[mnchar_id_arg]; double mnchar_rotation_arg = mnchar_id_rotation_dict[mnchar_id_arg]; auto new_mnchar = reinterpret_cast<Mnchar *>( get_mnchar_scene()->instantiate()); new_mnchar -> start(mnchar_id_arg, mnchar_translate_arg, mnchar_rotation_arg, mnchar_color_arg); add_child(new_mnchar); } }We're now adding new Mnchars to the game via a loop that iterates through an array of Mnchar IDs. These IDs are used to specify each player's color, translation, and rotation, all of which (together with the ID) then get passed to our
Mnchar::start()function.The
mnchars_to_includeArray will eventually get passed to this function, but for now, we'll store hardcoded "0" and "1" values within it.. -
Go ahead and compile your code, then restart the editor. Now that we have a second Mnchar within our game, we should add in inputs for this player. Go to Project --> Project Settings --> Input Map to access the input editor.
-
If you happen to have a game controller with two joysticks and left/right triggers available, connect it to your computer now. (If you don't, no worries--I'll show you another way to add these controls.) For the move_left_1, move_right_1, move_forward_1, and move_back_1 actions, move your left joystick left, right, forward, and back to add them as inputs. For the rotate_left and rotate_right actions, move your right joystick to the left and to the right. Finally, for the fire_1 and reset_1 commands, press the right and left triggers, respectively.
When adding in each of these actions, make sure to specify Device 1 (not 'All devices') as the input device. Otherwise, these actions will also affect the movement of Mnchar 0.
You can also peform similar steps for your existing actions that end in '_0'. Just make sure to select Device 0 for those rather than Device 1.
In order to make debugging easier, link the '0' key on your keyboard to the 'fire_0' command, and the '1' key to 'fire_1'. (You'll see why I recommend this soon.)
Once you've made these changes, the lower section of your input map should look like the following:
If you don't have a controller, or simply don't want to go through the trouble of adding in these commands, you can also go to this repository's project.godot file (https://github.com/kburchfiel/godot_cpp_3d_tutorial/blob/main/project/project.godot); copy all of the text between the
[input]entry and the [physics] entry; and then paste it into your own project's project.godot file. (This file should be available at /project/project.godot.) -
If you just really, really love adding these inputs, you can enter similar commands for device IDs 2 through 7 manually. However, I highly recommend that you instead copy those actions from the previous link and paste them into your project.godot file. (By the way, I created most of these inputs, aside from the keyboard-based ones, via another set of C++ code. See Reference 47 for the link.) Once you've finished these copy/paste commands, make sure that they're appearing within your input map.
-
Launch your main scene. You should see the following:
Two players are now present within the game area, as expected. But why are they both green? Was there an issue with our color dictionary? After quite a bit of debugging, I eventually found the solution in a post by Tobias Wink (Reference 40). The problem is that, currently, our Mnchar's material is shared across both of our Mnchar instances; as a result, changing the color of the second Mnchar will also change the first Mnchar's color.
To resolve this, double-click on your mnchar.tscn scene; click on the 'Body' MeshInstance3D; click the white cube within the Mesh section of the Inspector (not the game) to open the Edit window; open up the Resource section near the bottom; and check the 'Local to Scene' box. Next, click the white sphere within the Material section of the editor; scroll down to and open its own Resource section; and check that 'Local to Scene' box as well. After saving your scene and relaunching your game, you should now see one blue cube and one green cube within the game area:
-
As the blue Mnchar, try firing some projectiles towards the green Mnchar by pressing the space bar. (If the game crashes when you attempt to do so, make sure that your projectile.tscn is still showing up within the Mnchar's Packed Scene entry; if it's missing, load it back in.) You should see the projectiles stop in place when they reach the green Mnchar, though if enough of them get fired, it might shift it around a bit. We'll now change this behavior so that a Mnchar who gets hit by a projectile gets removed from the game scene.
-
In order to determine when a Mnchar has gotten hit, we'll need to add an Area3D node that can detect such collisions. Go ahead and add such a node as a child of your Mnchar, then rename it 'Projectile_Detector.' Next, add a CollisionShape3D as a child of Projectile_Detector; click on the 'empty' text next to the Shape section within its Inspector menu; and choose a BoxShape3D. Click on the 'BoxShape3D' text to enter the edit menu, then set the x, y, and z sizes to 2.0 meters. (Reference 41)
-
Go back to your Projectile_Detector node and deselect the 'Monitoring' property within the Inspector (but keep 'Monitorable' active). Next, click 'Collision' within the CollisionObject3D section of the Projectile_Detector's Inspector menu in order to open up its Layer and Mask settings. Deselect the '1' within the Layer and Mask sections, then select the '2'. This will allow the detector to recognize collisions only with objects with a Layer value of 2. (Reference 41)
-
As you might have guessed, we'll now want to set the Layer value of our Projectile's CollisionObject3D to 2. Double-click on projectile.tscn; then, with the Projcetile selected in the top left, deselect the '1' values within the Layer and Mask sections of the Inspector menu's CollisionObject3D section. Next, select the '2' value within the Layer section.
-
Go back into your code editor. Within mnchar.h, add the following line right after
void shoot_projectile()within thepublicsection:void _on_projectile_detector_body_entered(Node3D *node);
This function will allow us to react to a Projectile's collision with a Mnchar's Projectile_Detector component.
-
Next, within mnchar.cpp, add the following lines to the end of your
_bind_methods()definition:ADD_SIGNAL(MethodInfo("mnchar_hit", PropertyInfo(Variant::STRING, "mnchar_id"), PropertyInfo(Variant::STRING, "firing_mnchar_id"))); ClassDB::bind_method(D_METHOD("_on_projectile_detector_body_entered", "node"), &Mnchar::_on_projectile_detector_body_entered);Here, we're adding a signal (along with two arguments) as a property so that it can get accessed later on by main.cpp. We're also providing Godot with information about our
_on_projectile_detector_body_enteredfunction so that we can connect the Projectile_Detector's built-inbody_enteredsignal to it.(Reference 33)
-
Add the following right below your
shoot_projectile()function definition within mnchar.cpp:void Mnchar::_on_projectile_detector_body_entered(Node3D *node) { UtilityFunctions::print( "on_body_entered() just got called within mnchar.cpp."); Projectile *node_as_projectile = Object::cast_to<Projectile>(node); String firing_mnchar_id = node_as_projectile->get_firing_mnchar_id(); emit_signal("mnchar_hit", mnchar_id, firing_mnchar_id); queue_free(); }(References 8, 38, and 43)
The
queue_free()function removes this Mnchar from the game.Note that this function converts the
nodeargument to a Projectile so that we can access the ID of its firing player. (This step would most likely cause the game to crash if items other than projectiles were registered by our Projectile_Detector. However, setting our layer and mask options correctly within the editor should ensure that only projectile collisions will cause this function to get called.) -
Go ahead and compile the code, then restart the Godot editor to incorporate these changes. You'll find that, if you hit a Mnchar with a projectile, nothing will happen. This is because we haven't yet connected the Projectile_Detector's
body_enteredsignal to our_on_projectile_detector_body_enteredfunction. -
There are two ways to do this. I'll first describe the GUI-based approach. Within the editor, select select the Projectile_Detector node within mnchar.tscn. Next, go to the Signals tab (to the right of the Inspector); right-click the
body_entered(body: Node3D)signal; and select 'Connect'. Within the 'Connect a Signal to a Method' box that appears, Click on the Mnchar scene within the node list (which should bring up_on_projectile_detector_body_enteredas a suggested function, then hit the 'Connect' button. This will inform the game that, whenever thebody_enteredsignal gets triggered, the Mnchar's corresponding_on_projectile_detector_body_enteredfunction should be called. (Reference 41)If all goes well, you should then see a little green icon appear below the
body_enteredsignal in the Signals tab--along with the txt.. :: _on_projectile_detector_body_entered(). (The..signifies that you've conneced this signal to its parent, e.g. Mnchar.) -
Relaunch your game and try firing a projectile at the green Mnchar. Once the projectile hits the Mnchar, it should now disappear.
-
This approach works fine, except I've found that connected signals sometimes disappear from the editor--which requires me to re-add them on occasion. (As with the disppearing-packed-scene behavior I mentioned earlier, I'm not sure what's behind this issue.) Therefore, I now prefer to add signals within code, which is also a great option for connecting signals between nodes that don't share a scene tree in the editor. (An example of this, which we'll get too shortly, is the connection of a Mnchar signal to Main. Since Mnchars aren't present within the Main class by default, we can't use the GUI to connect this signal--but we can do so using C++.)
-
To connect the Projectile_Detector's
body_enteredsignal to_on_projectile_detector_body_entered, first add#include <godot_cpp/classes/area3d.hpp>to the bottom of your main.h file's#includestatements. Next, within main.cpp, add the following to the end of yourMnchar::start()function:get_node<Area3D>("Projectile_Detector")->connect( "body_entered", Callable(this, "_on_projectile_detector_body_entered"));This code first retrieves the Mnchar's Projectile_Detector, then connects its body_entered signal to the
_on_projectile_detector_body_enteredfunction of 'this' (which refers to Mnchar). (Reference 1)Note that it doesn't appear necessary to mention the Node3D argument of
body_entered(listed within the Godot editor) within thisconnect()call.If we didn't have a
start()function, we could have added this within a new_ready()function instead. (In contrast, I found that attempting to add this code within theMnchar::Mnchar()constructor did not work--perhaps because the Projectile_Detector wasn't yet accessible at that time.)If you compile your code once again and restart the game, you should still be able to remove another Mnchar from the game by hitting it with a projectile, even if the editor doesn't show a connection between the Projectile_Detector's
body_enteredsignal and the Mnchar's_on_projectile_detector_body_enteredfunction.
We're very close to having a working prototype of our game--one in which two players can attempt to hit one another with projectiles. Unless both players hit each other at the same time, we should be able to figure out who won (e.g. by seeing which player is left standing). However, it will be ideal to have the game determine this as well.
-
Within main.h, add the following code right below your
mnchar_id_color_dictinitialization:HashSet<String> active_mnchars{};This HashSet will store a list of active Mnchars. Once the size of this set becomes less than two, we can determine which Mnchar (if any) won the game. (If two Mnchars hit each other at exactly the same time, the length of this set will become 0, and no winner will be declared.)
(References 32, 44, and 45)
-
Next, right below
add_child(new_mnchar)within the for loop in main.cpp'sMain::_ready()function, add:active_mnchars.insert(mnchar_id_arg);This way, every Mnchar in the
mnchars_to_includearray will also get added to our list of active players.(I believe we could also have simply used the
mnchars_to_includearray to keep track of our active players; however, I did want to introduce the HashSet type within this tutorial, and this is a great way to do so.) -
Finally, at the end of
Main::_ready(), add the following code:UtilityFunctions::print("Printing out all active players in set:"); for (auto active_mnchars_iterator = active_mnchars.begin(); active_mnchars_iterator != active_mnchars.end(); ++active_mnchars_iterator) { UtilityFunctions::print(*active_mnchars_iterator); }This both demonstrates how to iterate through a HashSet and helps identify which Mnchars got added to the game. (Reference 46)
-
Once we allow multiple games to be played within a single session, we'll want to clear out
active_mncharsbefore each game so as to prevent dormant Mnchars from lingering around. To do so, add the following line at the start ofMain::_ready():active_mnchars.clear(); -
In order to remove Mnchars from
active_mnchars(and thus figure out when the game is over), we'll need to connect themnchar_hitsignal from main.cpp to a function within main.cpp that can process it. Let's declare and define this function now. First, within main.h, add the following right beforevoid _ready()within theprivatesection of main.h:void _on_mnchar_mnchar_hit(const String hit_mnchar_id_arg, const String firing_mnchar_id_arg); -
Next, at the bottom of your
Main::_bind_methods()function within main.cpp, add:ClassDB::bind_method(D_METHOD("_on_mnchar_mnchar_hit", "hit_mnchar_id_arg"), &Main::_on_mnchar_mnchar_hit);That way, we can reference this function within some signal-connection code that we'll add in shortly. (Note that both the function and its parameter should be included here.)
-
Finally, right above your
void Main::_ready() functionin main.cpp, define this newMain::_on_mnchar_mnchar_hitfunction as follows:void Main::_on_mnchar_mnchar_hit(const String hit_mnchar_id_arg, const String firing_mnchar_id_arg) { UtilityFunctions::print("The Mnchar with an ID of ", hit_mnchar_id_arg, " was just hit by the Mnchar with an ID of ", firing_mnchar_id_arg, "."); active_mnchars.erase(hit_mnchar_id_arg); // See godot-cpp/include/godot_cpp/templates/hash_set.hpp UtilityFunctions::print("Current size of active_mnchars: ", active_mnchars.size()); if (active_mnchars.size() == 1) { String winning_mnchar = *active_mnchars.begin(); UtilityFunctions::print("The player with the ID of "+ winning_mnchar+" won the game."); } else if (active_mnchars.size() == 0) { UtilityFunctions::print("Nobody won the game.") } }If, after a Mnchar gets hit, only one Mnchar remains in the HashSet, we can identify the winning Mnchar by creating an iterator to the first (and only) item in this set (using
.begin()), then dereferencing it to identify the winning ID.Meanwhile, if the final two players got hit at the exact same time,
active_mncharswill be empty. In this case, we'll announce that no one won the game.(References 45 and 46)
-
Next, add the following line right after
auto new_mnchar = reinterpret_cast<Mnchar *>(get_mnchar_scene()->instantiate());withinMain::_ready()in main.cpp:new_mnchar->connect("mnchar_hit", Callable(this, "_on_mnchar_mnchar_hit"));This will instruct Godot to call
_on_mnchar_mnchar_hitwhenever a Mnchar gets hit. (It's helpful, and perhaps necessary, to perform this connection via code rather than the editor, as the Mnchars in the game won't be present within the main.tscn scene tree beforehand.) -
Compile your code, restart your editor, and try hitting the blue Mnchar with a projectile. Once you succeed, the console should inform you that "The player with the ID of 0 won the game."
-
When a game ends, we'll want to remove the final active player (e.g. the winner) from the scene. Otherwise, that player will still be active when we begin a new game--which would be an interesting mechanic for sure, but not one we'll want for this tutorial.
To prepare to remove all players, select your Mnchar within mnchar.tscn's scene tree, then go over to the Groups tab (to the right of the Inspector and Signals tabs). Click the '+' to the left of the text bar to open up a Create New Group dialog. Name your group 'mnchars' and hit 'OK.'
-
We'll make use of this new scene group within a new
end_game()function. Within main.h'spublicsection, addvoid end_game(const String winning_mnchar_id);right after your_on_mnchar_mnchar_hit()function declaration. In addition, add#include <godot_cpp/classes/scene_tree.hpp>to the bottom to that file's list of#includestatements. -
Next, right before
void Main::_ready()within main.cpp, define this new function as follows:void Main::end_game(const String winning_mnchar_id) { get_tree()->call_group("mnchars", "queue_free"); String new_winner_message = "The winning player \ is: " + winning_mnchar_id + "\n\n"; UtilityFunctions::print(new_winner_message); }(Reference 8)
The first line of the function removes all Mnchars within the 'mnchars' group that we just created, thus preparing our game area for a subsequent round.
-
Update the
if/else ifsection ofMain::_on_mnchar_mnchar_hit())so that it reads as follows:if (active_mnchars.size() == 1) { String winning_mnchar = *active_mnchars.begin(); end_game(winning_mnchar); } else if (active_mnchars.size() == 0) { end_game("Nobody"); }Now that we're printing the winning player within
end_game(), I removed the print() calls from thisif/else ifcode, though you can also keep them in if you'd prefer. -
Compile your code, restart Godot, and play your main scene. When you hit one Mnchar with another, both Mnchars should be removed from the game area.
Congratulations! You now have a working 3D multiplayer game in C++. After launching the game, you and a friend or family member can try hitting each other's Mnchar with a projectile; once one of you succeeds, the console will announce a winner, and the game will end. You can then restart the scene to play again.
However, there are plenty of ways that we can improve on this setup:
- Since the debug console won't always be available, some in-game text should let you know who won.
- It would be ideal to be able to launch a new game without restarting the scene.
- Before we launch a new game, we should allow new players to join and previous players to opt out.
- The game could show statistics on how many hits each player achieved within the previous game--and the overall number of hits and wins each player has earned since the session began. There should also be a way to reset these overall stats.
- Players should be able to exit out of a game in progress while also preventing this canceled game from affecting their overall stats.
To implement these enhancements, we'll create a new Hud class that can display in-game text and, via its _process function, handle new-game-setup tasks. Here's a simplified overview of how this class will interact with Main:
-
When
Main::_end_game()is called, a label within Hud will announce the winner. Next, Hud's_process()function will launch, thus allowing new players to enter the game. -
Hud will store, and display, information about which players have chosen to enter the next game. (It will gather this information by checking for player inputs within
_process().) -
Once a player holds down both the
fireandresetbuttons, Hud() will emit a signal, along with information about the players who want to join, that Main will use to (1) call a new game-start function and (2) pause Hud's_process()function. -
To begin setting up the Hud class, create two new files--'hud.h' and 'hud.cpp'--within your src/ folder. (Since Hud is the final class we'll add during our tutorial, these will be our final two source files.)
-
Within hud.h, add the following code:
#pragma once #include <godot_cpp/classes/canvas_layer.hpp> #include <godot_cpp/variant/typed_dictionary.hpp> using namespace godot; class Hud : public CanvasLayer { GDCLASS(Hud, CanvasLayer) private: bool reset; Size2 screenSize; static void _bind_methods(); const String instructions = "To join this game, press Fire on \ your controller. To launch a game, press both Fire and Reset \ simultaneously. To reset overall stats, hold down Reset for \ two seconds.\n\n"; String winner_text{""}; String instructions_text{""}; String entrants_text{""}; Array mnchars_to_include {}; bool can_launch_new_game = true; public: Hud(); ~Hud(); String get_instructions const(); void set_winner_text(const String winner_arg); void set_instructions_text(const String instructions_arg); void set_entrants_text(const String entrants_arg); void update_between_game_message(); void set_can_launch_new_game(bool can_launch_new_game_arg); void _process(double delta) override; };Hud extends Godot's CanvasLayer class. It also defines a number of message components (
winner_message,instructions_message, andentrants_message) that will serve as the building blocks of a between-game message that the HUD will display. Our approach will be to update these building blocks periodically, then callupdate_within_game_message()as needed to combine these message components together. This function will also display this combined message via Labels that we'll add within the editor.(This surely isn't the only way to go about this task, but by presenting all of these messages together, we avoid having one message block partially cover another, and also decrease the amount of individual Label classes that we'll need to use.)
Similar code for updating a message that will get displayed within games will get added later.
By the way, the
_processfunction declared here won't actually override Hud's process function without thedouble deltaargument--so you should include it even if you don't actually need to make use of that parameter. (This may be obvious to many of you, but it did trip me up when I was first putting this code together, so I thought I would share it.)The
can_launch_new_gamevariable will be used to help ensure that a new game is not started multiple times in a row by our Hud's_process()function.Note: when defining your
instructionsvariable, don't add any space in between the start of lines 2 and onward and the text itself. Otherwise, these spaces will also show up within the in-game text. (See this project's hud.h file for reference if needed.)(References 48, Reference 8)
-
Next, within hud.cpp, add:
#include "hud.h" using namespace godot; Hud::Hud() { reset = false; } Hud::~Hud() {} void Hud::_bind_methods() {} String Hud::get_instructions() const { return instructions; } void Hud::set_winner_text(const String winner_arg) { winner_text = winner_arg; } void Hud::set_instructions_text(const String instructions_arg) { instructions_text = instructions_arg; } void Hud::set_entrants_text(const String entrants_arg) { entrants_text = entrants_arg; } void Hud::update_between_game_message() { String between_game_message = winner_text + instructions_text + entrants_text; } void Hud::set_can_launch_new_game(bool can_launch_new_game_arg) { can_launch_new_game = can_launch_new_game_arg; } void Hud::_process(double delta) {}Note how
update_between_game_messagecombines the output ofwinner_text,instructions_text, andentrants_textinto a single string. (Since this function performs the task of retrieving all three text blocks, it wasn't necessary to create separategetfunctions for them.)(Reference 8)
-
Finally, go back into register_types.cpp in order to inform Godot of this new class. You can probably guess at this point which two lines to include, but in case you need a refresher: add
#include "hud.h"below#include "projectile.h"near the top, andGDREGISTER_RUNTIME_CLASS(Hud);belowGDREGISTER_RUNTIME_CLASS(Projectile);withininitialize_example_module().
-
Compile your code, then restart your editor. As you did with your other three scenes, click on Scene --> New Scene; select 'Other Node' within the 'Create Root Node' menu; choose your newly-created Hud class; and then save this scene as hud.tscn. (This will be the last scene that we add within this tutorial.)
-
Add a Label as a child of Hud and rename it 'BetweenGameLabel.' This label, as the name suggests, will display relevant text (e.g. the winner of the previous game, if applicable; instructions for starting a new game; and the players who have been added to the game) in between active games. (Reference 48)
-
Go to BetweenGameLabel's inspector, then expand the Transform section. Set the x and y Position values to 20.0 pixels each in order to create a small buffer between this box and the edge of the game window. Next, set the x Size value to 300.0 pixels. Scroll back up to the Label section, then set the Autowrap Mode to 'Word'. This way, we won't need to set line breaks manually within our message (though we could do so if needed using
\n). -
Double-click on main.tscn, then right click over 'Main' and select 'Instantiate Child Scene:.' Double click on your new hud.tscn file to add it to your scene tree.
-
We'll need to handle two different 'out-of-game' scenarios within the Hud: (1) the experience when you first launch the game, and (2) what you see after finishing a game. We'll handle the first scenario first, since it's a bit simpler. Open up main.h, then add the following line right before your
end_game()function within the script'spublicsection:void _on_hud_start_game(const Array mnchars_to_include); -
In addition, add
#include "hud.h"right below#include "mnchar.h"within this script's list of#includestatements. -
Next, right before your
Main::end_game()function definition within main.cpp, add:void Main::_on_hud_start_game(const Array mnchars_to_include) { }Next, cut all of the contents between Main::_ready()'s opening and closing brackets, then paste them in between the starting and closing brackets of this new function. (Adding this code within Main::ready() allowed us to automatically launch each game with two players. However, we'll now want to allow code within Hud to specify when the game should be launched and how many Mnchars, exactly, should be included. Thus, it should now be placed within a new function.)
As the name of this function suggests, we'll eventually connect it to a 'start_game' signal that Hud will emit.
-
Within
void Main::_ready()'s opening and closing brackets, enter the following lines:get_node<Hud>("Hud")->set_winner_text( "Welcome to Cube Combat!\n\n"); get_node<Hud>("Hud")->set_instructions_text( get_node<Hud>("Hud")->get_instructions()); get_node<Hud>("Hud")->update_between_game_message(); -
This code adds our standard game-instructions text to the Hud's
instructions_textstring, then instructs the Hud code to update our between-game message to include this new text. (We won't always want to display the gameplay instructions, so it's useful to be able to clear them out, which our current setup allows.) It also precedes these instructions with a welcome message. (We'd normally display information here about the previous game's winning player, but since we're just starting the game, we don't have any such game to reference.) -
Go back to hud.h, then add
#include <godot_cpp/classes/label.hpp>right below#include <godot_cpp/variant/typed_dictionary.hpp>. Next, within hud.cpp, add the following lines to the bottom ofHud::update_between_game_message():auto between_game_label = get_node<Label>("BetweenGameLabel"); between_game_label -> set_text(between_game_message);(Reference 8)
-
Compile your game, restart your editor, and launch your main scene. The BetweenGameLabel should now display the
between_game_messageyou just configured in the top right. -
These instructions aren't correct yet, since we haven't added the corresponding code that will make them valid. Let's work on adding that code now. First, within hud.h, add
#include <godot_cpp/classes/input.hpp>to the bottom of your list of#includestatements. Next, fill in yourHud::_process()function within hud.cpp with the following code:auto input = Input::get_singleton(); for (int i = 0; i < 8; i++) { String strint = String::num_int64(i); if ( (input->is_action_just_pressed("fire_" + strint)) && (mnchars_to_include.has(strint) == false) ) { UtilityFunctions::print("Adding player " + strint + " to the game."); mnchars_to_include.append(strint); } if ((input->is_action_pressed("fire_" + strint)) && (input->is_action_pressed("reset_" + strint)) && (can_launch_new_game == true)) {UtilityFunctions::print("New game requested. Players:", mnchars_to_include); instructions_text = ""; winner_text = ""; entrants_text = ""; update_between_game_message(); can_launch_new_game = false; emit_signal("start_game", mnchars_to_include); } }This loop checks for relevant inputs from all eight possible players (with corresponding IDs of 0 through 7), then responds accordingly. When players request to get added to the game (by pressing their respective Fire buttons), they'll get added to the
mnchars_to_includearray--provided their IDs aren't already present within there.Once a player presses both the Fire and Reset button, the between-message text will get cleared (since we won't need this message when a game is in progress), and a "start_game" signal will get emitted. However, these steps will only be taken if our
can_launch_new_gamebool is set to true.(References 8 and 49)
-
Within Hud::_bind_methods() {} in hud.cpp, add:
ADD_SIGNAL(MethodInfo( "start_game", PropertyInfo(Variant::ARRAY, "mnchars_to_include")));This signal will soon get processed by main.cpp's
_on_hud_start_game()function. By the way, if you need to check how to specify a given Variant within the PropertyInfo section of an ADD_SIGNAL() call, you can check the godot-cpp code's variant.hpp file (reference 50), which includes types like Variant:ARRAY and Variant:DICTIONARY.)(References 1 and 50)
-
Relaunch your game, then press keys 1 through 7 on your keyboard followed by the Space Bar. (These were configured within your input settings to represent 'fire' actions for each possible player. Use the keys above your letters rather than the number pad.) Next, press both the Space Bar and R to request a new game. This should produce output like the following within your console:
Adding player 1 to the game. Adding player 2 to the game. Adding player 3 to the game. Adding player 4 to the game. Adding player 5 to the game. Adding player 6 to the game. Adding player 7 to the game. Adding player 0 to the game. New game requested. Players:["1", "2", "3", "4", "5", "6", "7", "0"] -
We'll now allow the new-game request within
Hud::_process()to actually start a game. Within main.cpp, add the following to the start ofMain::_ready():get_node<Hud>("Hud")->connect("start_game", Callable(this, "_on_hud_start_game"));(This could also be accomplished within the Godot editor.)
Note that, while we needed to include the
mnchars_to_includeargument within our recent ADD_SIGNAL() edition within main.h, it doesn't need to be mentioned within thisconnect()function call.(Reference 1)
-
Next, at the end of
Main::_bind_methods()within main.cpp, add:ClassDB::bind_method(D_METHOD("_on_hud_start_game"), &Main::_on_hud_start_game);(Without this addition, your Hud class's
start_gamesignal won't actually call your Main class's_on_hud_start_game()function.) -
Finally, at the very beginning of
Main::_on_hud_start_game(), add the following code:get_node<Hud>("Hud")->set_process_mode(PROCESS_MODE_DISABLED);This line stops Hud's
_processscript from running. That way, within-game player actions won't have any effect on that script. (Alternatively, if we needed_processto continue running, we could have added acan_launch_new_gameflag that would instruct the_processscript to skip past the loop we specified earlier. See my hud.cpp code within Reference 2 for an example. This tutorial's code, though, is far more readable and less cluttered than that version.)(References 51 and 52)
-
Compile your code, restart your editor, and launch your game. Press keys 1-7 on your keyboard, along with the space bar, to add all 8 Mnchars to your game; next, press Space Bar and R at the same time to launch it. You should now see the following:
We can now launch our game with an arbitrary number of players. However, we also need to allow players to return to the game-setup menu once a game is complete.
-
First, within hud.h, add
void clear_mnchars_to_include();right beforevoid _process()within this script'spublicsection. Next, right beforevoid Hud::_prcocess()within hud.cpp, define this new function as follows:void Hud::clear_mnchars_to_include() { mnchars_to_include.clear();}This function resets the
mnchars_to_includearray, thus allowing players to leave the game before a new round begins.(Reference 49)
-
Within the Godot editor, add a Timer as a child of Main and name it 'HudProcessTimer.' We'll use this timer to give players time to review the winner of the previous game before restarting Hud's
_process()function. (This timer will also prevent players from accidentally adding themselves to a new game in the process of finishing their previous one, since the fire and player-addition actions will use the same button.)Within the HudProcessTimer's Inspector, set the Wait Time to 2 seconds and the One Shot option to on. (The latter will prevent the timer from restarting after it counts down to 0.)
(By the way: I had originally made this timer a member of Hud. However, I wasn't able to get that code to work--perhaps because, when the Hud node was disabled, timer-related code wouldn't work correctly.)
(Reference 48)
-
In main.h, add
#include <godot_cpp/classes/timer.hpp>to the end of your list of#includestatements. -
In main.cpp, add the following text to the bottom of
Main::end_game():get_node<Hud>("Hud")->set_winner_text(new_winner_message); get_node<Hud>("Hud")->update_between_game_message(); get_node<Timer>("HudProcessTimer")->start();This code allows players to see the ID of the winning Mnchar within the UI. (We'll add updates later on to make this ID easier to interpret.) In addition, it begins the two-second timer that we just created within our Main scene.
If you'd like, you can also remove the
UtilityFunctions::print(new_winner_message);line, since it's now redundant.(References 53 and 54)
-
Back within main.h, insert
#include <godot_cpp/classes/timer.hpp>at the end of your list of#includestatements. Next, addvoid _on_hud_process_timer_timeout();right beforevoid _ready()within this file'spublicsection. -
Within main.cpp, add the following code right before
Main::_ready():void Main::_on_hud_process_timer_timeout() { get_node<Hud>("Hud")->clear_mnchars_to_include(); get_node<Hud>("Hud")->set_instructions_text( get_node<Hud>("Hud")->get_instructions()); get_node<Hud>("Hud")->update_between_game_message(); get_node<Hud>("Hud")->set_can_launch_new_game(true); get_node<Hud>("Hud")->set_process_mode(PROCESS_MODE_ALWAYS); }_on_hud_process_timer_timeoutresets the Hud's list of players to include; makes the instructions text visible again; and reactivates the Hud class's_processfunction. It also sets the Hud class'scan_launch_new_gamevariable back to true so that a player can once again launch a new game by holding down the Fire and Reset buttons.(Note: The code may very well function fine without the
can_launch_new_gamevariable. I added it in just in case the Hud class's_processfunction might continue to emit astart_gamesignal before that function gets shut down by the Main class.) -
Next, add the following code at the very start of
Main::_ready():void Main::_ready() { get_node<Timer>("HudProcessTimer")->connect( "timeout", Callable(this, "_on_hud_process_timer_timeout")); }This code addition connects the timer's built-in
timeout()signal to the_on_hud_process_timer_timeoutfunction. (Thistimeout()signal can be found within the Signals tab when the HudProcessTimer is selected within the Godot editor.) -
We'll also need to register
_on_hud_process_timer_timeoutwithin main.cpp's_bind_methods()function so that the signal-connection code within_ready()works. You can do so by adding the following code to the bottom of this function:ClassDB::bind_method(D_METHOD( "_on_hud_process_timer_timeout"), &Main::_on_hud_process_timer_timeout); -
Compile your code, restart the editor, and launch your game. Try adding some players to the game, then clear all but one player out in order to end it. Once the game ends, you should see the a message about the winning player (but no other text). Then, after 2 seconds, you should see the game's instructions and regain the ability to add players.
We're not far from finishing the game at this point! However, we still need to add a few more UI elements, including running overall stats totals--and allow players to reset both those stats totals and active games.
Right now, we're referring to players using integers. However, these numbers won't make much sense to players on their own. Therefore, we'll add in a dictionary that allows players' colors to be displayed together with their IDs.
-
First, at the end of your
privatesection within hud.h, add the following code:const TypedDictionary<String, String> mnchar_id_color_name_dict{ {String("0"), "Blue"}, {String("1"), "Green"}, {String("2"), "Cyan"}, {String("3"), "Red"}, {String("4"), "Magenta"}, {String("5"), "Yellow"}, {String("6"), "White"}, {String("7"), "Black"}};This dictionary clarifies which player color refers to which ID. (Each ID's color name should of course match its corresponding color value within the Main class's
mnchar_id_color_dict.) -
In addition, right after
~Hud();within hud.h'spublicsection, add:TypedDictionary<String, String> get_mnchar_id_color_name_dict() const;This will allow the Main class to retrieve this new dictionary. (We could also simply have made this dictionary public.)
-
Within hud.cpp, add the following function right before your
Hud::get_instructions()definition:TypedDictionary<String, String> Hud::get_mnchar_id_color_name_dict() const { return mnchar_id_color_name_dict; }(I had originally forgotten to add in this definition, which prevented Godot from successfully retrieving my classes within the editor. Adding the definition back in, compiling my code, and restarting Godot solved this issue.)
-
Still within hud.cpp, add the following right after
mnchars_to_include.append(strint)withinHud::_process()'s firstifstatement:entrants_text += "Added Player " + strint + " (" + String(mnchar_id_color_name_dict[strint]) + ") to the game.\n"; update_between_game_message();This will not only help players link IDs and colors, but also notify them when a particular player has been added to the game.
-
We can also use this ID-color name dictionary to improve our game-winner message. First, though, we'll need to give main.cpp access to it. One option would be to copy and paste its definition into main.h, but this would then double the work needed to maintain it. Instead, simply add the following to the end of the
privatesection of your main.h code:TypedDictionary<String, String> mnchar_id_color_name_dict; -
Next, open up main.cpp. Add the following to the very start of
Main::_ready():mnchar_id_color_name_dict = get_node<Hud>("Hud")->get_mnchar_id_color_name_dict();We're simply initializing the Main class's ID-color name dictionary as a copy of the Hud class's. This way, any changes we make to the Hud class's dictionary will automatically get applied within our Main class as well.
-
Within main.cpp, go to your
Main::end_game()function. Replace the following code:String new_winner_message = "The winning player \ is: " + winning_mnchar_id + "\n\n";With the following:
String new_winner_message = "The winning player \ is: " + winning_mnchar_id; if (winning_mnchar_id != "Nobody") { new_winner_message += " (" + String(mnchar_id_color_name_dict[ winning_mnchar_id]) + ")"; } new_winner_message += "\n\n";This code accounts for the possibility that two players will hit each other at exactly the same time, in which case no one will be the winner.
-
Compile your code, restart the Godot editor, and launch the game. You should now be able to see a list of entrants within the text that appears prior to the start of a new game. (This list will also get updated automatically whenever a new player gets added. In addition, the colors corresponding to these entrants' IDs, along with the winner's ID, will now be present within the between-game display.)
-
So far, the only 'stat' we're sharing with players is who won each game. It would be interesting, however, to keep track of--and display--the number of hits each player scored within each game, along with the overall number of hits and wins across games. Now that we have a decent amount of Hud code in place, this will be relatively easy to implement. First, add the following code to the bottom of the
privatesection of main.h:TypedDictionary<String, int> hits_achieved{}; TypedDictionary<String, int> overall_hits_achieved{}; TypedDictionary<String, int> overall_wins{};These dictionaries will store the three stats mentioned above.
-
Next, open up main.cpp. Directly below
active_players.clear();withinMain::_on_hud_start_game(), add:hits_achieved = TypedDictionary<String, int>{};This will reset any data present within this dictionary, thus ensuring that only stats for the current game are contained within it.
-
Next, right after
active_mnchars.insert(mnchar_id_arg);within this same function, add the following code:hits_achieved[mnchar_id_arg] = 0; if (overall_hits_achieved.has(mnchar_id_arg) == false) { overall_hits_achieved[mnchar_id_arg] = 0; } if (overall_wins.has(mnchar_id_arg) == false) { overall_wins[mnchar_id_arg] = 0; }This code checks to see whether the ID of the Mnchar that just got added to the game is present within our overall hit and win dictionaries. If it's not, it will get added to both with a starting value of 0.
(Reference 55)
-
Within your
Main::_on_mnchar_mnchar_hit()function definition in main.cpp, add the following code aboveactive_mnchars.erase(hit_mnchar_id_arg);:int current_hit_value = hits_achieved[firing_mnchar_id_arg]; current_hit_value += 1; hits_achieved[firing_mnchar_id_arg] = current_hit_value; int current_overall_hit_value = overall_hits_achieved[firing_mnchar_id_arg]; current_overall_hit_value += 1; overall_hits_achieved[firing_mnchar_id_arg] = current_overall_hit_value;This code updates both the
hits_achievedandoverall_hits_achieveddictionaries to reflect this new hit. -
Right after
new_winner_message += "\n\n";withinMain::end_game()in main.cpp, add the following code:Array hits_achieved_keys = hits_achieved.keys(); for (int key_index = 0; key_index < hits_achieved_keys.size(); key_index++) { String mnchar_id_arg = String(hits_achieved_keys[key_index]); String current_id_hits_achieved = String::num_int64(hits_achieved[mnchar_id_arg]); new_winner_message += "Player " + mnchar_id_arg + " (" + String(mnchar_id_color_name_dict[mnchar_id_arg]) + ")" + " scored " + current_id_hits_achieved + " hits.\n"; } new_winner_message += "\n";Here, we're storing an array of all of the keys within
hits_achievedso that we can more easily loop through this dictionary. For each of these keys, we'll determine how many hits the corresponding player scored, then add these to our new_winner_message. (This method of looping through a TypedDictionary is based on Reference 56.) -
Compile your code, restart the editor, and launch the game. Once you've hit all other players with a given player, you should be able to see that player's stats within the between-game message screen:
- You may also want to verify that, if you launch a follow-up game with a new set of players, only those players (and not any who were removed after the first game) show up within these post-game hit stats.
-
While you're within the editor, Go ahead and add another Label child of Hud within hud.tscn. Rename it 'ConstantLabel'. Within the Inspector, set its Autowrap value to Word and its Horizontal Alignment to Right. We'll make use of this new label very soon.
-
This label will get displayed on the right side of the screen so as not to overlap with the between-game message. However, before we can set its specific location, we should first expand the game window to full-HD dimensions. Go to Project --> Project Settings, then select the Display-->Window section within the menu on the left. Change the Viewport Width and Viewport Height options within the Window menu to 1920 and 1080, respectively.
(You're welcome to choose smaller dimensions in order to allow your game to display on smaller monitors; you'll just then need to modify certain label settings specified in this tutorial in order to make them compatible with these custom dimensions.)
-
Next, within the Layout section of the ConstantLabel's Inspector window, set the Anchors Preset option to 'Full Rect.' This will allow the text to stay near the right edge of the screen even if it's resized to a different value. (Reference 59)
-
The 'Full Rect' selection should have changed your x and y Size values to 1920.0 px and 1080.0 px, respectively. Change the x Size value to 1900, thus providing a bit of a buffer between the right edge of the text and the right side of the window. Similarly, change the y Position value to 20.0 px in order to create a buffer between this text and the top of the window.
Note: Many of the screenshots in this tutorial reflect an earlier set of text options--so don't worry if the ConstantLabel text within your game doesn't match these screenshots.
-
Now that we have a larger game window, we should also increase our font sizes accordingly. Within the Theme Overrides section of the ConstantLabel inspector, click on Font Sizes, then set the Font Size value to 20 pixels.
-
Increase the Font Size of the BetweenGameLabel to 20 pixels as well using the method described above. In addition, within the Transform section of the BetweenGameLabel's Inspector view, change the window's size to 600 pixels. (This will provide enough room for all between-game text to get displayed, even when 8 players are active.)
-
Next, within main.cpp's
Main::_on_mnchar_mnchar_hit()function, add the following code right beforeend_game(winning_mnchar)within theif (active_mnchars.size() == 1)condition:int current_overall_win_value = overall_wins[winning_mnchar]; current_overall_win_value += 1; overall_wins[winning_mnchar] = current_overall_win_value; -
The game is now keeping track of our overall win and hit information as well, but players don't have any way of viewing those stats. We can resolve this by adding a new set of code to our Hud class that will let us show this information. First, right after
within theprivatesection of main.h, add:String overall_hits_text{""}; String overall_wins_text{""}; -
Next, within the
publicsection of this file, add the following code directly below:void set_overall_hits_text(const String overall_hits_arg); void set_overall_wins_text(const String overall_wins_arg); -
Next, add
void update_constant_message();right belowvoid update_between_game_message();within thisprivatesection.The idea here is to store a 'constant' message (i.e. one that appears both within and between games') made up of overall-hit information and overall-win information. This information will be stored within Strings (
overall_hits_textandoverall_wins_text) that ourupdate_constant_message()function can access. -
Enter hud.cpp. Right after your
Hud::set_entrants_text()definition, add the following code:void Hud::set_overall_hits_text(String overall_hits_arg) { overall_hits_text = overall_hits_arg; } void Hud::set_overall_wins_text(String overall_wins_arg) { overall_wins_text = overall_wins_arg; } -
Next, after your
Hud::update_between_game_message()definition, add:void Hud::update_constant_message() { String constant_message = overall_hits_text + overall_wins_text; auto constant_label = get_node<Label>("ConstantLabel"); constant_label->set_text(constant_message); } -
Because determining what text to place within the overall-hits and overall-wins Strings will be somewhat involved, we'll create separate functions within main.cpp for this process. Add the following code to the end of
main.h'spublicsection:void generate_overall_hits_text(); void generate_overall_wins_text() -
Next, at the end of main.cpp, add the following definitions for these new functions:
void Main::generate_overall_hits_text() { String overall_hits_text = "Overall hits:\n"; Array overall_hits_achieved_keys = overall_hits_achieved.keys(); for (int key_index = 0; key_index < overall_hits_achieved_keys.size(); key_index++) { overall_hits_text += "Player " + String(overall_hits_achieved_keys[key_index]) + " (" + String(mnchar_id_color_name_dict[String( overall_hits_achieved_keys[key_index])]) + "): " + String::num_int64( overall_hits_achieved[overall_hits_achieved_keys[key_index]]) + "\n"; } get_node<Hud>("Hud")->set_overall_hits_text(overall_hits_text); get_node<Hud>("Hud")->update_constant_message(); } void Main::generate_overall_wins_text() { Array overall_wins_keys = overall_wins.keys(); String overall_wins_text = "\nOverall wins:\n"; for (int key_index = 0; key_index < overall_wins_keys.size(); key_index++) { overall_wins_text += "Player " + String(overall_wins_keys[key_index]) + " (" + String(mnchar_id_color_name_dict[String( overall_wins_keys[key_index])]) + "): " + String::num_int64(overall_wins[overall_wins_keys[key_index]]) + "\n"; } get_node<Hud>("Hud")->set_overall_wins_text(overall_wins_text); get_node<Hud>("Hud")->update_constant_message(); }These functions iterate through the overall_hits_achieved and overall_wins dictionaries to determine the number of hits and wins, respectively, achieved by each player during all games played thus far. They then update Hud's
overall_hits_textandoverall_wins_textvalues with this data, then callupdate_constant_message()to add these stats to the game window.(We could also have had these new functions return their text values, then call
set_overall_hits_text(),set_overall_wins_text(), andupdate_constant_message()separately.) -
The final step is to add in calls to these two new functions where needed. First, after
active_mnchars.erase(hit_mnchar_id_arg);withinMain::_on_mnchar_mnchar_hit(), addgenerate_overall_hits_text();. Next, right beforeend_game(winning_mnchar);within this same function, addgenerate_overall_wins_text();. -
Finally, at the very end of
Main::_on_hud_start_game, add:generate_overall_hits_text(); generate_overall_wins_text();This code will cause
update_constant_message()to get called two times in quick succession. If we had excluded that function fromgenerate_overall_hits_text()andgenerate_overall_wins_text(), we could then just call it once here. However, the current approach, while not the most efficient possible, shoudn't be too much of a computational burden. -
Compile your code, restart the Godot editor, and launch your game. Try adding 8 players to the game, then hit seven of them with one of your players. Next, within the ensuing between-game menu, try adding another set of 8 players to the game. Your Hud text should appear as follows:
Although a larger font size would have been useful, the 20-pixel size we chose allows all of this text to fit within the screen, and prevents the constant text from overlapping with the game area, which would be a major distraction for players.
In certain cases (and not just because they lost!), players may wish to exit out of an active game. For example, it's currently possible to add just one player to a game; this would result in a 'softlock' (https://en.wiktionary.org/wiki/softlock), as there's no way to exit out of this round without shutting down the game.
In addition, players might want to reset the hit and win stats on occasion. (For instance, players might want to do one or more practice rounds, then reset the statistics so that such rounds don't count towards their overall score.)
This part of the tutorial will add both of these features to the game using the Reset button that we've already added to our input map.
-
We'll start by giving players the ability to exit out of a game. Within mnchar.h, add the following line at the end of the
privatesection:double mnchar_game_reset_timer = 0.0; -
Next, within mnchar.cpp's
_physics_processfunction, add the following code right belowauto input = Input::get_singleton():if (input->is_action_pressed("reset_" + mnchar_id)) { mnchar_game_reset_timer += delta; } else { mnchar_game_reset_timer = 0.0; } if (mnchar_game_reset_timer >= 2.0) { emit_signal("reset_game"); }As a result of this addition, if a given player's Reset button is pressed continuously for at least 2 seconds (causing
mnchar_game_reset_timer's value to reach 2.0), a "reset_game" signal will be emitted. If this button gets released before 2 seconds have passed, though, themnchar_game_reset_timerwill get set back to 0.0.) -
For this signal to get picked up successfully by other classes, it must be listed within
Mnchar::_bind_methods(). You can do so by adding the following code right after the existingADD_SIGNALcall within this function:ADD_SIGNAL(MethodInfo("reset_game")); -
Open up main.h. Add
void _on_mnchar_reset_game();to the end of this file'spublicsection. -
Next, open main.cpp. Add the following code to the end of this file's
_bind_methods()code:ClassDB::bind_method(D_METHOD("_on_mnchar_reset_game"), &Main::_on_mnchar_reset_game); -
Next, add the following function definition to the very end of main.cpp:
void Main::_on_mnchar_reset_game() { Array hits_achieved_keys = hits_achieved.keys(); for (int key_index = 0; key_index < hits_achieved_keys.size(); key_index++) { String current_id = String(hits_achieved_keys[key_index]); int current_id_hits_achieved = hits_achieved[current_id]; overall_hits_achieved[current_id] = int(overall_hits_achieved[current_id]) - current_id_hits_achieved; } generate_overall_hits_text(); hits_achieved = TypedDictionary<String, int>{}; end_game("Nobody"); }This function first removes any hits scored within the current game from the overall-hits dictionary; that way, such hits won't count going forward. It then clears out the hits_achieved dictionary (so any hits within the game won't be reported within the between-game message); updates the game's overall-hits text; and calls end_game() without declaring any player the winner.
-
This new code won't have any effect quite yet, as we haven't yet connected the
reset_gamesignal emitted by the Mnchar to the Main class's corresponding function. To make this connection, add the following code right afternew_mnchar->connect("mnchar_hit", Callable(this, "_on_mnchar_mnchar_hit"));withinMain::_on_hud_start_game():new_mnchar->connect("reset_game", Callable(this, "_on_mnchar_reset_game")); -
Compile your code, restart the Godot editor, and launch the game. Try hitting at least one, but not all players, with a given player; next, press and hold the reset button (e.g. 'O' on a QWERTY keyboard) to have Player 0 reset the game. You should see a 0 next to each player ID within the 'Overall hits' section of the constant-game message; in addition, you shouldn't see any hits from the game you just exited within the between-game message.
-
We'll now allow players to reset the overall hit and win stats within the between-game menu. Within hud.h, add the following code to the end of the
privatesection:TypedDictionary<String, double> id_reset_time_dict{ {"0", 0.0}, {"1", 0.0}, {"2", 0.0}, {"3", 0.0}, {"4", 0.0}, {"5", 0.0}, {"6", 0.0}, {"7", 0.0}};This dictionary will keep track of how long each player has continuously held down the reset button, thus allowing us to determine whether or not to reset the game's overall stats. I think hardcoding all eight possible IDs is acceptable here, as it simplifies this section's code.
-
Within hud.cpp, add the following set of code right before the line in
Hud::_process()that readsif ((input->is_action_pressed("fire_" + strint)):if (input->is_action_pressed("reset_" + strint)) { id_reset_time_dict[strint] = double( id_reset_time_dict[strint]) + delta; } else { id_reset_time_dict[strint] = 0.0; } if (double(id_reset_time_dict[strint]) >= 2.0) { emit_signal("reset_overall_stats"); id_reset_time_dict[strint] = 0.0; }This code is similar to the reset-game code that we added within the Mnchar class. Note, however, that once a reset command has been triggered, that player's entry within the
id_reset_time_dictwill get reset to 0, thus preventing redundant signals from getting emitted. -
Still within hud.cpp, add
ADD_SIGNAL(MethodInfo("reset_overall_stats"));to the bottom of yourHud::_bind_methods()function. -
Add
void _on_hud_reset_overall_stats();to the end of main.h'spublicsection. -
Within main.cpp, add the following code to the end of your
_bind_methods()function:ClassDB::bind_method(D_METHOD("_on_hud_reset_overall_stats"), &Main::_on_hud_reset_overall_stats); -
Add the following code to the end of main.cpp's
Main::_ready()function:get_node<Hud>("Hud")->connect( "reset_overall_stats", Callable(this, "_on_hud_reset_overall_stats"));These steps are all quite similar to those you just took to implement your game-reset feature.
-
Next, right after your
Main::end_game()function in this same file, add the following code:void Main::_on_hud_reset_overall_stats() { overall_hits_achieved = TypedDictionary<String, int>{}; overall_wins = TypedDictionary<String, int>{}; generate_overall_hits_text(); generate_overall_wins_text(); }This function clears out our two overall-stats dictionaries, then updates our constant message accordingly.
Note that this setup only allows overall statistics to get reset outside of games (since, when a game is active, the Reset button will instead cause the player to exit the game).
To conclude this tutorial, we'll add two finishing touches to the game that, while not strictly necessary, are well worth the minor effort they involve.
First, you may have noticed that players can travel off the game area--and, indeed, outside the camera's view. To keep them within the lovely green square we've created, we'll now add walls around it. (The steps for creating these walls will be very similar to those we used to create the game area itself.)
-
Within your Godot editor, double-click on main.tscn, then select your Main node. Add a StaticBody3D child of this node and rename it RightWall. Add a CollisionShape3D as a child of RightWall.
-
Set the CollisionShape3D's shape to a BoxShape3D. Open up this BoxShape3D's edit menu, and set its x, y, and z Size values to 1.0, 3.0, and 60.0, respectivey.
-
Within the Transform section of the RightWall's Inspector menu, set the x and y Position values to -30.5 and 0.5, respectively. This should make the bottom of this wall flush with the bottom of the Ground object, and its inner side flush with the Ground object's edge.
-
Click 'Collision' within the CollisionObject3D section of this Inspector menu to open up the wall's Layer and Mask settings. Deselect the '1' from both the Layer and the Mask entries, then select the '5' within the Layer section.
-
Double-click on mnchar.tscn. Within the CollisionObject3D section of the Mnchar's Inspector menu, select the '5' within the Mask section. This will cause the wall we just created to block the player's progress.
-
Launch your game, then try to move a player past the right edge of the game area. The CollisionObject3D, though invisible, should successfully stop the player. (It won't stop projectiles, but you can configure that behavior also if you'd like.)
-
We can certainly keep this wall invisible, but giving it a shape and color will be a nice aesthetic touch. Add a MeshInstance3D as a child of RightWall, then assign it a BoxMesh. Open up this BoxMesh's edit menu, then make the x, y, and z Size values equal to the corresponding CollisionShape3D's values (i.e. 1.0, 3.0, and 60.0, respectively). Within the Edit menu's Material section, create a new StandardMaterial3D. Open up the Albedo section within this sphere's Edit menu, then change the color to whatever you'd like. (I chose my wall color by flipping the R (32) and G (128) colors of my ground while keeping the B value (64) constant. Thus, my wall's R, G, and B values are 128, 32, and 64, respectively.)
Here's what this wall should look like at this point (though your color may vary):
-
Now that we've created one wall, the other three will go much faster. Right click on your RightWall; select 'Duplicate'; and rename the new 'RightWall2' node that has appeared to 'LeftWall'. Within the Transform section of its Inspector, set the x position to 30.5.
-
Create another duplicate of RightWall; name this one TopWall. Change its y rotation within its Inspector's Transform menu to 90.0 degrees; its x position to 0.0; and its z position to 30.5. In addition, within the edit menus of the CollisionShape3D's BoxShape3D and the MeshInstance3D's BoxMesh, increase the x size values from 60.0 to 62.0. (This will fill in the corners of your walls.)
-
Finally, create a duplicate of TopWall; name it BottomWall; and change its z position within the Transform menu from 30.5 to -30.5.
Here's what your Main scene should look like now that all four walls have been added in:
-
Finally, we'll make Projectile colors equal to those of the Mnchars who are firing them. This will make it easier to identify which projectiles came from which players--but, more importantly, it looks cool. (Our code for updating Projectile colors will be very similar to our code for updating Mnchar colors.)
-
Open up projectile.h. At the bottom of your
#includestatements, add:#include <godot_cpp/classes/base_material3d.hpp> #include <godot_cpp/classes/mesh_instance3d.hpp> -
Next, add
Color projectile_color_argas a third parameter to yourstart()function. The declaration should now appear as follows:void start(const Transform3D transform, const String firing_mnchar_id, const Color projectile_color_arg); -
In addition, add
void set_projectile_color(const Color projectile_color_arg);after thisstart()function. -
Within projectile.cpp, add the following function definition right above
void Projectile::start():void Projectile::set_projectile_color(const Color projectile_color_arg) { Ref<BaseMaterial3D> projectilebody_mesh_material_3d = (get_node<Node3D>("Pivot") ->get_node<MeshInstance3D>("Body") ->get_mesh() ->surface_get_material(0)); projectilebody_mesh_material_3d->set_albedo(projectile_color_arg); get_node<Node3D>("Pivot") ->get_node<MeshInstance3D>("Body") ->get_mesh() ->surface_set_material(0, Ref<Material>(projectilebody_mesh_material_3d)); }This is essentially the same function as
Mnchar::set_mnchar_color()within main.cpp. I simply replaced 'Mnchar'/'mnchar' substrings with 'Projectile'/'projectile'. -
Still within main.cpp, add
const Color projectile_color_argas a third argument to yourvoid Projectile::start()function definition. Next, belowset_transform(transform);within this definition, addset_projectile_color(projectile_color_arg);. -
Now navigate over to mnchar.cpp. Within
Mnchar::shoot_projectile(), add the following code right before this function'sprojectile->start()call:Ref<BaseMaterial3D> mncharbody_mesh_material_3d = (get_node<Node3D>("Pivot") ->get_node<MeshInstance3D>("Body") ->get_mesh() ->surface_get_material(0)); -
Next, at the end of your projectile-> start() call, add
mncharbody_mesh_material_3d->get_albedo()as a third argument. This way, the Mnchar's color will also get assigned to the projectiles that it fires.(An alternative approach here would have been to store the Mnchar's initial color argument (passed to it by main.cpp) as a Color variable, then pass that variable to
projectile->start(). This would save us the trouble of retrieving the Mnchar's current color; however, the approach I took will also allow any updates to the Mnchar's color to also get reflected within the color of its projectiles.) -
Open up your Godot editor; double-click on Projectile.tscn; select the Projectile's Body node; and open up the Mesh's edit menu. Within this menu, navigate down to the Resource section and check the box next to 'Local to Scene.' Similarly, within the Mesh's Material's edit menu, go down to the Resource option and check that 'Local to Scene' box as well. (This will prevent changes to one projectile's color from affecting all other projectiles within the scene.)
-
Launch your game, then try firing different Projectiles from different Mnchars. The color of each Mnchar's Projectiles should match its own color; in addition, Projectile colors fired by one Mnchar should not change to match those fired by another Mnchar.
-
In order to make the game easier to distribute, it will be helpful to create a single-file release version. The first step is to create a release verison of your code. Navigate to your project's root folder in your terminal (e.g. the same folder from which you normally compile your code), then run the following command:
scons platform=linux target=template_release(Replace 'linux' with your own operating system if needed.)
This will create a release copy of both your own source code and your godot-cpp files. If you navigate to /project/bin, you should find your new shared release library under
libgdexample.linux.template_release.x86_64.so(though your exact name might differ, particularly if you're not using Linux).(Reference 57)
-
Select Project --> Export...; click the 'Add...' button next to Presets in the top left; and select your operating system of choice (Linux in my case).
-
If you haven't exported a game before, you'll probably see a series of red error messages that begin with "No export template found at the expected path:". To resolve this, simply click on the 'Manage Export Templates' text below the error messages, then click 'Download and Install' to the right of the 'Download from:' option. Depending on your internet speed, it may take a little while to download all of the templates. (Reference 58)
Once the templates have finished downloading, go back to Project --> Export to reopen the Export menu.
-
Click the 'On' button to the right of the 'Embed PCK' option. Next, select Export Project. Deselect the 'Export with Debug' option below the 'File:' text box. Hit Save, and you should see two files within your project/ folder--one that contains your program, and another that contains your shared library. (In my case, these are named 'Cpp 3D Tutorial.x86_64' and 'libgdexample.linux.template_release.x86_64.so', respectively.)
-
To make these two files easier to share, you may want to compress them into a .zip file. Note that the game will not work correctly if the shared library isn't in the same folder as the executable.
Congratulations! You have now programmed a multiplayer 3D game in C++ using Godot and its GDExtension feature. I had a lot of fun putting this game and tutorial together, and I hope it helps you in your future programming endeavors!
--Ken Burchfiel
If you'd like to preview the game before starting the tutorial, you can do so either by downloading a Linux binary or by cloning this repository.
To run this game, download the .zip file (together with the Readme) from https://kburchfiel.itch.io/cube-combat; unzip it; and double-click on the executable (Cpp 3D Tutorial.x86_64) in order to run it. At this time, an executable is only available for Linux.
From GitHub:
Clone this repository; copy a compiled version of the godot-cpp library into a 'godot-cpp' folder within the repository; and then compile this folder itself by running scons. (Note: I haven't yet tested out this setup, but it should work.)
In this 3rd-person-shooter game, your goal is to outlast all other players. This will involve both hitting other players and avoiding their own projectiles. It only takes one hit to remove a player from the map, so be careful!
The game will also keep track of each player's overall win and hit count, though these can also be reset as needed (see below).
This game, being multiplayer-only, is meant to be played with game controllers such as Nintendo Switch Pro Controllers (which I used for testing purposes). The keyboard commands built into the input map are really just meant for development and debugging.
(The following commands may vary among controllers. You can of update them within the Godot editor as needed.)
-
Right trigger: Join a game
-
Launch a game: Hold down left and right trigger at the same time
-
Holding down the left trigger for two or more seconds will reset all overall hit and win stats.
-
Note: If you accidentally add a player to the game, simply launch and reset the game to return to this menu. (See below for more details)
-
Fire: Right trigger. (This command will not work if the left trigger is also being held down.)
-
Move forward and back: Left joystick (up/down)
-
Strafe left and right: Left joystick (left/right)
-
Rotate left and right: Right joystick (left/right)
-
Holding down the left trigger for three or more seconds will return you to the player-selection menu while removing all hits scored in the current game from the overall-stats dictionary.
-
When first starting a round, I suggest launching a practice round so that everyone can get acquainted with his or her controls. You can then reset out of this round (without having it affect your stats) by holding down the left trigger for three or more seconds.
-
There are currently no limits on (1) how quickly you can fire projectiles or (2) how many you can fire within a game. I might update these settings within future versions of the game, though.
-
There's no music--but feel free to play your own tracks in the background!
Notes:
-
In some cases, a reference within this list was itself based heavily (or even entirely) on another reference. However, in order to keep this list simple and manageable, I won't include such details. You can find more information about the sources used for these references within their own repositories.
-
In addition, references that begin with '/godot-cpp' refer to a file within a compiled godot-cpp repository. (See Part 1 for more details on (1) how to access and compile this repository and (2) the exact version I'm using.)
-
Not all references are listed within every paragraph. For instance, if multiple paragraphs in a row use the same reference, I might only cite that reference within some of those paragraphs. In addition, once I've introduced a reference for a certain set of code (e.g. iterating through a TypedDictionary), I generally won't include it a second time.
-
Reference 1: https://docs.godotengine.org/en/4.6/tutorials/scripting/cpp/gdextension_cpp_example.html
-
Reference 2: https://github.com/kburchfiel/godot_cpp_3d_demo/
- Since this repository is essentially a step-by-step tutorial to creating the code in Reference 2, both source files correspond very closely to one another. For example, the mnchar.h code within this repository was based mostly on https://github.com/kburchfiel/godot_cpp_3d_demo/blob/main/src/mnchar.h .
-
Reference 3: https://docs.godotengine.org/en/4.6/getting_started/first_3d_game/01.game_setup.html
-
Reference 4: https://docs.godotengine.org/en/4.6/getting_started/first_3d_game/03.player_movement_code.html
-
Reference 5: https://docs.godotengine.org/en/4.6/getting_started/first_3d_game/02.player_input.html
-
Reference 6: /godot-cpp/gen/src/classes/character_body3d.cpp
-
Reference 7: /godot-cpp/gen/include/godot_cpp/classes/character_body3d.hpp
-
Reference 8: https://github.com/kburchfiel/cpp_yf2dg_gd_4pt_6
-
The vast majority of the code in this repository came from https://github.com/j-dax/gd-cpp , which was released under the BSD-3-Clause license by Matthew Piazza. My repository simply converted that code into a step-by-step guide (similar to this one one).
-
In order to simplify this reference list, I won't link to all of the individual source files that I consulted. However, I recommend checking player.cpp and player.h for the Mnchar class's code; main.cpp and main.h for the Main class's code; and hud.cpp and hud.h for the Hud class's code. (These source files are available within https://github.com/kburchfiel/cpp_yf2dg_gd_4pt_6/tree/main/src .)
-
-
Reference 9: https://godotengine.org/releases/4.3/#gdextension-runtime-class-registration
-
Reference 10: https://forum.godotengine.org/t/gdextension-register-runtime-class/77868/4?u=kburchfiel
-
Reference 11: https://www.reddit.com/r/godot/comments/13ikz4u/best_way_to_handle_controller_input_for_local/
-
Reference 12: https://github.com/remram44/godot-multiplayer-example
-
Reference 13: https://www.gdquest.com/library/split_screen_coop/
-
Reference 14: https://godotassetlibrary.com/asset/QdddqG/multiplayer-input
-
Reference 15: https://kidscancode.org/godot_recipes/3.x/2d/splitscreen_demo/index.html
-
Reference 16: https://docs.godotengine.org/en/stable/tutorials/2d/2d_movement.html
-
Reference 17: https://github.com/godotrecipes/characterbody3d_examples/blob/master/mini_tank.gd
-
Reference 18: https://docs.godotengine.org/en/stable/tutorials/3d/using_transforms.html
-
Reference 19: /godot-cpp/gen/src/classes/node3d.cpp
-
Reference 20: /godot-cpp/src/variant/vector3.cpp
-
Reference 21: /godot-cpp/include/godot_cpp/variant/vector3.hpp
-
Reference 22: https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html
-
Reference 23: https://docs.godotengine.org/en/stable/tutorials/physics/using_character_body_2d.html
-
Reference 24: https://kidscancode.org/godot_recipes/3.x/3d/3d_shooting/
-
Reference 25: https://github.com/godotengine/tps-demo/blob/master/player/player.gd
-
Reference 26: https://github.com/vorlac/godot-roguelite/blob/main/src/entity/projectile/projectile.cpp
-
Reference 27: /godot-cpp/gen/src/classes/node3d.cpp
-
Reference 28: /godot-cpp/include/godot_cpp/variant/basis.hpp
-
Reference 29: https://docs.godotengine.org/en/stable/classes/class_transform3d.html#class-transform3d-method-translated
-
Reference 30: https://docs.godotengine.org/en/stable/classes/class_node.html#class-node-method-get-parent
-
Reference 31: https://docs.godotengine.org/en/stable/classes/class_node.html#class-node-method-add-child
-
Reference 32: https://docs.godotengine.org/en/stable/engine_details/architecture/core_types.html
-
Reference 33: https://github.com/godotengine/godot-cpp/blob/master/test/src/example.cpp
-
Reference 34: /godot_cpp/core/math_defs.hpp
-
Reference 35: https://en.cppreference.com/cpp/container/map
-
Reference 36: godot-cpp/gen/include/godot_cpp/classes/mesh_instance3d.hpp
-
Reference 37: godot-cpp/gen/include/godot_cpp/classes/material.hpp
-
Reference 38: https://discord.com/channels/212250894228652034/342047011778068481/1487545947608322078
-
Reference 39: https://discord.com/channels/212250894228652034/342047011778068481/1489038771600101457
-
Reference 40: https://www.somethinglikegames.de/en/blog/2023/material-synchronization/
-
Reference 41: https://docs.godotengine.org/en/4.6/getting_started/first_3d_game/07.killing_player.html
-
Reference 42: https://docs.godotengine.org/en/4.6/getting_started/first_3d_game/06.jump_and_squash.html
-
Reference 43: https://docs.godotengine.org/en/stable/classes/class_node.html#class-node-method-queue-free
-
Reference 44: https://github.com/godotengine/godot/blob/master/core/templates/hash_set.h
-
Reference 45: godot-cpp/include/godot_cpp/templates/hash_set.hpp
-
Reference 46: https://stackoverflow.com/a/12863273/13097194
-
Reference 47: https://github.com/kburchfiel/godot_cpp_3d_demo/tree/main/input_map_creator
-
Reference 48: https://docs.godotengine.org/en/4.6/getting_started/first_2d_game/06.heads_up_display.html
-
Reference 49: /godot-cpp/gen/src/variant/array.hpp
-
Reference 50: /godot-cpp/include/godot_cpp/variant/variant.hpp
-
Reference 51: https://docs.godotengine.org/en/stable/tutorials/scripting/pausing_games.html
-
Reference 52: godot-cpp/gen/include/godot_cpp/classes/node.hpp
-
Reference 53: https://docs.godotengine.org/en/stable/classes/class_timer.html
-
Reference 54: /godot-cpp/gen/include/godot_cpp/classes/timer.hpp
-
Reference 55: /godot-cpp/gen/include/godot_cpp/variant/dictionary.hpp
-
Reference 56: https://godotforums.org/d/33822-how-to-loop-a-dictionary-in-godot-c-gdextension/3tutorial_screenshots/new_game_project.png)
-
Reference 57: https://docs.godotengine.org/en/stable/engine_details/development/compiling/compiling_for_linuxbsd.html
-
Reference 58: https://docs.godotengine.org/en/stable/tutorials/export/exporting_projects.html
-
Reference 59: https://forum.godotengine.org/t/making-a-textbox-that-scales-with-screen-size/74350/4?u=kburchfiel
(This was originally written for my Godot C++ 3D Demo project (Reference 2)).
When first getting acquainted with C++ in Godot, you might wonder how you can find C++ code equivalents for the GDScript code found within tutorials and other documentation materials. My search for a C++-based CharacterBody3D class shows what this process might look like for you.
Since Godot's Your First 3D Game (YF3DG) tutorial has GDScript and C# (but not C++) code excerpts, I first needed to double-check the name for this class within the C++ API. A content search within my godot-cpp library for 'characterbody' turned up two relevant code files:
-
godot_cpp/classes/character_body3d.hpp (I needed to include this file within the C++ code for my main-character file.)
-
godot-cpp/gen/src/classes/character_body3d.cpp
Using these files, I was able to confirm that this class is also titled CharacterBody3D within the C++ API. I also confirmed that this class has the move_and_slide() function referenced within YF3DG. (A content search for move_and_slide would also have helped me locate the character_body3d.cpp file.)
Once you become more familiar with the godot-cpp library, you may be able to bypass the process of looking up GDScript code by going directly to a particular source file of interest. For instance, to learn how to erase an entry from a HashSet, you can simply go to godot-cpp/include/godot_cpp/templates/hash_set.hpp and check for a relevant method or function (e.g. 'erase()'). In many cases, a function for a GDExtension type may be similar to a C++ STL function (as is the case with erase(): see https://en.cppreference.com/w/cpp/container/set/erase.html .)
Remember that a given function might be available within a class's parent. For example, you won't find has() within typed_dictionary.hpp, but you will find it within dictionary.hpp (godot-cpp/gen/include/godot_cpp/variant/dictionary.hpp).
Certain C++ API code, however, may not have any GDScript equivalent. In that case, you may need to instead look at the reference information for the Godot Engine's own source code--or directly at the code itself. For example, the Core Types page (https://docs.godotengine.org/en/stable/engine_details/architecture/core_types.html) was a huge help when adding dictionaries and sets into my code.
Of course, existing code that makes use of the C++ API can be very useful as well. For instance, the source code for the 'test' section of the godot-cpp project (https://github.com/godotengine/godot-cpp/tree/master/test/src), such as the example.cpp file (https://github.com/godotengine/godot-cpp/blob/master/test/src/example.cpp), was a lifesaver when I was trying to figure out how to get a TypedDictionary to work with my project. See the 'References and resources' section near the top of this page for more examples.
The gdnative-gdextension channel within the Godot-engine Discord (https://discord.com/invite/godotengine) is another great resource to bookmark. I'm very grateful to the participants who helped clarify the questions I asked of them there.
I certainly went through periods of frustration when working on this project. The game would crash without my understanding why; a seemingly-simple update took much longer than expected to implement; documentation on essential processes seemed hard to come by. However, as time went on, I found my confidence with C++--and my understanding of the editor--slowly building.
My personal belief is that these challenges are actually a crucial step towards gaining expertise in the language. It would have been nice to find answers right away, of course, but the hours I spent on debugging and testing helped me become much more acquainted with the editor. Plus, it felt so good once things finally worked!
So, if you find yourself going through challenges and frustrations of your own, don't give up--and remember that those challenges will make you a stronger developer in the end.
Saint Carlo Acutis, Pray for us!
MIT License
Copyright (c) 2026 Kenneth Burchfiel
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
This project also incorporates J-Dax's C++ code for the Your First 2D Game tutorial for Godot 4.3 (https://github.com/j-dax/gd-cpp). That code has been released under the BSD 3-Clause license:
Copyright 2025 Matthew Piazza
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
The project also makes use of code from Godot's own documentation. This documentation has been released under the Creative Commons 3.0 Attribution license.
In addition, this code makes extensive use of code from the godot-cpp repository (https://github.com/godotengine/godot-cpp). That code has been released under the MIT License.
This game uses Godot Engine, available under the following license:
Copyright (c) 2014-present Godot Engine contributors. Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.



































