2025/09/23

Type
Learning Resource
Format
Video
Version
Godot 4.x
  • Downloadable Demo
Code
Assets
All else
Copyright 2016-2025, GDQuest
Created
2023/12/06
Updated
2025/09/23

Your First 2D GAME From Zero in Godot 4 Vampire Survivor Style

It may come as a surprise, but roguelike games like Vampire Survivors are some of the easiest, most satisfying games that you can whip up in a single sitting. In this tutorial, you will learn how to make a simple 2D game in Godot 4, move a character around, aim at enemies, shoot projectiles, and spawn enemies that follow the player.

Complete code

Below you can find the complete code for all the scripts used in this project at the end of the video. You can use it to compare your code against it if you get stuck.

Code Reference: res://player.gd

Here's the complete code for the script at the end of the video:

extends CharacterBody2D

signal health_depleted

var health = 100.0


func _physics_process(delta):
	const SPEED = 600.0
	var direction = Input.get_vector("move_left", "move_right", "move_up", "move_down")
	velocity = direction * SPEED

	move_and_slide()

	if velocity.length() > 0.0:
		%HappyBoo.play_walk_animation()
	else:
		%HappyBoo.play_idle_animation()

	# Taking damage
	const DAMAGE_RATE = 6.0
	var overlapping_mobs = %HurtBox.get_overlapping_bodies()
	if overlapping_mobs:
		health -= DAMAGE_RATE * overlapping_mobs.size() * delta
		%HealthBar.value = health
		if health <= 0.0:
			health_depleted.emit()
Code Reference: res://gun.gd

Here's the complete code for the script at the end of the video:

extends Area2D


func _process(_delta):
	var enemies_in_range = get_overlapping_bodies()
	if enemies_in_range.size() > 0:
		var target_enemy = enemies_in_range.front()
		look_at(target_enemy.global_position)


func shoot():
	const BULLET = preload("res://bullet_2d.tscn")
	var new_bullet = BULLET.instantiate()
	new_bullet.global_transform = %ShootingPoint.global_transform
	%ShootingPoint.add_child(new_bullet)


func _on_timer_timeout() -> void:
	shoot()
Code Reference: res://mob.gd

Here's the complete code for the script at the end of the video:

extends CharacterBody2D

signal died


var speed = randf_range(200, 300)
var health = 3

@onready var player = get_node("/root/Game/Player")


func _ready():
	%Slime.play_walk()


func _physics_process(_delta):
	var direction = global_position.direction_to(player.global_position)
	velocity = direction * speed
	move_and_slide()


func take_damage():
	%Slime.play_hurt()
	health -= 1

	if health == 0:
		var smoke_scene = preload("res://smoke_explosion/smoke_explosion.tscn")
		var smoke = smoke_scene.instantiate()
		get_parent().add_child(smoke)
		smoke.global_position = global_position
		queue_free()
Code Reference: res://bullet_2d.gd

Here's the complete code for the script at the end of the video:

extends Area2D


var travelled_distance = 0


func _physics_process(delta):
	const SPEED = 1000
	const RANGE = 1200

	position += Vector2.RIGHT.rotated(rotation) * SPEED * delta

	travelled_distance += SPEED * delta
	if travelled_distance > RANGE:
		queue_free()


func _on_body_entered(body):
	queue_free()
	if body.has_method("take_damage"):
		body.take_damage()
Code Reference: res://game.gd

Here's the complete code for the script at the end of the video:

extends Node2D


func spawn_mob():
	%PathFollow2D.progress_ratio = randf()
	var new_mob = preload("res://mob.tscn").instantiate()
	new_mob.global_position = %PathFollow2D.global_position
	add_child(new_mob)


func _on_timer_timeout():
	spawn_mob()


func _on_player_health_depleted():
	%GameOver.show()
	get_tree().paused = true

Questions and troubleshooting

Why do slimes stick to the top of my character?

In this project, you might notice that when slimes touch the top of your player character, they sometimes get stuck there. This happens because the CharacterBody2D node we use for the player is set to the "grounded" motion mode by default, which is designed for platformer games.

In grounded mode, the engine treats the top of the character as a floor with a slope! So when slimes collide with it, they behave like they're walking up a hill instead of just generally hitting something they cannot move through. This actually creates a fun little mechanic where enemies can stick onto the player without needing complex code.

If you want to prevent this sticking effect, you can change the player's motion mode to "floating":

  1. Open the player scene
  2. Select the Player node in the Scene dock (this is our CharacterBody2D node)
  3. In the Inspector dock, find the Motion Mode property at the top and change it from "Grounded" to "Floating"

The "floating" mode is the setting we normally use for top-down games when we don't want characters to stick to each other. With that, slimes will not stick to the top of the player anymore!

Download files

This video comes with two zip files:

  1. The workbook version contains the assets. You need to follow along with the video and create the game yourself, step by step.
  2. The solutions version contains the finished code and code checkpoints. If you're stuck at some point and the troubleshooting points below do not answer your question, you can open the solution project in Godot and compare your scenes and scripts against it.

Updates / Code patches

Bonus

How to limit the number of spawned mobs

In the game, mobs will keep spawning forever and accumulate. This can cause performance problems and make the screen too crowded if the game runs for a long time. Here's how to limit the total number of mobs that can exist at the same time.

To implement this feature, you need to modify the script. Here's the updated code broken down step by step.

First, we add a variable to keep track of the current number of mobs and a constant to define the maximum allowed (MAX_MOBS):

extends Node2D

+const MAX_MOBS = 50
+
+var mob_count = 0

Then, we update the spawn_mob() function to count mobs and check if we've reached the maximum number of mobs before spawning a new one. When we create a new mob, we also increase our mob_count variable.

func spawn_mob():
+	if mob_count >= MAX_MOBS:
+		return

	%PathFollow2D.progress_ratio = randf()
	var new_mob = preload("res://mob.tscn").instantiate()
	new_mob.global_position = %PathFollow2D.global_position
	add_child(new_mob)

+	mob_count += 1

Next, we need to detect when mobs leave the game. For that we connect to the tree_exited signal for each mob we create. Every node emits this signal when it gets removed from the scene tree, which happens when mobs get destroyed by bullets.

func spawn_mob():
	if mob_count >= MAX_MOBS:
		return

	%PathFollow2D.progress_ratio = randf()
	var new_mob = preload("res://mob.tscn").instantiate()
	new_mob.global_position = %PathFollow2D.global_position
	add_child(new_mob)

	mob_count += 1
+	new_mob.tree_exited.connect(_on_mob_tree_exited)


+func _on_mob_tree_exited():
+	mob_count -= 1

Here, we connect the new mob's tree_exited signal to our _on_mob_tree_exited() function. This means whenever this mob gets destroyed, Godot will call our function once.

The _on_mob_tree_exited() function decreases our counter by 1. This makes room for a new mob to spawn next time the timer times out.

With these changes, the game will now limit the number of mobs to 50 at any given time. You can adjust the MAX_MOBS constant to increase or decrease this limit for your game.

Become an Indie Gamedev with GDQuest!

Don't stop here. Step-by-step tutorials are fun but they only take you so far.

Try one of our proven study programs to become an independent Gamedev truly capable of realizing the games you’ve always wanted to make.

Nathan

Founder and teacher at GDQuest
  • Starter Kit
  • Learn Gamedev from Zero
Check out GDSchool

You're welcome in our little community

Get help from peers and pros on GDQuest's Discord server!

20,000 membersJoin Server

Contribute to GDQuest's Free Library

There are multiple ways you can join our effort to create free and open source gamedev resources that are accessible to everyone!