Part of the
make-game skill. The skill links here when your agent needs the detail.2d-rpg-content.md.
Minimum playable base (build this first)
One level extending LevelBase: player with 8-way movement + one sword swing + dodge roll, a couple of chase enemies withtake_hit(), HUD health bar. Prove
“move, hit enemy, get hit, see numbers” before any dialogue or quest exists.
Two rules for every scene:
- Dynamic viewport:
display/window/stretch/mode = "canvas_items",stretch/aspect = "expand". UI via Control anchors; compute spawn points and bounds fromget_viewport_rect().size, never hardcoded 1920x1080. - Playable slice: every input gets feedback through the Juice autoload (shake, flash, float text, hitstop). A silent build fails even if every system works.
Architecture: modules + EventBus
Systems NEVER reference each other directly. All cross-system communication flows through EventBus, a global signal bus autoload. Each module can be added or removed without breaking others.EventBus signal groups (define up front)
Player:player_health_changed, player_energy_changed, player_died,
player_leveled_up, player_xp_gained. Combat: damage_dealt,
enemy_killed(enemy, xp_value), player_attack_executed. Interaction:
interaction_available/unavailable, dialogue_started/ended. Inventory:
item_picked_up/used/equipped/unequipped, gold_changed. Quests:
quest_started/updated/completed, quest_objective_progressed. Progression:
level_up_ready, perk_selected, perk_applied. Scene:
scene_transition_started/completed, scene_ready. Spawner:
wave_started/cleared. UI: notification_requested(text, color),
boss_health_changed.
Physics layers and input
Hitbox on 4/5, hurtbox monitors the opposite side’s layer.
Input map (9 actions): move_up/down/left/right (WASD + arrows + stick), attack
(LMB/Space), dodge (RMB/Shift), interact (E), pause (Esc), inventory (I).
GameManager: states and transitions
change_state() handles
pause/unpause. Gate gameplay input on GameManager.is_gameplay_active().
Scene transition flow (fade overlay owned by GameManager):
transition_to_scene(path, spawn_point, spawn_direction) -> fade to black
0.3s -> emit scene_transition_started -> change_scene_to_file -> wait TWO
process frames -> emit scene_ready, state PLAYING -> fade in -> emit
scene_transition_completed. The player reads
GameManager.player_spawn_point / player_spawn_direction in _ready().
Guard with an _is_transitioning flag; never call transition from inside a
transition callback.
Area transitions are trivial once this exists - an Area2D with three exports
(target_scene, spawn_point, spawn_direction) whose body_entered calls
GameManager.transition_to_scene(). Place at map edges and doorways.
Player controller
IDLE, MOVE, ATTACK, DODGE, HURT, DEAD, INTERACT.
- Attack: manual aims at mouse direction; optional auto_attack targets the
nearest enemy within 250px. Delegate to
weapon_manager.attack(direction)gated bycan_attack(). - Dodge roll: costs 20 energy (
stats.use_energyreturns false if broke), 0.25s duration, invincible for the duration, fading trail rects. - Hurt: ignore if invincible/dead/dodging.
stats.take_damage, 0.5s i-frames, knockback velocity,Juice.shake(5), red flash, float text, emitplayer_health_changed.
Stats: the ModifiedValue pipeline
final = (base + additive_sum) * (1.0 + multiplicative_sum). Modifiers are
tagged with a source string so a whole buff can be removed in one call:
take_damage() returns {damage, killed}, calculate_damage(is_crit),
roll_crit(), add_xp() returns true on level-up, use_energy().
MUST call stats.initialize() in _ready() - forgetting it leaves the
pipeline null and everything crashes on first hit.
Level-up: +10 max HP, +2 attack, +1 defense, +3 speed, full heal.
XP needed = 50 + level * 30.
Combat components (reusable trio)
- HitboxComponent (Area2D): the damage dealer.
damage,knockback_force,hit_cooldown(per-target cooldown dictionary),try_hit(target) -> bool,activate()/deactivate()(deactivate clears cooldowns). Emitshit_landed(target, damage, knockback). - HurtboxComponent (Area2D): the receiver.
invincibility_duration0.3s, emitsdamage_received(amount, source, knockback)viatake_hit(damage, source, knockback). - KnockbackComponent (Node on any CharacterBody2D): decaying velocity,
resistance0-1,decay_rate8.apply_knockback(force)then addget_knockback_velocity()to movement.
Weapon system (data-driven)
WeaponData resource carries everything: type string, base_damage, attack_speed (attacks/sec), knockback, range, piercing, projectile speed/count/fan_angle, area_radius, orbit_count/speed, color. WeaponManager (child of player) equips, switches (1-4 keys / scroll), and delegates attacks; aWEAPON_SCRIPTS const maps type string -> preloaded script.
Four archetypes:
- Sword: 120-degree arc swing, alternating direction, ease-out for weight, forward lunge, physics cone query, trail.
- Bow: 0.15s draw, then a fan of projectiles (count + fan_angle from data).
- Staff: homing bolts (homing_strength 3.0) + optional
trigger_nova()AoE. - Orb: passive - orbits the player, contact damage with per-enemy cooldown, always active regardless of selected weapon.
collision_mask and a
source_node so it never hits its shooter.
LevelBase and level authoring
All levels extend LevelBase, which does the boring wiring once:level_name, calls super._ready(), then adds its
spawner / NPCs / AreaTransitions. Town = safe hub with NPCs. Forest = combat
area with wave spawner. Dungeon = boss arena with a kill gate.
Build order
- project.godot: 6 autoloads in order, input map, layer names, 1920x1080 canvas_items/expand.
- EventBus with the full signal catalog (empty handlers are fine).
- Stats + ModifiedValue, then the combat component trio.
- Player: movement -> dodge -> WeaponManager with sword only.
- One chase enemy + Juice feedback + HUD health bar. Playable slice check.
- GameManager transitions + LevelBase + a second area with AreaTransition.
- Content systems from
2d-rpg-content.md: enemies, NPCs, quests, inventory, perks, effects, save/load.
Traps
- Direct module references. Never
get_node("/root/QuestManager")from a weapon script - emit an EventBus signal. Modules only know EventBus and their own data types; anything else rots into circular dependencies. - Skipping
stats.initialize(). Null pipeline, crash on first damage. - Huge
_ready()chains needing other nodes. Awaitget_tree().process_frameor connect toEventBus.scene_readyinstead. - UI without
process_mode = ALWAYS. Dialogue, inventory, level-up, and pause all pause the tree; the UI must keep processing. - Wrong hitbox/hurtbox layers. Hitbox layer 4/5, hurtbox masks the OPPOSING side. Getting this backwards silently produces no hits.
- Renaming InteractionArea. NPC scripts access
$InteractionAreaby exact name; a rename kills interaction with no error at parse time.
Source:
library/skills/make-game/references/2d-rpg.md, rendered from summer-engine@4.3.0 on npm.
