Skip to content

Add Rift Mode: detect unsolvable boards, show modal, persist state and styles - #25

Merged
ssfinney merged 6 commits into
mainfrom
codex/design-and-build-rift-mode-feature-w1fbei
Mar 22, 2026
Merged

Add Rift Mode: detect unsolvable boards, show modal, persist state and styles#25
ssfinney merged 6 commits into
mainfrom
codex/design-and-build-rift-mode-feature-w1fbei

Conversation

@ssfinney

Copy link
Copy Markdown
Owner

Motivation

  • Introduce a narrative "rift" mechanic that activates when a player move makes the puzzle unsolvable so the UI can present recovery options and a visual treatment.
  • Preserve the last solvable snapshot so players can restore to a known-good state or start a fresh puzzle while keeping state across reloads.

Description

  • Add a Rift UI to index.html including a modal (#riftModal) with return/restore/start-fresh buttons and a backdrop.
  • Implement rift logic in script.js: new constants and state (RIFT_COOLDOWN_MS, riftState, lastSolvableSnapshot, etc.), solver helpers (hasImmediateContradiction, hasAnySolution), rift lifecycle functions (triggerRiftEvent, evaluateRiftTrigger, openRiftModal, clearRiftVisualState, restoreLastSolvableState, captureLastSolvableSnapshot) and interaction locks to prevent regular actions while the rift is processing.
  • Integrate rift UI into rendering and input handling by marking rift nodes in the board, opening the modal when a rift-node is tapped, preventing certain actions when a rift is active/locked, and sequencing status lines with optional reduced-motion speed.
  • Persist rift-related data and the lastSolvableSnapshot into the save payload in saveGame and restore them in applyLoadedData, and ensure new games / restores capture the solvable snapshot.
  • Add CSS rules to style.css for rift visuals (rift-active, rift-glitch, rift-node, rift-modal), and bump the service worker cache name in sw.js to shandoku-wife-edition-v5.

Testing

  • No automated tests were added or executed for this change.

Codex Task

@github-actions

github-actions Bot commented Mar 22, 2026

Copy link
Copy Markdown

🌸 Diff Haiku

Puzzle breaks, a rift,
Modal opens, states preserved,
Choices mend the board.


Generated by spit-the-diff

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a 'Rift Mode' to the Shandoku game, which activates when a player makes a move that results in an unsolvable puzzle state. It provides a user interface for recovery options, including restoring to the last solvable state or starting a new game. The changes include adding new game logic, UI elements, persistence mechanisms, and styles to support this new feature.

Highlights

  • Rift Mode Introduction: Introduces a 'rift' mechanic that triggers when a player's move makes the puzzle unsolvable, presenting recovery options and a visual treatment.
  • State Preservation: Preserves the last solvable puzzle state, allowing players to restore to a known-good state or start a fresh puzzle while maintaining state across reloads.
  • UI Integration: Integrates a new Rift UI modal with return/restore/start-fresh buttons and backdrop into the existing game.
  • Persistence: Persists rift-related data and the last solvable snapshot into the save payload and restores them when loading a game.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a significant new 'Rift Mode' feature, which activates when the puzzle becomes unsolvable. The implementation is comprehensive, covering state detection, UI changes, animations, and persistence. The code is generally well-structured. My review includes a few suggestions to refactor some of the new logic for better clarity and maintainability, and to improve consistency in how DOM elements are handled.

Comment thread script.js Outdated
if(hasConflict(r,c)) conflicted.push({r,c});
}
}
if(conflicted.length) return conflicted.slice(0,Math.min(3,conflicted.length));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The expression Math.min(3, conflicted.length) is not necessary here. The slice method handles an end index that is larger than the array's length gracefully by simply slicing to the end of the array. You can simplify this to conflicted.slice(0, 3) for better readability.

Suggested change
if(conflicted.length) return conflicted.slice(0,Math.min(3,conflicted.length));
if(conflicted.length) return conflicted.slice(0, 3);

