Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Genre and Puzzle

Penmark splits puzzle representation into two pieces:

  • Genre — a runtime fn-pointer table (&'static Genre). Hosts every per-genre rule the dispatch path needs: domain shape, render style, how to build the constraint list from a substrate + clue grid, parsers, generators, enumerators. One static literal per genre (AKARI, SLITHER, SUDOKU, TAKUZU); they all live in the GENRES slice.
  • Puzzle — a single non-generic struct holding the canonical state (the runtime genre handle, rows, cols, substrate, constraints, meta).

Per-genre logic is authored as an impl GenreTrait for <X>Genre block on a zero-sized marker. The genre_static! macro coerces those monomorphized methods and constants into the runtime Genre struct’s fn-pointer fields. Authors think in trait methods; callers dispatch through puzzle.genre.method(args).

The split keeps the value-carrying struct dimension-uniform across genres while letting per-genre code stay co-located in core/src/puzzles/<genre>/. Heterogeneous storage and iteration (“every genre”) are now trivial — just walk &[&Genre].

The Genre struct

pub struct Genre {
    // ── Identity ────────────────────────────────────────────────
    pub name: &'static str,            // "sudoku", "akari", …
    pub display_name: &'static str,    // "Sudoku" — shown in UIs
    pub baseline_version: u32,         // wire-format schema version

    // ── Frontend defaults ──────────────────────────────────────
    pub mark_render: MarkRenderStyle,  // Lamp / Glyph / EdgeStroke
    pub layers: &'static [Layer],      // cells / edges / corners

    // ── Slider ranges ──────────────────────────────────────────
    pub gen_budget_range: (f32, f32),
    pub gen_initial_budget_secs: f32,
    pub enum_budget_range: (f32, f32),
    pub enum_initial_budget_secs: f32,
    pub enum_empty_message: &'static str,

    // ── Generator default ──────────────────────────────────────
    pub default_gen_density: f32,

    // ── Puzzle-free fn pointers ─────────────────────────────────
    pub default_substrate: fn(Dimensions) -> Substrate,
    pub dims_for: fn(usize, usize) -> Dimensions,
    pub build_constraints:
        fn(&Substrate, &Grid<Option<ClueValue>>) -> Vec<Constraint>,
    pub cell_counters: fn() -> &'static [CellCounter],
    pub format_value: fn(u8) -> String,
    pub emits_variant_directives_in_janko: fn() -> bool,
    pub variant_aliases: fn() -> &'static [(&'static str, &'static str)],
    pub material_vocab: fn() -> MaterialVocab,
    pub clue_host_material: fn() -> Material,
    pub clue_pattern: fn() -> CluePattern,
    pub parse_external_line: fn(&str) -> Option<String>,

    // ── Puzzle-aware fn pointers ────────────────────────────────
    pub full_domain: fn(&Puzzle, Coord) -> Vec<Mark>,
    pub format_value_for: fn(&Puzzle, u8) -> String,
    pub cell_clue: fn(&Puzzle, usize, usize) -> Option<u8>,
    pub extra_constraints: fn(&Puzzle) -> Vec<Constraint>,
    pub empty_at: fn(Dimensions) -> Puzzle,
    pub extract_clues: fn(&Puzzle) -> Grid<Option<ClueValue>>,
    pub on_clue_added: fn(&mut Puzzle, usize, usize, &ClueValue),
    pub derive_clue_at:
        fn(&Puzzle, &Solution, Coord) -> Option<ClueValue>,
    pub clue_domain: fn(&Puzzle, Coord) -> Vec<u8>,
    pub sample: fn() -> Puzzle,
    pub overlays:
        fn(&Puzzle, &Marks, &Status) -> Vec<Overlay>,
    pub generate:
        fn(&GenConfig, &AtomicBool) -> Option<Puzzle>,
    pub scaffold_passes: fn() -> &'static [ScaffoldPass],
    pub clue_post_filters: fn() -> &'static [CluePostFilter],
    pub apply_cli_density: fn(f32, &mut GenConfig),
    pub enumerate: fn(
        &EnumConfig,
        &AtomicBool,
        &mut dyn FnMut(&Puzzle),
        &mut dyn FnMut(EnumStats),
    ) -> String,
    pub parse_text: fn(&str) -> Result<Puzzle, ParseError>,
    pub from_janko_text: fn(&str) -> Result<Puzzle, ParseError>,
    pub to_janko_text: fn(&Puzzle) -> String,
    pub from_puzzlink_url: fn(&str) -> Result<Puzzle, ParseError>,
    pub to_puzzlink_url: fn(&Puzzle) -> Result<String, ParseError>,
}

pub static AKARI:   &Genre = genre_static!(AkariGenre);
pub static SLITHER: &Genre = genre_static!(SlitherlinkGenre);
pub static SUDOKU:  &Genre = genre_static!(SudokuGenre);
pub static TAKUZU:  &Genre = genre_static!(TakuzuGenre);

pub static GENRES: &[&Genre] = &[AKARI, SLITHER, SUDOKU, TAKUZU];

pub fn by_name(name: &str) -> Option<&'static Genre> {
    GENRES.iter().copied().find(|g| g.name == name)
}

