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 theGENRESslice.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). DrivesPuzzle::is_clue_targetand 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 defaultderive_clue_atbody dispatches on this enum and wraps the result inClueValue::Number. Genres whose clues don’t fit any of these shapes (Tapa runs) overridederive_clue_atdirectly and ignoreclue_pattern.derive_clue_at(p, sol, coord) -> Option<ClueValue>— inverse ofcell_clue. The walker calls it once per clueable cell to derive the canonical clue set from a bare-solve completion. Default body forwards throughclue_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 shipsonly_evensetc).apply_cli_density(d, cfg)— map the CLI’s--densityflag into the rightGenConfigslot for this genre.dims_for(rows, cols)— sudoku rounds rows to a square factor pair and returnsBoxed{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 { … }. Bothfor_each_genre!and theGenreEntryobject slice deleted. - One puzzle type.
Puzzlecarries its genre as a field; the generic parameter is gone everywhere.AnyPuzzledeleted. - Editor-friendly. Threading a runtime
&'static Genrethrough 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).