GDQuest Open RPG

Overworld exploration with turn-based party combat.

▶ Run in browser

Upstream: https://github.com/gdquest-demos/godot-open-rpg

Licence: this port's own code is offered under MIT, not the SimVX Examples Licence the rest of the gallery carries. See ATTRIBUTION.md for the upstream it re-implements, the terms of anything it bundles, and the credit each one requires.

Ports live in the repository only, not in the simvx-examples distribution, because each is a derivative work licensed individually against the game it re-implements. Read it with git clone https://git.simvx.com/simvx/simvx.

Tags: port tier-2

GDQuest Open RPG: SimVX port

A SimVX port of gdquest-demos/godot-open-rpg, GDQuest’s small Godot 4 turn-based RPG demo. Clean re-implementation: no upstream code or assets are bundled, every sprite and sound is generated in NumPy at runtime. See ATTRIBUTION.md and UPSTREAM_LICENSE.md.

Run

# from the repo root
uv run python examples/ports/gdquest_open_rpg/main.py            # interactive
uv run python examples/ports/gdquest_open_rpg/main.py --test     # headless capture
uv run simvx export web examples/ports/gdquest_open_rpg/main.py \
    -o /tmp/gdquest_open_rpg.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 the typewriter reveal to fast-forward the current line; press again to advance.

  • Left mouse button also advances dialogue.

Combat

  • W / S / arrow keys navigate the action menu and the target cursor.

  • ENTER / SPACE / E confirm the highlighted action or target.

  • ESC / X cancel back to the action menu.

  • Left mouse button clicks an action row to pick it, a foe to strike it, or the on-screen Cancel chip to back out. Combat is fully pointer-driven, and the web runtime delivers touch as MouseButton.LEFT, so battles play on touch. Overworld exploration is not there yet: talking to an NPC still needs E and quitting still needs Q, so a touch-only player cannot start a conversation.

Scope

The port ships a single 30x17 overworld map. Upstream ships three maps (forest / house / town) wired together by area-transition and door templates, plus a Dialogic-driven opening cutscene and inventory puzzles; this port compresses that into one hand-authored map that still exercises every gameplay system end to end, so the whole Field -> Encounter -> Battle -> Resolution loop fits in about two minutes of play:

  • WASD and click-to-path traversal on a Python-authored tile grid.

  • Three NPCs (Monk / Smith / Old Mage) with flat dialogue trees.

  • Three encounter triggers (wolves / bears / bugcats) feeding the same battle scene with different enemy rosters.

  • One save shrine writing the player’s cell to saves/open_rpg.json.

  • Fade-to-black overworld to battle transitions.

Architecture choices

  • Procedural sprites everywhere. No PNG dependency: tiles, party, enemies and UI are painted into NumPy RGBA arrays and handed straight to Sprite2D.

  • Pure-logic gameboard. nodes/gameboard.py exports TILES (a 30x17 list-of-lists), bfs_path() for click-to-path, TRIGGERS (cell to encounter/save) and NPCS (cell to kind + dialogue). All map data is hand-authored in Python: no Tiled or TileMap parsing.

  • Step-based movement. The player snaps to tile centres and a fixed-duration ease-out lerp animates between cells. After each arrival the update re-reads held keys so a held direction keeps stepping.

  • Coroutine-style actions. Each BattlerAction is a small per-frame state machine (start(source, targets, scene) plus tick(dt) -> done). The BattleScene drives one action at a time and pops the next when tick() returns True, which keeps the animation sequencing readable without Python coroutines.

  • AI is a random valid action against a random living target, cached at the start of the round. Execution is speed-sorted across both sides.

  • Immediate-mode overlays. The dialogue box, battle HUD, title card, controls strip and fade are Node2Ds that paint into the 2D draw stream from on_draw. Draw2D renders in submission order, and node order is settled by z_index, so layering is just a matter of picking the index. Every overlay measures itself against tree.screen_size, so the layout follows a resized window or browser canvas.

  • Procedural audio. NumPy oscillators through AudioSynth into AudioClip: short SFX plus a looping overworld theme and battle theme.

  • JSON save. A single saves/open_rpg.json (atomic write plus one .bak) recording the player’s last shrine cell. Battles always start the party at full health, so nothing else needs persisting.

