GDQuest Open RPG¶
Overworld + turn-based combat, party of three vs enemies.
▶ Run in browserUpstream: https://github.com/gdquest-demos/godot-open-rpg
Tags: port tier-2
GDQuest Open RPG: SimVX port¶
Port of gdquest-demos/godot-open-rpg (a small Godot 4 turn-based RPG demo) to SimVX. Tier 2 Port #17.
Scope¶
This port ships a single 30x17 overworld map. That is deliberate; do not “fix” it by adding more maps unless explicitly asked.
Upstream ships three maps (forest / house / town) wired together by
area_transition and door interaction templates, with a Dialogic-driven
opening cutscene, multi-area save state, and inventory/pedestal puzzles.
The Tier 2 port intentionally compresses all of that into one
hand-authored map that covers every gameplay system end-to-end:
WASD + click-to-path traversal on a Python-authored tile grid.
3 NPCs (Monk / Smith / Old Mage) with flat dialogue trees.
3 encounter triggers (wolves / bears / bugcats) feeding the same battle scene with different enemy specs.
1 save shrine writing
saves/open_rpg.json(cell + party HP).Fade-to-black overworld <-> battle transition.
The slice was chosen so a player can exercise the full Field -> Encounter
-> Battle -> Resolution loop within ~2 minutes without re-authoring the
upstream’s Dialogic timelines or Tiled maps. Multi-map work was not
estimated to fit Tier 2 scope; see ../NOTES.md “Deliberate deviations”
and “Engine gaps” for the full deviation list.
If you want to extend the port to multiple maps, expect to:
Replace module-level
TILES/TRIGGERS/NPCSinnodes/gameboard.pywith aMapdataclass keyed by name, and refactorOverworld+Playerto take the active map by reference.Add a
door_triggercell kind that calls_change_map(name, cell)onRPGRoot, paralleling the existingencounterandsavetrigger kinds.Extend
SaveStoreto record the active map name alongside the cell.Author 1-2 additional hand-painted maps (
source/overworld/maps/forestandtownare the upstream references; town is the most populated).Add a multi-map screenshot pass to
nodes/harness.py.
This is a multi-day effort, not a quick fix. File a TODO and get explicit approval before starting.
Run¶
All commands run from /home/fezzik/dev/simvx:
# Interactive
uv run python ported_games/gdquest_open_rpg/simvx_port/main.py
# Headless test capture (writes 7 screenshots to screenshots/)
uv run python ported_games/gdquest_open_rpg/simvx_port/main.py --test
# Web export
uv run simvx export web /home/fezzik/dev/ported_games/gdquest_open_rpg/simvx_port/main.py \
-o /home/fezzik/dev/ported_games/gdquest_open_rpg/simvx_port/web/index.html
Controls¶
Overworld¶
WASD / Arrow keys step the player one tile in the direction held.
Left mouse button clicks any walkable tile to path-find to it (BFS).
E interacts with the NPC in the cell directly in front of the player.
Q quits.
Dialogue¶
SPACE / ENTER / E advances one line. Press during typewriter to fast-forward the current line; press again to advance.
Left mouse button also advances dialogue.
Combat¶
W / S / Up / Down navigate the action menu / target cursor.
A / D / Left / Right switch target horizontally.
ENTER / SPACE / E confirm action / target selection.
ESC / X cancel back to the action menu.
Mobile / touch¶
Touchstart/move/end surface as MouseButton.LEFT in the web runtime, so
click-to-path on the overworld and confirm-on-tap in dialogue work on
touch devices. The combat UI still requires keyboard nav (no on-screen
buttons in v1, flagged in NOTES.md as a follow-up).
File map¶
simvx_port/
├── main.py # RPGRoot scene + InputMap + headless --test entry
├── pyproject.toml # [tool.simvx] root = "RPGRoot"
├── nodes/
│ ├── __init__.py
│ ├── settings.py # Constants (WIDTH/HEIGHT/TILE/colours)
│ ├── sprites.py # Procedural NumPy-painted sprites for tiles + characters
│ ├── gameboard.py # 30x17 hand-authored map + BFS pathfinder + DIALOGUES + TRIGGERS + NPCS
│ ├── overworld.py # Tile sprite layer + trigger highlight overlay
│ ├── player.py # PlayerController (step move, click-to-path, interact)
│ ├── npc.py # Stationary NPC with dialogue id
│ ├── dialogue.py # Bottom-anchored dialogue panel with typewriter
│ ├── encounter* # Encounter trigger logic lives in main.py + gameboard.TRIGGERS
│ ├── screen_transition.py # Fade-to-black overlay
│ ├── stats.py # BattlerStats dataclass + spec prototypes (party + enemies)
│ ├── actions.py # AttackAction / HealAction / AreaAttackAction (coroutine-style)
│ ├── battler.py # Battler sprite (flash, shake, selection-bob, dies)
│ ├── floating_label.py # Damage/heal/miss labels (+25 / -10 / MISS)
│ ├── battle.py # BattleScene state machine + UI (action menu, target cursor, party panel)
│ ├── audio_fx.py # Procedural NumPy SFX + music loops
│ ├── save_io.py # JSON save/load (save shrines)
│ └── harness.py # Headless --test capture (7 screenshots through full loop)
├── screenshots/ # Auto-captured by --test
└── web/index.html # Pyodide bundle (~2.7 MB)
Architecture choices¶
Procedural sprites everywhere: no PNG dependency, all tiles + party
enemies + UI cursors painted in NumPy. Mirrors the Solitaire / Balatro port pattern.
Pure-logic gameboard.
nodes/gameboard.pyexportsTILES(30x17 list-of-lists),bfs_path()for click-to-path,TRIGGERS(cell -> encounter/save), andNPCS(cell -> kind+dialogue). All map data hand-authored in Python; no Tiled / TileMap parsing.Step-based movement. Player snaps to tile centres; a fixed-duration ease-out lerp animates between cells. After each arrival, on_process re-checks held-keys so a held direction continues stepping naturally.
Coroutine-style actions. Each
BattlerActionis a per-frame state machine (start(source, targets, scene)+tick(dt) -> done). Avoids yielding into Python coroutines; the BattleScene drives one action at a time and pops to the next whentick()returns True.AI = random valid action + random target. Enemy battlers cache their action choices at the START of the round; player choices come in via the action menu. Speed-sorted execution.
Procedural audio. Same pattern as Claustrowordia / SNKRX: NumPy sine/saw/noise → AudioStream.from_pcm. Tiny SFX + a 4-bar overworld loop + a 4-bar battle loop.
JSON save. Single
saves/open_rpg.json(atomic write + one .bak) recording the player’s last shrine cell. Matches Solitaire’s pattern; game state itself doesn’t need full persistence.new_layer()for HUD layering. SimVX’s Draw2D draws fill primitives BEFORE textured quads within a single batch, so a HUD panel drawn from a node-tree on_draw appears BEHIND any later sprite. Callingrenderer.new_layer()before fills forces a fresh batch so HUD draws on top. SeeNOTES.md“Engine friction” #1.
Visual reference¶
simvx_port/screenshots/:
File |
Stage |
|---|---|
|
Initial overworld idle (player + 3 NPCs + tilemap + encounter highlights) |
|
NPC interaction: Monk dialogue typing |
|
Encounter dialogue: Bear roars before battle |
|
Battle scene loaded, party + enemies, HP bars |
|
Wizard’s action menu open (Attack / Heal) |
|
Mid-battle (Squirrel injured, both enemies present) |
|
VICTORY! banner over arena |
Acceptance bar¶
[x] Source cloned to
source/[x] Game launches from
main.py[x] Player walks the overworld via WASD or click-to-path
[x] Dialogue with NPCs (typewriter + advance)
[x] Encounter trigger → battle scene swap (with fade transition)
[x] Full turn-based combat (3 player vs 2-3 enemies, action menu, target cursor)
[x] Damage rolls (variance + crit + miss) with floating labels
[x] Heal action (Wizard), area attack (Squirrel)
[x] Camera shake / hit flash / energy regen on damage taken
[x] Victory + defeat resolution with dialogue
[x] Save shrine (JSON persistence)
[x] Procedural audio (SFX + music loops)
[x] Headless
--testcaptures 7 stage screenshots[x] Web export builds cleanly (~2.7 MB)
Source¶
1"""GDQuest Open RPG: Overworld + turn-based combat, party of three vs enemies.
2
3# /// simvx
4# tags = ["port", "tier-2"]
5# upstream = "https://github.com/gdquest-demos/godot-open-rpg"
6# web = { width = 960, height = 540, responsive = true }
7# ///
8
9Run:
10 uv run python ported_games/gdquest_open_rpg/simvx_port/main.py # interactive
11 uv run python ported_games/gdquest_open_rpg/simvx_port/main.py --test # headless capture
12"""
13from __future__ import annotations
14
15import sys
16from pathlib import Path
17
18_PORT_DIR = Path(__file__).parent
19if str(_PORT_DIR) not in sys.path:
20 sys.path.insert(0, str(_PORT_DIR))
21
22from nodes.audio_fx import AudioFX # noqa: E402
23from nodes.battle import BattleScene # noqa: E402
24from nodes.dialogue import DialogueBox # noqa: E402
25from nodes.gameboard import DIALOGUES, NPCS, TRIGGERS # noqa: E402
26from nodes.npc import NPC # noqa: E402
27from nodes.overworld import Overworld # noqa: E402
28from nodes.player import Player # noqa: E402
29from nodes.save_io import SaveStore # noqa: E402
30from nodes.screen_transition import ScreenTransition # noqa: E402
31from nodes.settings import HEIGHT, TITLE, WIDTH # noqa: E402
32
33from simvx.core import Node2D # noqa: E402
34from simvx.core.input.enums import Key, MouseButton # noqa: E402
35from simvx.core.input.map import InputMap # noqa: E402
36from simvx.core.input.state import Input # noqa: E402
37from simvx.graphics import App # noqa: E402
38
39# Phase identifiers used by the harness + screenshot capture.
40PHASE_OVERWORLD = "overworld"
41PHASE_DIALOGUE = "dialogue"
42PHASE_ENCOUNTER = "encounter"
43PHASE_BATTLE = "battle"
44PHASE_VICTORY = "victory"
45
46
47class RPGRoot(Node2D):
48 """Root scene: owns overworld, dialogue, screen transition, and battle."""
49
50 def on_ready(self) -> None:
51 # Input actions live in root.on_ready (web exporter skips main()).
52 InputMap.add_action("up", [Key.W, Key.UP])
53 InputMap.add_action("down", [Key.S, Key.DOWN])
54 InputMap.add_action("left", [Key.A, Key.LEFT])
55 InputMap.add_action("right", [Key.D, Key.RIGHT])
56 InputMap.add_action("confirm", [Key.ENTER, Key.SPACE, Key.E])
57 InputMap.add_action("interact", [Key.E])
58 InputMap.add_action("cancel", [Key.ESCAPE, Key.X])
59 InputMap.add_action("primary", [MouseButton.LEFT])
60 InputMap.add_action("quit", [Key.Q])
61
62 self.phase = PHASE_OVERWORLD
63 self.audio = AudioFX()
64 self.add_child(self.audio)
65
66 self.save = SaveStore()
67
68 # Overworld
69 self.overworld = Overworld(audio=self.audio)
70 self.add_child(self.overworld)
71
72 # Restore last save if present
73 save_data = self.save.load()
74 spawn = save_data.get("cell", (15, 8)) if save_data else (15, 8)
75 party_hp = save_data.get("party_hp") if save_data else None
76 self.player = Player(start_cell=spawn)
77 self.overworld.add_child(self.player)
78 for spec in NPCS:
79 npc = NPC(cell=spec["cell"], kind=spec["kind"], dialogue_id=spec["dialogue"])
80 self.overworld.add_child(npc)
81 self.overworld.npcs = list(self.overworld.iter_children_of_type(NPC))
82
83 self.player.cell_arrived.connect(self._on_player_arrived)
84 self.player.interact_requested.connect(self._on_interact)
85
86 # Dialogue box (top-level Control)
87 self.dialogue = DialogueBox()
88 self.add_child(self.dialogue)
89 self.dialogue.finished.connect(self._on_dialogue_finished)
90
91 # Screen transition overlay
92 self.transition = ScreenTransition()
93 self.add_child(self.transition)
94
95 # Battle scene placeholder
96 self.battle: BattleScene | None = None
97 self._pending_encounter: str | None = None
98 self._post_dialogue: str | None = None
99 # Stash starting party hp values from save (apply after battle scene exists)
100 self._loaded_party_hp = party_hp
101
102 self.audio.play_music("overworld")
103
104 # ------------------------------------------------------------------
105 # Trigger handlers
106 # ------------------------------------------------------------------
107 def _on_player_arrived(self, cx: int, cy: int) -> None:
108 if self.phase != PHASE_OVERWORLD:
109 return
110 trig = TRIGGERS.get((cx, cy))
111 if trig is None:
112 return
113 kind, ident = trig
114 if kind == "encounter":
115 self._start_encounter(ident)
116 elif kind == "save":
117 self._save_at_shrine()
118
119 def _on_interact(self, cell: tuple[int, int]) -> None:
120 if self.phase != PHASE_OVERWORLD:
121 return
122 for npc in self.overworld.npcs:
123 if npc.cell == cell:
124 self._start_dialogue(npc.dialogue_id)
125 return
126
127 def _start_dialogue(self, dialogue_id: str) -> None:
128 lines = DIALOGUES.get(dialogue_id, [("???", "...")])
129 self.phase = PHASE_DIALOGUE
130 self.player.set_input_enabled(False)
131 self.dialogue.show_lines(lines)
132 self.audio.play_sfx("blip")
133
134 def _on_dialogue_finished(self) -> None:
135 self.phase = PHASE_OVERWORLD
136 self.player.set_input_enabled(True)
137 if self._pending_encounter is not None:
138 ident = self._pending_encounter
139 self._pending_encounter = None
140 self._do_battle(ident)
141 return
142 if self._post_dialogue == "victory_return":
143 self._post_dialogue = None
144 self.transition.fade_out(callback=self._restore_overworld)
145 elif self._post_dialogue == "defeat_return":
146 self._post_dialogue = None
147 # Reset party HP, return to last shrine cell
148 data = self.save.load()
149 if data is not None:
150 self.player.cell = tuple(data["cell"])
151 self.player.position = self.player.cell_to_world(self.player.cell)
152 self.transition.fade_out(callback=self._restore_overworld)
153
154 def _start_encounter(self, ident: str) -> None:
155 # Show short dialogue then enter battle
156 self.phase = PHASE_DIALOGUE
157 self.player.set_input_enabled(False)
158 self._pending_encounter = ident
159 self.dialogue.show_lines(DIALOGUES.get(f"encounter_{ident}", [("!", "An encounter!")]))
160 self.audio.play_sfx("encounter")
161
162 def _do_battle(self, ident: str) -> None:
163 self.phase = PHASE_ENCOUNTER
164 self.audio.stop_music()
165 self.transition.fade_out(callback=lambda: self._enter_battle(ident))
166
167 def _enter_battle(self, ident: str) -> None:
168 # Construct a battle scene and add it on top of the overworld
169 self.battle = BattleScene(enemy_kind=ident, audio=self.audio,
170 party_hp=self._loaded_party_hp)
171 self._loaded_party_hp = None
172 self.add_child(self.battle)
173 self.overworld.visible = False
174 self.battle.victory.connect(self._on_battle_victory)
175 self.battle.defeat.connect(self._on_battle_defeat)
176 self.phase = PHASE_BATTLE
177 self.audio.play_music("battle")
178 self.transition.fade_in()
179
180 def _on_battle_victory(self) -> None:
181 self.phase = PHASE_VICTORY
182 self.audio.play_sfx("victory")
183 # Show victory dialogue then return
184 self.dialogue.show_lines(DIALOGUES["victory"])
185 self._post_dialogue = "victory_return"
186
187 def _on_battle_defeat(self) -> None:
188 self.phase = PHASE_VICTORY
189 self.audio.play_sfx("defeat")
190 self.dialogue.show_lines(DIALOGUES["defeat"])
191 self._post_dialogue = "defeat_return"
192
193 def _restore_overworld(self) -> None:
194 if self.battle is not None:
195 self.battle.destroy()
196 self.battle = None
197 self.overworld.visible = True
198 self.phase = PHASE_OVERWORLD
199 self.player.set_input_enabled(True)
200 self.audio.play_music("overworld")
201 self.transition.fade_out()
202
203 def _save_at_shrine(self) -> None:
204 self.phase = PHASE_DIALOGUE
205 self.player.set_input_enabled(False)
206 self.save.save({"cell": list(self.player.cell), "party_hp": None})
207 self.dialogue.show_lines(DIALOGUES["save"])
208 self.audio.play_sfx("save")
209
210 # ------------------------------------------------------------------
211 # Frame-by-frame: forward input to the dialogue / battle layer.
212 # ------------------------------------------------------------------
213 def on_process(self, dt: float) -> None:
214 # ESC -> quit (only from overworld)
215 if Input.is_action_just_pressed("quit") and self.phase == PHASE_OVERWORLD:
216 self.app.quit()
217
218
219def main() -> None:
220 headless = "--test" in sys.argv
221 if headless:
222 from nodes.harness import run_headless_capture
223 run_headless_capture()
224 else:
225 app = App(width=WIDTH, height=HEIGHT, title=TITLE)
226 app.run(RPGRoot())
227
228
229if __name__ == "__main__":
230 main()