HOME
can you survive long enough to save earth?
a work in progress prototype of a top down survivors game made in godot. right now it's a few rough levels where i build and try out mechanics, not a finished game
the clips below are recorded from the prototype with a script doing the steering.
Figure 1 - the squad level
WHAT IS THIS?
each level is there to test an idea. invasion is endless waves that get bigger every round, king hill is a boss ant hill that spawns ants faster the more you hurt it, and the squad level has three npc squad mates and buildings you can knock down. you only steer and your weapons fire on their own
skyscrapers take damage from your bullets and catch fire and then collapse into pieces. it's the newest mechanic and nothing in the level reacts to it yet
every character and prop is hand made pixel art, from the squad and the giant ants to the worm and the ant hill. the ant walks on its four frame cycle from the game

player

squad mate
giant ant

bee

honey trap

worm

snake

ant hill

skyscraper

xp crystal

slow badge

wasp stinger
SOUNDTRACK
i wrote the music for it too
01title theme - loops on the menus and under invasion and king hill
HOW IT'S BUILT
it's all godot 4 and gdscript. every level is a scene whose root node is called Main, with the player and the hud and two containers under it, and almost everything else is a scene of its own that gets dropped into one of those containers while you play
an enemy never adds its own blood or xp crystal or damage number to the scene. it emits on_enemy_spawn_node with the node and a type and the level puts it in the right container, so when the enemy frees itself on death everything it dropped stays on the ground
# enemy.gd
signal on_enemy_spawn_node(node_to_spawn:Node, node_type:String)
func _try_spawn_xp_crystal():
...
on_enemy_spawn_node.emit(new_crystal, "ITEM")
# level_template.gd
func spawn_new_node(new_node:Node, node_type:String):
if node_type == "ITEM":
item_container.add_child(new_node)
elif node_type == "FLOAT_LABEL":
float_label_container.add_child(new_node)Enemy holds the stats every bug has and does the shared work. health is a setter that plays the hurt sound and calls _die() at zero, and take_damage shows the number before it calls custom_take_damage, which does nothing in the base class. the ant fills that hook in to spray blood back the way the bullet came, more of it the harder it got hit
# enemy.gd
func take_damage(projectile_direction:Vector2, damage:int):
var new_damage_number = damage_number.instantiate()
new_damage_number.format_damage_number(damage, global_position)
on_enemy_spawn_node.emit(new_damage_number, "FLOAT_LABEL")
custom_take_damage(projectile_direction, damage)
current_health -= damage
# enemy_ant.gd
func custom_take_damage(projectile_direction: Vector2, damage: int):
blood_splatter.trigger_from_damage(projectile_direction, damage)the rifle and the flamethrower are the same weapon scene with a fire timer and a reload timer. the Weapons node makes two of them and swaps the script on each one, and the numbers live in a .tres resource so i can tune them in the inspector without touching code. each bullet rolls its damage when it lands, anywhere from 0 to 20
# weapons.gd
var primary_weapon:Weapon = weapon_node.instantiate()
primary_weapon.set_script(rifle)
var secondary_weapon:Weapon = weapon_node.instantiate()
secondary_weapon.set_script(flamethrower)
# rifle.tres
clip_size = 5
fire_time = 0.85
reload_time = 2.0
base_damage = 1
damage_random_multiplier = 20each step only knows about the next one, so the upgrade menu has no idea a rifle exists and the rifle has no idea there was a menu. each crystal's xp is divided by (level + 1) / 2 before it counts, which is what makes the later levels slower
any enemy with a plan has a state enum and a StateTimer. every timeout runs one arm of a match statement, which does its thing and sets the next state and restarts the timer. the worm hides and rolls a die to decide whether to come up, screams for four seconds and goes back down. the wasp flies at you and thinks and then shoots a needle. THINK always goes to SHOOT for now
Figure 4 - the worm's states, one per StateTimer timeout
the game is top down so physics gravity is set to zero. when a skyscraper runs out of health it hides its sprite and frees its collision and a BuildingCollapse node drops 28 sprite pieces of it with gravity it works out itself every frame. the higher pieces start falling later and each one bounces up to three times off a ground line set in the inspector
# collapse.gd
@export var stagger_by_height: bool = true
@export var gravity: float = 900.0
@export var ground_y: float = 400.0
@export var bounce_damping: float = 0.35
@export var max_bounces: int = 3getting hit adds 0.3 trauma to the camera. trauma drains at 1.5 a second and gets squared before it moves the camera along some noise, so small hits barely move it and stacked hits shake it hard. when a mission ends the game drops to 0.3x speed for a moment before it pauses on the results screen
WHAT I'D DO DIFFERENTLY