File map

gdquest_open_rpg/
├── main.py                   # RPGRoot scene + InputMap + headless --test entry
├── ATTRIBUTION.md            # upstream credit + licensing of this port
├── UPSTREAM_LICENSE.md       # verbatim upstream MIT text
└── nodes/
    ├── settings.py           # constants (WIDTH/HEIGHT/TILE/colours)
    ├── layout.py             # live viewport lookup shared by the overlays
    ├── sprites.py            # procedural NumPy sprites for tiles + characters
    ├── gameboard.py          # 30x17 map + BFS pathfinder + DIALOGUES + TRIGGERS + NPCS
    ├── overworld.py          # tile sprite layer + trigger highlight overlay
    ├── player.py             # step movement, click-to-path, interact
    ├── npc.py                # stationary NPC with a dialogue id
    ├── dialogue.py           # bottom dialogue panel with typewriter reveal
    ├── hud.py                # title card + bottom controls strip
    ├── screen_transition.py  # fade-to-black overlay
    ├── stats.py              # BattlerStats + spec prototypes (party + enemies)
    ├── actions.py            # AttackAction / HealAction / AreaAttackAction
    ├── battler.py            # battler sprite (flash, shake, selection bob)
    ├── floating_label.py     # damage/heal/miss labels (+25 / -10 / MISS)
    ├── battle.py             # BattleScene state machine + clickable HUD
    ├── audio_fx.py           # procedural SFX + music loops
    ├── save_io.py            # JSON save/load for the shrine
    └── harness.py            # headless --test capture through the full loop

Encounters have no module of their own: the trigger cells live in gameboard.TRIGGERS and RPGRoot turns them into battles.

Source files

File

Summary

Lines

main.py

GDQuest Open RPG: Overworld exploration with turn-based party combat.

280

nodes/__init__.py

GDQuest Open RPG → SimVX port nodes.

1

nodes/actions.py

Battler actions: attack, heal, area-attack. Coroutine-style execution.

225

nodes/audio_fx.py

Procedural audio (AudioSynth-based) for SFX + music loops.

211

nodes/battle.py

BattleScene: turn-based combat.

533

nodes/battler.py

Battler: a sprite + stats + actions, used in BattleScene.

109

nodes/dialogue.py

Dialogue box: bottom panel with typewriter text + advance-on-key.

136

nodes/floating_label.py

Short-lived damage / heal / miss label that floats up + fades.

44

nodes/gameboard.py

Hand-authored overworld map and BFS pathfinder.

159

nodes/harness.py

Headless –test capture: walks the player through the full demo loop.

140

nodes/hud.py

Front-of-game title card plus the persistent bottom controls strip.

101

nodes/layout.py

Viewport lookup shared by every screen-space overlay in the port.

20

nodes/npc.py

NPC: stationary or patrolling sprite with a dialogue id.

44

nodes/overworld.py

Overworld: tilemap renderer + holds NPC list.

94

nodes/player.py

PlayerController: WASD/arrow grid movement + interact.

138

nodes/save_io.py

JSON save / load for the save shrine.

43

nodes/screen_transition.py

Black overlay fade-in/out for scene transitions.

61

nodes/settings.py

Constants for GDQuest Open RPG port.

39

nodes/sprites.py

Procedural pixel-art sprite generation (no PNG assets).

220

nodes/stats.py

Battler stats: hp, energy, attack/defense, speed, hit/evasion.

99

