DEV Community

Zap Causal
Zap Causal

Posted on

How I Built a Constraint-Based Logic Puzzle in Next.js

I recently built Meowdoku, a browser-based cat logic puzzle.

The rules are simple:

  • Place exactly one cat in every row.
  • Place exactly one cat in every column.
  • Place exactly one cat in every colored region.
  • Cats cannot touch, including diagonally.

The final game looks relatively small, but implementing it raised several interesting engineering questions:

  • How should an irregular puzzle board be represented?
  • Which values should be stored, and which should be derived?
  • How can automatic X marks work without overwriting the player's notes?
  • How should a hint system provide one useful step instead of revealing the answer?
  • How can daily puzzles remain consistent across time zones?
  • How should progress be stored without requiring accounts or a backend?

This article explains the architecture I used and the lessons I learned while turning a small logic puzzle into a browser-first product.

1. Treating the Puzzle as a Constraint System

The UI is a grid, but the game itself is better understood as a collection of constraints.

For a board of size n, a valid solution must satisfy four conditions:

  1. Every row contains one cat.
  2. Every column contains one cat.
  3. Every region contains one cat.
  4. No two cats occupy touching cells.

That means the visual appearance of a cell is not the most important part of the data model. What matters is:

  • Its row
  • Its column
  • Its region
  • Its current player state

I represented coordinates with a small TypeScript type:

export type Position = {
  row: number;
  col: number;
};
Enter fullscreen mode Exit fullscreen mode

Each puzzle definition contains a board size and a region map:

export type PuzzleDefinition = {
  id: string;
  size: number;
  regions: number[][];
  starterCats?: Position[];
  solution?: Position[];
};
Enter fullscreen mode Exit fullscreen mode

A small example might look like this:

export const examplePuzzle: PuzzleDefinition = {
  id: "cozy-path-001",
  size: 6,
  regions: [
    [0, 0, 0, 1, 1, 1],
    [0, 2, 2, 2, 1, 1],
    [0, 2, 3, 3, 3, 1],
    [4, 2, 3, 5, 5, 5],
    [4, 4, 3, 3, 5, 5],
    [4, 4, 4, 3, 5, 5],
  ],
  starterCats: [{ row: 0, col: 1 }],
};
Enter fullscreen mode Exit fullscreen mode

Every number identifies a colored region. Cells with the same number belong to the same region, regardless of the region's shape.

This representation has several advantages:

  • Rendering regions is straightforward.
  • Region membership checks are constant-time operations.
  • The same rule engine works for 6×6, 8×8, and larger boards.
  • Puzzle content remains separate from the game component.
  • New boards can be added as data rather than new UI code.

2. Keeping Puzzle Content Separate from the Engine

One of the most useful architectural decisions was separating three concerns:

Puzzle definitions
        ↓
Rule and deduction engine
        ↓
React interface
Enter fullscreen mode Exit fullscreen mode

The puzzle definition should not know how a cat is rendered.

The React component should not need to understand how a forced move is calculated.

The rule engine should not care whether the player used a mouse, touchscreen, or keyboard.

A simplified directory structure could look like this:

src/
├── components/
│   └── meowdoku/
│       ├── MeowdokuBoard.tsx
│       ├── MeowdokuCell.tsx
│       └── MeowdokuControls.tsx
├── games/
│   └── meowdoku/
│       ├── puzzles.ts
│       ├── rules.ts
│       ├── candidates.ts
│       ├── hints.ts
│       └── persistence.ts
└── types/
    └── meowdoku.ts
Enter fullscreen mode Exit fullscreen mode

This made it possible to test the puzzle logic without rendering the entire application.

It also prevented the main game component from growing into a file containing board data, event handling, validation, hints, timers, storage, and presentation logic all at once.

3. Storing Player Actions, Deriving Everything Else

A cell can visually appear in several states:

  • Empty
  • Contains a cat
  • Contains a manual X
  • Automatically blocked
  • Incorrect
  • Highlighted by a hint
  • Preset and locked

It is tempting to store all of these states in one large matrix. That quickly becomes difficult to synchronize.

Instead, I store only the player's meaningful actions:

