LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
GDScriptBeginner~1 hour

Cookie Clicker Game

Build a UI-only idle clicker with a score, buyable upgrades, an auto-clicker driven by a Timer, and button signals.

Control NodesSignalsVariablesTimer

Overview

Clicker (or "idle") games are a great first Godot project because they need no art, no physics and no collision — just a few UI nodes and a script. Yet they cover the ideas you will use in every Godot game: finding nodes with @onready, reacting to signals, updating the UI from variables, and running code on a timer.

You click a big cookie button to earn cookies. Spend cookies on a "Bigger Clicks" upgrade that increases cookies per click, or on an "Auto-Clicker" that earns cookies every second by itself. Each upgrade costs more every time you buy it, and buttons disable themselves when you cannot afford them.

What You'll Build
  • A Control-based scene with a VBoxContainer, Labels and Buttons.
  • @onready references to every UI node the script needs.
  • Button pressed signals connected in code with .connect().
  • Two upgrades whose prices grow by 50% after each purchase.
  • A Timer node whose timeout signal pays out the auto-clickers once per second.
  • An update_ui() function that keeps every label and button in sync.

Prerequisites

  • Godot 4.x installed and a new project created — lesson 3.
  • Variables and typed variables — lesson 4.
  • Nodes, scenes and the scene tree — lesson 5.
  • Functions, _ready() and @onready — lesson 7.
  • Signals and .connect() — lesson 8.

Project Structure

res://
├── clicker.tscn # the only scene (set as the main scene)
└── clicker.gd # script attached to the root Control node

All game state — cookie count, click power, number of auto-clickers and upgrade prices — lives in variables at the top of clicker.gd. Every action changes those variables and then calls one function, update_ui(), which is the only place that writes to labels and buttons. Keeping "change data" and "show data" separate makes UI bugs much easier to track down.

Pasting Code into Godot

The code on this page is indented with spaces. If Godot reports "Mixed use of tabs and spaces for indentation" after you paste, open the script editor's Edit menu and choose Indentation > Convert Indent to Tabs (or set the editor to use spaces) so the whole file is consistent.

Step 1: Build the Scene

Create a new scene with a Control root named Clicker, set its layout to Full Rect, and add children so the tree matches the outline below. In the Inspector, give the CookieButton a large Custom Minimum Size (for example 220 × 120) and the text "COOKIE". Select the AutoTimer and confirm Wait Time is 1 and Autostart is enabled. Save the scene as clicker.tscn and set it as the main scene in Project Settings > Application > Run.

Clicker (Control) ← attach clicker.gd here
├── VBox (VBoxContainer) ← layout: Center
│ ├── CookieLabel (Label)
│ ├── RateLabel (Label)
│ ├── CookieButton (Button)
│ ├── ClickUpgradeButton (Button)
│ └── AutoUpgradeButton (Button)
└── AutoTimer (Timer) ← Wait Time: 1, Autostart: on

Step 2: Clicking & the Score Label

@onready delays each assignment until the node has entered the scene tree, so $VBox/CookieLabel is guaranteed to exist. Connecting the pressed signal in _ready() keeps all the wiring visible in code instead of hidden in the editor's Node dock.

extends Control
var cookies: int = 0
var cookies_per_click: int = 1
@onready var cookie_label: Label = $VBox/CookieLabel
@onready var cookie_button: Button = $VBox/CookieButton
func _ready() -> void:
cookie_button.pressed.connect(_on_cookie_pressed)
update_ui()
func _on_cookie_pressed() -> void:
cookies += cookies_per_click
update_ui()
func update_ui() -> void:
cookie_label.text = "Cookies: %d" % cookies

Step 3: Buying Upgrades

Each purchase checks the price, subtracts it, applies the upgrade and raises the next price by 50% with int(price * 1.5). In update_ui(), setting disabled = cookies < price greys out any upgrade the player cannot afford, so the purchase functions rarely need to show an error at all.

var click_upgrade_cost: int = 10
var auto_clickers: int = 0
var auto_upgrade_cost: int = 25
func _on_click_upgrade_pressed() -> void:
if cookies < click_upgrade_cost:
return
cookies -= click_upgrade_cost
cookies_per_click += 1
click_upgrade_cost = int(click_upgrade_cost * 1.5)
update_ui()
func _on_auto_upgrade_pressed() -> void:
if cookies < auto_upgrade_cost:
return
cookies -= auto_upgrade_cost
auto_clickers += 1
auto_upgrade_cost = int(auto_upgrade_cost * 1.5)
update_ui()

Step 4: The Auto-Clicker Timer

