
Godot Genre Romance
- 126 installs
- 454 repo stars
- Updated July 28, 2026
- thedivergentai/gd-agentic-skills
Helps with ai & agent building tasks.
About
godot-genre-romance is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- godot-genre-romance
- AI & Agent Building
- AI-coding skill
Godot Genre Romance by the numbers
- 126 all-time installs (skills.sh)
- +8 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #3,699 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/thedivergentai/gd-agentic-skills --skill godot-genre-romanceAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 126 |
|---|---|
| repo stars | ★ 454 |
| Last updated | July 28, 2026 |
| Repository | thedivergentai/gd-agentic-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Genre: Romance & Dating Sim
Romance games are built on the "Affection Economy"—the management of player time and resources to influence NPC attraction, trust, and intimacy.
Core Loop
1. Meet: Encounter potential love interests and establish baseline rapport. 2. Date: Engage in structured events to learn preferences and test compatibility. 3. Deepen: Invest resources (time, gifts, choices) to increase affection/stats. 4. Branch: Story diverges into character-specific "Routes" based on major milestones. 5. Resolve: Reach a specialized ending (Good/Normal/Bad) based on relationship quality.
NEVER Do (Expert Anti-Patterns)
Romance & NPC Logic
- NEVER create "Vending Machine" romance; strictly incorporate variables like NPC Mood, Timing, and Multi-Stat Thresholds to ensure characters feel autonomous.
- NEVER use binary Affection (Love/Hate); strictly use a Multi-Axial Model (Attraction, Trust, Comfort) for believable psychological depth.
- NEVER focus on 100% opaque stats; strictly provide Visible Indicators (heart UI, blushing text, pulsing hearts) to help players make informed choices.
- NEVER use the "Same Date Order" trap; strictly implement a Repetition Penalty (~30%) for visiting the same location twice in a row.
- NEVER forget "Missable" Milestones; strictly ensure meaningful consequences (e.g., missing events due to poor scheduling) to add weight to the experience.
- NEVER ignore NPC Autonomy; strictly allow NPCs to have their own Schedules and the ability to Reject the player based on low trust or conflicting events.
- NEVER use polling (
_process) for NPC schedule checks; strictly use a Signal-Driven TimeManager (Autoload) to broadcast hour/day changes for performant state updates. - NEVER hardcode character references for jealousy logic; strictly use Groups (`add_to_group`) to broadcast romantic events across the scene for decoupled, autonomous NPC reactions.
Technical & UI
- NEVER use
_processfor typewriter text; strictly use Tweens on `visible_ratio` for frame-independent, smooth reveals. - NEVER parse massive narrative files on the main thread; strictly use `ResourceLoader.load_threaded_request()` to prevent transition stutters.
- NEVER use exact float math for affection checks; strictly use `is_equal_approx()` to avoid jitter-based logic failures.
- NEVER structure complex dialogue purely in code; strictly design dialogue trees as Custom `Resource` classes to decouple narrative data from logic.
- NEVER rely on the global OS clock for timed choices; strictly use `SceneTreeTimer` which respects
Engine.time_scaleand pause states. - NEVER leave invisible controls with
MOUSE_FILTER_STOP; strictly set toIGNOREorPASSon non-opaque layers to avoid blocking dialogue progression. - NEVER hardcode dialogue strings; strictly map text to Localization Keys and retrieve via
tr()for internationalization. - NEVER use absolute pixel positioning for interfaces; strictly rely on Anchoring & Containers for responsive scaling across devices.
---
🛠 Expert Components (scripts/)
Original Expert Patterns
- romance_affection_manager.gd - Multi-axis (Attraction/Trust/amic_pricing_modifier.gd Attraction/Trust/Comfort) tracking and gift logic.
- romance_date_event_system.gd - Variety-aware dating logic with repetition penalties.
- romance_route_manager.gd - Flag-based route branching and CG gallery persistence.
Modular Components
- romance_patterns.gd - Reusable UI helpers: Typewriter tweens and heart-burst pulses.
---
| Phase | Skills | Purpose |
|---|---|---|
| 1. Stats | dictionaries, resources | Tracking multi-axis affection, character profiles |
| 2. Timeline | autoload-architecture, signals | Managing time/days, triggering scheduled dates |
| 3. Narrative | godot-dialogue-system, visual-novel | Conversational branching and choice consequence |
| 4. Persistence | godot-save-load-systems | Saving relationship states, CG gallery, flags |
| 5. Aesthetics | ui-theming, godot-tweening | Heart-themed UI, blushing effects, emotive icons |
Architecture Overview
1. Affection Manager (The Heart)
Handles complex relationship stats and gift preferences for all characters.
# affection_manager.gd
class_name AffectionManager
extends Node
signal milestone_reached(character_id, level)
var relationship_data: Dictionary = {} # character_id: { attraction: 0, trust: 0, comfort: 0 }
func add_affection(char_id: String, type: String, amount: int) -> void:
if not relationship_data.has(char_id):
relationship_data[char_id] = {"attraction": 0, "trust": 0, "comfort": 0}
relationship_data[char_id][type] = clamp(relationship_data[char_id][type] + amount, -100, 100)
check_milestones(char_id)
func get_gift_effect(char_id: String, item_id: String) -> int:
# Logic for likes/dislikes with diminishing returns
return 10 # Placeholder2. Date Event System
Manages the success or failure of romantic outings.
# date_event_system.gd
func run_date(character_id: String, location_res: DateLocation) -> void:
var score = 0
# Weighted calculation
score += relationship_data[character_id]["attraction"] * location_res.chemistry_mod
score += relationship_data[character_id]["trust"] * location_res.safety_mod
if score > location_res.success_threshold:
play_date_outcome("SUCCESS", character_id)
else:
play_date_outcome("FAILURE", character_id)3. Route Manager
Controls story branching and persistent unlocks.
# route_manager.gd
var unlocked_routes: Array[String] = []
func lock_in_route(char_id: String):
# Detect conflicts with other routes here
if flags.get("on_route"): return
current_route = char_id
flags["on_route"] = true
unlocked_cgs.append(char_id + "_prologue")Key Mechanics Implementation
Emotional Feedback (Juice)
Don't just change a number; show the change.
# ui_feedback.gd
func play_heart_burst(pos: Vector2):
var heart = heart_scene.instantiate()
add_child(heart)
heart.global_position = pos
var tween = create_tween().set_parallel()
tween.tween_property(heart, "scale", Vector2(1.5, 1.5), 0.5)
tween.tween_property(heart, "modulate:a", 0.0, 0.5)Time-Gated Events
Romance thrives on anticipation.
- Deadline Scheduling: "Confess by June 15th or lose."
- Contextual Dialogue: Characters reacting differently based on time of day or weather.
4. NPC Daily Schedule (Signal-Driven)
Avoid polling in _process. Use a central TimeManager and data-driven schedules.
# npc_schedule.gd (Resource)
class_name NPCSchedule extends Resource
@export var daily_routine: Dictionary = {8: "TownSquare", 12: "Tavern", 18: "Home"}
# npc_controller.gd
func _ready():
TimeManager.hour_changed.connect(_on_hour_changed)
func _on_hour_changed(hour: int):
var dest = schedule.daily_routine.get(hour, "")
if dest: _navigate_to(dest)5. Seasonal Dialogue Mapping
Inject world state into dialogue using .format() and Resource mapping.
# seasonal_dialogue.gd (Resource)
enum Season { SPRING, SUMMER, AUTUMN, WINTER }
@export var season_lines: Dictionary = { Season.WINTER: "Stay warm near the fire." }
# ui_layer.gd
func update_greeting(season: Season):
var text = dialogue_res.get_seasonal_line(season)
label.text = text.format({"player_name": Global.player_name})6. Jealousy Broadcasting (Groups)
Decouple the player from NPC logic using SceneTree.call_group().
# player_romance_manager.gd
func start_date(npc_name: String):
# Notify everyone in the group without needing direct references
get_tree().call_group("romantic_interests", "on_player_date_started", npc_name)
# npc_jealousy.gd
func _ready():
add_to_group("romantic_interests")
func on_player_date_started(dating_name: String):
if dating_name != self.name and affection > 30:
affection -= 10 # Jealousy penaltyCommon Pitfalls
1. The "Pervert" Trap: Forcing the player to always pick the flirtiest option to win. Fix: Allow "Trust" and "Friendship" paths to lead to romance eventually. 2. Opaque Success: Failing a date without knowing why. Fix: Use character dialogue to hint at preferences ("I'm not really a fan of loud places..."). 3. Route Conflict: Accidentally dating two people with zero consequences. Fix: Implement a "Jealousy" or "Conflict Detection" system in the Route Manager.
Godot-Specific Tips
- Resources for Characters: Use
CharacterProfileresources to store base stats, sprites, and gift preferences. - RichTextLabel Animations: Use custom BBCode for "blushing" text (pulsing pink) or "nervous" text (shaking).
- Dialogic Integration: While this skill focuses on the systems, pairing it with Godot's Dialogic plugin is highly recommended for handling the actual dialogue boxes.
Reference
- Master Skill: godot-master
- Sub-specialty: godot-genre-visual-novel
# affection_manager.gd
# Handles multi-axis relationship tracking, gift systems, and milestone triggers.
# Optimized for Godot 4.x with static typing and Signal-driven architecture.
class_name AffectionManager
extends Node
## Emitted when a relationship reaches a specific threshold.
signal milestone_reached(character_id: String, milestone_index: int, data: Dictionary)
## Emitted whenever any stats change. Useful for UI updates.
signal stats_changed(character_id: String, new_stats: Dictionary)
enum RelationStat { ATTRACTION, TRUST, COMFORT }
const MIN_STAT = -100
const MAX_STAT = 100
# Dictionary structure: { "character_id": { "attraction": 0, "trust": 0, "comfort": 0, "gift_history": {} } }
var _relationship_data: Dictionary = {}
## Adds to a specific stat for a character.
## Usage: AffectionManager.add_stat("alice", AffectionManager.RelationStat.TRUST, 5)
func add_stat(character_id: String, stat: RelationStat, amount: int) -> void:
_ensure_character(character_id)
var stat_name: String = _get_stat_name(stat)
var current_val: int = _relationship_data[character_id][stat_name]
var new_val: int = clamp(current_val + amount, MIN_STAT, MAX_STAT)
_relationship_data[character_id][stat_name] = new_val
stats_changed.emit(character_id, _relationship_data[character_id])
_check_milestones(character_id, stat_name, new_val)
## Handles gift giving with diminishing returns logic.
func give_gift(character_id: String, item_data: Dictionary) -> int:
_ensure_character(character_id)
var base_value: int = item_data.get("value", 5)
var gift_id: String = item_data.get("id", "generic")
# Diminishing returns calculation
var history: Dictionary = _relationship_data[character_id].get("gift_history", {})
var times_given: int = history.get(gift_id, 0)
# Formula: reduce effectiveness by 20% each time, floor at 1 point
var multiplier: float = max(0.2, 1.0 - (times_given * 0.2))
var final_amount: int = int(ceil(base_value * multiplier))
# Apply to Attraction by default for gifts
add_stat(character_id, RelationStat.ATTRACTION, final_amount)
# Update history
history[gift_id] = times_given + 1
_relationship_data[character_id]["gift_history"] = history
return final_amount
func get_stats(character_id: String) -> Dictionary:
_ensure_character(character_id)
return _relationship_data[character_id].duplicate()
func _ensure_character(character_id: String) -> void:
if not _relationship_data.has(character_id):
_relationship_data[character_id] = {
"attraction": 0,
"trust": 0,
"comfort": 0,
"milestones": [], # indices of milestones already fired
"gift_history": {}
}
func _get_stat_name(stat: RelationStat) -> String:
match stat:
RelationStat.ATTRACTION: return "attraction"
RelationStat.TRUST: return "trust"
RelationStat.COMFORT: return "comfort"
return ""
func _check_milestones(character_id: String, _stat_name: String, value: int) -> void:
# Example threshold-based milestones
var thresholds = [20, 50, 80]
var record = _relationship_data[character_id]
for i in range(thresholds.size()):
if value >= thresholds[i] and not i in record["milestones"]:
record["milestones"].append(i)
milestone_reached.emit(character_id, i, {"stat": _stat_name, "value": value})
# date_event_system.gd
# Manages date logic, location preferences, and outcome calculations.
# Designed to be used with a Resource-based system for DateLocations (not included).
class_name DateEventSystem
extends Node
## Emitted when a date concludes.
signal date_finished(character_id: String, outcome: String, score: int)
## Emitted when a specific interaction occurs during a date.
signal date_interaction(character_id: String, dialogue_key: String)
enum Outcome { DISASTER, MILD, SUCCESS, PERFECT }
# Tracker to prevent "Same Date Order" trap
# { "character_id": ["park", "cafe", "cinema"] }
var _date_history: Dictionary = {}
## Evaluates a date at a given location.
## location_data structure: { "id": "park", "trust_weight": 0.5, "attraction_weight": 1.5, "thresholds": [0, 10, 30, 50] }
func evaluate_date(character_id: String, location_data: Dictionary, choice_modifiers: int = 0) -> Outcome:
var stats: Dictionary = AffectionManager.get_stats(character_id)
# Weighted success calculation
var score: float = 0.0
score += stats["attraction"] * location_data.get("attraction_weight", 1.0)
score += stats["trust"] * location_data.get("trust_weight", 1.0)
score += stats["comfort"] * location_data.get("comfort_weight", 1.0)
score += choice_modifiers
# Apply variety penalty if same location repeated recently
var history = _date_history.get(character_id, [])
if history.size() > 0 and history[-1] == location_data["id"]:
score *= 0.7 # 30% penalty for repetitiveness
date_interaction.emit(character_id, "repetitive_date_complaint")
# Add to history
_add_to_history(character_id, location_data["id"])
# Determine outcome
var result_score = int(score)
var thresholds = location_data.get("thresholds", [-10, 10, 30, 60])
var outcome: Outcome = Outcome.DISASTER
if result_score >= thresholds[3]: outcome = Outcome.PERFECT
elif result_score >= thresholds[2]: outcome = Outcome.SUCCESS
elif result_score >= thresholds[1]: outcome = Outcome.MILD
_apply_outcome_rewards(character_id, outcome)
date_finished.emit(character_id, _get_outcome_string(outcome), result_score)
return outcome
func _add_to_history(character_id: String, location_id: String) -> void:
if not _date_history.has(character_id):
_date_history[character_id] = []
_date_history[character_id].append(location_id)
if _date_history[character_id].size() > 5:
_date_history[character_id].remove_at(0)
func _get_outcome_string(outcome: Outcome) -> String:
match outcome:
Outcome.DISASTER: return "DISASTER"
Outcome.MILD: return "MILD"
Outcome.SUCCESS: return "SUCCESS"
Outcome.PERFECT: return "PERFECT"
return "UNKNOWN"
func _apply_outcome_rewards(char_id: String, outcome: Outcome) -> void:
match outcome:
Outcome.PERFECT:
AffectionManager.add_stat(char_id, AffectionManager.RelationStat.ATTRACTION, 10)
AffectionManager.add_stat(char_id, AffectionManager.RelationStat.TRUST, 5)
Outcome.SUCCESS:
AffectionManager.add_stat(char_id, AffectionManager.RelationStat.ATTRACTION, 5)
Outcome.DISASTER:
AffectionManager.add_stat(char_id, AffectionManager.RelationStat.TRUST, -5)
AffectionManager.add_stat(char_id, AffectionManager.RelationStat.ATTRACTION, -2)
extends Node
class_name DialogueExpressionParser
## Expert Dialogue Logic (Godot 4.6).
## Evaluates complex conditions at runtime using the Expression class.
func can_show_choice(condition: String, player_stats: Dictionary) -> bool:
if condition.is_empty(): return true
var expression = Expression.new()
# Example: condition = "affection >= 20"
var error = expression.parse(condition, player_stats.keys())
if error != OK:
push_error("Dialogue condition parse error: " + expression.get_error_text())
return false
var result = expression.execute(player_stats.values())
return result if result is bool else false
## [SKILL NOTICE]: Use 'Expression' to evaluate dialogue conditions.
## It is much faster and safer than writing a custom logic parser in GDScript.
extends Node
## Expert Affection Tracker (Godot 4.6).
## Global Singleton for decoupled relationship management.
signal affection_updated(npc_id: String, new_val: int, change: int)
var _npc_data: Dictionary = {} # npc_id: current_affection
func modify_affection(npc_id: String, amount: int) -> void:
var current = _npc_data.get(npc_id, 0)
_npc_data[npc_id] = current + amount
# Expert Pattern: Decouple from UI using Signals
affection_updated.emit(npc_id, _npc_data[npc_id], amount)
func get_affection(npc_id: String) -> int:
return _npc_data.get(npc_id, 0)
## [SKILL NOTICE]: Use 'Signals' inside an 'Autoload' to notify the UI
## of affection changes without the dialogue system needing to know it exists.
extends CanvasLayer
class_name RomanceMoodManager
## Expert Mood Orchestrator (Godot 4.6).
## Manages atmospheric filters and character transitions.
@export var mood_overlay: ColorRect
@export var portrait_node: TextureRect
func transition_to_mood(color: Color, time: float = 1.0) -> void:
var tween = create_tween().set_trans(Tween.TRANS_SINE)
tween.tween_property(mood_overlay, "color", color, time)
func crossfade_portrait(new_tex: Texture2D, time: float = 0.5) -> void:
var tween = create_tween().set_parallel(true)
# Fade out old, Fade in new
tween.tween_property(portrait_node, "modulate:a", 0.0, time / 2)
tween.chain().tween_callback(func(): portrait_node.texture = new_tex)
tween.tween_property(portrait_node, "modulate:a", 1.0, time / 2)
## [SKILL NOTICE]: Use a 'ColorRect' as a screen-space overlay to instantly
## shift the emotional tone (sad, romantic, intense) of 2D narrative scenes.
# romance_patterns.gd
extends Node
# 1. Type-Safe Affection Dictionary
# EXPERT NOTE: Use StringNames for fast lookups in narrative state tracking.
var affection_stats: Dictionary[StringName, int] = {
&"CharacterA": 0,
&"CharacterB": 0
}
# 2. Custom RichTextEffect for Emotive Text
# EXPERT NOTE: Create dynamic text animations (like shaking) via BBCode effects.
@tool
class_name ShakeEffect extends RichTextEffect
var bbcode := "shake"
func _process_custom_fx(char_fx: CharFXTransform) -> bool:
char_fx.transform = char_fx.transform.translated(Vector2(randf_range(-1, 1), 0))
return true
# 3. Context-Aware Localization
# EXPERT NOTE: Resolves ambiguities (e.g., "Close" the door vs "Close" in distance).
func print_dialogue(label: Label, key: String, context: StringName) -> void:
label.text = tr(key, context)
# 4. Connecting Meta Clicks for Hyperlinked Choices
# EXPERT NOTE: Captures clicks on [url] tags inside the RichTextLabel for interactive logs.
func setup_meta_links(rtl: RichTextLabel) -> void:
rtl.meta_clicked.connect(func(meta): OS.shell_open(str(meta)))
# 5. Typewriter Effect via Tweens
# EXPERT NOTE: Cleaner than using _process timers; allows easy speed control.
func play_typewriter(label: RichTextLabel, duration: float) -> void:
label.visible_ratio = 0.0
var tween := create_tween()
tween.tween_property(label, "visible_ratio", 1.0, duration)
# 6. Serializing Preferences via ConfigFile
# EXPERT NOTE: Ideal for global settings like "Skip Read Text" or "Auto Play".
func save_romance_settings(path: String, skip_read: bool) -> void:
var config := ConfigFile.new()
config.set_value("Dialogue", "skip_read", skip_read)
config.save(path)
# 7. Unbinding Signals for Generic Advance
# EXPERT NOTE: Drops the default boolean argument emitted by buttons for clean callbacks.
func connect_advance_btn(btn: Button, callback: Callable) -> void:
btn.pressed.connect(callback.unbind(1))
# 8. Handling Pluralization Safely
# EXPERT NOTE: Properly translates "1 rose" vs "2 roses" based on locale rules.
func get_gift_text(amount: int) -> String:
return atr_n("You received %s rose.", "You received %s roses.", amount) % amount
# 9. Dynamic Choice Button Injection
# EXPERT NOTE: Instances buttons for branching choices based on narrative state.
func populate_choices(container: Control, choices: Array[StringName], callback: Callable) -> void:
for choice in choices:
var btn := Button.new()
btn.text = tr(choice)
btn.pressed.connect(callback.bind(choice))
container.add_child(btn)
# 10. Hiding UI for CG Views (Cinematic)
# EXPERT NOTE: Use alpha modulation to smoothly transition between UI and CG art.
func toggle_ui_visibility(canvas: CanvasLayer, is_visible: bool) -> void:
var tween := create_tween()
tween.tween_property(canvas, "modulate:a", 1.0 if is_visible else 0.0, 0.5)
# route_manager.gd
# Manages character-specific narrative routes, persistence, and endings.
# Integrated with the AffectionManager for milestone-based route unlocking.
class_name RouteManager
extends Node
## Emitted when the player officially enters a character's route.
signal route_locked_in(character_id: String)
## Emitted when a new CG (Computer Graphic) or Gallery item is unlocked.
signal cg_unlocked(cg_id: String)
enum EndingType { BAD, NORMAL, GOOD, TRUE }
# State tracking
var current_route: String = ""
var is_route_active: bool = false
var unlocked_cgs: Array[String] = []
# Persistent data for gallery/completionist features
const SAVE_PATH = "user://romance_gallery.save"
func _ready() -> void:
load_gallery()
# Connect to affection milestones to potentially trigger route entries
# AffectionManager.milestone_reached.connect(_on_affection_milestone)
## Attempts to lock in a character's route.
## Returns true if successful, false if already on another route.
func lock_in_route(character_id: String) -> bool:
if is_route_active:
if current_route == character_id:
return true
else:
push_warning("Attempted to enter %s route while already on %s route." % [character_id, current_route])
return false
current_route = character_id
is_route_active = true
unlock_cg(character_id + "_prologue")
route_locked_in.emit(character_id)
return true
## Unlocks a CG and saves to persistent storage.
func unlock_cg(cg_id: String) -> void:
if not cg_id in unlocked_cgs:
unlocked_cgs.append(cg_id)
cg_unlocked.emit(cg_id)
save_gallery()
## Pure logic for determining ending based on stats.
func determine_ending(character_id: String) -> EndingType:
var stats: Dictionary = AffectionManager.get_stats(character_id)
if stats["trust"] < 0:
return EndingType.BAD
if stats["attraction"] >= 80 and stats["trust"] >= 50:
if stats["comfort"] >= 70:
return EndingType.TRUE
return EndingType.GOOD
return EndingType.NORMAL
# --- Persistence ---
func save_gallery() -> void:
var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file:
var data = {
"unlocked_cgs": unlocked_cgs,
"global_progress": {} # Can store multi-playthrough flags here
}
file.store_var(data)
func load_gallery() -> void:
if FileAccess.file_exists(SAVE_PATH):
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
if file:
var data = file.get_var()
unlocked_cgs = data.get("unlocked_cgs", [])