Source

  1"""GDQuest Open RPG: Overworld exploration with turn-based party combat.
  2
  3A port of GDQuest's godot-open-rpg demo. Walk a hand-authored tile grid with
  4WASD or by clicking a destination (BFS path), talk to NPCs through a typewriter
  5dialogue box, save at a shrine, and fight turn-based battles that pit a party
  6of three against two or three foes, with an action menu you can click or
  7key-navigate, a target cursor and floating damage numbers. Every sprite and
  8every sound is generated procedurally with NumPy, so the port has no binary
  9assets: it leans on signals, Sprite2D, immediate-mode Draw2D overlays and JSON
 10persistence.
 11
 12# /// simvx
 13# tags = ["port", "tier-2"]
 14# upstream = "https://github.com/gdquest-demos/godot-open-rpg"
 15# web = { width = 960, height = 540, responsive = true }
 16# ///
 17
 18Run:
 19    uv run python examples/ports/gdquest_open_rpg/main.py            # interactive
 20    uv run python examples/ports/gdquest_open_rpg/main.py --test     # headless capture
 21"""
 22
 23from __future__ import annotations
 24
 25import sys
 26from pathlib import Path
 27
 28_PORT_DIR = Path(__file__).parent
 29if str(_PORT_DIR) not in sys.path:
 30    sys.path.insert(0, str(_PORT_DIR))
 31
 32from nodes.audio_fx import AudioFX  # noqa: E402
 33from nodes.battle import BattleScene  # noqa: E402
 34from nodes.dialogue import DialogueBox  # noqa: E402
 35from nodes.gameboard import DIALOGUES, NPCS, TRIGGERS  # noqa: E402
 36from nodes.hud import ControlsStrip, TitleScreen  # noqa: E402
 37from nodes.npc import NPC  # noqa: E402
 38from nodes.overworld import Overworld  # noqa: E402
 39from nodes.player import Player  # noqa: E402
 40from nodes.save_io import SaveStore  # noqa: E402
 41from nodes.screen_transition import ScreenTransition  # noqa: E402
 42from nodes.settings import HEIGHT, TITLE, WIDTH  # noqa: E402
 43
 44from simvx.core import Node2D  # noqa: E402
 45from simvx.core.input.enums import Key, MouseButton  # noqa: E402
 46from simvx.core.input.map import InputMap  # noqa: E402
 47from simvx.core.input.state import Input  # noqa: E402
 48from simvx.graphics import App  # noqa: E402
 49
 50# Phase identifiers: what owns the player's input right now.
 51PHASE_TITLE = "title"
 52PHASE_OVERWORLD = "overworld"
 53PHASE_DIALOGUE = "dialogue"
 54PHASE_ENCOUNTER = "encounter"
 55PHASE_BATTLE = "battle"
 56PHASE_RESOLUTION = "resolution"
 57
 58# Control legend shown in the bottom strip, per phase.
 59HINTS = {
 60    PHASE_TITLE: "",  # the title card carries its own prompt
 61    PHASE_OVERWORLD: "WASD or arrows: walk    click a tile: walk there    E: talk    Q: quit",
 62    PHASE_DIALOGUE: "SPACE / ENTER / click: advance",
 63    PHASE_ENCOUNTER: "",
 64    PHASE_BATTLE: "W/S: choose    ENTER: confirm    ESC: cancel    (or click the menu and your target)",
 65    PHASE_RESOLUTION: "SPACE / ENTER / click: continue",
 66}
 67
 68
 69class RPGRoot(Node2D):
 70    """Root scene: owns overworld, dialogue, screen transition, and battle."""
 71
 72    def on_ready(self) -> None:
 73        # Input actions live in root.on_ready (web exporter skips main()).
 74        InputMap.add_action("up", [Key.W, Key.UP])
 75        InputMap.add_action("down", [Key.S, Key.DOWN])
 76        InputMap.add_action("left", [Key.A, Key.LEFT])
 77        InputMap.add_action("right", [Key.D, Key.RIGHT])
 78        InputMap.add_action("confirm", [Key.ENTER, Key.SPACE, Key.E])
 79        InputMap.add_action("interact", [Key.E])
 80        InputMap.add_action("cancel", [Key.ESCAPE, Key.X])
 81        InputMap.add_action("primary", [MouseButton.LEFT])
 82        InputMap.add_action("quit", [Key.Q])
 83
 84        self.audio = AudioFX()
 85        self.add_child(self.audio)
 86
 87        self.save = SaveStore()
 88
 89        # Overworld
 90        self.overworld = Overworld(audio=self.audio)
 91        self.add_child(self.overworld)
 92
 93        # Restore the last shrine the player saved at, if any.
 94        save_data = self.save.load()
 95        spawn = save_data.get("cell", (15, 8)) if save_data else (15, 8)
 96        self.player = Player(start_cell=spawn)
 97        self.overworld.add_child(self.player)
 98        for spec in NPCS:
 99            npc = NPC(cell=spec["cell"], kind=spec["kind"], dialogue_id=spec["dialogue"])
