Clear Code Zelda¶
top-down ARPG with sword combat, magic, enemy AI and stat upgrades.
▶ Run in browserUpstream: https://github.com/clear-code-projects/Zelda
Licence: this port's own code is offered under CC0-1.0, 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-1
Clear Code Zelda: SimVX port¶
SimVX port of Clear Code’s “Zelda” Pygame ARPG.
Top-down ARPG with sword combat, magic, enemy AI, particles, y-sort, and a stat-upgrade screen. Source assets are CC0 (Ninja Adventure pack) and reused unchanged.
Run¶
# from the repo root
uv run python examples/ports/clear_code_zelda/main.py # interactive
uv run python examples/ports/clear_code_zelda/main.py --test # headless smoke run (12 frames)
uv run python examples/ports/clear_code_zelda/capture.py # headless screenshots (frame 30/60/120)
uv run python examples/ports/clear_code_zelda/harness.py # scripted-input capture (15 stages)
uv run simvx export web examples/ports/clear_code_zelda/main.py \
-o /tmp/clear_code_zelda.html
Controls¶
The game opens on a title screen; SPACE, ENTER or a click starts it. Everything is playable from the keyboard or entirely with a pointer (mouse or touch).
Key |
Action |
|---|---|
|
Walk (8-direction) |
|
Sword attack (melee) |
|
Cast magic (heal / flame) |
|
Cycle weapon (sword/lance/axe/rapier/sai) |
|
Cycle spell (heal/flame) |
|
Open / close upgrade menu |
|
Upgrade selection (in menu) |
|
Buy upgrade (in menu) |
|
Quit |
With a pointer, drag the stick in the lower-left corner to walk and tap the buttons
in the lower-right: ATK swings, MAG casts, Q and E swap weapon and spell, M
opens the upgrade screen. In the upgrade screen, a click selects a panel and a second
click on the selected panel buys it.
What works¶
57x50 tile map loaded from upstream CSV layouts (boundary, grass, objects, entities)
Player movement with 12-direction animation set (walk/idle/attack × 4 facings)
Sword attack with directional weapon sprite + cooldown
5 weapon types with different damage/cooldown values, swappable on-the-fly
2 spells (heal restores HP, flame is a projectile)
4 enemy types (squid, raccoon, spirit, bamboo) with idle/move/attack states
Enemy AI: notice radius → chase via
NavGrid2D→ attack radiusY-sort drawing via
YSortContainerso player and enemies depth-sort by yParticle effects: leaves on grass slash, magic sparkles on heal/flame, monster-specific death puffs
Hit feedback: the sprite flashes on damage and enemies are knocked back
Soundtrack plus sword, hit, heal, flame, monster-attack and death SFX through
AudioPlayerHUD: HP bar, energy bar, kills counter, XP counter, weapon/magic indicators, help strip
Upgrade screen: 5 panels (health/energy/attack/magic/speed) with selection, cost scales 1.4x per buy
AABB collision against invisible boundary tiles + objects (grass is walkable but slashable)
Web export
How it is put together¶
PlayerandEnemypublishSignals (attack_started,magic_cast,damaged,attacked,died) andLevelconnects to them, so neither side has to know about the other’s internals.Current values live on
Propertyfields (hp,max_hp,speed, …); the parallelstatsdict is the table the upgrade screen iterates over, andPlayer.upgrade_statis the only place that writes both.The upgrade screen pauses the tree (
tree.paused), and the HUDCanvasLayerruns withUpdateMode.ALWAYSso the overlay can still close itself.
What’s intentionally simplified¶
No procedural floor texture. Upstream uses a 3648×3200 ground.png (~180 KB). The port draws a flat green rect under the world to keep the web bundle small and avoid the texture-cache for one giant repeating image. Visually matches at a glance.
Flame projectile is a single trail of looping puffs, not the upstream’s chained particle anim with sparkle.
Sound is non-positional. The camera is faked by translating the y-sort container rather than with a
Camera2D, so there is no 2D listener to pan against; every effect plays through a plainAudioPlayeron the SFX bus, with the soundtrack on the Music bus.Camera is unsmoothed. Player stays exactly centred; the upstream has no smoothing either.
“Death” wraps to full-heal, not a Game Over screen; the upstream has the same loop.
Source files¶
File |
Summary |
Lines |
|---|---|---|
Clear Code Zelda: top-down ARPG with sword combat, magic, enemy AI and stat upgrades. |
116 |
|
Capture headless screenshots at frames 30, 60, 120 for review. |
36 |
|
Scripted input harness for the Zelda port: 15 stages, one screenshot each. |
94 |
|
0 |
||
Multi-PNG animated sprite: swaps Sprite2D.texture between a list of files. |
71 |
|
Enemy with idle/move/attack state machine + simple distance-based pathing. |
231 |
|
Level: the actual gameplay scene. |
481 |
|
Heal and flame spells: wrappers that spawn particle effects + damage hits. |
79 |
|
Title screen: shown before the level so the game opens on a menu. |
72 |
|
Particle effect node: plays a folder of frames once and self-destructs. |
86 |
|
Player character: 8-direction movement, sword attack, magic, weapon/spell switch. |
345 |
|
On-screen thumb-stick and action buttons, so the port plays with a pointer. |
147 |
|
Player HUD: health bar, energy bar, EXP counter, weapon/magic boxes. |
90 |
|
Upgrade screen: five vertical attribute panels with a selection cursor. |
160 |
|
Sword/weapon strike sprite: spawned in front of the player on attack. |
49 |
|
Game-wide constants for the Clear Code Zelda port. |
129 |
|
Asset loading helpers: counterparts to the upstream |
24 |
Source¶
1#!/usr/bin/env python3
2"""Clear Code Zelda: top-down ARPG with sword combat, magic, enemy AI and stat upgrades.
3
4# /// simvx
5# tags = ["port", "tier-1"]
6# upstream = "https://github.com/clear-code-projects/Zelda"
7# web = { width = 1280, height = 720, responsive = true }
8# ///
9
10A SimVX re-implementation of Clear Code's Pygame "Zelda" ARPG tutorial. CSV tile
11layouts build a 57x50 map, a YSortContainer depth-sorts the player, enemies and
12props, NavGrid2D drives enemy chase pathfinding around walls, and the HUD,
13title screen and upgrade overlay draw in screen space on a CanvasLayer.
14
15Walk with WASD or the arrows, SPACE to swing, CTRL to cast, Q/E to swap weapon
16and spell, M for the upgrade screen, ESC to quit. On a touch screen or with the
17mouse, drag the on-screen stick to walk and tap the action buttons.
18
19Run:
20 uv run python examples/ports/clear_code_zelda/main.py
21 uv run python examples/ports/clear_code_zelda/main.py --test # headless smoke run
22"""
23
24from __future__ import annotations
25
26import argparse
27import sys
28from pathlib import Path
29
30# Allow flat sibling imports (settings, support, nodes.*)
31_PORT_DIR = Path(__file__).resolve().parent
32if str(_PORT_DIR) not in sys.path:
33 sys.path.insert(0, str(_PORT_DIR))
34
35from nodes.level import Level # noqa: E402
36from nodes.menu import StartScreen # noqa: E402
37from settings import HEIGHT, WIDTH # noqa: E402
38
39from simvx.core import CanvasLayer, Input, InputMap, Key, Node2D, Property, UpdateMode # noqa: E402
40from simvx.graphics import App # noqa: E402
41
42
43class ZeldaRoot(Node2D):
44 """Root scene node: registers actions, then shows the title screen.
45
46 Pass ``show_menu=False`` to drop straight into the level, which is what the
47 headless smoke run and the capture scripts want.
48 """
49
50 # ESC must still quit while the upgrade screen has the tree paused. The
51 # level sets itself back to PAUSABLE so gameplay does not inherit this.
52 update_mode = Property(
53 UpdateMode.ALWAYS,
54 hint="Processing behaviour while the tree is paused",
55 on_change="_invalidate_update_mode_cache",
56 )
57
58 def __init__(self, show_menu: bool = True, **kwargs):
59 super().__init__(**kwargs)
60 self._show_menu = show_menu
61 self._menu_layer: CanvasLayer | None = None
62 self._menu: StartScreen | None = None
63
64 def on_ready(self):
65 InputMap.add_action("move_up", [Key.W, Key.UP])
66 InputMap.add_action("move_down", [Key.S, Key.DOWN])
67 InputMap.add_action("move_left", [Key.A, Key.LEFT])
68 InputMap.add_action("move_right", [Key.D, Key.RIGHT])
69 InputMap.add_action("attack", [Key.SPACE])
70 InputMap.add_action("magic", [Key.LEFT_CONTROL, Key.RIGHT_CONTROL])
71 InputMap.add_action("weapon_swap", [Key.Q])
72 InputMap.add_action("magic_swap", [Key.E])
73 InputMap.add_action("upgrade_menu", [Key.M])
74 InputMap.add_action("ui_left", [Key.LEFT, Key.A])
75 InputMap.add_action("ui_right", [Key.RIGHT, Key.D])
76 InputMap.add_action("ui_select", [Key.SPACE, Key.ENTER])
77 InputMap.add_action("quit", [Key.ESCAPE])
78
79 if self._show_menu:
80 self._menu_layer = self.add_child(CanvasLayer(name="MenuLayer"))
81 self._menu_layer.layer = 200
82 self._menu = self._menu_layer.add_child(StartScreen())
83 self._menu.start_requested.connect(self.start_game)
84 else:
85 self.start_game()
86
87 def start_game(self):
88 """Drop the title screen (if any) and load the level."""
89 if self._menu_layer is not None:
90 self._menu_layer.destroy()
91 self._menu_layer = None
92 self._menu = None
93 self.add_child(Level())
94
95 def on_update(self, dt: float):
96 if Input.is_action_just_pressed("quit"):
97 self.app.quit()
98
99
100def main():
101 parser = argparse.ArgumentParser()
102 parser.add_argument("--test", action="store_true", help="Headless smoke run, exit after a few frames.")
103 args = parser.parse_args()
104
105 if args.test:
106 # Headless: render a few frames of actual gameplay then exit.
107 app = App(width=WIDTH, height=HEIGHT, title="Clear Code Zelda (test)", visible=False)
108 frames = app.run_headless(ZeldaRoot(show_menu=False), frames=12, capture_frames=[11])
109 print(f"OK: rendered 12 frames, last one {frames[0].shape}")
110 return
111
112 App(width=WIDTH, height=HEIGHT, title="Clear Code Zelda").run(ZeldaRoot())
113
114
115if __name__ == "__main__":
116 main()