Call sites dispatch through the field:

let p = (puzzle.genre.parse_text)(s)?;
let dom = (puzzle.genre.full_domain)(&puzzle, coord);
if puzzle.genre.name == "sudoku" { /* … */ }

GenreTrait — the authoring interface

GenreTrait (in crate::puzzle::genre) is the trait per-genre markers implement. Its const items and method signatures mirror the Genre struct’s field types. The genre_static! macro fills in each &'static Genre literal by copying the monomorphized items out of the marker’s impl:

pub trait GenreTrait: 'static + Sized {
    const NAME: &'static str;
    const DISPLAY_NAME: &'static str;
    const LAYERS: &'static [Layer];
    const MARK_RENDER: MarkRenderStyle;
    const BASELINE_VERSION: u32 = 1;
    const DEFAULT_GEN_DENSITY: f32 = 0.22;
    /* …slider const ranges… */

    /// Runtime handle — must match the literal in `crate::genre`.
    /// Default methods that construct a `Puzzle` use it to set the
    /// `genre` field.
    const GENRE: &'static crate::genre::Genre;

    fn full_domain(p: &Puzzle, coord: Coord) -> Vec<Mark>;
    fn default_substrate(dims: Dimensions) -> Substrate;
    fn build_constraints(
        substrate: &Substrate,
        clues: &Grid<Option<ClueValue>>,
    ) -> Vec<Constraint>;
    fn clue_pattern() -> CluePattern;
    fn sample() -> Puzzle;
    /* …~25 more methods, most with sensible defaults… */
}

Required methods (no default): NAME, DISPLAY_NAME, LAYERS, MARK_RENDER, GENRE, full_domain, default_substrate, build_constraints, clue_pattern, sample. Everything else has a default — see core/src/puzzle.rs for the full trait.

Clue payload — ClueValue

Per-cell clues thread through build_constraints, extract_clues, add_clue, and derive_clue_at as a closed enum:

pub enum ClueValue {
    Number(u8),    // sudoku digit, akari/slither count, takuzu cell value
    Runs(Vec<u8>), // Tapa run-length sequences
    // future Yajilin arrows, Masyu pearls go here as new variants
}

The four shipped genres only ever emit ClueValue::Number(_); Puzzle::add_clue accepts impl Into<ClueValue> so existing u8 literal call sites continue to work via impl From<u8> for ClueValue. Multi-byte payloads (Tapa runs, Yajilin arrows when it lands) carry their own variants.

Genre::cell_clue: Option<u8> is unchanged — it’s the display shorthand renderers (eframe painter, CLI Janko writer) use to fold a clue down to a single glyph. Multi-byte payloads return their first byte. The typed ClueValue channel is for construction / canonical reconstruction / round-trip; rendering stays on the cheap u8 path.

Generation extension points (default-implemented hooks)

  • material_vocab() — non-Floor materials available on the cells layer. Drives the universal generator’s density sliders.
  • clue_host_material() — substrate material that holds clue cells (sudoku/takuzu: Floor; akari: Wall; slither: LabelOnly). Drives Puzzle::is_clue_target and the editor’s clue-cycle gate.
  • clue_domain(p, coord) — valid clue values; empty means no clue allowed at this coord.
  • clue_pattern() — declarative shortcut for the “given a solution, what’s the clue here?” question. Three canonical shapes: Pin (sudoku), Count{target, region} (akari/slither), ConnectedSize{target} (nurikabe). The default derive_clue_at body dispatches on this enum and wraps the result in ClueValue::Number. Genres whose clues don’t fit any of these shapes (Tapa runs) override derive_clue_at directly and ignore clue_pattern.
  • derive_clue_at(p, sol, coord) -> Option<ClueValue> — inverse of cell_clue. The walker calls it once per clueable cell to derive the canonical clue set from a bare-solve completion. Default body forwards through clue_pattern(); the escape valve for novel clue shapes.
  • scaffold_passes() — named structural pre-passes (akari ships six wall styles + hole layouts).
  • clue_post_filters() — value-filters applied after clue derivation (akari ships only_evens etc).
  • apply_cli_density(d, cfg) — map the CLI’s --density flag into the right GenConfig slot for this genre.
  • dims_for(rows, cols) — sudoku rounds rows to a square factor pair and returns Boxed{box_w, box_h}.

See the Generator chapter for the full configuration surface.

Implementations live in core/src/puzzles/<genre>/mod.rs. The marker struct is pub struct <Genre>Genre; — ZST, all logic on the trait impl block.

The Puzzle struct

pub struct Puzzle {
    pub genre: &'static crate::genre::Genre,
    rows: usize,
    cols: usize,
    substrate: Substrate,
    constraints: Vec<Constraint>,
    meta: PuzzleMeta,
}

Same six fields across every genre. Serialization delegates to WireDelta so the on-disk JSON elides any constraint / substrate cell that the genre’s build_constraints + default_substrate would produce on its own; custom constraints land in constraints_added. Deserialize is not implemented directly — rehydration needs the runtime &'static Genre handle, which serde can’t thread through. Callers go through Puzzle::from_tagged_json (envelope dispatch on the genre tag) or crate::puzzle_wire::from_wire_delta(genre, wire).

Puzzle exposes a uniform API that covers most engine and frontend needs:

impl Puzzle {
    // ── Construction ──
    pub fn from_state(
        genre: &'static Genre,
        rows: usize, cols: usize,
        substrate: Substrate,
        constraints: Vec<Constraint>,
    ) -> Self;

    // ── Reads ──
    pub fn rows(&self) -> usize;
    pub fn cols(&self) -> usize;
    pub fn substrate(&self) -> &Substrate;
    pub fn constraints(&self) -> &[Constraint];
    pub fn meta(&self) -> &PuzzleMeta;

    pub fn coords(&self) -> impl Iterator<Item = Coord> + '_;
    pub fn material(&self, r: usize, c: usize) -> Material;
    pub fn cell_clue(&self, r: usize, c: usize) -> Option<u8>;
    pub fn cell_clues(&self) -> impl Iterator<Item = ((usize, usize), u8)> + '_;
    pub fn is_play_target(&self, coord: Coord) -> bool;
    pub fn is_clue_target(&self, coord: Coord) -> bool;

    pub fn is_floor(&self, r: usize, c: usize) -> bool;
    pub fn is_wall(&self, r: usize, c: usize)  -> bool;
    pub fn is_hole(&self, r: usize, c: usize)  -> bool;
    pub fn walls(&self) -> impl Iterator<Item = (usize, usize)> + '_;
    pub fn holes(&self) -> impl Iterator<Item = (usize, usize)> + '_;
    pub fn box_size(&self) -> u8;  // sqrt(rows), sudoku-shaped

    // Geometry helpers (forward to substrate)
    pub fn edges_of_cell(&self, r: usize, c: usize) -> [Coord; 4];
    pub fn edges_of_vertex(&self, r: usize, c: usize) -> Vec<Coord>;
    pub fn vertices_of_cell(&self, r: usize, c: usize) -> [Coord; 4];
    pub fn vertices_of_edge(&self, edge: Coord) -> [Coord; 2];
    pub fn cells_of_edge(&self, edge: Coord) -> Vec<Coord>;
    pub fn cells_of_vertex(&self, r: usize, c: usize) -> Vec<Coord>;

    // ── Mutation (always rebuilds constraints via puzzle.genre) ──
    pub fn add_clue(&mut self, r: usize, c: usize, v: impl Into<ClueValue>);
    pub fn clear_clue(&mut self, r: usize, c: usize);
    pub fn set_material(&mut self, coord: Coord, m: Material);
    pub fn set_floor(&mut self, r: usize, c: usize);
    pub fn set_wall(&mut self, r: usize, c: usize);
    pub fn set_hole(&mut self, r: usize, c: usize);
    pub fn substrate_mut(&mut self) -> &mut Substrate;
    pub fn constraints_mut(&mut self) -> &mut Vec<Constraint>;

    // ── Genre-derived (dispatch through self.genre) ──
    pub fn full_domain(&self, coord: Coord) -> std::vec::IntoIter<Mark>;
    pub fn cell_counters(&self) -> &'static [CellCounter];
    pub fn format_value(&self, v: u8) -> String;

    // ── Wire format ──
    pub fn to_tagged_json(&self) -> String;
    pub fn from_tagged_json(s: &str) -> Result<Self, TagError>;
    pub fn to_tagged_json_with_marks(&self, marks: &Marks) -> String;
    pub fn from_tagged_json_with_marks(s: &str)
        -> Result<(Self, Option<Marks>), TagError>;
    pub fn to_janko(&self) -> String;
    pub fn content_hash(&self) -> String;
}

Editing model. Constraints are canonical state but never edited directly during normal use. Mutating the puzzle (add_clue, set_wall, …) updates the substrate and re-derives the constraint list from (self.genre.build_constraints)(substrate, clues). Builds run in single-digit microseconds across genres at human-interaction sizes, so we don’t bother with surgical updates.

constraints_mut() is exposed for use cases that need it (variant constraint authoring, debugging) but isn’t the normal path.

Per-genre aliases and constructors