100            self.overworld.add_child(npc)
101
102        self.player.cell_arrived.connect(self._on_player_arrived)
103        self.player.interact_requested.connect(self._on_interact)
104
105        # Dialogue box: screen-space overlay above the world
106        self.dialogue = DialogueBox()
107        self.add_child(self.dialogue)
108        self.dialogue.finished.connect(self._on_dialogue_finished)
109
110        # Bottom controls strip, refreshed from the current phase each frame
111        self.controls = ControlsStrip()
112        self.add_child(self.controls)
113
114        # Screen transition overlay
115        self.transition = ScreenTransition()
116        self.add_child(self.transition)
117
118        # Battle scene placeholder
119        self.battle: BattleScene | None = None
120        self._pending_encounter: str | None = None
121        self._post_dialogue: str | None = None
122
123        # Title card first: the world is built but frozen until the player starts.
124        self.phase = PHASE_TITLE
125        self.player.set_input_enabled(False)
126        title = TitleScreen()
127        self.add_child(title)
128        title.started.connect(self._on_start)
129
130        self.tree.screen_resized.connect(self._on_screen_resized)
131
132    def on_exit_tree(self) -> None:
133        if self.tree is not None:
134            self.tree.screen_resized.disconnect(self._on_screen_resized)
135
136    def _on_start(self) -> None:
137        self.phase = PHASE_OVERWORLD
138        self.player.set_input_enabled(True)
139        self.audio.play_music("overworld")
140
141    def _on_screen_resized(self, size) -> None:
142        """Overlays lay out from the viewport, so re-capture them on resize."""
143        self.transition.queue_redraw()
144        self.controls.queue_redraw()
145        if self.battle is not None:
146            self.battle.layout_battlers()
147
148    # ------------------------------------------------------------------
149    # Trigger handlers
150    # ------------------------------------------------------------------
151    def _on_player_arrived(self, cx: int, cy: int) -> None:
152        if self.phase != PHASE_OVERWORLD:
153            return
154        trig = TRIGGERS.get((cx, cy))
155        if trig is None:
156            return
157        kind, ident = trig
158        if kind == "encounter":
159            self._start_encounter(ident)
160        elif kind == "save":
161            self._save_at_shrine()
162
163    def _on_interact(self, cell: tuple[int, int]) -> None:
164        if self.phase != PHASE_OVERWORLD:
165            return
166        for npc in self.overworld.find_all(NPC, direct=True):
167            if npc.cell == cell:
168                self._start_dialogue(npc.dialogue_id)
169                return
170
171    def _start_dialogue(self, dialogue_id: str) -> None:
172        lines = DIALOGUES.get(dialogue_id, [("???", "...")])
173        self.phase = PHASE_DIALOGUE
174        self.player.set_input_enabled(False)
175        self.dialogue.show_lines(lines)
176        self.audio.play_sfx("blip")
177
178    def _on_dialogue_finished(self) -> None:
179        # Control is handed back by whoever owns the next step: only the plain
180        # NPC / shrine case returns to the overworld right here. The battle
181        # paths keep input locked until the fade lands in _restore_overworld,
182        # so nobody can walk onto a trigger mid-fade.
183        if self._pending_encounter is not None:
184            ident = self._pending_encounter
185            self._pending_encounter = None
186            self._do_battle(ident)
187            return
188        if self._post_dialogue == "victory_return":
189            self._post_dialogue = None
190            self.transition.fade_out(callback=self._restore_overworld)
191            return
192        if self._post_dialogue == "defeat_return":
193            self._post_dialogue = None
194            # Return to the last shrine cell
195            data = self.save.load()
196            if data is not None:
197                self.player.cell = tuple(data["cell"])
198                self.player.position = self.player.cell_to_world(self.player.cell)
199            self.transition.fade_out(callback=self._restore_overworld)
200            return
201        self.phase = PHASE_OVERWORLD
202        self.player.set_input_enabled(True)
203
204    def _start_encounter(self, ident: str) -> None:
205        # Show short dialogue then enter battle
206        self.phase = PHASE_DIALOGUE
207        self.player.set_input_enabled(False)
208        self._pending_encounter = ident
209        self.dialogue.show_lines(DIALOGUES.get(f"encounter_{ident}", [("!", "An encounter!")]))
210        self.audio.play_sfx("encounter")
211
212    def _do_battle(self, ident: str) -> None:
213        self.phase = PHASE_ENCOUNTER
214        self.audio.stop_music()
215        self.transition.fade_out(callback=lambda: self._enter_battle(ident))
216
217    def _enter_battle(self, ident: str) -> None:
218        # Construct a battle scene and add it on top of the overworld
219        self.battle = BattleScene(enemy_kind=ident, audio=self.audio)
220        self.add_child(self.battle)
221        self.overworld.visible = False
222        self.battle.victory.connect(self._on_battle_victory)
223        self.battle.defeat.connect(self._on_battle_defeat)
224        self.phase = PHASE_BATTLE
225        self.audio.play_music("battle")
226        self.transition.fade_in()
227
228    def _on_battle_victory(self) -> None:
229        self.phase = PHASE_RESOLUTION
230        self.audio.play_sfx("victory")
231        # Show victory dialogue then return
232        self.dialogue.show_lines(DIALOGUES["victory"])
233        self._post_dialogue = "victory_return"
234
235    def _on_battle_defeat(self) -> None:
236        self.phase = PHASE_RESOLUTION
237        self.audio.play_sfx("defeat")
238        self.dialogue.show_lines(DIALOGUES["defeat"])
239        self._post_dialogue = "defeat_return"
240
241    def _restore_overworld(self) -> None:
242        if self.battle is not None:
243            self.battle.destroy()
244            self.battle = None
245        self.overworld.visible = True
246        self.phase = PHASE_OVERWORLD
247        self.player.set_input_enabled(True)
248        self.audio.play_music("overworld")
249        # The screen is fully black at this point: fade back IN to the world.
250        self.transition.fade_in()
251
252    def _save_at_shrine(self) -> None:
253        self.phase = PHASE_DIALOGUE
254        self.player.set_input_enabled(False)
255        self.save.save({"cell": list(self.player.cell)})
256        self.dialogue.show_lines(DIALOGUES["save"])
257        self.audio.play_sfx("save")
258
259    # ------------------------------------------------------------------
260    # Frame-by-frame: keep the controls strip honest, handle quit.
261    # ------------------------------------------------------------------
262    def on_update(self, dt: float) -> None:
263        self.controls.hint = HINTS.get(self.phase, "")
264        if Input.is_action_just_pressed("quit") and self.phase in (PHASE_TITLE, PHASE_OVERWORLD):
265            self.app.quit()
266
267
268def main() -> None:
269    headless = "--test" in sys.argv
270    if headless:
271        from nodes.harness import run_headless_capture
272
273        run_headless_capture()
274    else:
275        app = App(width=WIDTH, height=HEIGHT, title=TITLE)
276        app.run(RPGRoot())
277
278
279if __name__ == "__main__":
280    main()