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

# the export runs in a copy made by `simvx examples copy ports_claustrowordia`
simvx examples run ports_claustrowordia            # interactive
simvx examples run ports_claustrowordia --test     # headless capture (3 frames)
simvx export web main.py \
    -o 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_context, so the same bindings apply on desktop and on web, where touch arrives as Mouse.LEFT.

Source files

File

Summary

Lines

main.py

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

86

harness.py

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

181

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