Comment thread script.js Outdated
function openRiftModal(){
const copy=riftCopySets[riftState.copyKey]||riftCopySets.pattern;
riftBodyEl.textContent=copy.fragment;
document.getElementById('riftTitle').textContent=copy.title;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency with how other DOM elements are cached at the top of the script, and for minor performance/maintainability benefits, it's better to query for riftTitle and other rift-modal-related elements (like the buttons) once and store them in constants. This avoids repeated calls to document.getElementById within functions where the elements are used.

Comment thread script.js Outdated
Comment on lines +525 to +543
if(data.riftState&&data.riftState.active){
riftState.active=true;
riftState.sequenceRunning=false;
riftState.nodes=(data.riftState.nodes||[]).slice(0,3);
riftState.hasTriggered=!!data.riftState.hasTriggered;
riftState.cooldownUntil=Number(data.riftState.cooldownUntil)||0;
riftState.copyKey=data.riftState.copyKey||'pattern';
boardShellEl.classList.add('rift-active');
statusEl.classList.add('rift-status');
} else {
riftState={
active:false,
sequenceRunning:false,
nodes:[],
hasTriggered:!!(data.riftState&&data.riftState.hasTriggered),
cooldownUntil:Number(data.riftState&&data.riftState.cooldownUntil)||0,
copyKey:(data.riftState&&data.riftState.copyKey)||'pattern'
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic to restore riftState from saved data is a bit complex and contains some repetition. It can be refactored to be more concise and easier to read, which will improve maintainability. By using a single object for the saved state and providing defaults, you can handle all cases within a single block of code.

    const savedRiftState = data.riftState || {};
    const isActive = !!savedRiftState.active;

    riftState = {
      active: isActive,
      sequenceRunning: false,
      nodes: isActive ? (savedRiftState.nodes || []).slice(0, 3) : [],
      hasTriggered: !!savedRiftState.hasTriggered,
      cooldownUntil: Number(savedRiftState.cooldownUntil) || 0,
      copyKey: savedRiftState.copyKey || 'pattern'
    };

    if (isActive) {
      boardShellEl.classList.add('rift-active');
      statusEl.classList.add('rift-status');
    }

@ssfinney

Copy link
Copy Markdown
Owner Author

@codex

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0c042b6e13

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread script.js
Comment on lines +281 to +283
riftState.active=false;
riftState.sequenceRunning=false;
riftState.nodes=[];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cancel pending Rift sequence when clearing state

clearRiftVisualState() resets flags/classes but never invalidates the in-flight runStatusSequence() token, so if a user starts a new game or restores state while the Rift sequence is mid-animation, the original triggerRiftEvent() continues and can re-activate Rift on the new board a few seconds later. This produces stale status text and incorrect riftState after the user has already moved to a different game state.

Useful? React with 👍 / 👎.

Comment thread script.js Outdated
Comment on lines +639 to +640
render(); startTimer(); saveGame(); hideSplash();
captureLastSolvableSnapshot();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Capture solvable snapshot before writing new-game save

In newGame(), saveGame() runs before captureLastSolvableSnapshot(), so localStorage can briefly persist the previous puzzle’s lastSolvableSnapshot with the new puzzle’s grid/startingGrid. If the app is reloaded before the next autosave tick, a later “Restore last solvable” can restore an out-of-sync board from the old puzzle into the new game context.

Useful? React with 👍 / 👎.

@ssfinney

Copy link
Copy Markdown
Owner Author

@gemini review last commit

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a significant new feature, 'Rift Mode', which activates when the Sudoku board becomes unsolvable. The changes are extensive, touching HTML to add a new modal, CSS for styling the rift state, and a large amount of JavaScript for the core logic. This includes a new solver to detect unsolvable states, state management for the rift, and persistence to local storage.

Overall, the implementation of the feature is comprehensive. However, I have a few points of feedback primarily concerning performance and maintainability in the JavaScript code.

  • There is a significant performance concern with running a full Sudoku solver on every player move, which could lead to UI lag.
  • There are opportunities to reduce code duplication and refactor complex functions to improve maintainability.
  • The pull request description notes that no automated tests were added. For a feature with complex logic like the board solver, adding unit tests would be highly beneficial to ensure correctness and prevent future regressions.

My detailed comments are below.

Comment thread script.js Outdated
Comment on lines +351 to +365
function evaluateRiftTrigger(origin='system'){
if(origin!=='player-move') return;
if(riftState.active||riftState.sequenceRunning) return;
if(Date.now()<riftState.cooldownUntil) return;
const solvable=hasAnySolution(grid);
if(solvable){
boardWasSolvable=true;
captureLastSolvableSnapshot();
return;
}
if(boardWasSolvable){
boardWasSolvable=false;
triggerRiftEvent();
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The evaluateRiftTrigger function calls hasAnySolution on every player move. This function performs a full backtracking search to determine if the board is solvable, which can be computationally expensive and may lead to UI freezes, especially on more complex boards or slower devices.

Consider these potential mitigations:

  • Web Worker: Offload the solver to a Web Worker to prevent blocking the main UI thread.
  • Debouncing: Debounce the call to hasAnySolution so it doesn't run on every single rapid input.
  • Trigger Condition: Only trigger this expensive check under more specific conditions, rather than on every move. For example, you could run it only when a move introduces a direct conflict.

Comment thread script.js Outdated
for(const line of lines){
if(id!==statusSequenceId) return false;
setStatus(line);
await new Promise(resolve=>setTimeout(resolve,fast?500:950));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The setTimeout delay uses magic numbers (500 and 950). To improve readability and maintainability, it's best to extract these values into named constants defined at the top of the script, for example:

const STATUS_SEQUENCE_DELAY_MS = 950;
const STATUS_SEQUENCE_DELAY_REDUCED_MOTION_MS = 500;

Then you can use these constants here.

Comment thread script.js Outdated
Comment on lines +521 to +543
if(data.lastSolvableSnapshot && data.lastSolvableSnapshot.grid && data.lastSolvableSnapshot.notes){
lastSolvableSnapshot={
...data.lastSolvableSnapshot,
grid:cloneGrid(data.lastSolvableSnapshot.grid),
notes:data.lastSolvableSnapshot.notes.map(row=>row.map(arr=>new Set(arr)))
};
} else {
lastSolvableSnapshot=null;
}
const savedRiftState=data.riftState||{};
const isRiftActive=!!savedRiftState.active;
riftState={
active:isRiftActive,
sequenceRunning:false,
nodes:isRiftActive?(savedRiftState.nodes||[]).slice(0,3):[],
hasTriggered:!!savedRiftState.hasTriggered,
cooldownUntil:Number(savedRiftState.cooldownUntil)||0,
copyKey:savedRiftState.copyKey||'pattern'
};
if(isRiftActive){
boardShellEl.classList.add('rift-active');
statusEl.classList.add('rift-status');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for restoring the rift state within applyLoadedData adds significant complexity to an already large function. To improve maintainability and readability, consider extracting this block of code into its own dedicated helper function, for example restoreRiftState(data). This would make applyLoadedData easier to follow and the rift-specific restoration logic would be neatly encapsulated.

Comment thread script.js Outdated
Comment on lines +646 to +647
if(interactionLocked) return;
if(riftState.active){ setStatus('Resolve the rift first.'); return; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These two lines for checking interactionLocked and riftState.active are repeated in many of the user action functions (clearSelected, giveHint, checkBoard, solveBoard, etc.). This creates code duplication which can be a maintenance issue.

Consider refactoring this logic into a shared helper function to check if an interaction is allowed. This would centralize the guard logic and make the code cleaner and easier to update.

@ssfinney ssfinney left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@codex review this commit

@gemini review this commit

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 15ce83ea76

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread script.js
cell.addEventListener('click',()=>{ selected={r,c}; render(); saveGame(); });
cell.addEventListener('click',()=>{
if(interactionLocked) return;
if(isRiftNode){ openRiftModal(); return; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate rift-node click handling on current rift state

isRiftNode is captured when the cell is rendered, but this handler opens the modal without checking riftState.active. After closeRift() is used from Return/backdrop/Escape (which does not re-render), tapping a previously marked node still reopens the Rift modal even though the rift was closed, so users can get pulled back into Rift mode until another action forces a re-render.

Useful? React with 👍 / 👎.

Comment thread script.js Outdated
Comment on lines +369 to +372
const solvable=hasAnySolution(grid);
if(solvable){
boardWasSolvable=true;
captureLastSolvableSnapshot();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Avoid re-solving the board after solvability is known

This path computes solvable = hasAnySolution(grid) and then immediately calls captureLastSolvableSnapshot(), which runs hasAnySolution(grid) again internally. Because that solver is recursive backtracking, doing it twice on every solvable player move adds avoidable latency and can degrade responsiveness on harder boards or slower devices.

Useful? React with 👍 / 👎.

Comment thread script.js
if(movesSinceSolvabilityCheck<RIFT_NON_CONFLICT_CHECK_INTERVAL) return;
movesSinceSolvabilityCheck=0;
}
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@codex hard to read

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

  • Refactored the Rift solvability-check gating logic into a dedicated helper, shouldRunSolvabilityCheck(options={}), so the intent is clearer and the nested branching is easier to read. This keeps the same behavior for force, conflictIntroduced, and interval-based checks while improving maintainability. script.jsL368-L378
  • Simplified evaluateRiftTrigger(...) by replacing the prior nested gating block with a single early-return guard: if(!shouldRunSolvabilityCheck(options)) return;, which directly addresses the readability concern around this section. script.jsL380-L385

Testing

  • node --check script.js

View task →

@ssfinney
ssfinney merged commit 3c52847 into main Mar 22, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant