Build a Wave Defense Game in Godot 4

Build a first-person wave defense prototype with enemy AI in Godot 4.5.

Introduction

30 Second Summary

Think about the arcade games that trapped you in a single room with waves of enemies closing in. That claustrophobic tension of standing your ground while threats pile up from every direction is one of gaming's most satisfying loops to play and to build.

In this project, you will build a first-person wave defense prototype in Godot 4.5 using GDScript. You will create a storage unit environment, an interactive arcade cabinet that triggers the game, a hitscan laser rifle constrained to a forcefield zone, and a full win/lose game loop with a 3-minute survival timer.

What You'll Build

You walk into an abandoned storage unit, approach a glowing arcade cabinet, press START, and suddenly you are holding a laser rifle with enemies rushing you from a forcefield perimeter. Survive 3 minutes and the exit door opens. Fail, and the room goes dark.

By the end of this project, you'll have:

  • A playable first-person controller with full mouse-look and WASD movement that lets you explore the storage unit and aim at enemies.
  • An interactive arcade cabinet you walk up to and press E to trigger a slideshow tutorial, then start the wave defense game loop.
  • A hitscan laser rifle that only fires inside the forcefield zone, so you can shoot enemies rushing the cabinet but lose your weapon the moment you step outside.
  • A complete game state machine with win and lose conditions. Survive the 3-minute timer and the door opens. Let 3 enemies reach the cabinet and everything goes dark.
  • Secret Mission: Add a rate-of-fire cooldown system to your laser rifle, making combat more tactical.

Are there any prerequisites?

You need Godot 4.5 installed and basic familiarity with the Godot editor (scene tree, nodes, Inspector panel). A Python background helps because GDScript's syntax is similar.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters for your larger game vision.

Set Up the First-Person Controller

Without a working first-person controller, nothing else in your game can be tested or experienced. The controller is the foundation that every other system builds on.

In this step, you will create a new Godot project, configure input actions for movement and shooting, and build a first-person character with full mouse-look and WASD movement inside a box-shaped room.

In this step, get ready to:
  • Create a new Godot project and configure the Input Map with movement, shooting, and interaction actions.
  • Build a Player scene with a CharacterBody3D, Camera3D, and movement script.
  • Construct a storage unit room with CSG walls and test the controller.
Create the project and configure the Input Map

The Input Map lets you assign labels to keys and buttons, keeping your scripts clean and easy to change later.

  • Open Godot. You will see the Project Manager window.
  • Click Create in the top-left of the Project Manager.
  • Enter wave-defense as the project name.
  • Enable Create Folder and choose your Desktop as the save location using Browse.
  • Select Compatibility as the renderer.
  • Click Create to generate the project and open the editor.

Why Compatibility renderer?

Your MacBook Pro 2020 uses an Intel Iris Plus GPU. The Compatibility renderer runs well on integrated graphics and is ideal for prototyping with CSG geometry.

Now configure the six input actions your game needs.

  • Click Project in the top menu bar.
  • Click Project Settings... to open the settings window.
  • Click the Input Map tab at the top of the window.
  • Type move_forward in the action name bar at the top and press Enter to add it.
  • Click the + button next to move_forward, press the W key, then click OK.
  • Repeat for move_back bound to S.
  • Repeat for move_left bound to A.
  • Repeat for move_right bound to D.
  • Add shoot and bind it to Left Mouse Button.
  • Add interact and bind it to E.
  • Close the Project Settings window.

You should see all six actions listed in the Input Map tab with their assigned keys before closing.

Don't see your actions listed?

Make sure you pressed Enter or clicked Add after typing each action name. If an action is missing its key binding, click the + button next to it, press the correct key, and click OK.

Help me fix my Input Map actions

Now create the main scene with a ground plane the player can stand on.

  • Click Scene in the top menu, then New Scene.
  • Click 3D Scene in the quick-add panel that appears. This creates a Node3D root.
  • Rename the root node to Main by double-clicking its name in the Scene tree.
  • Right-click Main and select Add Child Node.
  • Search for StaticBody3D and add it. Rename it to Ground.
  • Right-click Ground and add a MeshInstance3D child.
  • With MeshInstance3D selected, find the Mesh property in the Inspector (right panel) and set it to New PlaneMesh.
  • Click the PlaneMesh to expand its settings. Set Size to 20 x 20.
  • Right-click Ground again and add a CollisionShape3D child.
  • In the Inspector, set its Shape property to New WorldBoundaryShape3D.
  • Save the scene as main.tscn by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Your scene tree now shows Main with a Ground child containing both a visible mesh and an infinite collision plane.

Build the Player scene and attach the movement script

The Player is a separate scene so it stays modular and reusable.

  • Click Scene in the top menu, then New Scene.
  • Click Other Node, search for CharacterBody3D, and add it as the root.
  • Rename the root to Player.
  • Right-click Player and add a Camera3D child.
  • Select Camera3D and set its Position Y to 0.8 in the Inspector (eye height).
  • Right-click Camera3D and add a RayCast3D child.
  • Select RayCast3D. In the Inspector, set Target Position to (0, 0, -100) and check Enabled.
  • Right-click Player and add a CollisionShape3D child.
  • Select CollisionShape3D and set its Shape to New CapsuleShape3D.
  • Right-click Player and add an AudioStreamPlayer child. Rename it to DudSound.
  • Save this scene as player.tscn by pressing Cmd+S (macOS) or Ctrl+S (Windows).

What is the RayCast3D for?

The RayCast3D extends 100 units forward from the camera. Later, it will detect what you are aiming at when you shoot. Setting it up now means the script can reference it immediately.

Now attach a script to the Player node.

  • Select the Player root node in the Scene tree.
  • Click the Attach Script icon (scroll icon) at the top of the Scene tree panel.
  • Leave the default path as player.gd and click Create.

Godot opens the Script Editor with a blank template. Delete all the template code and replace it with the following chunks.

  • Set up the script header with exports, state variables, and node references by pasting this code into player.gd:
extends CharacterBody3D

@export var move_speed: float = 5.0
@export var mouse_sensitivity: float = 0.003
@export var gravity: float = 9.8

var shooting_enabled: bool = false
var in_arcade_ui: bool = false
var inside_forcefield: bool = false

@onready var camera: Camera3D = $Camera3D
@onready var ray: RayCast3D = $Camera3D/RayCast3D
@onready var dud_sound: AudioStreamPlayer = $DudSound

signal interaction_pressed

func _ready() -> void:
	Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)

What does this code do?

`@export` variables let you tweak speed and sensitivity from the Inspector without editing code. The @onready lines grab references to child nodes once the scene tree is ready.

`Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)` hides the cursor and locks it to the game window for FPS-style mouse look.

  • Add the mouse-look function directly below _ready:
func _unhandled_input(event: InputEvent) -> void:
	if in_arcade_ui:
		return
	if event is InputEventMouseMotion:
		rotation.y -= event.relative.x * mouse_sensitivity
		camera.rotation.x -= event.relative.y * mouse_sensitivity
		camera.rotation.x = clampf(camera.rotation.x, deg_to_rad(-89), deg_to_rad(89))

What does this code do?

Mouse movement on the X axis rotates the whole body (yaw). Mouse movement on the Y axis tilts the camera up and down (pitch), clamped to 89 degrees so you cannot flip upside down.

  • Add the physics process function that handles gravity, WASD movement, and interaction below _unhandled_input:
func _physics_process(delta: float) -> void:
	if in_arcade_ui:
		return

	# Gravity
	if not is_on_floor():
		velocity.y -= gravity * delta

	# Movement
	var input_dir := Vector3.ZERO
	if Input.is_action_pressed("move_forward"):
		input_dir.z -= 1
	if Input.is_action_pressed("move_back"):
		input_dir.z += 1
	if Input.is_action_pressed("move_left"):
		input_dir.x -= 1
	if Input.is_action_pressed("move_right"):
		input_dir.x += 1
	input_dir = input_dir.normalized()

	var direction := (transform.basis * input_dir)
	velocity.x = direction.x * move_speed
	velocity.z = direction.z * move_speed

	move_and_slide()

	# Interaction
	if Input.is_action_just_pressed("interact"):
		interaction_pressed.emit()

What does this code do?

Gravity pulls the player down each frame until they land on the floor. The movement section builds a direction vector from WASD input, transforms it to face the direction the player is looking, and passes it to move_and_slide().

In Godot 4, move_and_slide() takes no arguments. It reads the built-in velocity property from the CharacterBody3D automatically.

  • Add the shooting and utility functions below _physics_process. These are stubs that later steps will activate:
func _attempt_shoot() -> void:
	if not inside_forcefield:
		dud_sound.play()
		_flash_crosshair_grey()
		return
	shoot()

func shoot() -> void:
	ray.force_raycast_update()
	if ray.is_colliding():
		var collider = ray.get_collider()
		if collider.is_in_group("enemy"):
			collider.take_damage(1)

func _flash_crosshair_grey() -> void:
	var hud_crosshair = get_tree().get_first_node_in_group("crosshair")
	if hud_crosshair:
		hud_crosshair.add_theme_color_override("font_color", Color.GRAY)
		var tween := create_tween()
		tween.tween_callback(func():
			hud_crosshair.add_theme_color_override("font_color", Color.WHITE)
		).set_delay(0.15)

func enable_shooting() -> void:
	shooting_enabled = true

func disable_shooting() -> void:
	shooting_enabled = false

What do these stubs do?

These functions define the shooting and forcefield logic. They exist now so later steps can call them without restructuring the file. Right now nothing triggers them because shooting_enabled is false and no code calls _attempt_shoot() yet.

  • Save player.gd by pressing Cmd+S (macOS) or Ctrl+S (Windows).

The script editor should show no errors in the bottom panel.

Seeing a red error about a missing node?

Errors like "Node not found" mean the scene tree does not match the @onready paths. Confirm your Camera3D is a direct child of Player, RayCast3D is a child of Camera3D, and the AudioStreamPlayer is named exactly DudSound.

Help me fix my node path error

✔️ Awesome, I've got everything!

Great. Make sure you have saved both player.gd and player.tscn.

ⓧ I'd like to double check the full code

Scroll up and confirm you have all four code chunks in player.gd in this order:

  • Script header (extends, exports, vars, onready, signal, _ready).
  • Mouse-look (_unhandled_input).
  • Physics process (_physics_process with gravity, movement, interaction).
  • Shooting stubs (_attempt_shoot, shoot, _flash_crosshair_grey, enable_shooting, disable_shooting).
Build the storage unit and run the scene

CSGBox3D nodes let you build geometry directly in the editor without needing external 3D assets. You will swap these for your Maya or Blender models later.

  • Switch back to main.tscn by clicking its tab at the top of the editor.
  • Right-click Main and add a CSGBox3D child. Rename it to WallNorth.
  • In the Inspector, set Size to (20, 4, 0.2) and Position to (0, 2, -10).
  • Duplicate WallNorth (Cmd+D or Ctrl+D), rename to WallSouth, and set Position Z to 10.
  • Add another CSGBox3D child of Main. Rename it to WallWest.
  • Set its Size to (0.2, 4, 20) and Position to (-10, 2, 0).
  • Duplicate WallWest, rename to WallEast, and set Position X to 10.
  • Add one more CSGBox3D child of Main. Rename it to Ceiling.
  • Set its Size to (20, 0.2, 20) and Position to (0, 4, 0).

