This is the final part of this series for creating a multiplayer game in Godot 4 using Firebase Realtime DB. Checkout Part 1, Part 2 and Part 3 to get started with the project. In this chapter we will beautify the game by using free game assets. Let’s get started!

Asset Pack

First we need to download the asset pack we are going to use. I decided to use the awesome pack from cup noobles. You can download it for free from the following link: https://cupnooble.itch.io/sprout-lands-asset-pack

Texture Filter

By default godot’s texture filtering is set to linear. This will blur small textures when they are upscaled in the game. However we want to keep the pixelated look of the asset pack. To change the filter setting, open the Project Settings and search for Default Texture Filter and change it to Nearest.

Texture Filter Linear
Texture Filter Nearest

Tilemap

Let’s start by creating the level using the tilemap from the asset pack. Create a new folder for the assets in the root of the project and add the Grass.png atlas from the Tilesets directory of the downloaded assets.

In the world scene, add a new TileMap node and then in its inspector add a new local TileSet.

Auto-tiling

We will make use of the auto-tiling feature of Godot to quickly paint a level. In the TileSet properties, add a new Terrain Set and then inside the generated terrain set a new Terrain and give it a name (e.g. Grass Terrain).

Next go to the TileSet tab at the bottom of the window, then drag and drop the Grass.png from the FileSystem to the Tiles and confirm the dialog to automatically create the tiles from the atlas.

Now in the Select mode, hold down Shift and select the tiles shown in the following screenshot, we are only going to use these tiles for this demo. Change their Terrain Set and Terrain to 0, which corresponds to the Grass Terrain we created before.

Now we have to manually go through each tile and assign its terrain peering bits. These tell the auto-tiling how the tiles should be aligned together. Let’s go through the first one as an example. Select the most top left tile and change the Right Side, Bottom Right Side and Bottom Side bits to zero. That way it will be placed if there is a tile to its right side, bottom side and to its bottom right side.

Now do the same thing for all other relevant tiles. In the end the bitmask should look like this.

For the 12 tiles at the bottom we want them to randomly be scattered on the terrain. Let’s adjust their probability to 2% for each of the tiles. Therefore in total 24% of the terrain will be covered with one of these.

Background Layer

Before we start painting the grass ground, let’s paint the water background since we will be creating an island level. First rename the Layer 0 to Water and while at it create a second layer and name it Grass.

Import the Water.png from the asset pack and add it to the tileset.

Now select the TileMap tab and choose the Rect paint tool. Select all water tiles and enable the Place Random Tile option. Then drag a rectangle in the scene that fills the whole screen.

Make sure that the TileMap node in the scene tree is above the player node to render the tiles behind the player.

Grass Layer

Not it’s time to paint the grass layer. Go to the Terrain tab in the TileMap and start painting. Make sure you are on the Grass layer.

Character

Now let’s focus on the player character. Start by importing the Basic Character Spritesheet.png into the project. Then replace the current sprite in the Sprite2D from the local player node with the character spritesheet. In the animation properties set the Hframes and VFrames to 4. Also adjust the CollisionShape2D to match the size of the sprite by selecting this node in the scene tree and dragging the red circles on the border.

Animation

To animate the character we could have used an AnimatedSprite2D node and created animations for the different animations in the sprite sheet. However since this requires a lot of UI interactions for creating all the 8 different animations with 2 frames each (4 directional idle and walk), I am going to take the lazy route and do the animations in code by setting the frame of the Sprite2d node. The indexing starts in the top left at 0 and increases from left to right then row by row.

The first two columns are the animation frames for the idle animation and the last two columns are the walk cycle. For example if the player is standing still and looking to the left side, the sprite animation should alternate between frame 8 and 9.

Let’s turn this into code. Start by adding a new script to the Sprite2D node and call it player_animation.gd. Create a function _get_sprite_frame that will calculate which sprite frame to select. The function’s arguments consist of the look_dir, an integer representing the direction that the character is facing where 0 is down, 1 is up, 2 is left and 3 is right. This correlates with the row number in the spritesheet. By multipling this number by 4 we get the frame index of the correct row. For exmaple: looking left -> look_dir is 2 -> multiply by 4 -> frame = 8, which is the correct index for the left facing animation row in the spritesheet.

extends Sprite2D

func _get_sprite_frame(look_dir: int, is_walking: bool, animation_frame: int) -> int:
var sprite_frame = look_dir * 4
# TODO idle/walk
# TODO animation
return sprite_frame

