GDQuest Open RPG

Overworld + turn-based combat, party of three vs enemies.

▶ Run in browser

Upstream: 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:

  1. Replace module-level TILES/TRIGGERS/NPCS in nodes/gameboard.py with a Map dataclass keyed by name, and refactor Overworld + Player to take the active map by reference.

  2. Add a door_trigger cell kind that calls _change_map(name, cell) on RPGRoot, paralleling the existing encounter and save trigger kinds.

  3. Extend SaveStore to record the active map name alongside the cell.

  4. Author 1-2 additional hand-painted maps (source/overworld/maps/forest and town are the upstream references; town is the most populated).

  5. 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.py exports TILES (30x17 list-of-lists), bfs_path() for click-to-path, TRIGGERS (cell -> encounter/save), and NPCS (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 BattlerAction is 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 when tick() 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. Calling renderer.new_layer() before fills forces a fresh batch so HUD draws on top. See NOTES.md “Engine friction” #1.

Visual reference

simvx_port/screenshots/:

File

Stage

01_overworld.png

Initial overworld idle (player + 3 NPCs + tilemap + encounter highlights)

02_dialogue.png

NPC interaction: Monk dialogue typing

03_encounter.png

Encounter dialogue: Bear roars before battle

04_battle_player.png

Battle scene loaded, party + enemies, HP bars

05_battle_target.png

Wizard’s action menu open (Attack / Heal)

06_battle_enemy.png

Mid-battle (Squirrel injured, both enemies present)

07_victory.png

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 --test captures 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()