Clear Code Zelda

top-down ARPG with sword combat, magic, enemy AI and stat upgrades.

▶ Run in browser

Upstream: 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

WASD / arrows

Walk (8-direction)

SPACE

Sword attack (melee)

LEFT/RIGHT CTRL

Cast magic (heal / flame)

Q

Cycle weapon (sword/lance/axe/rapier/sai)

E

Cycle spell (heal/flame)

M

Open / close upgrade menu

LEFT / RIGHT

Upgrade selection (in menu)

SPACE / ENTER

Buy upgrade (in menu)

ESC

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 radius

  • Y-sort drawing via YSortContainer so player and enemies depth-sort by y

  • Particle 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 AudioPlayer

  • HUD: 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

  • Player and Enemy publish Signals (attack_started, magic_cast, damaged, attacked, died) and Level connects to them, so neither side has to know about the other’s internals.

  • Current values live on Property fields (hp, max_hp, speed, …); the parallel stats dict is the table the upgrade screen iterates over, and Player.upgrade_stat is the only place that writes both.

  • The upgrade screen pauses the tree (tree.paused), and the HUD CanvasLayer runs with UpdateMode.ALWAYS so 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 plain AudioPlayer on 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

main.py

Clear Code Zelda: top-down ARPG with sword combat, magic, enemy AI and stat upgrades.

116

capture.py

Capture headless screenshots at frames 30, 60, 120 for review.

36

harness.py

Scripted input harness for the Zelda port: 15 stages, one screenshot each.

94

nodes/__init__.py

0

nodes/anim_sprite.py

Multi-PNG animated sprite: swaps Sprite2D.texture between a list of files.

71

nodes/enemy.py

Enemy with idle/move/attack state machine + simple distance-based pathing.

231

nodes/level.py

Level: the actual gameplay scene.

481

nodes/magic.py

Heal and flame spells: wrappers that spawn particle effects + damage hits.

79

nodes/menu.py

Title screen: shown before the level so the game opens on a menu.

72

nodes/particles.py

Particle effect node: plays a folder of frames once and self-destructs.

86

nodes/player.py

Player character: 8-direction movement, sword attack, magic, weapon/spell switch.

345

nodes/touch.py

On-screen thumb-stick and action buttons, so the port plays with a pointer.

147

nodes/ui.py

Player HUD: health bar, energy bar, EXP counter, weapon/magic boxes.

90

nodes/upgrade.py

Upgrade screen: five vertical attribute panels with a selection cursor.

160

nodes/weapon.py

Sword/weapon strike sprite: spawned in front of the player on attack.

49

settings.py

Game-wide constants for the Clear Code Zelda port.

129

support.py

Asset loading helpers: counterparts to the upstream support.py.

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()