Instead of using an integer for the look direction that has an implicit meaning, it is better to define the directions explicitly using an enum. We can define a new enum LookDir at the beginning of the script and adjust our method accordingly:

...

enum LookDir {LEFT=2, RIGHT=3, UP=1, DOWN=0}

func _get_sprite_frame(look_dir: LookDir, is_walking: bool, animation_frame: int) -> int:
var sprite_frame = int(look_dir) * 4
...

The second argument for the function is an is_walking boolean. When this is set, we need to add 2 to the frame index, to get to the frames in the third and fourth column of the sprite sheet. These correspond to the walking frames of the sprite sheet.

 if is_walking:
sprite_frame += 2

Finally the animation_frame argument alternates between 0 and 1 to select the individual frame for the 2 sprite animation.

 sprite_frame += animation_frame

So all in all the function should look like this:

func _get_sprite_frame(look_dir: LookDir, is_walking: bool, animation_frame: int) -> int:
var sprite_frame = int(look_dir) * 4
if is_walking:
sprite_frame += 2
sprite_frame += animation_frame
return sprite_frame

Now let’s get the animation playing. We need to keep track of the elapsed time to pace the animation. Let’s add an animation_time variable that keeps track of how many seconds have passed since the start. By multiplying this timer by a speed value and then taking that value modulo 2, we get an alternating value between 0 and 1 at a predefined speed.

@export var animation_speed: float = 5.0

var animation_time: float = 0.0

func _process(delta: float) -> void:
animation_time += delta
frame = _get_sprite_frame(LookDir.DOWN, false, int(animation_time * animation_speed) % 2)

Now the player is already runing the idle animation. However, the animation is not responding to direction changes and movement. To get this information, we need a reference to the parent CharacterBody2D node. We can then check if the velocity is not zero and set the is_walking flag accordingly.

@onready var character: CharacterBody2D = get_parent()

func _process(delta: float) -> void:
animation_time += delta
var is_walking = character.velocity.length() > 0
frame = _get_sprite_frame(LookDir.DOWN, is_walking, int(animation_time * animation_speed) % 2)

Now finally we need to check when the player is moving for the direction he’s moving in. We also need to keep track of the direction the player is facing to keep him oriented when he’s not moving.

...

var curr_look_dir: LookDir = LookDir.DOWN

@onready var character: CharacterBody2D = get_parent()

func _process(delta: float) -> void:
animation_time += delta

var is_walking = character.velocity.length() > 0

if is_walking:
if character.velocity.x < 0:
curr_look_dir = LookDir.LEFT
if character.velocity.x > 0:
curr_look_dir = LookDir.RIGHT
if character.velocity.y < 0:
curr_look_dir = LookDir.UP
if character.velocity.y > 0:
curr_look_dir = LookDir.DOWN

frame = _get_sprite_frame(curr_look_dir, is_walking, int(animation_time * animation_speed) % 2)

...

Animating Remote Players

To animate the remote players, we can simply change the sprite of the remote player scene to the character sprite sheet and add the animation script as well. Don’t forget to set the Hframes and Vframes.

Hmm, doesn’t quite look right yet. The problem here is, that the remote players are not synchronized every frame and don’t have a velocity vector yet, they get instantly teleported to the new location when they are updated.

To change that, we have to modify the player_remote script. Let’s remove the teleportation in the _move_to_target function and instead set a variable target_pos. In the process function we can then move the remote player by setting the velocity and calling the integrated move_and_slide of the CharacterBody2D.

@export var speed: float = 200

var target_pos: Vector2

func _process(delta: float) -> void:
var diff = target_pos - global_position
velocity = diff.normalized() * speed
move_and_slide()

...

func _move_to_target(target_pos_x, target_pos_y) -> void:
target_pos = Vector2(target_pos_x, target_pos_y)

Now when the remote player reaches its target, it starts going crazy and oscillates around the target point. To prevent this, we can check if the player is very close to its target and then teleport him for the last small distance and resetting the velocity to zero.

func _process(delta: float) -> void:
var diff = target_pos - global_position

if diff.length() < 1:
global_position = target_pos
velocity = Vector2.ZERO
else:
velocity = diff.normalized() * speed
move_and_slide()

Now lastly we need to deal with the initial target setting. When the player spawns, we don’t want them to slowly move from (0,0) to their position, but instead we want them to be instantly spawned at their location. We can achieve this by adding an initialized property and the first time we receive a movement target, teleport the player and reset that flag.


var is_initialized: bool = false

...

