doc 2 of 11

Survivors Game — Architecture

Top-down 2D auto-battler, 640×360 logical viewport (window 1280×720), Godot 4.7, Forward+ off — Mobile/Compatibility-friendly, Forward Plus is the default and it's fine. This document is the map of the whole design: every system, the pattern it uses, and why that pattern — written before the first line of code, updated whenever a decision lands. Reading it before building is how you avoid discovering the architecture by accident.

Systems map#

SystemPatternWhy
Player movementCharacterBody2D, velocity from Input.get_axis() in _physics_processEngine-native kinematics at physics rate; one body, many enemies — the collision cost asymmetry decides the enemy design (docs)
Enemy movementFake velocityArea2D + global_position += direction * speed * delta300 CharacterBody2Ds means 300 bodies in the physics server; 300 areas with simple math does not. Enemies never need to push anything — they only need to not be in the same pixel as each other forever. Decision 1
SpawningPath2D circle as a child of the player + PathFollow2D.progress + Timer + a hard capThe ring must move with the player, so it's their child. A PathFollow2D at a random progress is a random point on the circle with zero trigonometry. The cap is checked at spawn time, not with a countdown, because enemies die
Nearest-enemy lookupA linear scan over get_nodes_in_group("enemies")At 300 entries and a lookup a few times per second per weapon, the scan is microseconds. Spatial hashing is the measured upgrade path — see stage 08, not stage 05
CombatHitbox/hurtbox: two Area2D scenes; the hitbox is only enabled during the attack's active frames, driven by an animation track on the shape's disabled propertyThe weapon owns timing, the shape owns geometry, and "am I live right now" is a boolean the animation flips. No per-frame overlap polling. Decision 2
DamageHurtbox.take_damage(amount) emits damage_received; a stats node owns health behind a setter that emits health_changed / health_depletedThe shape never computes a number; the stats node is the only writer of health. Damage amounts originate in the weapon (or its upgrade), never in the shape
DropsExp Area2D with a picked_up signal; two phases — a short push-away tween, then a chase in _physics_processThe arc reads as "picked up" before the number actually arrives; the chase means a drop never strands. The crediting seam is decision 3
LevelingPlayerStats emits level_up with the rolled choices; the UI pauses the tree and offers threeThe curve is max_exp *= 2 per level — exponential cost, linear-ish reward, so late levels feel rare. The pause is the game freezing around a UI decision
UpgradesWeaponUpgrade extends Resource (name, description, upgrade_type enum, value) + a per-weapon upgrade_listAn upgrade is data applied to a stat, not a code branch per item. Adding upgrade #9 is a .tres file
PerformanceFrame-skipped presentation work (skin flips) · pooled floating text · monitor-first in stage 08The budget math: at 60 fps you have 16.6 ms; the rule is measure before you buy — stage 08 is a measurement stage, not an optimization stage

Scene tree#

Game (Node2D)
├── TileMapLayer                       ← floor; one layer is enough for this slice
├── Entities (Node2D, group "entities")  ← everything that can exist and die lives here
│   ├── Player (CharacterBody2D, group "player")
│   │   ├── PlayerStats (Node)          ← health, exp, level — the number home
│   │   ├── Weapons (Node2D)
│   │   │   └── GreatLongSword (Weapon) ← AttackSpeedTimer · SkinRoot · Hitbox
│   │   ├── Collider (CollisionShape2D)
│   │   ├── PickUpArea (Area2D)         ← the player's magnet reach
│   │   ├── Hurtbox (Area2D)            ← what can hurt the player
│   │   └── SkinRoot (Skin, AnimationPlayer)
│   └── (enemies and drops are added here at runtime)
└── Interfaces (CanvasLayer)
    ├── PlayerUI                        ← health bar, exp bar, score
    └── LevelUpInterface                ← hidden until a level-up; process_mode WHEN_PAUSED

The spawner's ring (Path2DPathFollow2DMarker2D) plus its Timer are children of the player, so the ring translates with them for free. Camera is a child of the player too — following needs no code.