A Timer node emits timeout every wait_time seconds. Because the timer is set to autostart and is not one-shot, it simply keeps firing; the handler adds one cookie per auto-clicker owned. A short create_tween() animation makes the button "pop" on every manual click.

@onready var auto_timer: Timer = $AutoTimer
func _ready() -> void:
# ...other connections...
auto_timer.timeout.connect(_on_auto_timer_timeout)
func _on_auto_timer_timeout() -> void:
if auto_clickers > 0:
cookies += auto_clickers
update_ui()
func _pop_button() -> void:
cookie_button.pivot_offset = cookie_button.size / 2
var tween := create_tween()
tween.tween_property(cookie_button, "scale", Vector2(1.1, 1.1), 0.05)
tween.tween_property(cookie_button, "scale", Vector2.ONE, 0.1)

Complete Code

clicker.gd
extends Control
var cookies: int = 0
var cookies_per_click: int = 1
var click_upgrade_cost: int = 10
var auto_clickers: int = 0
var auto_upgrade_cost: int = 25
@onready var cookie_label: Label = $VBox/CookieLabel
@onready var rate_label: Label = $VBox/RateLabel
@onready var cookie_button: Button = $VBox/CookieButton
@onready var click_upgrade_button: Button = $VBox/ClickUpgradeButton
@onready var auto_upgrade_button: Button = $VBox/AutoUpgradeButton
@onready var auto_timer: Timer = $AutoTimer
func _ready() -> void:
cookie_button.pressed.connect(_on_cookie_pressed)
click_upgrade_button.pressed.connect(_on_click_upgrade_pressed)
auto_upgrade_button.pressed.connect(_on_auto_upgrade_pressed)
auto_timer.timeout.connect(_on_auto_timer_timeout)
update_ui()
func _on_cookie_pressed() -> void:
cookies += cookies_per_click
_pop_button()
update_ui()
func _on_click_upgrade_pressed() -> void:
if cookies < click_upgrade_cost:
return
cookies -= click_upgrade_cost
cookies_per_click += 1
click_upgrade_cost = int(click_upgrade_cost * 1.5)
update_ui()
func _on_auto_upgrade_pressed() -> void:
if cookies < auto_upgrade_cost:
return
cookies -= auto_upgrade_cost
auto_clickers += 1
auto_upgrade_cost = int(auto_upgrade_cost * 1.5)
update_ui()
func _on_auto_timer_timeout() -> void:
if auto_clickers > 0:
cookies += auto_clickers
update_ui()
func _pop_button() -> void:
cookie_button.pivot_offset = cookie_button.size / 2
var tween := create_tween()
tween.tween_property(cookie_button, "scale", Vector2(1.1, 1.1), 0.05)
tween.tween_property(cookie_button, "scale", Vector2.ONE, 0.1)
func update_ui() -> void:
cookie_label.text = "Cookies: %d" % cookies
rate_label.text = "%d per click | %d per second" % [cookies_per_click, auto_clickers]
click_upgrade_button.text = "Bigger Clicks (+1) - %d cookies" % click_upgrade_cost
auto_upgrade_button.text = "Auto-Clicker (+1/s) - %d cookies" % auto_upgrade_cost
click_upgrade_button.disabled = cookies < click_upgrade_cost
auto_upgrade_button.disabled = cookies < auto_upgrade_cost

Testing the Game

Press F5 (or the Play button) to run the main scene, then walk through this checklist to confirm everything works:

  1. At start the label reads "Cookies: 0" and both upgrade buttons are greyed out.
  2. Click the cookie 10 times: the label reaches 10 and "Bigger Clicks" becomes clickable.
  3. Buy Bigger Clicks: cookies drop to 0, the rate label shows "2 per click", and the price rises to 15.
  4. Earn 25 cookies and buy an Auto-Clicker: the count now rises by 1 every second without clicking.
  5. Every manual click makes the cookie button briefly grow and shrink.
Labels right after buying one of each upgrade (13 clicks after Bigger Clicks)

Click Run to see what this code prints.

Extend This Project

  • Save progress with ConfigFile (or FileAccess and JSON) in user://save.cfg, and load it in _ready().
  • Add a third upgrade, such as a "Grandma" that earns 5 cookies per second, using an Array of upgrade Dictionaries.
  • Show floating "+2" labels that rise and fade out with a tween every time you click.
  • Format big numbers as "1.2K" and "3.4M" with a small helper function.
  • Award "offline earnings" by storing Time.get_unix_time_from_system() when the game closes.

Summary

You built a complete, playable game using only Control nodes and a single script: @onready node references, signals connected in code, typed variables for game state, a Timer for passive income and a tween for feedback. The "change state, then refresh the UI from one function" pattern you used here scales to menus, HUDs and inventories in much larger Godot games.