main.gd spawns the coins and bombs, counts score, makes everything faster over time, handles game over and saves your record.
The core idea is a Timer node. Every time it times out, I spawn one object at a random x position above the screen. Usually a coin, sometimes a bomb (35% chance). The bomb and coin are separate scenes, so main just does this:
var spawn_x := randf_range(SPAWN_MARGIN, screen_width - SPAWN_MARGIN)
var spawn_position := Vector2(spawn_x, -40.0)
if randf() < BOMB_CHANCE:
var bomb := BombScene.instantiate() as Bomb
bomb.position = spawn_position
bomb.fall_speed = fall_speed * BOMB_FALL_MULTIPLIER
bomb.hit_player.connect(_on_bomb_hit_player)
add_child(bomb)
randf() gives a number from 0 to 1, so < BOMB_CHANCE with 0.35 means 35% bombs. Same code for the coin, just with the “collected” signal instead.
This is the part i’m most proud of. There’s one value called difficulty that goes from 0 to 1, and everything reads from it. It’s computed like this:
func _compute_difficulty() -> float:
var time_progress := time_alive / RAMP_DURATION
var milestones := int(float(score) / SCORE_STEP)
var score_progress := milestones * SCORE_STEP_BONUS
return minf(time_progress + score_progress, 1.0)
Two things feed it: the clock (maxes out on its own after RAMP_DURATION, which is 300 seconds = 5 minutes) and your score (every SCORE_STEP = 10 coins adds a permanent 0.04 bump). The minf(…, 1.0) caps it so nothing gets faster past full difficulty.
And then everything scales off that one value with lerp:
player.speed = lerpf(PLAYER_SPEED_START, PLAYER_SPEED_MAX, difficulty)
spawn_timer.wait_time = lerpf(SPAWN_TIME_START, SPAWN_TIME_MIN, difficulty)
var fall_speed := lerpf(FALL_SPEED_START, FALL_SPEED_MAX, difficulty)
lerpf(a, b, t) just means “slide from a to b as t goes 0 to 1”. Because the player speed scales off the SAME difficulty value as the coins, it stays fair. The numbers are picked so even at max speed you can still cross the whole screen in the time a coin takes to fall. Bombs are what actually kill you, not unreachable coins.
Small trap in that code: see the float(score) part? In GDScript, score / SCORE_STEP with two ints does integer division, so 5 / 10 gives 0 and rounds everything down. Without that float() the milestones just silently don’t work. Tiny thing, cost me a while.
- connecting everything with signals
Main connects to the signal right when it spawns each object. That way the coin doesn’t know the score exists and the bomb doesn’t know there’s a game over screen. Main is the only one who knows everything.
This is where I lost the most time honestly. Chain of pain:
Got “Class Bomb hides a global script class”. Turned out there were basically two scripts fighting over the same class_name. Had to find the duplicate and kill it.
Then got a Nil crash when spawning. The as Bomb cast in the spawn code was quietly returning null because after fixing #1 the scene’s script situation was messed up. Godot doesn’t crash on a failed cast, it just hands you nothing and lets you crash one line later.
THEN “script inherits from Area2D, can’t be assigned to CollisionShape2D”. While re-attaching the script I put it on the wrong node lol.
The logic is done, but the game still looks like colored triangles and squares. I’ll ask my friend(BIG shoutout to an artist @ wild_roe in tiktok and youtube, I’ll ask her to use her character I like a lot) to make the sprites and the background, and the music i’m gonna make myself.