export type GameState = {
  cats: Set<string>;
  manualMarks: Set<string>;
  hintCell: string | null;
  mistakes: number;
  startedAt: number | null;
  completedAt: number | null;
};
Enter fullscreen mode Exit fullscreen mode

A position can be serialized into a stable key:

export function positionKey(row: number, col: number): string {
  return `${row}:${col}`;
}
Enter fullscreen mode Exit fullscreen mode

The automatically blocked cells are derived from the placed cats.

The completed state is derived from the current cats.

Conflicts are derived from the current cats.

The number of cats in a region is derived from the current cats.

This follows a useful React rule:

Store the minimum amount of state required to reproduce the interface.

Derived data should usually remain derived rather than becoming another mutable source of truth.

4. Building the Rule Engine

The central rule engine receives a set of cat positions and returns conflicts.

export type RuleViolation =
  | "duplicate-row"
  | "duplicate-column"
  | "duplicate-region"
  | "touching-cat";
Enter fullscreen mode Exit fullscreen mode

First, I convert stored keys back into coordinates:

export function parsePositionKey(key: string): Position {
  const [row, col] = key.split(":").map(Number);

  return { row, col };
}
Enter fullscreen mode Exit fullscreen mode

Then each placed cat can be checked against the others.

export function catsAreTouching(
  first: Position,
  second: Position,
): boolean {
  const rowDistance = Math.abs(first.row - second.row);
  const colDistance = Math.abs(first.col - second.col);

  return (
    rowDistance <= 1 &&
    colDistance <= 1 &&
    !(rowDistance === 0 && colDistance === 0)
  );
}
Enter fullscreen mode Exit fullscreen mode

The row, column, and region checks can be implemented by counting occupied values:

export function findConflictingCats(
  puzzle: PuzzleDefinition,
  catKeys: Set<string>,
): Set<string> {
  const positions = [...catKeys].map(parsePositionKey);
  const conflicts = new Set<string>();

  for (let index = 0; index < positions.length; index += 1) {
    const current = positions[index];

    for (
      let comparisonIndex = index + 1;
      comparisonIndex < positions.length;
      comparisonIndex += 1
    ) {
      const other = positions[comparisonIndex];

      const sameRow = current.row === other.row;
      const sameColumn = current.col === other.col;

      const currentRegion =
        puzzle.regions[current.row]?.[current.col];

      const otherRegion =
        puzzle.regions[other.row]?.[other.col];

      const sameRegion = currentRegion === otherRegion;
      const touching = catsAreTouching(current, other);

      if (sameRow || sameColumn || sameRegion || touching) {
        conflicts.add(positionKey(current.row, current.col));
        conflicts.add(positionKey(other.row, other.col));
      }
    }
  }

  return conflicts;
}
Enter fullscreen mode Exit fullscreen mode

For small boards, this pairwise approach is easy to understand and fast enough.

For larger boards, the row, column, and region checks could be optimized with maps:

const rowCounts = new Map<number, number>();
const columnCounts = new Map<number, number>();
const regionCounts = new Map<number, number>();
Enter fullscreen mode Exit fullscreen mode

However, with puzzle sizes such as 6×6, 8×8, or 10×10, clarity is usually more valuable than premature optimization.

5. Deriving Automatic X Marks

The Auto X feature marks cells that are immediately impossible because of an existing cat.

When a cat is placed, the following cells become blocked:

  • Every other cell in its row
  • Every other cell in its column
  • Every other cell in its region
  • Every adjacent cell, including diagonals

I generate this blocked set from the current cat positions:

export function getAutomaticallyBlockedCells(
  puzzle: PuzzleDefinition,
  catKeys: Set<string>,
): Set<string> {
  const blocked = new Set<string>();

  for (const catKey of catKeys) {
    const cat = parsePositionKey(catKey);
    const catRegion = puzzle.regions[cat.row][cat.col];

    for (let row = 0; row < puzzle.size; row += 1) {
      for (let col = 0; col < puzzle.size; col += 1) {
        const key = positionKey(row, col);

        if (key === catKey) {
          continue;
        }

        const sameRow = row === cat.row;
        const sameColumn = col === cat.col;
        const sameRegion =
          puzzle.regions[row][col] === catRegion;

        const adjacent =
          Math.abs(row - cat.row) <= 1 &&
          Math.abs(col - cat.col) <= 1;

        if (sameRow || sameColumn || sameRegion || adjacent) {
          blocked.add(key);
        }
      }
    }
  }

  return blocked;
}
Enter fullscreen mode Exit fullscreen mode