Your 3D viewport now shows a closed box room with a floor, four walls, and a ceiling.

  • Right-click Main and add a DirectionalLight3D child.
  • Rotate it slightly downward in the Inspector (set Rotation X to -45 degrees) so it lights the room.
  • Drag player.tscn from the FileSystem dock (bottom-left panel) into the scene tree under Main. This instances the Player scene.
  • Select the Player instance and set its Position to (0, 1, 5) so it spawns inside the room, above the floor.
  • Save main.tscn.

Finally, set this scene as the one that runs when you press Play.

  • Click Project in the top menu, then Project Settings....
  • In the General tab, navigate to Application then Run.
  • Set Main Scene to res://main.tscn.
  • Close Project Settings.

Before you run this, what do you expect to happen when the scene starts? Think about what _ready does to your mouse cursor.

  • Press F5 (or Cmd+B on macOS) to run the project.

Your mouse cursor disappears (captured). Moving the mouse looks around the room. Pressing W, A, S, D moves your character. You do not fall through the floor.

How do I exit the running game?

Press Esc to release the mouse, then close the game window. Alternatively, click the Stop button in the Godot editor toolbar.

Falling through the floor?

Make sure your Ground node has a CollisionShape3D child with a WorldBoundaryShape3D. The MeshInstance3D provides visuals only. Without the collision shape, the CharacterBody3D has nothing to stand on.

Also confirm the Player instance is positioned above Y = 0. If it starts below the floor, it will never land.

Help me fix my floor collision

Your first-person controller is alive. Next up, you will build the arcade cabinet that gives the player something to interact with and triggers the entire game loop.

Build the Arcade Cabinet and Tutorial Slides

Your first-person controller works, but you are walking around an empty box with nothing to do.

The arcade cabinet is the focal point of the entire Nighttime Phase. It gives the player a reason to explore, a way to learn the rules, and the trigger that starts the wave defense loop. In this step, you will place the cabinet, build a full-screen tutorial UI, and write the game state script that transitions the player from exploration into combat.

In this step, get ready to:
  • Place the arcade cabinet, door, and forcefield geometry in the scene.
  • Create a HUD with a menu screen and tutorial slide system.
  • Write a game state script that transitions from exploration to combat.
Place the arcade cabinet, door, and forcefield

The cabinet is the centerpiece of the room. You will also add a door (starts closed) for the win condition, and a forcefield cylinder that will appear during combat.

  • In the main.tscn scene tree, right-click the root Node3D and add a child CSGBox3D node. Rename it to ArcadeCabinet.
  • In the Inspector, set the ArcadeCabinet size to roughly (0.8, 1.8, 0.6) and position it at the center of the room (0, 0.9, 0).
  • Add a Sprite3D child to ArcadeCabinet. This will represent the screen face. Position it slightly in front of the cabinet body.
  • Select ArcadeCabinet and click Node in the Inspector panel, then the Groups tab. Type arcade_cabinet and click Add.
  • Add an Area3D child to ArcadeCabinet. Add a CollisionShape3D child to that Area3D and set its shape to a BoxShape3D with a large size (about (3, 2, 3)) so the player can trigger interaction from a few steps away.

What is an Area3D interaction zone?

An Area3D detects when physics bodies enter or leave its space. The large CollisionShape3D around the cabinet acts as an invisible trigger zone. When the player walks into it, the game knows the player is close enough to interact.

  • Add another CSGBox3D as a child of the root Node3D. Rename it to Door. Position it against one of the walls (e.g. (0, 1.5, -5)) and size it like a doorway ((1.2, 3, 0.3)).
  • Add a MeshInstance3D as a child of the root node. Rename it to Forcefield. Set its mesh to a CylinderMesh with a radius of about 5 and height of 4. Center it at the cabinet position.
  • On the Forcefield mesh material, create a new StandardMaterial3D. Set Albedo Color to a blue with low alpha (e.g. (0.2, 0.5, 1.0, 0.3)) and enable Transparency in the material.
  • In the Inspector for Forcefield, uncheck Visible so it starts hidden.
  • Add an Area3D as a child of the root node. Rename it to ForcefieldZone. Add a CollisionShape3D child with a CylinderShape3D matching the forcefield radius.
  • Add a Path3D as a child of the root node. Rename it to SpawnPath. In the viewport, draw a circular path around the cabinet (matching the forcefield perimeter). Add a PathFollow3D child and rename it to SpawnLocation.
  • Add a Timer node as a child of the root. Rename it to SpawnTimer. Set Wait Time to 1.5 and leave Autostart off.
  • Add another Timer node. Rename it to GameTimer. Set Wait Time to 180 and check One Shot.
  • Press F5 (or Cmd+B on macOS) to run the scene.

You should see your CSGBox3D cabinet standing in the center of the room and the door against the far wall. The forcefield is hidden so you will not see it yet.

Cabinet not visible?

Make sure the ArcadeCabinet position Y value places it above the floor. A Y of 0.9 centers a 1.8-tall box on the ground plane. Also check that you did not accidentally uncheck Visible on the cabinet itself.

Help me troubleshoot my arcade cabinet not showing up.

Create the HUD and write the game state script

A CanvasLayer renders 2D UI elements on top of the 3D scene, independent of camera movement. You will build the arcade menu, tutorial labels, and gameplay HUD elements here.

  • In the scene tree, add a CanvasLayer as a child of the root node. Rename it to HUD.
  • Add a Panel (Control node) as a child of HUD. Rename it to ArcadeUI. Set its anchor to Full Rect so it covers the entire screen.
  • Add a VBoxContainer child inside ArcadeUI. Inside that, add three Label nodes with text: [ START ], OPTIONS (locked), EXIT (locked). Set the OPTIONS and EXIT label font colors to grey.
  • Add a separate Label inside ArcadeUI (sibling to the VBoxContainer). Rename it to SlideLabel. This will display tutorial text.
  • Add these Label nodes directly under HUD (not inside ArcadeUI): TimerLabel, HPLabel, MessageLabel, Crosshair.
  • Set the Crosshair text to + and anchor it to the center of the screen.
  • Add a ColorRect under HUD. Rename it to Darkness. Set it to Full Rect anchor and color (0, 0, 0, 0) (transparent black).

