Part of the
make-game skill. The skill links here when your agent needs the detail.2d-survivors-progression.md.
Minimum playable base (build this first)
One arena scene: player CharacterBody2D with WASD movement, one auto-firing projectile weapon, a handful of chasing enemies, and visible feedback on every hit (flash + damage number). Prove “move, enemies chase, weapon kills them” before waves, shop, or characters exist. Two rules for every scene:- Dynamic viewport:
display/window/stretch/mode = "canvas_items",stretch/aspect = "expand". All UI uses Control anchors, never hardcoded pixel positions. Spawn enemies relative to the player (650-900px out), never from screen coordinates. - Playable slice, not tech demo: every hit needs shake/flash/particles/damage number. A silent wave where numbers move but nothing feels alive fails.
Architecture
GameManager (single source of truth for ALL run state -
stats, gold, XP, health, wave, signals) and AudioManager (pooled SFX + music).
Every system reads and writes GameManager; UI listens to its signals.
Physics layers
Wiring: Player body layer 1 mask 16 (walls only). Player Hurtbox layer 0 mask 34
(Enemies + EnemyProjectiles). PickupRange layer 0 mask 4. Enemies layer 2 mask 0.
Player projectiles layer 8 mask 2. Enemy projectiles layer 32 mask 1.
Input map: move_up/down/left/right (WASD + left stick), dash (Space + R2),
pause (Escape + Start).
Wave state machine (arena.gd)
The arena scene owns the player, spawner, all managers, and all UI CanvasLayers. Everything wires together via signals in_ready(). Per wave, 4 phases:
kill_all_enemies(), gold bonus (5 + wave*2),
flash + slow_motion(0.4, 0.8) + zoom pulse. GameManager pauses the tree for
LEVEL_UP / SHOPPING / PAUSED / GAME_OVER states and unpauses on PLAYING:
Player
area_entered signals. Check
hurtbox.get_overlapping_areas() manually each _physics_process and apply
damage with a 0.6s invincibility window. Signals miss continuous contact;
polling + i-frames is reliable.
Player also handles: regen tick (1s interval, GameManager.player_regen HP),
pickup magnet (attract pickups inside PickupRange), and a _weapons Dictionary
keyed by weapon id - add_or_upgrade_weapon(id) upgrades if owned, else
instantiates the weapon script onto WeaponMount.
Weapons (auto-fire, no aiming)
All weapons are Node2D scripts on WeaponMount, firing from_physics_process.
Damage scales with GameManager.player_damage_mult, cooldowns divide by
player_attack_speed_mult, crits roll player_crit_chance for 2x. Three bases:
- Projectile: fires at nearest enemy within 800px (compare
distance_squared_toagainst 640000, never sqrt). Extra projectiles fan out at 0.15 rad spread. If no target, retry in 0.1s instead of a full cooldown. - Orbital: N shields orbit at radius 80, speed 3.5 rad/s. Per-enemy hit cooldown Dictionary (0.4s keyed by instance id) so one orbit does not multi-hit the same enemy every frame.
- Area (nova): every 3.0s damage all enemies within radius 120 (group scan + distance_squared), show an expanding ring tween.
Pickups and XP
XP is NOT a physical pickup - it auto-earns on kill (GameManager.add_xp,
amount = enemy xp_value). Gold and health orbs ARE physical Area2D pickups in
the “pickups” group with magnet behavior: once attract() is called (player
range overlap, or level-up vacuums all), accelerate toward the player at up to
650px/s and collect within 12px. Level-up curve:
xp_needed = 5 + level*level + level*3.
Camera effects (the juice layer)
camera_effects.gd attaches as a child of the player’s Camera2D. Provides
screen_shake(intensity, duration) (tween_method randomized offset decaying to
zero), screen_flash(color, duration), slow_motion(scale, duration) (set
Engine.time_scale, restore via tween with set_ignore_time_scale(true)),
zoom_pulse, and a low-HP vignette shader that fades in below 35% health.
Register it on GameManager (GameManager.camera_fx) so every system can call it.
Build order
- project.godot: autoloads, input map, layer names, main scene, 1920x1080 canvas_items/expand stretch. Audio buses: Master, Music, SFX.
- Scenes smallest-first: pickup, projectile, enemy_projectile, enemy, player, character_select, main, arena.
- Base loop: player move + dash, projectile weapon, walker enemies chasing.
- GameManager stats + HUD (health, gold, XP bar, wave timer, kills, dash bar).
- Wave state machine + spawner + wave data (progression file).
- Level-up upgrades, shop, elites, evolution, characters (progression file).
Traps
- Physics-based enemies. Never RigidBody2D or move_and_slide for enemies -
direct
position +=on Area2D. Hundreds of colliding bodies kill perf; enemies only need to chase, not collide with each other. - Heavy enemy scenes. One Area2D + CollisionShape2D + Polygon2D + ProgressBar. No AnimationPlayer, no particles, no per-enemy shader materials - you will have 200+ alive.
- UI without
process_mode = ALWAYS. The tree pauses during level-up, shop, and game over; without ALWAYS, no button responds and the game soft-locks. - Manual aim. The entire input surface is move + dash. Weapons auto-target; adding aim breaks the genre.
- Editor-connected signals on runtime-instantiated scenes. Connect in
_ready()via code for enemies/projectiles/pickups - editor connections on templates are fragile and undebuggable. - On-screen spawns. Always 650-900px from the player, clamped to arena bounds.
Source:
library/skills/make-game/references/2d-survivors.md, rendered from summer-engine@4.3.0 on npm.