A key detail is that automatic marks and manual marks are separate sets.

const visibleMarks = new Set([
  ...manualMarks,
  ...automaticallyBlockedCells,
]);
Enter fullscreen mode Exit fullscreen mode

This avoids a common interaction problem:

  1. The player adds a manual X.
  2. Auto X later marks the same cell.
  3. The player removes a cat.
  4. The automatic mark disappears.
  5. The player's original manual note is accidentally lost.

By keeping the two sources separate, automatic state can change without destroying player input.

6. Using a Reducer for Game Actions

The game includes several related actions:

  • Place or remove a cat
  • Add or remove a manual X
  • Undo
  • Restart
  • Request a hint
  • Change puzzle
  • Complete a puzzle

A collection of unrelated useState calls becomes difficult to manage once undo and persistence are added.

I used reducer-style actions instead:

type GameAction =
  | {
      type: "TOGGLE_CAT";
      key: string;
    }
  | {
      type: "TOGGLE_MANUAL_MARK";
      key: string;
    }
  | {
      type: "SHOW_HINT";
      key: string;
    }
  | {
      type: "CLEAR_HINT";
    }
  | {
      type: "RESTART";
      initialState: GameState;
    };
Enter fullscreen mode Exit fullscreen mode

A simplified reducer:

export function gameReducer(
  state: GameState,
  action: GameAction,
): GameState {
  switch (action.type) {
    case "TOGGLE_CAT": {
      const cats = new Set(state.cats);

      if (cats.has(action.key)) {
        cats.delete(action.key);
      } else {
        cats.add(action.key);
      }

      const manualMarks = new Set(state.manualMarks);
      manualMarks.delete(action.key);

      return {
        ...state,
        cats,
        manualMarks,
        hintCell: null,
        startedAt: state.startedAt ?? Date.now(),
      };
    }

    case "TOGGLE_MANUAL_MARK": {
      if (state.cats.has(action.key)) {
        return state;
      }

      const manualMarks = new Set(state.manualMarks);

      if (manualMarks.has(action.key)) {
        manualMarks.delete(action.key);
      } else {
        manualMarks.add(action.key);
      }

      return {
        ...state,
        manualMarks,
        hintCell: null,
        startedAt: state.startedAt ?? Date.now(),
      };
    }

    case "SHOW_HINT":
      return {
        ...state,
        hintCell: action.key,
      };

    case "CLEAR_HINT":
      return {
        ...state,
        hintCell: null,
      };

    case "RESTART":
      return action.initialState;

    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

In production, the reducer can be extended to keep a history stack for undo.

export type GameSnapshot = {
  cats: string[];
  manualMarks: string[];
};
Enter fullscreen mode Exit fullscreen mode

Before each meaningful move, the current snapshot is pushed into history. Undo restores the most recent snapshot rather than trying to reverse every action manually.

7. Calculating Candidate Cells

A useful hint system needs to understand which cells are still legal.

For every empty cell, I ask:

Would placing a cat here immediately violate a rule?

export function canPlaceCat(
  puzzle: PuzzleDefinition,
  catKeys: Set<string>,
  position: Position,
): boolean {
  const targetKey = positionKey(position.row, position.col);

  if (catKeys.has(targetKey)) {
    return false;
  }

  const targetRegion =
    puzzle.regions[position.row][position.col];

  for (const catKey of catKeys) {
    const existing = parsePositionKey(catKey);

    if (existing.row === position.row) {
      return false;
    }

    if (existing.col === position.col) {
      return false;
    }

    if (
      puzzle.regions[existing.row][existing.col] ===
      targetRegion
    ) {
      return false;
    }

    if (catsAreTouching(existing, position)) {
      return false;
    }
  }

  return true;
}
Enter fullscreen mode Exit fullscreen mode

From there, a candidate map can be produced:

export function getCandidateCells(
  puzzle: PuzzleDefinition,
  catKeys: Set<string>,
): Set<string> {
  const candidates = new Set<string>();

  for (let row = 0; row < puzzle.size; row += 1) {
    for (let col = 0; col < puzzle.size; col += 1) {
      const position = { row, col };

      if (canPlaceCat(puzzle, catKeys, position)) {
        candidates.add(positionKey(row, col));
      }
    }
  }

  return candidates;
}
Enter fullscreen mode Exit fullscreen mode

This candidate set becomes the foundation for both hints and completion checks.

8. Giving One Useful Hint Instead of Revealing the Board

A hint system can be implemented in two very different ways.

The simplest version compares the current board with a stored solution and highlights the next missing cat.

That works, but it does not explain why the move is valid.

A more interesting approach is deduction-based.

For example, if one row has exactly one remaining candidate, that candidate must contain a cat.

export function findForcedCellInRows(
  puzzle: PuzzleDefinition,
  candidateKeys: Set<string>,
  catKeys: Set<string>,
): string | null {
  for (let row = 0; row < puzzle.size; row += 1) {
    const rowAlreadySolved = [...catKeys].some((key) => {
      return parsePositionKey(key).row === row;
    });

    if (rowAlreadySolved) {
      continue;
    }

    const candidates = [...candidateKeys].filter((key) => {
      return parsePositionKey(key).row === row;
    });

    if (candidates.length === 1) {
      return candidates[0];
    }
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

The same technique applies to columns and regions.

export function getHint(
  puzzle: PuzzleDefinition,
  catKeys: Set<string>,
): string | null {
  const candidates = getCandidateCells(puzzle, catKeys);

  return (
    findForcedCellInRows(puzzle, candidates, catKeys) ??
    findForcedCellInColumns(puzzle, candidates, catKeys) ??
    findForcedCellInRegions(puzzle, candidates, catKeys) ??
    null
  );
}
Enter fullscreen mode Exit fullscreen mode

My preferred hint hierarchy is:

  1. A forced cat in a region
  2. A forced cat in a row
  3. A forced cat in a column
  4. A useful elimination
  5. A solution-assisted fallback

This lets the game provide a helpful next step without turning the Hint button into a complete answer button.

It also creates an opportunity to show a reason:

This region has only one legal cell remaining.
Enter fullscreen mode Exit fullscreen mode

That makes the hint educational rather than purely corrective.

9. Checking Puzzle Completion

A board is complete when:

  • The number of cats equals the board size.
  • Every row has exactly one cat.
  • Every column has exactly one cat.
  • Every region has exactly one cat.
  • No cats touch.
export function isPuzzleComplete(
  puzzle: PuzzleDefinition,
  catKeys: Set<string>,
): boolean {
  if (catKeys.size !== puzzle.size) {
    return false;
  }

  const positions = [...catKeys].map(parsePositionKey);
  const rows = new Set<number>();
  const columns = new Set<number>();
  const regions = new Set<number>();

  for (const position of positions) {
    rows.add(position.row);
    columns.add(position.col);
    regions.add(
      puzzle.regions[position.row][position.col],
    );
  }

  if (
    rows.size !== puzzle.size ||
    columns.size !== puzzle.size ||
    regions.size !== puzzle.size
  ) {
    return false;
  }

  return findConflictingCats(puzzle, catKeys).size === 0;
}
Enter fullscreen mode Exit fullscreen mode

This does not require direct comparison with a stored solution.

That matters because a custom puzzle could theoretically have more than one valid solution. Validation should confirm that the player's board satisfies the rules, not only that it matches one array exactly.

Puzzle generation and uniqueness checking are separate concerns.

10. Handling Click, Long Press, and Right Click

Desktop and mobile players expect different interactions.

For the game:

  • Click or tap places a cat.
  • Clicking a cat again removes it.
  • Right click adds a manual X on desktop.
  • Long press adds a manual X on touch devices.

Right click is straightforward:

function handleContextMenu(
  event: React.MouseEvent<HTMLButtonElement>,
  key: string,
) {
  event.preventDefault();

  dispatch({
    type: "TOGGLE_MANUAL_MARK",
    key,
  });
}
Enter fullscreen mode Exit fullscreen mode

Long press requires a timer:

const longPressTimer = useRef<ReturnType<
  typeof setTimeout
> | null>(null);

function startLongPress(key: string) {
  longPressTimer.current = setTimeout(() => {
    dispatch({
      type: "TOGGLE_MANUAL_MARK",
      key,
    });
  }, 450);
}

function cancelLongPress() {
  if (longPressTimer.current) {
    clearTimeout(longPressTimer.current);
    longPressTimer.current = null;
  }
}
Enter fullscreen mode Exit fullscreen mode

It is also necessary to prevent a completed long press from triggering the normal tap action immediately afterward.

A ref can track whether the long-press action fired:

const didLongPress = useRef(false);
Enter fullscreen mode Exit fullscreen mode

Without this guard, a player might add an X and then unintentionally replace it with a cat.

11. Rendering the Board with CSS Grid

The board does not require canvas.

CSS Grid provides:

  • Responsive sizing
  • Native buttons
  • Keyboard focus
  • Semantic accessibility
  • Easier borders and region styling
<div
  className="meowdoku-grid"
  style={{
    gridTemplateColumns: `repeat(${puzzle.size}, 1fr)`,
  }}
>
  {cells.map((cell) => (
    <button
      key={cell.key}
      type="button"
      className={getCellClassName(cell)}
      aria-label={getCellLabel(cell)}
      onClick={() => handleCellClick(cell.key)}
      onContextMenu={(event) =>
        handleContextMenu(event, cell.key)
      }
    >
      {cell.hasCat ? "🐱" : cell.hasMark ? "×" : ""}
    </button>
  ))}
</div>
Enter fullscreen mode Exit fullscreen mode

A simplified style:

.meowdoku-grid {
  display: grid;
  width: min(100%, 38rem);
  aspect-ratio: 1;
  margin-inline: auto;
}

.meowdoku-grid button {
  display: grid;
  place-items: center;
  min-width: 0;
  border: 1px solid rgb(148 163 184);
  font: inherit;
  cursor: pointer;
  touch-action: manipulation;
}
Enter fullscreen mode Exit fullscreen mode

Using actual buttons instead of clickable div elements improves keyboard behavior and assistive-technology support with little extra work.

12. Representing Region Borders

A region map also makes border calculation possible.

For every cell, I compare its region with the cells above, below, left, and right.

export function getRegionEdges(
  puzzle: PuzzleDefinition,
  row: number,
  col: number,
) {
  const region = puzzle.regions[row][col];

  return {
    top:
      row === 0 ||
      puzzle.regions[row - 1][col] !== region,

    right:
      col === puzzle.size - 1 ||
      puzzle.regions[row][col + 1] !== region,

    bottom:
      row === puzzle.size - 1 ||
      puzzle.regions[row + 1][col] !== region,

    left:
      col === 0 ||
      puzzle.regions[row][col - 1] !== region,
  };
}
Enter fullscreen mode Exit fullscreen mode

The returned values can be translated into classes:

const edges = getRegionEdges(puzzle, row, col);

const className = [
  edges.top && "region-edge-top",
  edges.right && "region-edge-right",
  edges.bottom && "region-edge-bottom",
  edges.left && "region-edge-left",
]
  .filter(Boolean)
  .join(" ");
Enter fullscreen mode Exit fullscreen mode

This is much easier to maintain than manually attaching border metadata to every cell.

13. Persisting Progress Without User Accounts

The game runs without login, so browser storage is enough for:

  • Current puzzle progress
  • Best times
  • Completed board IDs
  • Settings such as Auto X
  • Daily streak information

I use a versioned storage object:

export type StoredMeowdokuData = {
  version: 1;
  settings: {
    autoMarks: boolean;
  };
  progress: Record<
    string,
    {
      cats: string[];
      manualMarks: string[];
      elapsedSeconds: number;
    }
  >;
  bestTimes: Record<string, number>;
  completedPuzzleIds: string[];
};
Enter fullscreen mode Exit fullscreen mode

A version number is important because storage structures change.

const STORAGE_KEY = "meowdoku-state-v1";
Enter fullscreen mode Exit fullscreen mode

Loading should always be defensive:

export function loadStoredData():
  | StoredMeowdokuData
  | null {
  if (typeof window === "undefined") {
    return null;
  }

  try {
    const rawValue = window.localStorage.getItem(
      STORAGE_KEY,
    );

    if (!rawValue) {
      return null;
    }

    const parsed = JSON.parse(
      rawValue,
    ) as StoredMeowdokuData;

    if (parsed.version !== 1) {
      return null;
    }

    return parsed;
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

A malformed local storage value should never prevent the game from loading.

14. Avoiding Hydration Problems in Next.js

Browser storage is unavailable during server rendering.

Reading it directly during initial rendering can cause one of two problems:

  • window is not defined
  • The server output differs from the first client render

The safe pattern is to start with deterministic default state and load browser progress after mounting.

const [hasLoadedStorage, setHasLoadedStorage] =
  useState(false);

useEffect(() => {
  const storedData = loadStoredData();

  if (storedData) {
    restoreProgress(storedData);
  }

  setHasLoadedStorage(true);
}, []);
Enter fullscreen mode Exit fullscreen mode

Saving should wait until restoration has finished:

useEffect(() => {
  if (!hasLoadedStorage) {
    return;
  }

  saveStoredData(serializeGameState(state));
}, [hasLoadedStorage, state]);
Enter fullscreen mode Exit fullscreen mode

Otherwise, the default state may overwrite the player's existing progress before it has been loaded.

15. Making Daily Puzzles Stable Across Time Zones

Daily puzzles introduce an easy-to-miss date problem.

This code uses the visitor's local time:

new Date().toLocaleDateString();
Enter fullscreen mode Exit fullscreen mode

Two players can therefore receive different daily boards at the same moment.

For a globally consistent puzzle, I generate a UTC key:

export function getUtcDateKey(
  date = new Date(),
): string {
  const year = date.getUTCFullYear();
  const month = String(
    date.getUTCMonth() + 1,
  ).padStart(2, "0");
  const day = String(
    date.getUTCDate(),
  ).padStart(2, "0");

  return `${year}-${month}-${day}`;
}
Enter fullscreen mode Exit fullscreen mode

The puzzle can then be selected from a date map:

export const dailyPuzzles: Record<
  string,
  PuzzleDefinition
> = {
  "2026-07-21": puzzleA,
  "2026-07-22": puzzleB,
  "2026-07-23": puzzleC,
};
Enter fullscreen mode Exit fullscreen mode

Another option is deterministic rotation:

export function selectDailyPuzzle(
  puzzles: PuzzleDefinition[],
  dateKey: string,
): PuzzleDefinition {
  let hash = 0;

  for (const character of dateKey) {
    hash =
      (hash * 31 + character.charCodeAt(0)) >>> 0;
  }

  return puzzles[hash % puzzles.length];
}
Enter fullscreen mode Exit fullscreen mode

A fixed date map gives editorial control. Deterministic rotation requires less maintenance but can repeat boards and makes archives less explicit.

For a daily puzzle with answer pages and archives, I prefer explicit date mapping.

16. Static Deployment Changes the Security Model

A static browser game has no meaningful secret client-side data.

Even minified solution arrays can be inspected by a determined player.

For this type of puzzle, that is usually acceptable. The goal is not to protect competitive financial data. The goal is to provide a smooth solving experience.

Still, it is useful to distinguish two concerns:

  • Validation: Does the player's board satisfy the rules?
  • Puzzle secrecy: Can the player inspect the shipped solution?

The first belongs in the rule engine.

The second cannot be completely guaranteed in a fully client-side application.

If secrecy were essential, verification and hints would need to move to a server endpoint. That would add latency, infrastructure, and operational complexity that may not be justified for a casual logic game.

17. Accessibility Is Easier When Added Early

Puzzle interfaces often communicate too much information through color.

That creates problems for:

  • Color-blind players
  • Screen-reader users
  • Keyboard users
  • Players using high-contrast settings

Some improvements I added or considered include:

  • Using region borders in addition to background colors
  • Making each cell a button
  • Adding row and column information to labels
  • Distinguishing cats and X marks with symbols
  • Preserving visible focus outlines
  • Avoiding motion-dependent feedback
  • Making touch targets large enough for mobile use

An accessible label can describe the cell state:

export function getCellLabel({
  row,
  col,
  hasCat,
  hasMark,
  region,
}: {
  row: number;
  col: number;
  hasCat: boolean;
  hasMark: boolean;
  region: number;
}): string {
  const position =
    `Row ${row + 1}, column ${col + 1}, ` +
    `region ${region + 1}`;

  if (hasCat) {
    return `${position}, cat placed`;
  }

  if (hasMark) {
    return `${position}, marked as unavailable`;
  }

  return `${position}, empty`;
}
Enter fullscreen mode Exit fullscreen mode

Accessibility is much harder to retrofit after the entire interaction model has been built around pointer events and visual-only feedback.

18. Testing the Logic Without Testing the UI

Most critical bugs in this game are rule-engine bugs, not rendering bugs.

That makes pure functions particularly valuable.

Example tests:

import { describe, expect, it } from "vitest";

describe("catsAreTouching", () => {
  it("detects diagonal contact", () => {
    expect(
      catsAreTouching(
        { row: 2, col: 2 },
        { row: 3, col: 3 },
      ),
    ).toBe(true);
  });

  it("allows separated cats", () => {
    expect(
      catsAreTouching(
        { row: 2, col: 2 },
        { row: 4, col: 4 },
      ),
    ).toBe(false);
  });
});
Enter fullscreen mode Exit fullscreen mode

Completion tests should cover:

  • Too few cats
  • Duplicate rows
  • Duplicate columns
  • Duplicate regions
  • Horizontal contact
  • Vertical contact
  • Diagonal contact
  • A valid completed board

Candidate tests should cover:

  • Cells blocked by a row
  • Cells blocked by a column
  • Cells blocked by a region
  • Cells blocked only by diagonal contact
  • A row with exactly one candidate remaining

Once these functions are well tested, the React layer becomes much less risky.

19. Keeping the Product Page Separate from Supporting Content

The playable page should have one clear primary action: start the game.

Long rule explanations, solving strategies, archives, and alternative modes are useful, but they should not push the board out of view or make the first interaction difficult to find.

I separated the product into focused routes:

/meowdoku
/meowdoku-unlimited
/meowdoku-daily
/meowdoku-rules
/meowdoku-tips
Enter fullscreen mode Exit fullscreen mode

This architecture serves both users and maintainability:

  • The main route remains a direct play destination.
  • New players have a dedicated rules page.
  • Experienced players can open unlimited boards.
  • Daily players have a stable daily route.
  • Strategy content can grow without bloating the game component.

This is not only a content decision. It prevents one page component from trying to solve every product requirement.

20. What I Would Improve Next

The current architecture leaves room for several improvements.

A stronger deduction engine

The candidate system currently handles direct restrictions and forced cells. More advanced strategies could examine interactions between regions, rows, and columns.

Better explanations for hints

Instead of highlighting only a cell, hints could return structured reasoning:

type Hint = {
  cell: string;
  action: "place-cat" | "add-mark";
  reason:
    | "only-cell-in-row"
    | "only-cell-in-column"
    | "only-cell-in-region"
    | "touching-conflict";
};
Enter fullscreen mode Exit fullscreen mode

A board validation tool

Puzzle authors need an internal tool that verifies:

  • Region count
  • Region connectivity
  • Solution validity
  • Unique solvability
  • Difficulty estimates

Keyboard navigation

Arrow-key movement, Enter for cats, and a second key for X marks would improve desktop accessibility.

Progress migration

As the storage format evolves, older versions should be migrated rather than discarded.

Final Thoughts

The biggest lesson from building Meowdoku was that a small puzzle still benefits from clear boundaries.

The most important decisions were not visual:

  • Represent the board as structured data.
  • Keep the rule engine independent from React.
  • Store player actions and derive visual consequences.
  • Separate automatic marks from manual notes.
  • Make hint logic return one useful deduction.
  • Use a stable date system for daily boards.
  • Treat browser storage as optional and untrusted.
  • Build accessibility into the cell component from the beginning.

These choices made it easier to add new puzzle sizes, difficulty modes, daily boards, hints, undo, and browser persistence without rebuilding the game each time.

You can try the current version of the Meowdoku browser puzzle here.

I would be interested to hear how other developers structure constraint-based puzzle engines, especially when implementing explainable hints and unique-solution validation.

Top comments (0)