Lesson 35

Idle Clicker

No player, no enemies, and nothing that can kill you. Click a rock, buy something that clicks for you, and watch the next price climb.

Loading the game engine. This takes a moment the first time.

Play it first, then read this. There is no warrior, no arrow keys, and no way to die. Every other lesson in this course can kill you. This one cannot, and it is still a game, which is the point.

WHAT TO NOTICE WHILE YOU PLAY

  • Click the rock. That is the whole game for about ten seconds.
  • Buy one miner and then stop clicking. The number keeps going up without you. You just turned your clicking into something that clicks for you, and that trade is what the style is built on.
  • Watch a price after you buy it. It goes UP every time. The tenth miner costs about four times what the first one did.
  • The goal moves. Reach it and a bigger one takes its place, forever. A game with no losing needs somewhere to keep pointing.

THE TWO NUMBERS FIGHTING EACH OTHER

Your income grows by ADDING: each miner is worth the same 0.6 gold a second as the one before it. The price grows by MULTIPLYING: every purchase makes the next one 15% dearer. Multiplying always wins in the end, so each upgrade buys you less time than the last, and the only way forward is the next tier up. Designers call that curve the wall, and pricing it is the whole craft of this style.

WHERE THE IDLE INCOME COMES FROM

One line, in _process(): "gold += per_second * delta". _process runs every frame, and delta is how much of a second that frame took, so the gold rises smoothly no matter how fast the computer is. Nothing in that line is waiting on a click.

HOW THE BUTTONS ARE BUILT

The four upgrades are a LIST at the top of this file, not four blocks of copy-pasted code. _ready() walks that list and makes one button per entry, the same way the lesson menu makes one button per lesson. Add a fifth dictionary to the list and a fifth button appears, with no other change anywhere.

New code in this lesson

None of the earlier lessons use these. Each one shows the line where it appears here.

pow()

A number multiplied by itself: pow(2, 3) is 2 * 2 * 2, which is 8.

game.gd
return float(upgrade["base_price"]) * pow(growth, count)

floor()

Rounds a number down to the whole number below.

game.gd
if value < 10.0 and value != floor(value):

The scenes

■ UI node · ■ 2D node · % unique name · .tscn instanced scene

game.tscn
  • Game Control game.gd
  • Margin MarginContainer
  • Columns HBoxContainer
  • Left VBoxContainer
  • GoldLabel Label %
  • RateLabel Label %
  • GoalLabel Label %
  • MineButton Button %
  • Hint Label
  • Right VBoxContainer
  • UpgradesTitle Label
  • UpgradeBox VBoxContainer %
  • MessageLabel Label %
  • HomeButton Button %

FileSystem

  • game.tscn
  • game.gd

The code

extends Control

## How much dearer each purchase makes the next one. 1.15 means 15% more.
@export var growth: float = 1.15

# The upgrades, as a list. One dictionary per button: what it is called,
# what it does, what the FIRST one costs, and the two ways it can pay you
# back. Everything below reads this list instead of naming any upgrade.
const UPGRADES: Array[Dictionary] = [
	{
		"key": "pick",
		"name": "Sharpen the pick",
		"effect": "+1 gold per click",
		"base_price": 12.0,
		"per_click": 1.0,
		"per_second": 0.0
	},
	{
		"key": "miner",
		"name": "Hire a miner",
		"effect": "+0.6 gold per second",
		"base_price": 25.0,
		"per_click": 0.0,
		"per_second": 0.6
	},
	{
		"key": "cart",
		"name": "Buy a cart",
		"effect": "+4 gold per second",
		"base_price": 240.0,
		"per_click": 0.0,
		"per_second": 4.0
	},
	{
		"key": "drill",
		"name": "Buy a drill",
		"effect": "+22 gold per second",
		"base_price": 2000.0,
		"per_click": 0.0,
		"per_second": 22.0
	}
]

# The first target. Reaching it multiplies it by ten, which is why the goal
# can never actually be finished.
const FIRST_GOAL: float = 250.0

var gold: float = 0.0
var per_click: float = 1.0
var per_second: float = 0.0
var goal: float = FIRST_GOAL

# How many of each upgrade you own, keyed the same way as UPGRADES.
var owned: Dictionary = {}

# One button per upgrade, in the same order as UPGRADES, so rewording a row
# is just reading the same slot number out of both lists.
var upgrade_buttons: Array[Button] = []

