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

# the export runs in a copy made by `simvx examples copy ports_clear_code_zelda`
simvx examples run ports_clear_code_zelda            # interactive
simvx examples run ports_clear_code_zelda --test     # headless smoke run (12 frames)
simvx export web main.py \
    -o 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.

101

capture.py

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

36

controls.py

What the player can do, declared once and carried by the nodes that read it.

52

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.

477

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.

74

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.

324

nodes/ui.py

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

91

nodes/upgrade.py

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

172

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# /// simvx
  3# tags = ["port", "tier-1"]
  4# upstream = "https://github.com/clear-code-projects/Zelda"
  5# web = { width = 1280, height = 720, responsive = true }
  6# ///
  7"""Clear Code Zelda: top-down ARPG with sword combat, magic, enemy AI and stat upgrades.
  8
  9A SimVX re-implementation of Clear Code's Pygame "Zelda" ARPG tutorial. CSV tile
 10layouts build a 57x50 map, a YSortContainer depth-sorts the player, enemies and
 11props, NavGrid2D drives enemy chase pathfinding around walls, and the HUD,
 12title screen and upgrade overlay draw in screen space on a CanvasLayer.
 13
 14Walk with WASD or the arrows, SPACE to swing, CTRL to cast, Q/E to swap weapon
 15and spell, M for the upgrade screen, ESC to quit. On a controller the left
 16stick walks, A swings, B casts, the shoulders swap weapon and spell, and START
 17opens the upgrade screen. On a touch screen the same pad is drawn on the glass,
 18and every screen can also be left with a tap.
 19
 20Run:
 21    simvx examples run ports_clear_code_zelda
 22    simvx examples run ports_clear_code_zelda --test   # headless smoke run
 23"""
 24
 25from __future__ import annotations
 26
 27import argparse
 28
 29from controls import SHELL  # noqa: E402
 30from nodes.level import Level  # noqa: E402
 31from nodes.menu import StartScreen  # noqa: E402
 32from settings import HEIGHT, WIDTH  # noqa: E402
 33
 34from simvx.core import CanvasLayer, Input, Node2D, Property, UpdateMode  # noqa: E402
 35from simvx.graphics import App  # noqa: E402
 36
 37
 38class ZeldaRoot(Node2D):
 39    """Root scene node: declares the shell actions, then shows the title screen.
 40
 41    Pass ``show_menu=False`` to drop straight into the level, which is what the
 42    headless smoke run and the capture scripts want.
 43    """
 44
 45    #: Registered by the tree whenever this scene becomes the root, so a web
 46    #: export and a test that loads the scene both have them.
 47    input_context = SHELL
 48
 49    # ESC must still quit while the upgrade screen has the tree paused. The
 50    # level sets itself back to PAUSABLE so gameplay does not inherit this.
 51    update_mode = Property(
 52        UpdateMode.ALWAYS,
 53        hint="Processing behaviour while the tree is paused",
 54        on_change="_invalidate_update_mode_cache",
 55    )
 56
 57    def __init__(self, show_menu: bool = True, **kwargs):
 58        super().__init__(**kwargs)
 59        self._show_menu = show_menu
 60        self._menu_layer: CanvasLayer | None = None
 61        self._menu: StartScreen | None = None
 62
 63    def on_ready(self):
 64        if self._show_menu:
 65            self._menu_layer = self.add_child(CanvasLayer(name="MenuLayer"))
 66            self._menu_layer.layer = 200
 67            self._menu = self._menu_layer.add_child(StartScreen())
 68            self._menu.start_requested.connect(self.start_game)
 69        else:
 70            self.start_game()
 71
 72    def start_game(self):
 73        """Drop the title screen (if any) and load the level."""
 74        if self._menu_layer is not None:
 75            self._menu_layer.destroy()
 76            self._menu_layer = None
 77            self._menu = None
 78        self.add_child(Level())
 79
 80    def on_update(self, dt: float):
 81        if Input.just_pressed("quit"):
 82            self.app.quit()
 83
 84
 85def main():
 86    parser = argparse.ArgumentParser()
 87    parser.add_argument("--test", action="store_true", help="Headless smoke run, exit after a few frames.")
 88    args = parser.parse_args()
 89
 90    if args.test:
 91        # Headless: render a few frames of actual gameplay then exit.
 92        app = App(width=WIDTH, height=HEIGHT, title="Clear Code Zelda (test)", visible=False)
 93        frames = app.run_headless(ZeldaRoot(show_menu=False), frames=12, capture_frames=[11])
 94        print(f"OK: rendered 12 frames, last one {frames[0].shape}")
 95        return
 96
 97    App(width=WIDTH, height=HEIGHT, title="Clear Code Zelda").run(ZeldaRoot())
 98
 99
100if __name__ == "__main__":
101    main()