Now you will create the main.gd script that drives the game state machine. Select the root Node3D in main.tscn and click Attach Script (the scroll icon). Name it main.gd.

  • Replace the default content with the game state declarations and node references by pasting this code:
extends Node3D

enum GameState { EXPLORE, ARCADE_SCREEN, SLIDES, COMBAT, GAME_OVER, WIN }

@export var enemy_scene: PackedScene
@export var spawn_interval: float = 1.5
@export var game_duration: float = 180.0
@export var cabinet_max_hp: int = 3

var state: GameState = GameState.EXPLORE
var cabinet_hp: int
var current_slide: int = 0
var slides: Array[String] = [
	"THEY ARE COMING\nDemonic aliens approach from the void.",
	"AIM AND SHOOT\nYour laser rifle fires where you point. Click to shoot!",
	"STAY IN THE LIGHT\nThe rifle only works inside the forcefield. Don't stray!"
]

What does this code do?

  • The GameState enum defines every possible phase: exploring, viewing the arcade screen, reading slides, in combat, game over, or win.
  • The @export variables let you tweak spawn rate, game duration, and cabinet health from the Inspector without editing code.
  • The slides array holds three tutorial messages that display on the arcade screen before combat begins.
  • Below the slides array, add all the node references that the script needs:
@onready var player: CharacterBody3D = $Player
@onready var spawn_timer: Timer = $SpawnTimer
@onready var game_timer: Timer = $GameTimer
@onready var spawn_path: PathFollow3D = $SpawnPath/SpawnLocation
@onready var cabinet_position: Vector3 = $ArcadeCabinet.global_position
@onready var forcefield: MeshInstance3D = $Forcefield
@onready var forcefield_zone: Area3D = $ForcefieldZone
@onready var door: Node3D = $Door
@onready var arcade_ui: Control = $HUD/ArcadeUI
@onready var slide_label: Label = $HUD/ArcadeUI/SlideLabel
@onready var timer_label: Label = $HUD/TimerLabel
@onready var hp_label: Label = $HUD/HPLabel
@onready var message_label: Label = $HUD/MessageLabel
@onready var crosshair: Label = $HUD/Crosshair
@onready var darkness: ColorRect = $HUD/Darkness

What are @onready references?

The @onready annotation grabs a reference to each node when the scene is loaded. This is faster than calling get_node() every frame. The dollar-sign paths ($HUD/Crosshair) match the scene tree hierarchy you just built.

  • Below the references, add the _ready() function that initializes UI visibility and connects signals:
func _ready() -> void:
	cabinet_hp = cabinet_max_hp
	forcefield.visible = false
	arcade_ui.visible = false
	timer_label.visible = false
	hp_label.visible = false
	message_label.visible = false
	darkness.visible = false
	crosshair.visible = false

	spawn_timer.wait_time = spawn_interval
	game_timer.wait_time = game_duration
	game_timer.one_shot = true

	player.interaction_pressed.connect(_on_player_interact)
	spawn_timer.timeout.connect(_on_spawn_timer_timeout)
	game_timer.timeout.connect(_on_game_timer_timeout)
	forcefield_zone.body_entered.connect(_on_forcefield_zone_body_entered)
	forcefield_zone.body_exited.connect(_on_forcefield_zone_body_exited)

What does _ready() set up?

Everything starts hidden until the player triggers it. The signal connections wire up the interaction flow: when the player presses E near the cabinet, when timers fire, and when the player enters or leaves the forcefield zone.

  • Now add the interaction handler, the forcefield zone handlers, and the stub functions that the signal connections expect. Paste this below _ready():
func _process(_delta: float) -> void:
	pass

func _on_player_interact() -> void:
	if state == GameState.EXPLORE:
		state = GameState.ARCADE_SCREEN
		arcade_ui.visible = true
		slide_label.text = "[ START ]\n\nOPTIONS (locked)\nEXIT (locked)"
		player.in_arcade_ui = true
		Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)

func _on_forcefield_zone_body_entered(body: Node3D) -> void:
	if body == player:
		player.inside_forcefield = true

func _on_forcefield_zone_body_exited(body: Node3D) -> void:
	if body == player:
		player.inside_forcefield = false

func _on_spawn_timer_timeout() -> void:
	pass

func _on_enemy_reached_cabinet() -> void:
	pass

func _on_game_timer_timeout() -> void:
	pass

What does _on_player_interact() do?

When the player presses E during the EXPLORE state, the game transitions to ARCADE_SCREEN. It shows the arcade UI panel, sets the slide label to the menu text, locks the player's movement, and releases the mouse cursor so they can click START.

  • Save main.gd and run the scene with F5.
  • Walk toward the cabinet and press E. The full-screen arcade UI should appear with [ START ] in white text.

UI not appearing when you press E?

Make sure you are close enough to the cabinet (inside its Area3D). Check that the interaction_pressed signal from player.gd emits when E is pressed. Verify the node path $HUD/ArcadeUI matches your scene tree exactly.

Help me debug why the arcade UI does not show.

Wire the slide system and combat transition

The menu is showing, but clicking does nothing. You need the _input() function to handle state transitions: clicking START enters slide mode, pressing E advances slides, and after the last slide the game enters combat.

  • In main.gd, add the input handler and slide display function below _on_player_interact() (before the forcefield zone handlers):
func _input(event: InputEvent) -> void:
	if state == GameState.ARCADE_SCREEN:
		if event.is_action_pressed("shoot") or event.is_action_pressed("interact"):
			state = GameState.SLIDES
			current_slide = 0
			_show_slide()
	elif state == GameState.SLIDES:
		if event.is_action_pressed("interact") or event.is_action_pressed("shoot"):
			current_slide += 1
			if current_slide >= slides.size():
				_start_combat()
			else:
				_show_slide()