Each genre exposes a friendly type alias (vestigial since the collapse — they’re just shorthand for Puzzle) and inherent constructors on its marker:

pub type Sudoku = Puzzle;

impl SudokuGenre {
    pub fn new(box_w: u8, box_h: u8,
               givens: HashMap<(usize, usize), u8>) -> Puzzle { /* … */ }
    pub fn empty(box_size: u8) -> Puzzle { /* … */ }
}

So SudokuGenre::new(3, 3, givens) returns a Puzzle whose .genre is set to crate::genre::SUDOKU. The constructor builds an empty puzzle, calls set_wall / set_hole / add_clue as needed, and the substrate-then-rebuild path keeps constraints in sync.

Akari constructors take (rows, cols, walls, clues, holes); Slitherlink takes (rows, cols, clues).

Why a struct of fn pointers instead of a trait?

The pre-collapse design had trait Genre and Puzzle<G: Genre>. Heterogeneous iteration over every genre meant a for_each_genre! macro expanded at 11+ call sites across the workspace; runtime genre lookup needed a parallel GenreEntry trait object slice; loading a puzzle without knowing its genre at compile time meant an AnyPuzzle enum.

Collapsing the trait into a value-typed struct paid off three ways:

  • One iteration mechanism. Every “for every genre” call site becomes for g in GENRES { … }. Both for_each_genre! and the GenreEntry object slice deleted.
  • One puzzle type. Puzzle carries its genre as a field; the generic parameter is gone everywhere. AnyPuzzle deleted.
  • Editor-friendly. Threading a runtime &'static Genre through gesture-codec, variant, and apply paths is mechanical; threading <G> through them is friction. The collapse unblocked the universal-editor work.

The cost is a small indirection (an fn-pointer call) on the per-genre dispatch path, which doesn’t show up in benchmarks (propagation work dominates), and a one-time macro that copies monomorphized items into the runtime literal. The full design lives in penmark/notes/genre-collapse.md.

Presentation declarations

Every frontend (eframe today, WASM/web/SVG/terminal later) needs to know two things to paint a puzzle: what’s at each position structurally (Floor? Wall? Hole?) and what player marks mean visually (lamp? digit? edge stroke?). The first answer reads from Substrate (per-position Material); the second is a small closed enum (MarkRenderStyle) declared per-genre as a const on the GenreTrait impl and surfaced on the runtime handle as genre.mark_render. A frontend implements one paint pass per Material variant and one per MarkRenderStyle variant; new genres land without touching the renderer.

The substrate side deliberately doesn’t carry given information (clue values, pinned digits) — that comes from walking the constraint list at paint time, not from the per-coord material. Cell clues are read uniformly via Puzzle::cell_clue(r, c) which forwards to (self.genre.cell_clue)(self, r, c).

pub enum MarkRenderStyle {
    /// Cell-substrate, binary marks. `Binary(true)` is a token
    /// (lamp / bulb / star), `Binary(false)` is a small X.
    /// Akari, Star Battle.
    Lamp,
    /// Cell-substrate, numeric marks rendered via
    /// `genre.format_value`. Sudoku, KenKen.
    Glyph,
    /// Cell-substrate, binary marks, tri-state visual: wall fill,
    /// explicit empty fill, and a third "unknown" tone for cells
    /// with no committed mark. Nurikabe, LITS, Hitori, Tapa,
    /// Yin-Yang.
    Fill,
    /// Cell-substrate, numeric marks where `Numeric(tag)` is a
    /// region tag; the renderer derives a partition. Fillomino,
    /// Suguru.
    Partition,
    /// Edge-substrate, binary marks. `Binary(true)` is a thick
    /// line, `false` is an X cross. Slitherlink, Masyu, Yajilin
    /// loop edges.
    EdgeStroke,
    /// Edge-substrate, numeric marks: `Numeric(1|2)` is one or
    /// two parallel lines. Hashi.
    BridgeCount,
    /// Corner-substrate, binary marks. Shingoki and other
    /// vertex-mark genres.
    CornerCircle,
    /// Standard pass paints nothing; the genre's overlay handles
    /// all mark visualisation.
    Custom,
}

MarkRenderStyle is a semantic commitment (“Akari marks ARE lamps”) — what a lamp looks like (yellow disc with rays in eframe, a <div class="lamp"> in web, a * in a terminal) is the frontend’s call. The genre declares what; frontends decide how.

The four small read accessors a frontend uses, in paint order:

  • puzzle.material(r, c) -> Material — paint backgrounds.
  • puzzle.cell_clue(r, c) -> Option<u8> — paint clue text.
  • puzzle.is_play_target(coord) -> bool — gate input dispatch.
  • puzzle.format_value(v) -> String — render a small int as the genre’s glyph. Forwards to (self.genre.format_value)(v).