Claustrowordia

Port of Antti Haavikko’s LD50 jam winner, with procedural audio and word scoring.

▶ Run in browser

Upstream: https://github.com/anttihaavikko/claustrowordia

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

Claustrowordia (SimVX port)

SimVX port of anttihaavikko/claustrowordia LD50 jam winner. Crossword-on-a-grid puzzle: place letter tiles on a 7×7 board and score every valid English word they spell along a row or column, read either way.

Licensing: clean re-implementation under MIT; the bundled dictionary is the public-domain ENABLE word list (3-7 letters, profanity-filtered), not the upstream’s. See ATTRIBUTION.md and LICENSE.

Run

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

Controls

  • Click (or tap) the title screen to start.

  • Click a letter in the hand picks up the tile (it follows the cursor).

  • Click on an empty grid cell drops the tile there. Words formed in rows / columns (forward and reversed) are scored.

  • Right-click while holding a tile cancels and returns it to the hand.

  • R, or a click, restarts after game-over.

  • Escape quits.

What this port demonstrates

  • Procedural textures. Every tile face, grid cell, and drop preview is an anti-aliased rounded rectangle built as a numpy RGBA array and handed straight to Sprite2D.texture: no image files ship with the port (nodes/textures.py).

  • Procedural audio. The place pop, the per-letter ascending notes and the word chime are baked from AudioSynth (oscillator + ADSR) into AudioClips at load time. The pickup chirp and the game-over drop are hand-written numpy instead, because one needs a falling pitch sweep and the other concatenates notes, and AudioSynth models neither (nodes/audio.py).

  • Coroutines for sequencing. Scoring is a generator: it walks the matched tiles, pulses each one, plays its note, and waits between them with yield from wait(...), so the animation reads as straight-line code (Game._check_words / _announce_word).

  • Tween state on a node. Tiles spring toward their target slot and layer a damped-sine punch, a decaying shake, and a scale pulse on top (nodes/tile.py).

  • Signals. Game emits score_changed and game_over_changed, so a HUD, an achievement tracker, or a test can follow the run without polling the node.

  • MSDF text. Letters and HUD are Text2D nodes ordered by z_index, so the same scene renders identically on desktop Vulkan and in the browser.

  • Input actions. The root declares input_actions, so the same bindings apply on desktop and on web, where touch arrives as MouseButton.LEFT.

Source files

File

Summary

Lines

main.py

Claustrowordia: Port of Antti Haavikko’s LD50 jam winner, with procedural audio and word scoring.

89

harness.py

Scripted-input harness: exercises the title screen, pickup, drop, and scoring.

180

nodes/__init__.py

Claustrowordia port: node modules.

1

nodes/audio.py

Procedural audio: built on the engine’s AudioSynth API.

137

nodes/dictionary.py

Word list + letter pool.

101

nodes/game.py

Game: the top-level scene wiring grid, hand, scoring, and audio.

502

nodes/grid.py

Grid: the 7×7 board.

117

nodes/hand.py

Hand: the bottom row of letter tiles waiting to be placed.

69

nodes/textures.py

Procedural tile textures.

129

nodes/tile.py

Tile node: a single letter tile.

179

Source

 1"""Claustrowordia: Port of Antti Haavikko's LD50 jam winner, with procedural audio and word scoring.
 2
 3# /// simvx
 4# tags = ["port", "tier-1"]
 5# upstream = "https://github.com/anttihaavikko/claustrowordia"
 6# web = { width = 1280, height = 800, responsive = true }
 7# ///
 8
 9Run:
10    uv run python examples/ports/claustrowordia/main.py
11    uv run python examples/ports/claustrowordia/main.py --test    # headless capture
12"""
13
14# /// script
15# requires-python = ">=3.14"
16# dependencies = ["numpy"]
17# ///
18
19from __future__ import annotations
20
21import sys
22from pathlib import Path
23
24# Allow running from any cwd
25_PORT_DIR = Path(__file__).parent
26if str(_PORT_DIR) not in sys.path:
27    sys.path.insert(0, str(_PORT_DIR))
28
29from nodes.game import Game  # noqa: E402
30
31from simvx.core import Node2D  # noqa: E402
32from simvx.core.input.enums import Key, MouseButton  # noqa: E402
33from simvx.core.math.types import Vec2  # noqa: E402
34from simvx.graphics import App  # noqa: E402
35
36WIDTH = 1280
37HEIGHT = 800
38
39
40class ClaustrowordiaRoot(Node2D):
41    """Root scene wrapper: declares the input actions and adds the Game node."""
42
43    # Declared on the root so the scene tree registers them on mount: the web
44    # runtime instantiates this class directly and never calls main().
45    # Touch surfaces as MouseButton.LEFT, so "primary" covers mouse and finger.
46    input_actions = {
47        "primary": [MouseButton.LEFT],
48        "secondary": [MouseButton.RIGHT],
49        "start": [Key.ENTER, Key.SPACE],
50        "restart": [Key.R],
51        "quit": [Key.ESCAPE],
52    }
53
54    def on_ready(self) -> None:
55        self.game = self.add_child(Game(viewport_size=Vec2(WIDTH, HEIGHT)))
56
57
58def main() -> None:
59    headless = "--test" in sys.argv
60    if headless:
61        from simvx.core.testing import InputSimulator
62        from simvx.graphics import save_png
63
64        # The game opens on its title screen; dismiss it so the captures show
65        # the board rather than three copies of the menu.
66        sim = InputSimulator()
67
68        def on_frame(idx: int, _t: float) -> None:
69            if idx == 5:
70                sim.press_mouse(MouseButton.LEFT, (WIDTH / 2, HEIGHT / 2))
71            elif idx == 10:
72                sim.release_mouse(MouseButton.LEFT)
73
74        capture_at = [30, 60, 120]
75        app = App(width=WIDTH, height=HEIGHT, title="Claustrowordia (SimVX)", visible=False)
76        frames = app.run_headless(ClaustrowordiaRoot(), frames=130, on_frame=on_frame, capture_frames=capture_at)
77        out_dir = _PORT_DIR / "screenshots"
78        out_dir.mkdir(exist_ok=True)
79        for idx, img in zip(capture_at, frames, strict=False):
80            out_path = out_dir / f"frame_{idx}.png"
81            save_png(img, out_path)
82            print(f"saved {out_path}")
83    else:
84        app = App(width=WIDTH, height=HEIGHT, title="Claustrowordia (SimVX)")
85        app.run(ClaustrowordiaRoot())
86
87
88if __name__ == "__main__":
89    main()