func _show_slide() -> void:
	slide_label.text = slides[current_slide] + "\n\n[Press E to continue]"

What does this code do?

  • In the ARCADE_SCREEN state, any click or E press transitions to SLIDES and shows the first slide.
  • In the SLIDES state, each E press increments the slide counter. After the third slide, it calls _start_combat().
  • The _show_slide() function updates the label text with the current slide content and a prompt to continue.
  • Finally, add the _start_combat() function that transitions from slides into the active game:
func _start_combat() -> void:
	state = GameState.COMBAT
	arcade_ui.visible = false
	player.in_arcade_ui = false
	Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
	player.enable_shooting()
	crosshair.visible = true
	crosshair.add_to_group("crosshair")
	forcefield.visible = true
	timer_label.visible = true
	hp_label.visible = true
	hp_label.text = "CABINET HP: %d/%d" % [cabinet_hp, cabinet_max_hp]
	spawn_timer.start()
	game_timer.start()

What does _start_combat() do?

  • Hides the arcade UI and re-enables player movement by capturing the mouse again.
  • Calls enable_shooting() on the player, makes the crosshair visible, and adds it to the crosshair group so other scripts can find it.
  • Shows the forcefield cylinder and the HUD labels (timer, cabinet HP).
  • Starts both the spawn timer (enemies every 1.5 seconds) and the 3-minute game timer.
  • Save main.gd.

Before you run, predict what you expect to see: will the forcefield appear? Will enemies spawn? Think about which systems are wired and which are still stubs.

  • Press F5 to run the scene.
  • Walk to the cabinet and press E. You should see the START/OPTIONS/EXIT menu.
  • Click or press E again. The first slide ("THEY ARE COMING") appears.
  • Press E twice more to advance through all slides.
  • After the last slide, the UI closes, a + crosshair appears at screen center, and the blue semi-transparent forcefield cylinder fades in around the cabinet.

No enemies spawn yet because _on_spawn_timer_timeout() is still a stub. You will build that in the next step.

Slides not advancing?

Check that your Input Map has the interact action mapped to the E key. Also confirm that the _input() function is at the top-level of main.gd (not nested inside another function).

Help me debug why my tutorial slides are not advancing.

✔️ Awesome, I've got everything!

Great. Double check that you have saved main.gd and that the forcefield and crosshair both appear after the last slide.

ⓧ I'd like to double check the full code

Your main.gd is longer than 30 lines, so scroll through and confirm these sections are present in order:

  • The extends Node3D line, GameState enum, @export variables, state variables, and slides array.
  • All 15 @onready references (player through darkness).
  • The _ready() function with visibility setup and five .connect() calls.
  • The _process() stub (just pass).
  • The _on_player_interact() function.
  • The _input() and _show_slide() functions.
  • The _start_combat() function.
  • The forcefield zone handlers (_on_forcefield_zone_body_entered and _on_forcefield_zone_body_exited).
  • Three stub functions: _on_spawn_timer_timeout, _on_enemy_reached_cabinet, _on_game_timer_timeout.

The arcade cabinet now drives the game flow from exploration into combat. In the next step, you will bring enemies to life and make the laser rifle actually fire.

Add the Laser Rifle and Enemy Invasion

The forcefield is up and the crosshair is on screen, but nothing is happening. You can click all day and nothing responds.

In this step, you will create the enemies that rush toward the cabinet, wire up the spawn system that sends them from the forcefield perimeter, and connect the laser rifle so your clicks actually kill things. The enemies arriving gives meaning to the rifle you just received and completes the core combat loop.

In this step, get ready to:
  • Create an Enemy scene with AI movement that rushes toward the cabinet.
  • Wire up the spawn timer so enemies appear from the forcefield perimeter.
  • Connect the laser rifle so clicking inside the forcefield kills enemies.
Create the Enemy scene