An enemy is one reusable scene: Area2D (group "enemies") → EnemyStats, Collider (the separation sensor), Hurtbox, SkinRoot. Different enemy species are scene variants that change stats and skin, not code.

Physics layers#

LayerNameWho's on itWho masks it
1Worldfloor (if it gets collision)player body
5Playerplayer body
6Player_Hitboxweapon hitboxesenemy hurtboxes' mask
7Player_Hurtboxplayer hurtboxenemy hitboxes' mask
13Enemyenemy areas
14Enemy_Hitboxenemy hitboxes (later species)player hurtbox's mask
15Enemy_Hurtboxenemy hurtboxesplayer hitboxes' mask
21/22Chest / Coindrops & pickupsPickUpArea's mask

The contract in one sentence: a hitbox's mask lists exactly the hurtboxes it may hit, and nothing else. The player's PickUpArea masks only the drop layer. If you find yourself adding "everything" to a mask to make a bug go away, the bug is in the contract.

Data model#

  • Stats nodes (PlayerStats, EnemyStats) — the only writers of their numbers, with setters that emit on change. They are Nodes, not Resources, because their lifetime is bound to the entity, not shared across instances.
  • Upgrades (WeaponUpgrade extends Resource) — immutable data; applying one mutates the weapon's stats. Ten golems never share a stats node; ten instances of one upgrade share one .tres.
  • Standing rule: a number that describes a kind (base damage, exp drop, move speed) lives on the scene as an @export or in a Resource; a number that describes this moment (current health, days_grown-style counters) lives on the stats node.

Signals#

SignalEmitted byListened to by
damage_received(amount)Hurtboxthe entity's stats node
health_changed(current, previous)stats nodesUI bars (later: vfx, audio)
health_depletedstats nodesthe entity's death code (drop + queue_free)
picked_upExpits own _on_picked_up (start the arc)
level_up(choices)PlayerStatsLevelUpInterface
upgrade_selected(upgrade)LevelUpInterfacePlayerStats/weapon — applies it

Signals are named in past tense and flow upward — parents call children directly, children announce events without knowing who listens. The one deliberate exception is the PickUpArea → Exp.picked_up.emit() in stage 06, and stage 06 tells you why it's allowed.

The three design decisions — yours to make#

  1. Enemy bodies: real or fake — make this call in stage 02. (A) each enemy is a CharacterBody2D with move_and_slide(): correct collision, bodies that push each other, and 300 of them in the physics server; (B) each enemy is an Area2D and you move it yourself: zero body cost, enemies pass through each other until you decide otherwise. One-line trade-off: A is honest physics; B is honest performance. Your call and your reason: ______
  2. Where damage lives — stage 05's call, once the weapon attacks. (A) the hitbox shape carries a damage export: the number is next to the geometry; (B) the weapon carries the stat and the hitbox reads it: upgrades have a home. One-line trade-off: A is less wiring today; B is where upgrade #7 lands tomorrow. Your call and your reason: ______
  3. How a drop credits experience — stage 06's call, once the magnet works. (A) the drop reaches into the player: player.player_stats.gain_exp(value) — a direct reference, simplest possible; (B) the drop emits a signal and the player's stats subscribe — no reference, one more wire. One-line trade-off: A is one line; B survives the day the player stops being a global. Your call and your reason: ______

Build order#

First milestone — the walking skeleton: player walks, one enemy spawns from the ring and chases, the weapon hits it once and it dies. No drops, no leveling, no UI. The smallest complete loop — spawn → chase → hit → die — end to end, before any system gets deep.

Then: the crowd (stats, variance, frame-skip) → full combat (layers, knockback, float text) → the weapon's timing (hit frames) → drops and the magnet → level-up and upgrades → the 300-enemy measurement stage → ship it (death, restart, score).

Later modules, in whatever order the itch strikes: a ranged weapon with real projectiles (this is where object pooling earns its keep) · a second enemy species as a pure data change · audio · game feel pass (hit-stop, screen shake) · a boss minute at the ten-minute mark.