func _move_to_target(target_pos_x, target_pos_y) -> void:
target_pos = Vector2(target_pos_x, target_pos_y)

if not is_initialized:
global_position = target_pos
is_initialized = true

One last thing we have to adapt is the animation. Because the remote players move diagonally due to the delay in the received position update and one direction always taking precedence, the remote player is always facing left or right while moving. We can check for the main direction of moving by comparing the absolute value of the velocities and set the look direction based on that. In The player_remote.gd script adjust the process function:

...

func _process(delta: float) -> void:
...
if is_walking:
if abs(character.velocity.x) > abs(character.velocity.y):
if character.velocity.x < 0:
curr_look_dir = LookDir.LEFT
if character.velocity.x > 0:
curr_look_dir = LookDir.RIGHT
else:
if character.velocity.y < 0:
curr_look_dir = LookDir.UP
if character.velocity.y > 0:
curr_look_dir = LookDir.DOWN
...

Collisions

To add collisions to our tile map, we need to add a physics layer. Select the TileMap node and under Physics Layer clikc on Add.

Now we can define the collision boundaries for all tiles. The easiest way to do this is select multiple tiles that have the same collisions but rotated and annotate them together. Then you can go into each individually and rotate the collision shapes.

Now repeat this for the sides and now the players cannot walk of the map.

One small thing I adjusted is the collision shape of the player. I adjusted it to fit the are where the player is standing. Also don’t forget to adjust the collision shape on the remote player as well.

Now one problem is that if you spawn in the water, you cannot get back to the main land, you are stuck.

One easy way to prevent spawning in the water is setting the spawn point near the center of the map. We can achieve this by manually placing the player in the center of our map and defining a radius in which the player will be randomly offset. In the player_local script adjust the _set_random_spawn function accordingly.

func _set_random_spawn() -> void:
var spawn_radius = 50
var rand_x = randf_range(-spawn_radius, spawn_radius)
var rand_y = randf_range(-spawn_radius, spawn_radius)
global_position = global_position + Vector2(rand_x, rand_y)

Camera Movement

To keep track of your local player, we can create a camera and let it zoom in and follow the character. Add a Camera2D node as a child of the player. Make sure that its transform is reset to 0, 0. I set the camera zoom factor to 2 to get a bit closer to the player. Then finally if you want to, you can enable the position smoothing option to make the camera smoothly follow the player.

Colors

One of the final touches will be to add the random color that is assigned to each player. To set the local player color, we need to add a reference to the sprite in the player script. There are multiple ways to do this, my preferred way is to create a variable with the export variable and assign it in the editor. That way this reference does not depend on the name of the sprite node. In The player_local script add the exported variable and in the ready function we can set the modulation to the player color. Also note here I adjusted the hsv values for the random player color, I reduced the saturation to 50%, resulting in a more tinted look of the player.

...

@export var sprite: Sprite2D

var player_id: int = randi_range(100000, 999999)
var player_color: Color = Color.from_hsv(randf(), 0.5, 1.0)

func _ready() -> void:
_set_random_spawn()

sprite.modulate = player_color

...

Now don’t forget to assign this reference in the editor.

Now your local player should be tinted randomly on each startup of the game.

For the remote player we can follow a similar procedure. Create an exported variable for the sprite node and assign it in the editor. Then in the update_from_vent function we can set the modulate color of the sprite to the received color.

...
@export var sprite: Sprite2D
...


func update_from_event(player_data: Dictionary) -> void:
player_id = str(player_data["player_id"])

color = Color.html(player_data["color"])
sprite.modulate = color

_move_to_target(player_data["position_x"], player_data["position_y"])

...

And voila, all your players are now colored!

Y Sorting

A small problem we can fix now is the y-sorting. The local player is always drawn below the remote players and the remote players among themselves are drawn in the order they are spawned. If two players overlap, ideally the one with higher y-values should be drawn above.

Local player is drawn below remote player

To fix this, simply enable y-sorting on the parent node shared between the nodes, which in our case is the world node.

Y-sorting enabled

Source Code

The full source code is available on github, feel free to report any issues.

Final Words

This project is meant to highlight an interesting way to create a multiplayer game in Godot. Note that this approach using a database for state synchronization is far from ideal, especially for a larger multiplayer game with many changing parts. Although there might be some applications where this might also work for larger player bases, for example turn based games, I would not suggest to use this setup for a 2D game, as the associated costs after the free tier are not scalable.

--

--

Flipflo Dev

A hobby game developer coming from a robotics and electrical engineering background, hoping to provide fun and interesting content for like minded people.