Building an X and O game using Godot
Learn game development by making a game
Introduction
Do you love playing video games? I sure do! I enjoy making games too, and it’s highly likely that you would too. Game development is an umbrella term for ranging from designing levels, mechanics to coding the logic to making art and music. It has a little something for everyone. If you would like to learn how to make games, then this guide is a great place to start, if I do say so myself.
In this guide, we will make an X and O game using Godot. This is a great first game to make as it is not too complex, and yet it covers many important aspects of game development.
We will look at some of the most important programming paradigms in game development. We will learn how to make games using Godot.
There are no prerequisites, except for critical thinking, reasoning, and basic computer skills. Prior programming experience is helpful, but not necessary. The goal of the guide is to get you comfortable with using Godot to build simple games.
By the end of this guide, you will be able to:
- Design a gameplay loop for the game
- Create a complete working game
- Display images and text in the game
- Keep track of the state of the game
- Listen for clicks in an area
- Detect and respond to keyboard presses
- Create, emit, and connect signals
- Decide and check win conditions of a game
- Export your game and share it with others
About Godot
We will use Godot – an amazing game engine – to make our game. A game engine is software that handles and integrates various aspects of game development: graphics, physics, logic, sound, interface, input, and more. Some other popular game engines are Unity and Unreal. I will describe some advantages that Godot has over them.
Godot is free and open-source. You can visit Godot’s GitHub page and look at Godot’s source code. You can study how it’s made and how it works. You can report bugs and contribute to the codebase yourself.
Any game that you make using Godot belongs to you, and any profit you make is yours to keep. This is not the case with most other game engines.
Godot works on Windows, macOS, and Linux. In particular, Godot has first-class support for Linux. Since I primarily use Linux, this is perfect for me.
Godot is lightweight. Godot is only a 35 MB download, it starts up in seconds, and is very light on CPU and memory.
Godot is a joy to use. The UI is simple and shows you only what you need to see, allowing you to focus on your work. It is also customisable: you can change the colour scheme and the fonts of the editor as you please.
You can download Godot for free from Godot’s website. We will write code in GDScript and not C#, so we do not need the Mono version. Select your operating system, download the Standard version, and extract and run!
This guide uses Godot 3.3.2, the latest stable version available at the time of writing. Godot tries to maintain backward compatibility, so this guide should work for future versions of Godot 3 as well.
The complete code of the finished version of the game is available on my GitHub page. If you face any issue, if something’s not working, you can compare your code with it. You can also play the final version game to see how it works on my itch.io page.
Let’s get started!
Setting things up
Planning the game
Before we start any work on a game in Godot, it is important to plan out how the game would look and behave. Having a concrete picture of the game in mind will help us when we develop the game in Godot.
Here are some helpful questions that we can ask:
- What is the genre/theme of the game?
- What is the name of the game?
- How many players can play the game? (single-player or multiplayer)
- What are the objectives of the game?
- What device will it be played on?
- How does the user interact with the game?
- How does the user interface look like?
- What happens in a typical gameplay?
- How long does a gameplay session last?
This list isn’t exhaustive, but it gets you thinking about most major details of the game. The answers to these questions will directly impact the decisions you take when you develop the game. For example,
- The theme of the game will affect the choice of your art, sound, and music.
- A multiplayer game will have different input systems and networking requirements compared to a single-player game.
- Games developed for mobile will have a different interface compared to games developed for desktop.
Let’s answer these questions for our X and O game.
- It is a puzzle game. It is bright and colourful.
- We will call it “X and O”.
- It is played by two players.
- The objective of the player is to place three of the same symbol in a row, column, or diagonal.
- It can be played on desktop.
- The player clicks on the squares to enter symbols.
- I have made a rough sketch how the interface would look like. This will be a simple game with just one scene. In more complex games, we would need more scenes.
- We will describe the gameplay loop using a flowchart. It will also tell us how long a session will last.
The gameplay loop
We want to write down how the state of the game changes as the player plays the game. We want to explicitly state how the game receives inputs, what decisions it takes, what changes it makes, and how all of these relate to each other.
“X and O” is a small and simple game, so its gameplay loop is simple too.
To summarise:
- When the game starts, we listen for clicks on the grid.
- When the player clicks on a cell, we show the appropriate symbol in that cell.
- We then compute if someone has won. If not, then we check if the game has drawn.
- If the game has ended (by a win or a draw), then we display the name of the winner and give an option to start a new game. Otherwise, we go back to listening for clicks.
Each round of the game lasts no more than a minute. Players may play multiple rounds in a session.
Now that we have a good picture of how the game would work, we are ready to start working in Godot.
Project setup
Open Godot and create a new project. We will call it x-and-o
. Choose an empty folder to save the project in, preferably with the same name as the game.
Select “Create & Edit” to create the project.
By default, when we start a new project, Godot starts in 3D mode. We are making a 2D game, so switch to 2D mode by clicking on “2D” at the top. The default shortcut to enter 2D mode is Ctrl + F1.
Screens come in a variety of shapes and sizes. Some are square, some are tall, some are wide. We want our game to render correctly on all screens. Since this is our first game, we will keep it simple. We will target a fixed resolution, so we do not have to worry about making the layout responsive.
To achieve this, open Project -> Project Settings.
In the General tab, look for the section named “Window” in “Display”.
- Set
Width
to1280
andHeight
to720
. This sets the aspect ratio of the game to 16:9, the most common aspect ratio for screens. - In the same section, scroll down to “Stretch”, and set
Mode
to2d
andAspect
tokeep
. Now, if the aspect ratio of the screen is not 16:9, then Godot will not distort the content. It will pad the content with black bars instead.
This way our game looks reasonably good on all screens with minimal effort. We will learn how to make a responsive layout another time.
By default, the background colour of the game is dark grey. Let’s change the background colour. In the same “General” tab of project settings, go to Rendering -> Environment -> Default Clear Colour.
As mentioned before, I want the game to be bright and colourful.
I set it to #ffffff
, which is the hex code for white. Feel free to choose a colour that goes with your theme.
Importing assets
Looking at the sketch interface, we see that we need to display some X’s, some O’s, and the grid on the screen. We would load the images for these in Godot. We show some text in our game, and we will need to load a nice font for it.
I have made an assets folder that you can use to make the game. It contains x.png
, o.png
, grid.png
, and Inter-Regular.ttf
.
You can
I have included the Inter
font in the assets. I chose this font because it looks sharp even at low resolutions and it goes well with the “X” and “O” sprites that I have made. You can use any font that goes with your theme.
We need to add these assets to our project in Godot.
Make an assets
folder in your game’s root folder x-and-o
using your file manager. (Use File Explorer if you are using Windows, Finder if you are on macOS). Move the sprites and the font in it.
When you open Godot, you will see those files in the FileSystem tab.
Building the interface
We have imported all the resources that we need in Godot. We can now start building the user interface.
Whether we want to display an image, play a sound, or display some text in Godot, we do so by adding a node for it. A Godot game is made by composing together different kinds of nodes.
We will add a root node that will hold all nodes in our game.
Click on Create root node
-> 2D node
in the Scene tab. This will create a root node named Node2D
. Rename it to Game
.
Now that we have a root node, we can save the scene and run the game. Save the scene by pressing Ctrl+S.
Make a scenes
folder in the root directory and save this scene in it as Game.tscn
.
Run the game by clicking the play button (or press F5). The first time you run the game, Godot will ask you to set the main scene. The main scene is the scene that loads when you run the game. Select the Game.tscn
scene we just saved.
Of course, we haven’t added anything to our scene yet, so we only see a blank screen when the game is run. Close the window (or press F8) to stop the preview.
We look to our sketch to help us build the interface. We need a grid, some X’s, some O’s, and some text labels.
Let’s start with the labels because they are simpler.
Labels
We can display text in a game with a Label
node. Select Game
and click on the plus icon to add a child node. In the “Create New Node” dialog that appears, search for Label
, and click on “Create”.
A Label
node appears as a child of Game
in the scene tab.
Rename the name of this node to GameName
because that’s what it is.
We do not see any change in our game because the label has no text. We can add/edit the text of the Label
by editing the Text
property in the inspector tab. This label will display the name of the game: “X and O”. Change the text of the label to “X and O”.
The label is hard to read: it is small and is white on a white background. We will fix this by using a custom font.
In the inspector tab, in Custom Fonts
, enable the Font property. Click on Empty -> New DynamicFont.
Click on “Dynamic Font” to show some additional settings. In Font -> Font Data, click on Empty -> Load and select your font file. We can adjust the font size in Settings
. We would like the name of the game to be big, so I set the font size to 96
. We can also change the font colours in Custom Colours -> Font colours. Play with the parameters until you have happy with the appearance.
We have three more labels to show in our interface. Repeat this process three times to end up with a total of four labels.
I made three new Label
nodes with names WinCondition
, PlayerTurn
, and PlayerInstruction
. My game looks like this now.
Our text looks good. Next, we will add the grid.
The Grid
The grid is a bit more complex. In addition to showing the grid, we also need to listen for clicks and display an “X” or “O” in each of the nine cells.
Create a 2D node and name it Grid
. We will store all nodes related to the grid as children of this node. This will make our Game
scene more organised.
We can show images in Godot using the Sprite
node.
Add a child node to Grid
of type Sprite
, and rename the node to GridSprite
.
We do not see any change in our game because the sprite does not have a texture. Drag and drop grid.png
from the FileSystem to the Texture
property of the sprite.
We can see the grid in our game now, but it’s too big for our screen. We need to scale it down.
In GridSprite
, in transform, set the Scale to 0.3
and 0.3
.
The grid fits in the screen now.
We want to be able to detect clicks.
We can detect clicks in an area using the Area2D
node. We will have nine Area2D
nodes, one for each cell of the grid.
First, let’s add one Area2D
node as a child of Grid
, and rename it to GridCell
. This gives
a yellow warning symbol. Hover over it, and it tells us to add a CollisionShape2D
as a child.
We get this warning because we have an Area2D
node, but we haven’t defined its area. Add a CollisionShape2D
as a child of GridCell
to remove this warning.
The CollisionShape2D
now shows a warning: it needs a shape to function. Our cells are squares, so we select New RectangleShape2D in the inspector tab.
Resize and move the blue area so it covers one of the grid cells. Using Grid Snap will make it easier to fit the CollisionShape2D on the cell perfectly.
We would like to show “X” and “O” symbols in the cells when the game is played. Recall that we show images using the Sprite
node.
Add a Sprite
as a child of GridCell
, and rename it to CellSymbol
. Choose the texture as x.png
. If you are using my assets, you will need to scale it to 0.3
and translate it by 96
and 96
to centre the image in the cell.
Our GridCell
node is ready. We could duplicate it nine times and our game would work.
However, if we want to make any changes to this node in the future, we would have to apply the changes on all nine nodes. This is tedious at best, and a source for errors at worst.
We solve this problem by saving the GridCell
node as a scene. Then, anytime we want to use this node, we can create an instance of it. Any changes that we make to the GridCell
scene will be reflected in all of its instances.
To save the node as a scene, right-click on the node in the Scene tab, select “Save Branch as Scene”, and save it as GridCell.tscn
in the scenes
folder.
The GridCell
node has changed to an instance of the scene. You can see the new button now. Click on the “Open in Editor” button to edit it.
Duplicate the node nine times. Move the nodes so we have one in each grid. Make sure to arrange them in order, because we will refer to them later when we want to detect win conditions. Also, rename GridCell
to GridCell1
for consistency.
The texture that we set in the sprite in GridCell
appears nine times in the grid. If we change the texture of the sprite in the GridCell
scene, say to x.png
, then observe that the sprite of every cell in the grid changes to an “X”.
Since we start the game with the empty grid, we don’t want any of the X’s and O’s to show initially. We will show them one by one, programmatically, when the players play the game. For now, remove the texture from the CellSymbol
sprite in GridCell
to get an empty grid.
We have added all the nodes needed to make the game, but the game is still static and doesn’t do much. We will now add the code to make it dynamic and interactive.
Implementing the logic
We control the behaviour of nodes using scripting. Godot officially supports 4 languages for scripts: GDScript, VisualScript, C#, and C++. You can read more about them in Scripting docs
We will write our scripts in GDScript. Its syntax is similar to Python. You can read the Godot Docs for GDScript for more information about GDScript.
Click on the Attach Script button to attach a script to the Game
node.
This shows the “Attach Script dialog”.
The default options are fine, click “Create”. This will create a file named Game.gd
. This is where will write the logic for the game.
The contents of this script will look like this.
extends Node2D
# Declare member variables here. Examples:
# var a = 2
# var b = "text"
# Called when the node enters the scene tree for the first time.
func _ready():
pass # Replace with function body.
# Called every frame. 'delta' is the elapsed time since the previous frame.
#func _process(delta):
# pass
The _ready
method is called when the Game
node and all its children have entered the scene tree and have become active. Therefore, any work that we want to do when the game starts, we do it in _ready
.
This mostly involves initialising variables.
The _process
method is called every frame. Anything that must be done every frame goes in _process
. For example, in a platformer game, we would need to update the position of the character every frame, so we would calculate its position in _process
. We won’t use _process
much in this game.
Since the function _ready
contains only pass
, and _process
is commented out, these functions don’t do anything now.
Let’s look at the gameplay loop again. The game takes decisions based on the state of the game. We need a way to store the game’s state.
Storing the state of the game
What information would we like to save in a state? Given the state, we must be able to accurately make the decisions in our gameplay flowchart. We must be able to tell if someone has won, if the game has drawn or if it is still going on. In the last case, we must also be able to tell whose turn it is to play. We need to know the state of each cell in order to determine if someone has drawn or not.
We will save the following information in our state:
game_started
: Is a game in progress? If yes, then we need to listen for clicks and check for wins and draws. Otherwise, we need to show a “Start New Game” button.turns_played
: How many turns have been played so far? This value will help us check whether all cells have been filled, in which case the game has ended.player_turn
: We would like to store whose turn it is to play now. We will use this to determine if the next click should add an “X” or an “O”. We will also display this information to the players.cells
: For each cell in the grid, we need to store whether it is empty, has an “X” or an “O”.
For each property, we will declare the variable and initialise it.
We will declare the variables outside _ready
so we can access them in other functions.
We will initialise our variables in _ready
.
game_started
This variable tells us if a game is in progress.
Once the scene is loaded, we are ready to play. We should begin listening for clicks and check for wins. We set game_started
is true
in _ready
.
var game_started
func _ready():
game_started = true # Game has started
turns_played
We keep track of the number of turns played in turns_played
.
When the scene is loaded, no turn has been played so far, so we start turns_played
with 0
.
var turns_played
func _ready():
...
turns_played = 0 # No turns played yet
player_turn
The variable player_turn
will store the name of the player whose turn it is to play. While we can give these players any name we wish, let’s call them player 1
and player -1
.
This choice will make our work in the future easier.
Player 1
will play first and will place X’s. Player -1
will go second and will place O’s.
Let’s initialise player_turn
in _ready
.
var player_turn
func _ready():
...
player_turn = 1 # Player with 'X' goes first
Once a player’s turn ends, the other player gets to play. Therefore, the value of player_turn
must change at the end of every turn.
Let’s write a function change_player
function that changes the state of player_turn
.
We need to care about the details of how we change the player in this function only. Then, any time we want to change the player, we can call change_player
without having to know how it works.
This is called
Due to our choice of player names, this is function is a one-liner.
func change_player():
player_turn *= -1
Calling change_player
switches the value of player_turn
from 1
to -1
and vice versa.
cells
We need to store the state of each cell in the grid.
The variable cells
will be an array in which we store references to all GridCell
nodes.
We store these in an array so we can access them easily.
We will be able to read/edit the properties of the nodes using these references.
We get a reference to a node using the get_node
method. To access a node, we pass the path of the node relative to the current node (the node that the script is attached to) to get_node
.
Since accessing nodes is a common pattern in Godot, there is a shorthand notation for it. We can write $Grid/GridSprite
instead of get_node("Grid/GridSprite")
. We will follow this shorter notation from now on.
Once we have a reference to a node, we can manipulate it. Any changes that we can make in the Inspector tab, we can also make in our script. If you hover your mouse over any property in the inspector tab, you will see the name of the property that you can use in scripts.
For example, if you want to hide the GridSprite
when the game starts, you can do the following:
func _ready():
$Grid/GridSprite.visible = false
If you play the game now, the grid lines will not be visible. This is how we can control nodes from our scripts. (Let’s undo this so the grid is visible when you play the game.)
Now that we know how to access the GridCell
nodes, we might try something like this:
var cells
func _ready():
...
var cell1 = $Grid/GridCell1
var cell2 = $Grid/GridCell2
var cell3 = $Grid/GridCell3
...
var cell9 = $Grid/GridCell9
cells = [cell1, cell2, cell3, cell4, cell5, cell6, cell7, cell8, cell9]
This code works, but there is a lot of repetition. It is difficult to read it or make changes to it. If you find yourself repeating the same code a lot, there is probably a better way to write it.
We can do better. We can make use of the
Create a node of type Node2D
in Grid
and name it GridCells
. Select all nine GridCell
nodes and drag them on to GridCells
to make them children of GridCells
.
Now, all GridCell
we want to access are children of GridCells
.
We can call $Grid/GridCells.get_children()
to get an array of references to the GridCell
nodes.
We can delete the ten lines we added in _ready
and add this one line in its place:
func _ready():
...
cells = $Grid/GridCells.get_children():
The resulting code is much cleaner.
We have references to each GridCell
node. Now, we need to save information in each node. We use a script!
Attach a script to the GridCell
scene.
This creates a script with the name GridCell.gd
. Delete everything except the first line extends Area2D
.
For each cell, we want to store if it is empty, or has an X or an O. Let’s store this information in a variable named value
.
Continuing our notation with the player names, a cell with an “X” has value
equal to 1
and a cell with an “O” has value
-1
. Cells can also be empty. Let empty cells have value
0
.
Declare the value
variable in GridCell.gd
:
extends Area2D
var value
Now, every GridCell
node has a property named value
. If you can access a GridCell
node, you can also access its value
property.
For example, in Game.gd
, cells[0]
holds a reference to GridCell1
. We can access its value
variable with cells[0].value
. We can now read and edit the value
of any GridCell
node.
We can use the print
method to read the value during development. The output of print
is shown in the Output tab (below the code editor).
func _ready():
...
print("The value of GridCell1 is ", cells[0].value)
If we run the game now, we see The value of GridCell1 is Null
in the output. Oops. This is because we declared value
, but did not initialise it.
Since all cells are empty initially, we will initialise the value
of each GridCell
with 0
.
func _ready():
...
for cell in cells:
cell.value = 0
If you print cells[0].value
after the initialisation, you will see it has value
equal 0
and not Null
.
Listening for clicks using signals
We have initialised the state of the game. We need a way to detect when cells are clicked and respond to it.
We will write a function play_turn
in Game.gd
that makes appropriate changes to the game’s state when the player clicks on a grid cell.
func play_turn():
print("play_turn called")
We will fill in this function later. For now, we just want to call it when a cell is clicked. The print
method lets us know if the method is called.
Here’s one way to call play_turn
when a cell is clicked. In GridCell.gd
, we could check if the cell is clicked. If it is clicked, we call the play_turn
method in Game.gd
.
if cell is clicked:
call play_turn in Game.gd
This is acceptable for a small game, but it would quickly get out of hand as the game becomes bigger. For every node that needs to
react to the cell being clicked, we would need to call a method in GridCell.gd
.
if cell is clicked:
call method_a in A.gd
call method_b in B.gd
call method_c in C.gd
call method_d in D.gd
This is bad code design, as we have a lot of code in GridCell.gd
that is not relevant to GridCell
.
A better way of achieving the same effect is to use signals (also known as the observer pattern).
We add only the following code in GridCell.gd
.
if cell is clicked:
emit signal "cell was clicked"
Now, any node that needs to react to the cell being clicked observes GridCell
, and if it hears the cell was clicked
signal, it calls the appropriate method.
listen for signal "cell was clicked" from GridCell
if signal is received:
call play_turn
The concerns remain separated, and the code is more organised and manageable.
As you have seen, signals are a very important concept in game development. We will now see how to implement this in Godot.
Nodes in Godot can emit signals when certain events occur. Each node in Godot has some signals predefined in them. You can see the signals defined for any node by clicking on the Node tab next to the Inspector tab.
We can also make our own custom signals.
For example, in our game, we can make a signal named cell_clicked
for GridCell
. We do this by declaring it in GridCell.gd
.
signal cell_clicked
We can get a node to emit a signal with the emit_signal
method. In the following code, cell
will emit the cell_clicked
signal if it is clicked.
if cell is clicked:
emit_signal("cell_clicked")
Nodes can also listen for signals.
We want the Game
node to listen for the cell_clicked
signal emitted by a GridCell
node.
We use the connect
method to listen for signals. The syntax for connecting signals is:
SOURCE_NODE.connect(SIGNAL_NAME, TARGET_NODE, METHOD_NAME)
The way to read this is if when the SOURCE_NODE
emits the SIGNAL_NAME
signal, the TARGET_NODE
calls the METHOD_NAME
method. Let’s see this in action for our game.
func _ready():
...
# listen for clicks
for cell in cells:
cell.connect("cell_clicked", self, "play_turn")
We want to listen for signals from every cell in the grid, so we have a for
loop that iterates over all cells in the grid.
For each cell, when the cell
node emits the cell_clicked
signal, the self
node (in this case the Game
node) calls the play_turn
method.
Great, we are almost done. We just need to fix the if cell is clicked
statement in GridCell.gd
.
How do we check if a cell is clicked? Godot has a predefined signal for it! We can use the input_event
signal defined in nodes of type Area2D
. If we hover over the name, we see that it is emitted when an input event occurs. What is an input event? We will investigate this soon. First, let’s connect this signal.
We have seen how to connect signals using code. We can also use the GUI to connect signals in Godot. Let’s see how to do that now.
Select the source node, the node that emits the signal. In our game, it’s the GridCell
node in the GridCell
scene. It is of type Area2D
. Open the Node tab, find the input_event
signal and click ``Connect…’'.
We must now select the target node, the node that listens for the signal.
We are listening for the signal in the GridCell
node as well. This is a case where we connect a node to itself.
Also note the “Receiver method” field says _on_GridCell_input_event
. This is the method that will be called in the target node when the signal is emitted.
Select GridCell
and click “Connect”.
This creates the receiver function _on_GridCell_input_event
in GridCell.gd
for us.
func _on_GridCell_input_event(viewport, event, shape_idx):
pass
The _on_GridCell_input_event
takes in three parameters. The signal contains some information about the input_event
and we can access it in this method.
If you add print(event)
in the method and run the game, you will see that moving your mouse on a cell creates an InputEventMouseMotion
event, and clicking on a cell creates an InputEventMouseButton
event.
We are only interested if the players click on a cell. We do not care if the players move their mouse on the grid.
We will filter only the mouse click events with an if
statement.
func _on_GridCell_input_event(viewport, event, shape_idx):
if event is InputEventMouseButton:
print(event)
If you run the game now and move the mouse on the grid, the output does not change. If you click on a cell, two mouse click events appear. Once when you press the button, and once when you release it. We would like to emit only one signal per click.
func _on_GridCell_input_event(viewport, event, shape_idx):
if event is InputEventMouseButton and event.is_pressed():
print(event)
This is working well. We are printing an event if and only if a cell is clicked. We can replace print
with emit_signal
.
func _on_GridCell_clicked(viewport, event, shape_idx):
if event is InputEventMouseButton and event.is_pressed():
emit_signal("cell_clicked")
Now every time we click a cell, play_turn
is called and we see play_turn is called
in the output.
We know if a cell is clicked, but we don’t know which cell is clicked.
Signals can carry additional data that can be passed as a parameter to the method. We can add additional data to a signal by appending arguments in the emit_signal
method.
func _on_GridCell_clicked(viewport, event, shape_idx):
if event is InputEventMouseButton and event.is_pressed():
emit_signal("cell_clicked", self)
Here, we are passing self
as additional data in the signal. Any node that is listening for the cell_clicked
signal will also get to know the node that has emitted the signal.
Since the signal is now carrying additional data, we need to update its declaration to reflect this change.
signal cell_clicked(cell)
We fix this by adding an argument to play_turn
. We can print the name of the cell and see if it’s correct.
func play_turn(cell):
print("play_turn called")
print(cell.name, " was clicked")
Run the game, click on a cell, and see that the correct name is printed in the output.
Great! We can detect if a cell is clicked, and if so, then which cell is clicked in Game.gd
. We will use this information to make appropriate changes to the game’s state.
Executing a turn of the game
We will now fill in the play_turn
method. Recall that it is called when the cell_clicked
signal is emitted, which happens when a player clicks on a cell.
We look at the gameplay loop flowchart and describe how play_turn
should behave.
- If the game has not started, we do nothing.
- If a cell is clicked, then
- We need to set its value depending on
player_turn
. - We stop listening for clicks on this cell.
- Increment
turns_played
. - Check for wins. If someone won, it’s
game_over
. - Else, check for a draw. If it’s a draw, it’s
game_over
. - Else, we change the player and update the labels.
- We need to set its value depending on
We write this in code.
func play_turn(cell):
if game_started:
if player_turn == 1:
cell.setX()
else:
cell.setO()
cell.disconnect("cell_clicked", self, "play_turn")
turns_played += 1
if check_win():
game_over()
return
if check_draw():
game_over()
return
change_player()
update_labels()
This code reads a lot like our description of the function. It is almost written in English.
Once a cell is clicked, we disconnect the signal to the node using the disconnect
method.
We have introduced many new functions (that we haven’t defined yet) to keep concerns separate and make the play_turn
method easy to read.
The check_win
method will return true
if someone has won. In that case, we call the game_over()
to clean things up, and return
prevents the execution of the remainder of the play_turn
method.
If check_win
is false, then we check for draws with check_draw
. This returns true
if the game has drawn. Again, we call game_over()
and return
out of the play_turn
method.
We will first work on cell.setX
, cell.setO
, and cell.isEmpty
. Declare all other methods and write pass
in their bodies for now. This suppresses the “Method not declared” errors while we work on the cell functions.
func check_win():
pass # TODO
func check_draw():
pass # TODO
func game_over():
pass # TODO
func update_labels():
pass # TODO
Since the setX
method is related to the cell, we will define it setX
in GridCell.gd
. This is also why we call it as cell.setX()
in Game.gd
and not just setX
.
For each cell, we store a value
and display a sprite.
When we set “X” in a cell, we must update the value
of the cell and change the sprite shown in the cell.
Recall that X
has value
1
and O
has value -1
.
We can change the sprite shown by changing the texture of the CellSymbol
node.
We load and access the textures in our scripts using the preload
method.
const x_symbol = preload("res://assets/x.png")
const o_symbol = preload("res://assets/o.png")
We define the setX
method:
func setX():
value = 1
$CellSymbol.texture = x_symbol
The setO
method is defined similarly.
func setO():
value = -1
$CellSymbol.texture = o_symbol
Finally, let’s also define setEmpty
. To remove the texture of a sprite, we set its texture to null
. Otherwise, this is similar to setX
and setO
as well.
func setEmpty():
value = 0
$CellSymbol.texture = null
Try playing the game now. Click on different cells. You will an X and an O appearing alternatively with each click. Our setters work!
The text labels are currently static.
We would like to update the labels after each turn. We would like to use the labels to tell the players whether it’s “X” or “O” to play. Let’s write the update_labels
method now.
We want to show
- “Player ___’s turn” in the
PlayerTurn
label, and - “Place an ___” in the
PlayerInstruction
label.
We add an “X” or an “O” in the blanks depending on whose turn it is. We can implement this in our script using format strings.
var player_turn_text = "Player %s's turn"
var player_instruction_text = "Place an %s"
Here, %s
acts as a blank. We can add any string in the blank by writing it after a %
sign.
print(player_turn_text % "X")
print(player_instruction_text % "X")
This prints “Player X’s turn” and “Place an X” in the output. The %s
in the format strings is replaced by “X”.
Instead of “X”, we will replace the %s
in the format strings with a variable that depends on the value of player_turn
. The variable will be equal to “X” if player_turn
is equal to 1
, and it will be “O” if player_turn
is -1
.
We can do the following.
var player_symbols = [":)", "X", "O"]
func update_labels():
$PlayerTurn.text = player_turn_text % player_symbols[player_turn]
$PlayerInstruction.text = player_instruction_text % player_symbols[player_turn]
We have used a trick while writing the player_symbols
array to make this work.
- Arrays are counted starting with 0. Therefore,
player_symbols[1]
returns “X”. - We can access arrays from the end using negative indices. The
-1
index of an array is the last element of the array. Therefore,player_symbols[-1]
returns “O”. - We can set the zeroeth element of the array to anything, as it will never be read.
Thus, when update_labels
is called, it updates the labels with the correct text.
Checking for wins and draws
If we play the game now, we can keep adding symbols until all cells have been filled. Even then, the labels say it’s Player O’s turn to play when clearly the game is over. We would like the game to stop once someone has won or the game has drawn, and we would like to display to result on the screen.
Display the name of the winner will be easier if we have a state variable that stores the name of the winner.
If no one has won yet, then winner
stores 0
. Otherwise, it stores 1
or -1
depending on who won.
Let’s declare and initialise it.
var winner
func _ready():
...
winner = 0 # no one has won yet
We have already called the check_win
, check_draw
, and game_over
methods in play_turn
. Now, it’s time to fill these in.
check_win
A player wins when they get three of the same symbols in a row, column, or diagonal. There are three rows, three columns, and two diagonals, so we need to check for three in a line in eight lines.
This is where choosing values 1
and -1
for “X” and “O” is useful again. To check if someone has won, we calculate the sum of values of each row, column, and diagonal. If any of them is 3 or -3, then we have a winner.
Let’s add this in check_win
.
func check_win():
var row1 = cells[0].value + cells[1].value + cells[2].value
var row2 = cells[3].value + cells[4].value + cells[5].value
var row3 = cells[6].value + cells[7].value + cells[8].value
var col1 = cells[0].value + cells[3].value + cells[6].value
var col2 = cells[1].value + cells[4].value + cells[7].value
var col3 = cells[2].value + cells[5].value + cells[8].value
var diag1 = cells[0].value + cells[4].value + cells[8].value
var diag2 = cells[2].value + cells[4].value + cells[6].value
var lines = [row1, row2, row3, col1, col2, col3, diag1, diag2]
Recall that check_win
returns true if someone has won, and false if not.
It also updates the state variable winner
with the value of the player who won. This is what this code does:
func check_win():
...
for line in lines:
if line >= 3:
winner = 1
return true
elif line <= -3:
winner = -1
return true
else:
winner = 0
return false
check_draw
Detecting if the game has drawn is easier.
If nine turns have been played and no one has won so far, then and only then it is a draw.
We have been updating turns_played
in the function play_turn
. We use this value to check if the grid has been filled completely.
func check_draw():
if turns_played >= 9:
return true
else:
return false
game_over
Try playing the game now. You can still add symbols after someone has won. The labels still say it’s someone’s turn to play.
We need to stop listening for clicks, display the result on the screen, and update our state variables.
We can use format strings again to display the winner.
var winner_text = "Player %s won!"
var drawn_text = "Game drawn."
func game_over():
if winner == 0:
$PlayerTurn.text = drawn_text
else:
$PlayerTurn.text = winner_text % player_symbols[winner]
The game is over, so we set game_started
to false.
func game_over():
...
game_started = false
If the game is over, then we no longer want to listen for clicks on cells.
We have disconnected the cell_clicked
signal on cells that have been clicked, but the signal is still connected to cells that have not been clicked.
If we try to disconnect a signal that is not connected, we get an error.
Therefore, we iterate over all cells, and disconnect the signal only if it still connected.
We check if a signal is connected with the is_connected
method.
func game_over():
...
for cell in cells:
if cell.is_connected("cell_clicked", self, "play_turn"):
cell.disconnect("cell_clicked", self, "play_turn")
Once a game is over and we display the results, we would like the players to be able to play again without having to quit and restart. We could add a restart button that starts a new game. Let’s show this instruction to the players.
var restart_text = "Press 'R' to start a new game."
func game_over():
...
$PlayerInstruction.text = restart_text
Starting a new game
We want to restart the game when the players press the ‘R’ button on their keyboard. We will implement this now.
We need to set things up again for a new game.
A lot of the code that we require is already in _ready
. We would like to execute all of those commands when the players click “Play again”. We will extract all of those into a function start_game
and call it. To reset the game, we simply call this function.
func _ready():
cells = $Grid/GridCells.get_children()
start_game()
func start_game():
game_started = true
player_turn = 1 # Player 1 starts the game
turns_played = 0
winner = 0
update_labels()
# start listening for clicks
for cell in cells:
cell.connect("cell_clicked", self, "play_turn")
We don’t need to initialise the references to GridCells again. We can move the rest of the code from _ready
to start_game
. We call start_game
in _ready
.
We have also added update_labels
so that correct instructions are shown when the game starts.
func start_game():
...
update_labels()
We need to remove all symbols from the grid and reset all values too. We set all cells to empty.
func start_game():
...
# remove symbols from grid
for cell in cells:
cell.setEmpty()
The game still works after this refactoring.
We need to listen for the reset button pressed in the game over screen, in which case we call the start_game
function.
Add input map in Project Settings -> Input Map. Add a ui_restart
action. Add event Keyboard press “R” to it.
func _process(delta):
if !game_started:
if Input.is_action_pressed("ui_reset"):
print("Resetting game")
start_game()
It should work now. Our game is complete.
Finishing up
Well, almost complete. We can play the game in Godot. If you had to share it with others, we do not expect them to install Godot to play our game. This is not even possible on mobile. We need to export our game.
Exporting the game
Click on Project -> Export…
We can now export to Windows, macOS, Linux, Android, iOS, and the Web.
Next steps
Some new features that can be added:
- Make it usable on mobile. devices without a keyboard. We can add a play again button.
- Undo (This is a bit hard. Store the move history in a stack.)
Hurray! Make it on your own. Improve it, publish it. Make more games!
If you are curious to know how the Godot Engine works, head over Godot’s GitHub page and look at its Godot’s source code. You can see how it’s made and how it works. You can report bugs and submit improvements here.
If you enjoyed this guide and found it useful, consider supporting my work on Ko‑fi. I would love to write more guides on game development and your support will go a long way.