Casual Crusade

a card-laying dungeon crawler with path-walk scoring.

▶ Run in browser

Upstream: https://github.com/anttihaavikko/casual-crusade

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-1

Casual Crusade (SimVX port)

A SimVX port of Antti Haavikko’s js13k 2023 entry, a card-laying dungeon crawler on a domino-style board.

Licensing: a clean re-implementation under MIT, drawn entirely with procedural on_draw calls (no upstream art, audio, or fonts are bundled). See ATTRIBUTION.md and LICENSE.

Run

# from the repo root
uv run python examples/ports/casual_crusade/main.py            # interactive
uv run python examples/ports/casual_crusade/main.py --test     # headless capture (3 frames)
uv run python examples/ports/casual_crusade/harness.py         # scripted-input capture (8 stages)
uv run simvx export web examples/ports/casual_crusade/main.py \
    -o /tmp/casual_crusade.html

Controls

Action

Input

Place card

Drag from hand onto a legal (highlighted) tile

Cancel drag

Right-click

Restart

R, or tap the RESET chip

Load run

L, or tap the LOAD chip

Save run

S, or tap the SAVE chip

Everything is reachable with the mouse alone (so it plays on touch): the three chips on the bottom controls strip mirror the three keys.

Gameplay summary

  • Each card has 1-4 directional edges. Place a card so at least one edge meets a matching neighbour edge already on the board.

  • The pilgrim then walks the newly connected path, scoring at each step. Later steps in a walk are worth more, and the level number multiplies the lot.

  • Stepping next to a chest loots it and opens a reward picker: the card you pick joins your permanent deck. Each option shows its gem’s name and effect.

  • Gems bend the rules: Fibonacci’s Boon draws an extra card, Pope’s Blessing heals, Khan’s Legacy fills the neighbouring tiles with blanks, Penance recycles a card in hand, Dynasty doubles the running step multiplier, and Indulgence scores tenfold.

  • Empty (non-reward) tiles at level end cost one life each. The run ends at zero lives.

Layout

The port lays out in a fixed 1280x720 design space: every rect and hit-test in nodes/constants.py and nodes/game.py is in those coordinates.

Saving writes saves/casual_crusade.json relative to the directory you launched from, never into the example’s source tree.

File map

casual_crusade/
├── main.py            # entry point: root node + input actions
├── harness.py         # scripted-input capture (8 stages)
├── nodes/
│   ├── constants.py   # design resolution, colours, gem table, level titles
│   ├── card_data.py   # CardData + random_card + starter_deck
│   ├── card.py        # Card (drag, hover, procedural draw)
│   ├── tile.py        # Tile (board cell, accepts/marked/hilite, chest)
│   ├── dude.py        # the pilgrim (path-walk hop + procedural draw)
│   └── game.py        # Game (board, hand, scoring, picker, save/load)
└── screenshots/       # harness + idle captures

Source files

File

Summary

Lines

main.py

Casual Crusade: a card-laying dungeon crawler with path-walk scoring.

81

harness.py

Scripted-input harness: exercises menu, hover, drag-drop, scoring, save/load.

154

nodes/__init__.py

0

nodes/card.py

Card: interactive node that draws a card face procedurally.

188

nodes/card_data.py

Card data record and deck-generation helpers.

55

nodes/constants.py

Casual Crusade: global constants.

94

nodes/dude.py

Dude: pilgrim avatar that walks the path of placed cards.

83

nodes/game.py

Game: the scene managing board, hand, scoring, pilgrim and save/load.

1160

nodes/tile.py

Tile: single board cell. Owns optional Card content; draws grass or chest.

111

Source

 1"""Casual Crusade: a card-laying dungeon crawler with path-walk scoring.
 2
 3# /// simvx
 4# tags = ["port", "tier-1"]
 5# upstream = "https://github.com/anttihaavikko/casual-crusade"
 6# web = { width = 1280, height = 720, responsive = true }
 7# ///
 8
 9A port of Antti Haavikko's js13k 2023 entry. Drag cards from your hand onto the
10board so their direction lines meet; the pilgrim then walks the newly connected
11path and scores every step. Stepping beside a chest loots it for a reward card,
12gems on the cards bend the rules, and every tile still empty when the level ends
13costs a life.
14
15Everything on screen is drawn procedurally in ``on_draw`` (no image assets):
16coroutines animate the walk and time the banner messages, polled input actions
17drive the drag-and-drop with snap targets, ``Text2D`` carries the HUD, and a run
18saves to and loads from JSON.
19
20Run:
21    uv run python examples/ports/casual_crusade/main.py
22    uv run python examples/ports/casual_crusade/main.py --test
23"""
24
25from __future__ import annotations
26
27import sys
28from pathlib import Path
29
30_PORT_DIR = Path(__file__).parent
31if str(_PORT_DIR) not in sys.path:
32    sys.path.insert(0, str(_PORT_DIR))
33
34from nodes.constants import HEIGHT, WIDTH  # noqa: E402
35from nodes.game import Game  # noqa: E402
36
37from simvx.core import InputMap, Key, MouseButton, Node2D  # noqa: E402
38from simvx.graphics import App  # noqa: E402
39
40
41class CasualCrusadeRoot(Node2D):
42    """Root node: owns the input actions and hosts the Game subtree."""
43
44    autostart = False
45
46    def on_ready(self) -> None:
47        # Actions are registered here, in the root's ready path, because the web
48        # export instantiates the root directly and never calls ``main()``.
49        # Everything the Game polls goes through these names.
50        InputMap.add_action("primary", [MouseButton.LEFT])
51        InputMap.add_action("secondary", [MouseButton.RIGHT])
52        InputMap.add_action("save", [Key.S])
53        InputMap.add_action("load", [Key.L])
54        InputMap.add_action("restart", [Key.R])
55        self.game = self.add_child(Game(autostart=self.autostart))
56
57
58def main() -> None:
59    headless = "--test" in sys.argv
60    if headless:
61        from simvx.graphics import save_png
62
63        capture_at = [30, 60, 120]
64        app = App(width=WIDTH, height=HEIGHT, title="Casual Crusade (SimVX)", visible=False)
65        # Run with autostart so the headless capture exercises gameplay UI.
66        root = CasualCrusadeRoot()
67        root.autostart = True
68        frames = app.run_headless(root, frames=121, capture_frames=capture_at)
69        out_dir = _PORT_DIR / "screenshots"
70        out_dir.mkdir(exist_ok=True)
71        for idx, img in zip(capture_at, frames, strict=False):
72            out_path = out_dir / f"frame_{idx}.png"
73            save_png(img, out_path)
74            print(f"saved {out_path}")
75    else:
76        app = App(width=WIDTH, height=HEIGHT, title="Casual Crusade (SimVX)")
77        app.run(CasualCrusadeRoot())
78
79
80if __name__ == "__main__":
81    main()