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¶
# 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.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 + 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 |
|---|---|---|
GDQuest Open RPG: Overworld exploration with turn-based party combat. |
280 |
|
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. |
533 |
|
Battler: a sprite + stats + actions, used in BattleScene. |
109 |
|
Dialogue box: bottom panel with typewriter text + advance-on-key. |
136 |
|
Short-lived damage / heal / miss label that floats up + fades. |
44 |
|
Hand-authored overworld map and BFS pathfinder. |
159 |
|
Headless –test capture: walks the player through the full demo loop. |
140 |
|
Front-of-game title card plus the persistent bottom controls strip. |
101 |
|
Viewport lookup shared by every screen-space overlay in the port. |
20 |
|
NPC: stationary or patrolling sprite with a dialogue id. |
44 |
|
Overworld: tilemap renderer + holds NPC list. |
94 |
|
PlayerController: WASD/arrow grid movement + interact. |
138 |
|
JSON save / load for the save shrine. |
43 |
|
Black overlay fade-in/out for scene transitions. |
61 |
|
Constants for GDQuest Open RPG port. |
39 |
|
Procedural pixel-art sprite generation (no PNG assets). |
220 |
|
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()