Move Validation & Scoring
The engine treats validation and scoring as two halves of the same pipeline. Validation proves that an incoming move is legal for the current geometry and rule set; scoring then walks the validated placements and totals every affected word using the active bonuses, stacking mode, and bingo rules.
Use this page together with the Animated Move Breakdown for a visual walkthrough, and the Move Generation chapter to see how the rules feed back into automated search.
Validation pipeline
- Collect placements – The caller provides board coordinates plus a tile payload (
kind_id, optionalmark, and for blanks theas_symbol). For graph boards, coordinates are node IDs; for grids they are(x, y)pairs. - Check occupancy & rack – No placement may overwrite an existing tile unless stacking is enabled and the stack height limit allows it. Rack multiset counts ensure you cannot place the same tile twice.
- Detect direction –
- Rectangular boards infer a direction (row/column) by inspecting whether all
xor allycoordinates are equal. - Graph boards follow their
DirTagmetadata. Placements must lie on a path where every consecutive edge shares the same direction family (e.g.E,NE,SEfor hex boards). - 3D boards treat layer offsets as an additional direction family (
Up,Down).
- Rectangular boards infer a direction (row/column) by inspecting whether all
- Contiguity – The placements are sorted along the inferred vector. The validator confirms that there are no gaps between newly placed tiles after accounting for existing board tiles. First move must anchor the origin node/center cell; subsequent moves must touch at least one existing tile (or stack).
- Dictionary / rule plugins – The
RulePluginchain runspre_validatehooks, then dictionary lookups (unless free-word mode is active) for the main word plus every crossing word. Custom plugins may apply extra checks (e.g., forbidding diagonals, stacking restrictions, or alternate scoring prototypes). - Produce
ValidatedMove– The engine returns a rich object holding sorted placements, affected words, applied bonuses, and any plugin annotations so the scoring phase can run without recomputing geometry.
The validator only requires that the provided graph has consistent direction metadata. You can generate
it through the helper mask builders used by the Playground (BoardGraph::from_mask) or load it from JSON.
Line detection works even for irregular shapes (holes, diamonds) because the direction test relies on
edge labels, not cartesian math.
If stacking is enabled (stack_on = true), validation ensures the target stack height does not exceed
max_height and optional forbid_same rules are respected. For 3D boards the validator flattens a (x, y, z)
placement into a virtual row so that scoring can keep using the existing 2D matrix representation while the
slice slider rewinds the correct layer.
Scoring pipeline
- Letter base values – Each placement contributes its letter score. For blanks, the mapped symbol is used for dictionary checks but the base value remains the blank’s configured score (often zero).
- Letter bonuses – Only newly placed tiles receive letter multipliers (double/triple letter etc.). Stacking adds the multiplier to the tile on top of the stack only.
- Word bonuses – Word multipliers apply once per word if any newly placed tile sits on the bonus. The default implementation follows classic board-word game conventions, but plugins can swap in alternate behavior.
- Cross words – Every perpendicular word is scored with the same rules (letter bonus for new tiles, word multipliers from cells touched by the placement). Total score is the sum of the main word and all cross words.
- Bingos / combo bonuses – If the move uses all tiles from the player’s rack, the bingo bonus defined in the ruleset is added. Additional plugin-specific bonuses (e.g., directional achievements) run here.
- Stack scoring modes – Rules expose
StackScoring::TopOnly(default) andStackScoring::SumStack. WithSumStack, every tile already in the stack is added to the subtotal before word bonuses. - Audit trail – The
ScoreBreakdownreturned by the engine includes per-word subtotals, matched bonuses, and plugin annotations so the UI can display a detailed breakdown or feed animations.
All calculations happen in integer space. If you add custom plugins that apply fractional modifiers, pair them with a deterministic rounding strategy to keep cross-binding parity.
Worked examples
- Classic 2D
- Stacking bingo
- Hex graph
Scenario: Playing READ through the center on a 15×15 classic board.
- Valid because the placements form a straight horizontal line, touch the center, and abut existing tiles.
- Letter scores:
R=1, E=1, A=1, D=2→ subtotal5. - Word multiplier: center cell →
×2→10total.
let mut engine = Engine::default();
let mut state = engine.new_game(GameConfig::classic())?;
let placements = placements!(
(7, 7) => "R",
(8, 7) => "E",
(9, 7) => "A",
(10, 7) => "D",
);
let validated = engine.rules().validate(&state, &placements)?;
let score = engine.rules().score(&state, &validated);
engine.rules().commit(&mut state, validated, &score)?;
assert_eq!(score.total, 10);
See the animated breakdown for a frame-by-frame highlight of this example.
Scenario: Upwords-style stack with SumStack scoring and a full-rack bonus.
- Placement overlays tiles at
(7, 7)up to height3; validation ensuresmax_heightis not exceeded and you are not placing the same symbol twice ifforbid_sameis true. - Letter subtotal includes the previous stack values because of
SumStack. - Word bonus is applied once after stacking subtotal is computed.
- Rack exhausted → default bingo bonus (+50) appended.
let mut rules = CrosswordRules::default();
rules.stack_on = true;
rules.stack_scoring = StackScoring::SumStack;
let mut state = rules.new_game(GameConfig::stacking_demo())?;
let placements = placements!(
(7, 7) => "N",
(7, 8) => "E",
(7, 9) => "T",
);
let validated = rules.validate(&state, &placements)?;
let score = rules.score(&state, &validated);
assert!(score.bingo_awarded);
rules.commit(&mut state, validated, &score)?;
The resulting ScoreBreakdown includes stack_contributions entries for UI overlays.
Scenario: Hex configuration generated from a diamond mask with hex adjacency enabled.
- Direction is derived from edge tags (
E,NE,SE). The move is only legal if placements follow one of these axes without switching direction mid-word. - Cross checks leverage the same adjacency metadata, so perpendicular words use the correct hex neighbors.
{
"board_layout": {
"type": "graph",
"preset": "hex-diamond",
"width": 9,
"height": 9
},
"ruleset_id": "crossword_hex",
"dictionary_id": "en_test_small"
}
The validator constructs a ValidatedMove whose axis field is DirTag::East, and the scoring phase lists
hex-shaped cross words in its breakdown.
Animations & UI hooks
- The Playground consumes the
ScoreBreakdownto drive callouts for letter totals, word multipliers, and cross-word subtotals. Toggle “Show legal moves” to see the validator/generator pipeline in action. - The Animated Move Breakdown page embeds a self-contained SVG that reads the same data the engine exposes—perfect for docs and release notes.
- Unity and Godot bindings use the same breakdown structure to flash tile bonuses and stack heights.
FAQ
- Can I bypass dictionary checks? Yes. Call
set_free_word_mode(true)(WASM/FFI) or setrules.free_word_mode = true(Rust) to skip dictionary validation while still enforcing geometry rules. - How are blanks scored? The blank symbol is attached during validation; the scoring phase uses the
blank’s intrinsic value (often
0) but still remembers the chosen letter for future cross checks. - What about simultaneous words (e.g., hooks)? Every affected coordinate is visited exactly once, so the breakdown lists one entry per formed word. Cross words with zero length (no new tile touched) are skipped.
- Can plugins override scoring? Yes. A plugin can mutate the
ScoreBreakdownduringscore_hooksto add custom bonuses or penalties, but should avoid removing core entries to keep bindings consistent.
With these rules in place, your bindings and UI can surface precise scoring summaries while maintaining legality across every supported geometry.