Validation

This page answers two questions the app gets asked a lot: why validate level data we wrote ourselves, and why validate storage we wrote ourselves. The short answer to both: because "ours" is not the same as "safe". The long answer is the app's whole philosophy.

The philosophy: parse at the boundary

Every piece of data crosses a boundary between "unknown" and "trusted". A boundary is any place where data enters the program from the outside world: a JSON file on disk, a localStorage read, a URL segment, a number typed into an input. At every boundary the app runs safeParse against a valibot schema, exactly once, and then trusts the parsed output everywhere else. The parsed output type is the single source of truth; nothing is ever as-cast after parsing.

The practical result: the inside of the app can assume the data is correct. The simulator can index the map without checking every cell, the renderer can assume every block kind is one of five, and no component needs defensive plumbing for shapes that "should not happen".

Why validate level files we wrote ourselves

The level files are written by a human, and humans make mistakes that a game's logic turns into crashes or nonsense:

The schema turns all of these from "crashes somewhere later" into "the registry throws at startup with the file name" or "the test fails with the exact message". Validation is also documentation: LevelSchema is the executable spec of the level format, the thing the Level schema page describes in prose.

Why validate localStorage we wrote ourselves

localStorage is not ours. The player can open devtools and edit it, an older version of the app may have written a different shape, or an extension may have mangled it. The schemas in schemas/persistence.schema.ts and the merge rules in stored-sequence.ts ensure a hand-edited program either rebuilds correctly or is discarded for the level's defaults, as described on Persistence. The same applies to the skin and the locale.

Why the user-built sequence is re-validated

The program tree is mutated by drag-and-drop, a stateful pointer system with many moving parts. parseSequence re-reads the sequence at every mutation boundary: if a drag bug ever produced a malformed node, the app would refuse it instead of corrupting the tree silently. The schema is the invariant the drag code must maintain.

The boundary table

SourceSchemaParsed atResult
Level*.jsonLevelSchemaparseLevel (registry load + level mount + tests)LevelData | null
sequence re-readsSequenceSchemaparseSequenceSequenceItem[] | null
for count inputCountSchemaparseCountnumber | null
optimalSolutionOptimalSolutionSchemaparseOptimalSolutionOptimalSolutionItem[] | null
stored skinSkinSchemaparseSkinSkin | null
stored level programStoredSequenceSchemaloadLevelSequenceStoredSequenceItem[] | null
stored last levelLastLevelSlugSchemaloadLastLevelSlugstring | null
stored completionsCompletedLevelSlugsSchemaloadCompletedLevelSlugsstring[]
PlayerState, internal UI statenonederivednot validated

Internal UI state (PlayerState, the animation timers, the drag session) is derived state: it is produced by trusted code from validated inputs, so it does not get a schema. The distinction is deliberate: validate what crosses a boundary, derive everything else.

Cross-field rules

Some rules span several fields, so they live in check pipes inside the schema, with human-readable messages:

The tests surface the messages (issues[0].message), so a failing level file says what is wrong, not just that something is.

The one deliberate cast

Recursive schemas need a self-reference, and valibot's lazy needs a typed anchor. The schema files use one as unknown as GenericSchema<...> per recursive schema, exactly where the recursion is declared, and nowhere else. It is the single sanctioned cast in the codebase; casts at call sites are a lint target and a code-review smell.

Next: Internationalization.