@onready var gold_label: Label = %GoldLabel
@onready var rate_label: Label = %RateLabel
@onready var goal_label: Label = %GoalLabel
@onready var message_label: Label = %MessageLabel
@onready var mine_button: Button = %MineButton
@onready var upgrade_box: VBoxContainer = %UpgradeBox
@onready var home_button: Button = %HomeButton

func _ready() -> void:
	home_button.pressed.connect(_on_home_pressed)
	mine_button.pressed.connect(_on_mine_pressed)
	_build_upgrade_buttons()
	_refresh()

# The idle half of an idle game, in one line. delta is the slice of a
# second this frame covered, so multiplying by it turns "22 gold a second"
# into however much 22 a second is worth for this one frame.
func _process(delta: float) -> void:
	if per_second > 0.0:
		gold += per_second * delta
	if gold >= goal:
		# Beating a goal does not end anything. It just moves the goal.
		goal *= 10.0
		_say("Goal reached. The next one is ten times bigger.")
	_refresh()

func _on_mine_pressed() -> void:
	gold += per_click

# Make it, fill it in, add_child(). One button per entry in UPGRADES, the
# same three steps the lesson menu uses to build itself.
func _build_upgrade_buttons() -> void:
	for i: int in UPGRADES.size():
		var upgrade: Dictionary = UPGRADES[i]
		owned[upgrade["key"]] = 0
		var button := Button.new()
		button.custom_minimum_size = Vector2(0, 78)
		button.focus_mode = Control.FOCUS_NONE
		# ".bind(i)" glues this row's number onto the press, so the handler
		# knows which upgrade was bought.
		button.pressed.connect(_buy.bind(i))
		upgrade_box.add_child(button)
		upgrade_buttons.append(button)

func _buy(index: int) -> void:
	var upgrade: Dictionary = UPGRADES[index]
	var price := _price_of(index)
	if gold < price:
		return
	gold -= price
	owned[upgrade["key"]] = int(owned[upgrade["key"]]) + 1
	# Both payouts ADD, so ten miners really are worth ten times one miner.
	# That is the losing half of the fight described up top.
	per_click += float(upgrade["per_click"])
	per_second += float(upgrade["per_second"])

# The cost curve. The first one costs base_price, and after that every copy
# you already own multiplies the price again.
func _price_of(index: int) -> float:
	var upgrade: Dictionary = UPGRADES[index]
	var count: int = owned[upgrade["key"]]
	return float(upgrade["base_price"]) * pow(growth, count)

# The buttons are made once and reworded every frame. Rebuilding them
# instead would throw away the one you are hovering, sixty times a second.
func _refresh() -> void:
	gold_label.text = "%s gold" % _pretty(gold)
	rate_label.text = "%s gold per second      %s per click" % [
		_pretty(per_second), _pretty(per_click)
	]
	goal_label.text = "Goal: %s gold" % _pretty(goal)
	mine_button.text = "MINE\n+%s" % _pretty(per_click)

	for i: int in upgrade_buttons.size():
		var upgrade: Dictionary = UPGRADES[i]
		var price := _price_of(i)
		var count: int = owned[upgrade["key"]]
		var button := upgrade_buttons[i]
		button.text = "%s      owned %d\n%s      cost %s gold" % [
			upgrade["name"], count, upgrade["effect"], _pretty(price)
		]
		# Greyed out is the price talking. Nothing else stops you buying.
		button.disabled = gold < price

# Big numbers get unreadable fast in a game whose whole subject is big
# numbers, so anything past a thousand gets shortened.
func _pretty(value: float) -> String:
	if value >= 1000000.0:
		return "%.2fM" % (value / 1000000.0)
	if value >= 1000.0:
		return "%.2fK" % (value / 1000.0)
	if value < 10.0 and value != floor(value):
		return "%.1f" % value
	return "%d" % int(value)

func _say(text: String) -> void:
	message_label.text = text
	var timer: SceneTreeTimer = get_tree().create_timer(3.0)
	timer.timeout.connect(func() -> void:
		if message_label.text == text:
			message_label.text = "")

# Loads the lesson list screen, which replaces this whole scene.
func _on_home_pressed() -> void:
	get_tree().change_scene_to_file("res://shared/ui/lesson_select.tscn")