Loading...

STORIES

Create a 2D Game for Mobile, Desktop and the Web

Godot is impressive for a number of reasons:

So let's create a very simple 2D platformer game. The source code for this is available on GitHub.

Start a New Project

Create a new project and select OpenGL ES 2.0. For more info, see Differences between GLES2 and GLES3.

Catering for Different Screen Sizes

The default aspect ratio for the game that we are creating is 1.5:1, so in Project Settings, set the screen width and height to 720 pixels by 480 pixels, and set Stretch Mode to Viewport and Stretch Aspect to Keep. More info on multiple resolutions in the docs.

Project Structure and Resources

The game will have a player, enemies, loot, a parallax scrolling background, a heads-up display, a first level and some audio. All of the resources are linked in the credits below, and available on GitHub. Notice how Godot automatically imports assets as they are copied into the project's resource folders. Essentially, we create the following folders, and add the necessary resources:

  • audio
  • background
  • enemy
  • hud
  • level1
  • loot
  • player

The Player Scene

Change the default scene layout when creating a new project to 2D, add a new custom node to the default scene, selecting KinematicBody2D, rename the node Player, and save the scene as Player. Under this root node, add an AnimatedSprite node, a CollisionShape2D node and a Camera2D node. Set up the nodes as per the following video, starting with setting up the player image resources as sprite frames and finishing with setting the camera's bottom scroll limit to the project's screen height, and setting it as the current camera.

Then add the following script to the scene's root node:

extends KinematicBody2D

signal collided

export var gravity = 2500
export var speed = 300 # Pixels per second
export var jump = -900

var jumping = false
var is_active = true
var velocity = Vector2()

func _ready():
	pass
	
func start(start_position):
	position = start_position
	$AnimatedSprite.play()

func _physics_process(delta):
	velocity.x = 0
	velocity.y += gravity * delta

	if is_active:
		jumping = false
		if Input.is_action_pressed('ui_right'):
			velocity.x += 1
		if Input.is_action_pressed('ui_left'):
			velocity.x -= 1
		velocity.x *= speed
		if Input.is_action_just_pressed('ui_select'):
			jumping = true

	velocity = move_and_slide(velocity, Vector2(0, -1))

	if is_active:
		for i in get_slide_count():
			var collision = get_slide_collision(i)
			if collision:
				emit_signal('collided', collision)
	
		if is_on_floor() and jumping:
			velocity.y = jump
	
		if velocity.y != 0 and velocity.y < 0:
			$AnimatedSprite.animation = 'jump'
		elif velocity.x != 0:
			$AnimatedSprite.animation = 'run'
			$AnimatedSprite.flip_v = false
			$AnimatedSprite.flip_h = velocity.x < 0
		elif velocity.y != 0 and velocity.y > 0:
			$AnimatedSprite.animation = 'landing'
		else:
			$AnimatedSprite.animation = 'idle'

func die():
	is_active = false
	$AnimatedSprite.animation = 'mid-air'

func win():
	is_active = false
	$AnimatedSprite.animation = 'jump'

This handles movement from user input, raises signals for collisions, and has die and win methods.

The Enemy Scenes

Similar to the Player scene, let's create an enemy scene with a KinematicBody2D node as the root node, named Enemy, and under that an AnimatedSprite node and a CollisionShape2D node:

Then add the following script to the scene:

extends KinematicBody2D

signal collided

export var gravity = 2500
export var speed = 50  # Pixels per second

var velocity = Vector2()
var move_left = false
var move_right = false
var loop_animation = true

func _ready():
	add_to_group('enemy')
	$AnimatedSprite.connect('animation_finished', self, '_on_animation_finished')
	self.connect('collided', self, '_on_collided')

func start(start_position):
	position = start_position
	$AnimatedSprite.play()
	_move_left()

func _move_left():
	move_left = true
	move_right = false
	$AnimatedSprite.flip_h = true
	$CollisionShape2D.position.x = 10
	$AnimatedSprite.animation = 'walk'
	loop_animation = true
	
func _move_right():
	move_left = false
	move_right = true
	$AnimatedSprite.flip_h = false
	$CollisionShape2D.position.x = 0
	$AnimatedSprite.animation = 'walk'
	loop_animation = true
	
func attack_left():
	move_left = false
	move_right = false
	$AnimatedSprite.flip_h = true
	$CollisionShape2D.position.x = 10
	$AnimatedSprite.animation = 'attack'
	loop_animation = false
	
func attack_right():
	move_left = false
	move_right = false
	$AnimatedSprite.flip_h = false
	$CollisionShape2D.position.x = 0
	$AnimatedSprite.animation = 'attack'
	loop_animation = false
	
func _physics_process(delta):
	if move_left:
		velocity.x = -speed
	elif move_right:
		velocity.x = speed
	else:
		velocity.x = 0

	velocity.y += gravity * delta
	velocity = move_and_slide(velocity, Vector2(0, -1))
	for i in get_slide_count():
		var collision = get_slide_collision(i)
		if collision:
			emit_signal('collided', collision, self)

func _on_animation_finished():
	if !loop_animation:
		$AnimatedSprite.stop()

func _on_collided(collision, enemy):
	if collision.collider is TileMap:
		if collision.normal.x == 1:
			_move_right()
		elif collision.normal.x == -1:
			_move_left()

This handles enemy movement including detecting collisions, raises signals for collisions, and has methods to attack the player to the left and the right.

In addition to the default enemy scene, we also want to create a scene for placing enemies in our game level called EnemyStartPosition. It is just a Position2D node with the following script:

extends Position2D

func _ready():
	add_to_group('enemy_start_position')

The Parallax Background

