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

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

78

harness.py

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

162

nodes/__init__.py

0

nodes/card.py

Card: interactive node that draws a card face procedurally.

180

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.

78

nodes/game.py

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

1137

nodes/tile.py

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

100

Source

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