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

# the export runs in a copy made by `simvx examples copy ports_gdquest_open_rpg`
simvx examples run ports_gdquest_open_rpg            # interactive
simvx export web main.py \
    -o 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 Mouse.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
├── 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

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.

256

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.

532

nodes/battler.py

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

109

nodes/controls.py

What the player can do, declared once per mode and carried by a node.

71

nodes/dialogue.py

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

134

nodes/floating_label.py

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

43

nodes/gameboard.py

Hand-authored overworld map and BFS pathfinder.

159

nodes/harness.py

Headless screenshot capture: walks the player through the full demo loop.

146

nodes/hud.py

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

91

nodes/npc.py

NPC: stationary or patrolling sprite with a dialogue id.

44

nodes/overworld.py

Overworld: tilemap renderer + holds NPC list.

102

nodes/player.py

PlayerController: grid movement on one typed action, plus interact.

147

nodes/save_io.py

JSON save / load for the save shrine.

43

nodes/screen_transition.py

Black overlay fade-in/out for scene transitions.

59

nodes/settings.py

Constants for GDQuest Open RPG port.

42

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