For the scrolling parallax background images, we create a scene with a ParallaxBackground root node, with a Layer property of -1, and set the name to Background, and save the scene as Background. Then we add 5 ParallaxLayer child nodes, with a Sprite child node each. Then import the background images by dragging these onto the Texture properties of the Sprite nodes, and then set the Motion Scale properties of the ParallaxLayers to 1, 1.1, 1.2, 1.3 and 1.4, from front to back, so that the layers in front scroll faster than the layers at the back. We also set the Position properties of the layers to x 384 and y 216, to center these, and set the Motion Mirroring x property to 384, so that the background images will be repeated horizontally. After this, because the background images will not fill the entire display, we add a CanvasLayer, with a Layer property of -2, with a ColorRect node set to fill the entire display with the color at the top and bottom of the background layer in the front, with a hex value of #2b5754.

The Loot Scenes

For loot, we create three scenes with Area2D root nodes, containing Sprite and CollisionShape nodes, and name the root nodes and save the scenes as Bag, Coins and TreasureSack.

To all three of these scenes we attach the same Loot script:

extends Area2D

signal collected

var velocity = Vector2()

func _ready():
	add_to_group('loot')

func _physics_process(delta):
	for body in get_overlapping_bodies():
		if body.name.find('Player') != -1 and self.visible:
			emit_signal('collected', self)
			self.hide()

This script adds all instantiated loot scenes to the loot group, and handles player interaction by hiding and sending a signal.

The Heads-Up Display

The HUD scene consists of a CanvasLayer root node, with a couple of Labels, a Button and a Timer:

The following script is attached for displaying the player's game score, for showing messages and for handling the start button click event that then sends a signal:

extends CanvasLayer

signal start_game

func show_message(text):
	$MessageLabel.text = text
	$MessageLabel.show()
	$MessageTimer.start()

func show_game_over():
	show_message('Game Over')
	yield($MessageTimer, 'timeout')
	$StartButton.show()

func show_win():
	$MessageLabel.text = 'You Win!'
	$MessageLabel.show()

func update_score(score):
	$ScoreLabel.text = 'Score: ' + str(score)

func _on_StartButton_pressed():
    $StartButton.hide()
    emit_signal('start_game')

func _on_MessageTimer_timeout():
    $MessageLabel.hide()

The Game Level

The level scene consists primarily of a TileMap node. With tilemaps, entire levels can be added. The Godot documentation on tilemaps is worth a read, but the following videos should demonstrate this well and it is a breeze once you get the hang of it. The scene root node is a plain Node2D node, and in addition to the TileMap node contains instances of the loot scenes, instances of the enemy start position scenes and a Position2D node called PlayerStartPosition.

The Main Scene

The main scene is a Node2D node containing instances of the HUD, Level1 and Background scenes:

The scene is linked to this script, bringing it all together:

extends Node2D

export (PackedScene) var Player
export (PackedScene) var Enemy

var score = 0
var player
var audio_player
var audio_player2

func _ready():
	var camera = Camera2D.new()
	camera.make_current()
	camera.position = $Level1/PlayerStartPosition.position
	camera.limit_bottom = 480
	self.add_child(camera)

	audio_player = AudioStreamPlayer.new()
	self.add_child(audio_player)
	audio_player2 = AudioStreamPlayer.new()
	self.add_child(audio_player2)

	$HUD.connect('start_game', self, 'new_game')

func new_game():
	score = 0
	$HUD.update_score(score)

	audio_player.stream = load('res://audio/04forest3.wav')
	audio_player.play()

	# Clear and create the player
	if player:
		player.queue_free()
	player = Player.instance()
	player.name = 'Player'
	player.add_to_group('loot')
	add_child(player)
	player.start($Level1/PlayerStartPosition.position)
	player.connect('collided', self, '_on_player_collided')

	# Clear and create the enemies
	for enemy in get_tree().get_nodes_in_group('enemy'):
		enemy.queue_free()
	for enemy_start_position in get_tree().get_nodes_in_group('enemy_start_position'):
		var enemy = Enemy.instance()
		add_child(enemy)
		enemy.start(enemy_start_position.position)
		enemy.connect('collided', self, '_on_enemy_collided')
	
	# Set/reset the loot
	for loot in get_tree().get_nodes_in_group('loot'):
		loot.show()
		loot.connect('collected', self, '_on_loot_collected')

func game_over():
	player.die()
	$HUD.show_game_over()
	audio_player.stream = load('res://audio/13gameover1V1NL.wav')
	audio_player.play()

func _process(delta):
	if player:
		var player_pos = $Level1/TileMap.world_to_map(player.position)
		if player_pos.y > 100 and player.is_active:
			game_over()
		# print('Player: ' + str(player_pos))
	# var pointer_pos = $Level1/TileMap.world_to_map(get_global_mouse_position())
	# print('Pointer: ' + str(pointer_pos))
	
func _on_player_collided(collision):
	if collision.collider.name.find('Enemy') != -1:
		if collision.normal.x > 0:
			collision.collider.attack_right()
		else:
			collision.collider.attack_left()
		game_over()

func _on_enemy_collided(collision, enemy):
	if collision.collider.name == 'Player':
		if collision.normal.x > 0:
			enemy.attack_left()
		else:
			enemy.attack_right()
		game_over()

func _on_loot_collected(item):
	score += 1
	$HUD.update_score(score)

	# Win
	if item.name.find('TreasureSack') != -1 and player.is_active:
		player.win()
		$HUD.show_win()

		audio_player.stream = load('res://audio/12win1NL.wav')
		audio_player.play()

		for enemy in get_tree().get_nodes_in_group('enemy'):
			enemy.queue_free()
	else:
		audio_player2.stream = load('res://audio/14short3NL.wav')
		audio_player2.play()

The Game

Play the game here!

Credits