The Quest System — Giving Players Reasons to Keep Flying
Rings weren't enough
The first version of Oliver's Planes had one thing to do: fly through rings. That's fun for about three minutes. Then you've done it and there's no reason to come back. I needed progression — something that made you want to keep flying, earn money, and buy the next plane. Enter the quest system.
How it works, quickly
Quests is an autoload singleton. It holds a flat array of quest definitions and an event-driven report system. The world reports events ("rings_total", "reach_altitude", etc.) and matching quests progress toward their targets. When a quest hits its target, it awards cash through GameState.
Here's the quest definition table:
const DEFS := [
{"id": "first_ring", "title": "First Flight", "kind": "rings_total", "target": 1, "reward": 100,
"desc": "Fly through your very first glowing ring."},
{"id": "halfway", "title": "Getting the Hang of It", "kind": "rings_total", "target": 4, "reward": 175,
"desc": "Pass four rings in total (across any runs)."},
{"id": "finish_course", "title": "Round the Whole Course", "kind": "course_complete", "target": 1, "reward": 275,
"desc": "Complete the full eight-ring course."},
{"id": "high_climb", "title": "Up Among the Clouds", "kind": "reach_altitude", "target": 160, "reward": 250,
"desc": "Climb to 160 m altitude."},
{"id": "speed_run", "title": "Need for Speed", "kind": "reach_speed_kmh", "target": 270, "reward": 300,
"desc": "Hit 270 km/h."},
{"id": "clean_lap", "title": "Not a Scratch", "kind": "no_crash_course", "target": 1, "reward": 400,
"desc": "Finish all eight rings without crashing once."},
{"id": "redline_dive", "title": "Redline Dive", "kind": "reach_speed_kmh", "target": 320, "reward": 500,
"desc": "Push past 320 km/h in a committed dive."},
{"id": "ace_time", "title": "Ace Pilot", "kind": "course_time_under", "target": 40, "reward": 600,
"desc": "Blaze the whole course in under 40 seconds."},
]Simple dictionaries. No classes, no inheritance, no quest graph. Just data.
The report pattern
When something happens in the game — you pass a ring, hit a speed, finish a course — the world calls Quests.report(kind, value). The quest system loops through all definitions, skips completed ones, and checks if the value meets the target:
func report(kind: String, value: float) -> void:
for d in DEFS:
if d.kind != kind or is_done(d.id):
continue
match kind:
"rings_total", "reach_altitude", "reach_speed_kmh":
_peak[d.id] = maxf(float(_peak.get(d.id, 0.0)), value)
if value >= float(d.target):
_complete(d)
"course_time_under":
if value <= float(d.target):
_complete(d)
"course_complete", "no_crash_course":
if value >= 1.0:
_complete(d)Notice the _peak dictionary — it tracks the best progress for each quest during the session. The HUD uses this to show a progress bar. If you've hit 240 km/h and the target is 270, the bar fills to about 89%. That visual feedback is surprisingly motivating. You can see yourself getting closer.
Completion and persistence
When a quest completes, the reward goes straight into the player's wallet:
func _complete(d: Dictionary) -> void:
if is_done(d.id):
return
GameState.quests_done.append(d.id)
GameState.add_money(int(d.reward))
quest_completed.emit(d)GameState.add_money() persists immediately — it writes to the browser's localStorage (via Godot's FileAccess on the web export's virtual filesystem). Quests completed are stored as an array of ID strings. No database, no server. Everything lives in the player's browser.
The progression loop
Here's what the quest system creates:
1. Fly through rings → earn small amounts of money 2. Complete quests → earn bigger payouts (100–600 per quest) 3. Buy better planes → faster, more agile, cooler looking 4. Attempt harder quests → "Redline Dive" needs 320 km/h, which requires a fast plane 5. Repeat
The quest order is designed so the first few are trivially easy. "First Flight" rewards you for touching a single ring. "Getting the Hang of It" just needs four. By the time you hit "Not a Scratch" and "Ace Pilot," you actually need skill and a decent plane. The difficulty ramp is baked into the target numbers.
The HUD tracker
Up to two active quests show in the top-right corner of the HUD at all times:
func active(n: int) -> Array:
var out: Array = []
for d in DEFS:
if not is_done(d.id):
out.append(d)
if out.size() >= n:
break
return outIt returns the first n incomplete quests in definition order. That means players always see the next achievable quest. No quest menu, no quest log to dig through. The HUD just quietly tells you what to aim for. I debated adding a full quest journal screen and decided against it. Less UI, more flying.
Would I change anything?
I'd add repeatable daily quests — "fly 20 rings today" or "reach 200 km/h three times." The current set is finite: once you've done all eight, there's nothing left. A rotating daily objective would give players a reason to come back every day. That's probably the next update.
Written by Oliver
Indie game developer building 3D web experiences in Godot and Next.js. I write about creative coding, procedural generation, and game feel.