GDQuest Open RPG¶
Overworld exploration with turn-based party combat.
▶ Run in browserUpstream: 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.pyexportsTILES(a 30x17 list-of-lists),bfs_path()for click-to-path,TRIGGERS(cell to encounter/save) andNPCS(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
BattlerActionis a small per-frame state machine (start(source, targets, scene)plustick(dt) -> done). TheBattleScenedrives one action at a time and pops the next whentick()returnsTrue, 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 fromon_draw. Draw2D renders in submission order, and node order is settled byz_index, so layering is just a matter of picking the index. Every overlay measures itself againsttree.screen_size, so the layout follows a resized window or browser canvas.Procedural audio. NumPy oscillators through
AudioSynthintoAudioClip: 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 |
|---|---|---|
GDQuest Open RPG: Overworld exploration with turn-based party combat. |
256 |
|
GDQuest Open RPG → SimVX port nodes. |
1 |
|
Battler actions: attack, heal, area-attack. Coroutine-style execution. |
225 |
|
Procedural audio (AudioSynth-based) for SFX + music loops. |
211 |
|
BattleScene: turn-based combat. |
532 |
|
Battler: a sprite + stats + actions, used in BattleScene. |
109 |
|
What the player can do, declared once per mode and carried by a node. |
71 |
|
Dialogue box: bottom panel with typewriter text + advance-on-key. |
134 |
|
Short-lived damage / heal / miss label that floats up + fades. |
43 |
|
Hand-authored overworld map and BFS pathfinder. |
159 |
|
Headless screenshot capture: walks the player through the full demo loop. |
146 |
|
Front-of-game title card plus the persistent bottom controls strip. |
91 |
|
NPC: stationary or patrolling sprite with a dialogue id. |
44 |
|
Overworld: tilemap renderer + holds NPC list. |
102 |
|
PlayerController: grid movement on one typed action, plus interact. |
147 |
|
JSON save / load for the save shrine. |
43 |
|
Black overlay fade-in/out for scene transitions. |
59 |
|
Constants for GDQuest Open RPG port. |
42 |
|
Procedural pixel-art sprite generation (no PNG assets). |
220 |
|
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()