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#
| System | Pattern | Why |
|---|---|---|
| Player movement | CharacterBody2D, velocity from Input.get_axis() in _physics_process | Engine-native kinematics at physics rate; one body, many enemies — the collision cost asymmetry decides the enemy design (docs) |
| Enemy movement | Fake velocity — Area2D + global_position += direction * speed * delta | 300 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 |
| Spawning | Path2D circle as a child of the player + PathFollow2D.progress + Timer + a hard cap | The 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 lookup | A 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 |
| Combat | Hitbox/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 property | The 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 |
| Damage | Hurtbox.take_damage(amount) emits damage_received; a stats node owns health behind a setter that emits health_changed / health_depleted | The 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 |
| Drops | Exp Area2D with a picked_up signal; two phases — a short push-away tween, then a chase in _physics_process | The arc reads as "picked up" before the number actually arrives; the chase means a drop never strands. The crediting seam is decision 3 |
| Leveling | PlayerStats emits level_up with the rolled choices; the UI pauses the tree and offers three | The 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 |
| Upgrades | WeaponUpgrade extends Resource (name, description, upgrade_type enum, value) + a per-weapon upgrade_list | An upgrade is data applied to a stat, not a code branch per item. Adding upgrade #9 is a .tres file |
| Performance | Frame-skipped presentation work (skin flips) · pooled floating text · monitor-first in stage 08 | The 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 (Path2D → PathFollow2D → Marker2D) 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#
| Layer | Name | Who's on it | Who masks it |
|---|---|---|---|
| 1 | World | floor (if it gets collision) | player body |
| 5 | Player | player body | — |
| 6 | Player_Hitbox | weapon hitboxes | enemy hurtboxes' mask |
| 7 | Player_Hurtbox | player hurtbox | enemy hitboxes' mask |
| 13 | Enemy | enemy areas | — |
| 14 | Enemy_Hitbox | enemy hitboxes (later species) | player hurtbox's mask |
| 15 | Enemy_Hurtbox | enemy hurtboxes | player hitboxes' mask |
| 21/22 | Chest / Coin | drops & pickups | PickUpArea'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 areNodes, notResources, 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
@exportor in a Resource; a number that describes this moment (current health,days_grown-style counters) lives on the stats node.
Signals#
| Signal | Emitted by | Listened to by |
|---|---|---|
damage_received(amount) | Hurtbox | the entity's stats node |
health_changed(current, previous) | stats nodes | UI bars (later: vfx, audio) |
health_depleted | stats nodes | the entity's death code (drop + queue_free) |
picked_up | Exp | its own _on_picked_up (start the arc) |
level_up(choices) | PlayerStats | LevelUpInterface |
upgrade_selected(upgrade) | LevelUpInterface | PlayerStats/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#
- Enemy bodies: real or fake — make this call in stage 02.
(A) each enemy is a
CharacterBody2Dwithmove_and_slide(): correct collision, bodies that push each other, and 300 of them in the physics server; (B) each enemy is anArea2Dand 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: ______ - Where damage lives — stage 05's call, once the weapon attacks.
(A) the hitbox shape carries a
damageexport: 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: ______ - 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.