Each enemy is a self-contained scene with its own movement logic. You will give it a target (the cabinet's position) and it will sprint straight toward it every physics frame.

  • In Godot, click Scene in the top menu bar, then click New Scene.
  • Select Other Node and search for CharacterBody3D. Select it and click Create.
  • Rename the root node to Enemy.
  • Add a CollisionShape3D child. In the Inspector, set its Shape to a new CapsuleShape3D.
  • Add a MeshInstance3D child. Set its Mesh to a new CapsuleMesh.
  • Click the CapsuleMesh to expand it. Under Material, create a new StandardMaterial3D. Set its Albedo > Color to red.
  • Add an Area3D child (this will detect the cabinet later).
  • Save the scene as enemy.tscn by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Now attach a script to the Enemy root node. Right-click the Enemy node, select Attach Script, and save it as enemy.gd.

  • Replace the default script content with the enemy movement and damage logic:
extends CharacterBody3D

@export var move_speed: float = 3.0
@export var max_hp: int = 1

var hp: int
var target_position: Vector3 = Vector3.ZERO

signal reached_cabinet

func _ready() -> void:
	hp = max_hp
	add_to_group("enemy")

func initialize(spawn_pos: Vector3, cabinet_pos: Vector3) -> void:
	global_position = spawn_pos
	target_position = cabinet_pos
	look_at(target_position, Vector3.UP)

func _physics_process(_delta: float) -> void:
	var direction := global_position.direction_to(target_position)
	velocity = direction * move_speed
	move_and_slide()

	var distance := global_position.distance_to(target_position)
	if distance < 1.5:
		reached_cabinet.emit()
		queue_free()

func take_damage(amount: int) -> void:
	hp -= amount
	if hp <= 0:
		queue_free()

What does this code do?

This script gives each enemy a single-minded goal: reach the cabinet.

  • add_to_group("enemy") registers the enemy so the player's raycast can identify it as a valid target.
  • initialize() places the enemy at its spawn point and faces it toward the cabinet.
  • direction_to() returns a normalized vector pointing from the enemy to the cabinet. Multiplying by move_speed sets the velocity, then move_and_slide() moves the body.
  • When the enemy gets within 1.5 units of the cabinet, it emits reached_cabinet and removes itself from the scene.
  • Save enemy.gd and save the scene again with Cmd+S (macOS) or Ctrl+S (Windows).

Script won't attach?

  • Make sure you right-clicked the Enemy root node (the CharacterBody3D), not one of its children.
  • If Godot says the file already exists, delete the auto-generated script and try again.

Help me attach a script to my Enemy node.

✔️ Awesome, I've got everything!

Great. Double check you saved both enemy.gd and enemy.tscn.

ⓧ I'd like to double check the full code

extends CharacterBody3D

@export var move_speed: float = 3.0
@export var max_hp: int = 1

var hp: int
var target_position: Vector3 = Vector3.ZERO

signal reached_cabinet

func _ready() -> void:
	hp = max_hp
	add_to_group("enemy")

func initialize(spawn_pos: Vector3, cabinet_pos: Vector3) -> void:
	global_position = spawn_pos
	target_position = cabinet_pos
	look_at(target_position, Vector3.UP)

func _physics_process(_delta: float) -> void:
	var direction := global_position.direction_to(target_position)
	velocity = direction * move_speed
	move_and_slide()

	var distance := global_position.distance_to(target_position)
	if distance < 1.5:
		reached_cabinet.emit()
		queue_free()

func take_damage(amount: int) -> void:
	hp -= amount
	if hp <= 0:
		queue_free()
Wire the spawn system in main.gd

The enemy scene exists, but nothing creates instances of it yet. You need to fill in the spawn timer handler so enemies appear from random points on the forcefield perimeter every 1.5 seconds.

  • Switch back to main.gd in the script editor.
  • Find the _on_spawn_timer_timeout() function (currently just pass). Replace everything from that function through to the end of the file with this code:
func _on_spawn_timer_timeout() -> void:
	if state != GameState.COMBAT:
		return
	# Pick a random point on the circular spawn path
	var enemy = enemy_scene.instantiate()
	spawn_path.progress_ratio = randf()
	var spawn_pos := spawn_path.global_position
	add_child(enemy)
	enemy.initialize(spawn_pos, cabinet_position)
	enemy.reached_cabinet.connect(_on_enemy_reached_cabinet)

func _on_enemy_reached_cabinet() -> void:
	# Decrement cabinet health each time an enemy arrives
	cabinet_hp -= 1
	hp_label.text = "CABINET HP: %d/%d" % [cabinet_hp, cabinet_max_hp]
	if cabinet_hp <= 0:
		_game_over()

func _on_game_timer_timeout() -> void:
	pass

func _game_over() -> void:
	pass

func _win() -> void:
	pass

What does this code do?

  • spawn_path.progress_ratio = randf() moves the PathFollow3D to a random point (0 to 1) along the circular Path3D. This is why enemies appear from different directions each time.
  • enemy_scene.instantiate() creates a new copy of the enemy scene. After calling add_child(), it enters the scene tree and starts running its _physics_process().
  • _on_enemy_reached_cabinet() is connected per-enemy so each one decrements cabinet HP independently when it arrives.
  • _game_over() and _win() are stubs you will fill in during the next step.
  • Save main.gd.
  • Now assign the enemy scene to the export variable. Click the main root node in the main.tscn scene tree.
  • In the Inspector, find the Enemy Scene property (under the script variables section).
  • Drag enemy.tscn from the FileSystem dock into that slot, or click the property and select Load to browse for it.

Before you add shooting, run the scene to confirm enemies are spawning correctly.

What do you think will happen when you interact with the cabinet and reach the COMBAT state? Will you be able to defend yourself?

  • Press F5 (or Cmd+B on macOS) to run the scene.
  • Walk up to the cabinet, press E, click START, advance through the slides, and watch the forcefield perimeter.

Red capsule-shaped enemies should start spawning from different points around the forcefield every 1.5 seconds and rush toward the cabinet. Clicking does nothing to stop them. Your rifle is not wired up yet.

No enemies appearing?

  • Make sure you assigned enemy.tscn to the Enemy Scene export on the main root node. Without it, enemy_scene.instantiate() will cause a null error.
  • Check the Output panel at the bottom of Godot for errors. A "Nil" error on the spawn line means the PackedScene was not assigned.
  • Confirm you reached the COMBAT state. If the slides did not advance, your input actions may not be mapped correctly.

Help me debug why enemies aren't spawning.

Connect the laser rifle input

The shooting functions (_attempt_shoot() and shoot()) already exist in player.gd, but nothing calls them yet. You need to add the input check inside _physics_process() so clicking the left mouse button triggers the shot.

  • Open player.gd and scroll to the _physics_process() function.
  • Find this line near the bottom of the function:
	move_and_slide()

	# Interaction
	if Input.is_action_just_pressed("interact"):
  • Add the following shooting check between move_and_slide() and the # Interaction comment, so the section looks like this:
	move_and_slide()

	# Shooting
	if Input.is_action_just_pressed("shoot") and shooting_enabled:
		_attempt_shoot()

	# Interaction
	if Input.is_action_just_pressed("interact"):
		interaction_pressed.emit()

Why check shooting_enabled?

The shooting_enabled flag is set to true only after the player finishes the tutorial slides. This prevents shooting during the EXPLORE phase when the player is just walking around.

Once _attempt_shoot() is called, it checks inside_forcefield. If the player stepped outside, it plays the dud sound and flashes the crosshair grey instead of firing.

  • Save player.gd.

Clicking doesn't kill enemies?

  • Make sure the RayCast3D on your Camera3D has Enabled checked in the Inspector, and its Target Position is set to (0, 0, -100) so it casts forward from the camera.
  • Check that the enemy's CollisionShape3D is a CapsuleShape3D, not a placeholder. The raycast needs to hit a physics body to register.
  • Confirm you are standing inside the forcefield. If your crosshair turns grey on click, you are outside the zone.

Help me debug why my raycast isn't hitting enemies.

Time for the full test. What do you expect will happen when you shoot an enemy inside the forcefield? What about when you step outside and try to fire?

  • Press F5 (or Cmd+B on macOS) to run the full game flow.
  • Walk to the cabinet, press E, click START, advance through the slides.
  • After the slides close, aim at a red capsule enemy and left-click while standing inside the forcefield.
  • Now walk outside the blue cylinder and click. Observe the crosshair flash grey.

Enemies spawn from the forcefield perimeter every 1.5 seconds and rush toward the cabinet. Left-clicking while aiming at an enemy inside the forcefield kills it instantly. Stepping outside and clicking plays the dud sound and greys the crosshair for a moment.

Why constrain shooting to the forcefield?

The zone constraint creates spatial tension. You cannot retreat to a safe corner and snipe from afar. You are trapped with the enemies in the same space, forced to aim quickly as they close in from all directions.

The core combat loop is alive. Enemies spawn, rush the cabinet, and you can eliminate them with the laser rifle as long as you stay inside the zone. Next up, you will add the stakes: a 3-minute survival timer, cabinet health tracking, and proper win/lose endings.

Complete the Game Loop with Win and Lose Conditions

Your combat loop works. Enemies spawn, rush the cabinet, and you can shoot them down from inside the forcefield.

But right now the game never ends. You can shoot enemies indefinitely with no stakes, and enemies that reach the cabinet decrement a number you never see reset. Without win and lose conditions, this prototype is a sandbox, not a game.

In this step, get ready to:
  • Display a live countdown timer on the HUD during combat.
  • Code the defeat sequence that fades to black when the cabinet takes 3 hits.
  • Code the victory sequence that opens the exit door after 3 minutes of survival.
Wire up the countdown display

The GameTimer node is already running once combat starts, but the player has no way to see how much time remains. A ticking countdown creates urgency and tells the player exactly what they need to survive.

  • In main.gd, find the _process function. It currently looks like this:
func _process(_delta: float) -> void:
	pass
  • Replace the entire _process function with this countdown display logic:
func _process(_delta: float) -> void:
	if state == GameState.COMBAT:
		var time_left := game_timer.time_left
		var minutes := int(time_left) / 60
		var seconds := int(time_left) % 60
		timer_label.text = "%d:%02d" % [minutes, seconds]

What does this code do?

  • game_timer.time_left returns the seconds remaining on the running Timer node.
  • Integer division by 60 gives the minutes. The modulo (% 60) gives the remaining seconds.
  • The format string "%d:%02d" pads seconds with a leading zero so you see "3:00" instead of "3:0".

Next, the timer expiring needs to trigger the win condition. Right now _on_game_timer_timeout is an empty stub.

  • Scroll down to the _on_game_timer_timeout function. Find this line:
func _on_game_timer_timeout() -> void:
	pass
  • Replace pass with _win(), so the function now looks like this:
func _on_game_timer_timeout() -> void:
	_win()
  • Save main.gd.
  • Run the scene with F5 (or Cmd+B on macOS). Walk to the cabinet, press E, click START, advance through the slides.

You should see "3:00" on the HUD counting down second by second, alongside "CABINET HP: 3/3".

Timer not counting down?

  • Make sure you replaced pass inside _process and not inside a different function.
  • Check that timer_label.visible = true is set in your _start_combat() function. If the label is hidden, the text updates but you cannot see it.
  • Why isn't my countdown timer showing on the HUD during combat?
Code the defeat sequence

When 3 enemies reach the cabinet, the game should end dramatically. The screen fades to black and displays "GAME OVER" to signal total failure.

  • In main.gd, find the _game_over function stub:
func _game_over() -> void:
	pass
  • Replace the entire _game_over function with this defeat sequence, and add the _show_game_over_message helper directly below it:
func _game_over() -> void:
	state = GameState.GAME_OVER
	spawn_timer.stop()
	game_timer.stop()
	player.disable_shooting()
	timer_label.visible = false
	hp_label.visible = false
	crosshair.visible = false

	darkness.visible = true
	var tween := create_tween()
	tween.tween_property(darkness, "color", Color(0, 0, 0, 1), 1.0)
	tween.tween_callback(_show_game_over_message)

func _show_game_over_message() -> void:
	message_label.visible = true
	message_label.text = "GAME OVER"

What does this code do?

  • Both timers stop immediately so no more enemies spawn and the countdown freezes.
  • player.disable_shooting() prevents clicks from firing the rifle after the game ends.
  • The Tween animates the Darkness ColorRect from transparent to fully opaque black over 1 second. Once the fade completes, the callback shows the "GAME OVER" text on top of the black screen.
  • Save main.gd and run the scene.
  • Start combat via the cabinet, then stand aside and let 3 enemies walk past you into the cabinet.

After the third hit, the screen should fade to solid black over 1 second, then "GAME OVER" appears centered on the dark screen.

Screen not fading to black?

  • Confirm your Darkness node is a ColorRect covering the full viewport. Its initial color should have alpha set to 0 (fully transparent).
  • Make sure darkness.visible = true is set before the Tween starts. A hidden node won't animate its color.
  • My game over screen isn't fading to black when cabinet HP reaches 0. Help me debug.
Code the victory sequence

Surviving the full 3 minutes should feel like a reward. The enemies vanish, the forcefield retracts, the rifle returns to the cabinet, and the exit door slides open.

  • Find the _win function stub in main.gd:
func _win() -> void:
	pass
  • Replace the entire _win function with this victory sequence:
func _win() -> void:
	state = GameState.WIN
	spawn_timer.stop()
	# Rifle returns to cabinet
	player.disable_shooting()
	crosshair.visible = false
	# Remove remaining enemies
	for enemy in get_tree().get_nodes_in_group("enemy"):
		enemy.queue_free()
	# Retract forcefield
	var tween := create_tween()
	tween.tween_property(forcefield, "scale", Vector3(0.01, 0.01, 0.01), 1.5)
	tween.tween_callback(func(): forcefield.visible = false)
	# Open the exit door
	var door_tween := create_tween()
	door_tween.tween_property(door, "position", door.position + Vector3(0, 3, 0), 2.0)
	# Update HUD
	timer_label.visible = false
	hp_label.visible = false
	message_label.visible = true
	message_label.text = "YOU SURVIVED"

What does this code do?

  • get_tree().get_nodes_in_group("enemy") finds every living enemy in the scene. Calling queue_free() on each removes them instantly.
  • The forcefield shrinks to near-zero over 1.5 seconds, then hides completely via a callback. This creates a smooth retraction effect rather than a hard disappearance.
  • A separate Tween moves the door upward by 3 units over 2 seconds, sliding it open. Both Tweens run in parallel since they are independent.

Speed up testing

Waiting 3 minutes to test the win condition gets tedious. Temporarily change @export var game_duration: float = 180.0 to 10.0 at the top of main.gd. This shortens the match to 10 seconds so you can verify the win sequence quickly. Change it back to 180.0 once you confirm it works.

  • Save main.gd.

Before you run this, think: what do you expect to see when the countdown hits 0:00?

  • Run the scene. Start combat via the cabinet. Shoot enemies to stay alive until the timer expires.

When the timer reaches 0:00, all enemies vanish, the forcefield shrinks and disappears, the crosshair vanishes (rifle returned to cabinet), the door slides upward, and "YOU SURVIVED" displays on the HUD.

Door not sliding open?

  • Check that your Door node is a direct child of the main scene and its node path matches $Door in the @onready declaration.
  • If the door is a CSGBox3D, make sure it is not a child of another CSG node (CSG children combine into their parent shape and cannot be moved independently).
  • My victory sequence runs but the door doesn't move. Help me figure out why.

Test the defeat path too. Run again, start combat, and intentionally let 3 enemies reach the cabinet. The screen fades to black and "GAME OVER" appears.

Why two separate Tweens in the win sequence?

Each create_tween() call produces an independent timeline. The forcefield shrinks over 1.5 seconds while the door slides open over 2 seconds, both playing simultaneously. If you chained them on one Tween, the door would wait for the forcefield to finish first.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.gd and that both the win and game over paths work when you test them.

ⓧ I'd like to double check the full code

Scroll up through main.gd and confirm each of these sections is present in order:

  • _process reads game_timer.time_left and formats it as "%d:%02d" during COMBAT.
  • _on_game_timer_timeout calls _win().
  • _game_over stops both timers, disables shooting, hides HUD elements, tweens Darkness to opaque, then calls _show_game_over_message().
  • _show_game_over_message shows message_label with text "GAME OVER".
  • _win stops spawn timer, disables shooting, frees all enemies, tweens forcefield scale to zero, tweens door upward, and shows "YOU SURVIVED".

Your prototype is now a complete game with stakes. The player has a clear objective (survive 3 minutes), meaningful consequences (fade to black on failure), and a satisfying payoff (door opens on victory). Next up, you will extend this foundation with a Secret Mission that makes combat more tactical.

Secret mission

Add a Rate-of-Fire Cooldown

Your laser rifle fires as fast as you can click, making combat trivially easy. Add a cooldown system that forces you to aim each shot carefully, creating real tension when multiple enemies rush the cabinet at once.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your machine, so there are no ongoing costs or cloud resources to worry about.

Resources you used:

  • A local Godot project folder containing your scene files (main.tscn, player.tscn, enemy.tscn) and scripts (player.gd, enemy.gd, main.gd).

Keep everything running

No action needed. Choose this if you plan to keep building on this prototype.

  • Your project files stay on your machine indefinitely with no ongoing cost.
  • This is the Nighttime Phase foundation for your larger dual-phase horror game. You can open it anytime in Godot 4.5 and continue adding features.
  • Godot 4.5 remains installed on your machine. It takes no background resources when closed.

Pause - I'll come back to this later

Since this is a local project with no running servers or cloud services, pausing just means closing Godot.

  • Close the Godot editor if it is open.
  • Your project folder remains on your machine and takes up minimal disk space.
  • When you are ready to return, open Godot and select your project from the Project Manager.

Delete - I don't want to use this again

Remove all project files from your machine.

  • Close Godot if it is open.
  • Delete your project folder from your Desktop by running the appropriate command for your operating system.

macOS

  • Open Terminal and run:
rm -rf ~/Desktop/[[PROJECT_FOLDER="your Godot project folder name"]]

Windows

  • Open PowerShell and run:
Remove-Item -Recurse -Force ~\Desktop\[[PROJECT_FOLDER="your Godot project folder name"]]
  • Open Godot's Project Manager. If your deleted project still appears in the list, right-click it and select Remove From List.

That's a Wrap!

That's a Wrap!

Nice work! You just built a complete first-person wave defense prototype in Godot 4.5, from an empty scene to a fully playable game loop with win and lose conditions.

You've learned how to:

  • Built a first-person controller with mouse-look, WASD movement, and hitscan shooting using a RayCast3D that only fires inside a forcefield zone.
  • Created an interactive arcade cabinet with a slideshow tutorial, enemies that spawn from a Path3D perimeter and rush the cabinet, and a full game state machine driving EXPLORE, COMBAT, WIN, and GAME_OVER transitions.
  • Designed complete win/lose conditions: a 3-minute survival timer, 3-hit cabinet health, enemy cleanup on victory, the rifle returning to the cabinet, and the exit door sliding open.
  • Secret Mission: Added a rate-of-fire cooldown system to the laser rifle, making combat tactical and laying the groundwork for the power-up system.

Ready to quiz yourself?