SudokuGamePanel.razor

The main UI panel for a Sudoku game, written as a Razor panel. Draws the title menu with difficulty buttons, the 9x9 board with pencil notes and peer/same-number highlighting, the numpad and action bar, plus settings and pause overlays. Generates puzzles by backtracking fill and index shuffling, tracks mistakes, hints and an undo history, and handles mouse and keyboard input.

🐞 WrongFlash is set to true on a wrong entry and only reset inside BeginPuzzle, so the wrong-flash effect on the board fires once per puzzle and not on later mistakes.
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Collections.Generic;
@namespace Sandbox
@inherits Panel

<root>
    <div class="game-screen @(Phase == GamePhase.Menu ? "menu-mode" : "")" onmousedown=@FocusPanel>

        @if ( Phase != GamePhase.Menu )
        {
            <div class="sudoku-header">
                <h2>Sudoku</h2>
                <p class="subtitle">Fill the grid · Numbers 1–9</p>
            </div>
        }

        @if ( ShowSettings )
        {
            <div class="settings-layer">
                <div class="settings-backdrop" onclick=@CloseSettings></div>
                <div class="settings-panel">
                    <div class="pause-head">
                        <label class="pause-title">Settings</label>
                        <label class="pause-sub">Changes apply instantly</label>
                    </div>

                    <div class="setting-row clickable" onclick=@ToggleShowMistakes>
                        <div class="setting-info">
                            <label class="setting-title">Show Mistakes</label>
                            <label class="setting-desc">Highlight incorrect numbers in red</label>
                        </div>
                        <div class="toggle @(ShowMistakes ? "on" : "off")"><div class="knob"></div></div>
                    </div>

                    <div class="setting-row clickable" onclick=@ToggleAutoNotes>
                        <div class="setting-info">
                            <label class="setting-title">Auto-Clear Notes</label>
                            <label class="setting-desc">Remove pencil marks when a number is placed</label>
                        </div>
                        <div class="toggle @(AutoClearNotes ? "on" : "off")"><div class="knob"></div></div>
                    </div>

                    <div class="setting-row clickable" onclick=@ToggleHighlightPeers>
                        <div class="setting-info">
                            <label class="setting-title">Highlight Peers</label>
                            <label class="setting-desc">Shade the selected cell's row, column and box</label>
                        </div>
                        <div class="toggle @(HighlightPeers ? "on" : "off")"><div class="knob"></div></div>
                    </div>

                    <div class="setting-row">
                        <div class="setting-info">
                            <label class="setting-title">Text Size</label>
                            <label class="setting-desc">Scale the board numbers and labels</label>
                        </div>
                        <div class="scale-ctrl">
                            <div class="scale-btn" onclick=@DecreaseTextScale><label class="scale-glyph">remove</label></div>
                            <label class="scale-val">@($"{TextScale}%")</label>
                            <div class="scale-btn" onclick=@IncreaseTextScale><label class="scale-glyph">add</label></div>
                        </div>
                    </div>

                    <div class="settings-done" onclick=@CloseSettings>
                        <div class="pause-key"><label>Esc</label></div>
                        <label class="settings-done-label">Done</label>
                    </div>
                </div>
            </div>
        }

        @if ( Phase == GamePhase.Menu )
        {
            <div class="menu-hero">
                <div class="logo-grid">
                    <div class="logo-cell filled">5</div>
                    <div class="logo-cell"></div>
                    <div class="logo-cell filled">3</div>
                    <div class="logo-cell"></div>
                    <div class="logo-cell filled">7</div>
                    <div class="logo-cell"></div>
                    <div class="logo-cell"></div>
                    <div class="logo-cell"></div>
                    <div class="logo-cell filled">1</div>
                </div>

                <h1 class="brand-title">SUDOKU</h1>
                <p class="brand-tagline">The classic number puzzle</p>
            </div>

            <div class="menu-panel">
                <p class="menu-title">Select Difficulty</p>

                <button class="diff-btn easy" onclick=@StartEasy>
                    <span class="diff-key">1</span>
                    <div class="diff-info">
                        <span class="diff-name">Easy</span>
                        <span class="diff-desc">36 cells empty · A relaxed warm-up</span>
                    </div>
                    <span class="diff-arrow">›</span>
                </button>

                <button class="diff-btn medium" onclick=@StartMedium>
                    <span class="diff-key">2</span>
                    <div class="diff-info">
                        <span class="diff-name">Medium</span>
                        <span class="diff-desc">46 cells empty · A balanced challenge</span>
                    </div>
                    <span class="diff-arrow">›</span>
                </button>

                <button class="diff-btn hard" onclick=@StartHard>
                    <span class="diff-key">3</span>
                    <div class="diff-info">
                        <span class="diff-name">Hard</span>
                        <span class="diff-desc">54 cells empty · For seasoned solvers</span>
                    </div>
                    <span class="diff-arrow">›</span>
                </button>
            </div>

            <div class="menu-footer">
                <div class="control-hint">
                    <span class="kbd">1</span><span class="kbd">2</span><span class="kbd">3</span>
                    <span class="hint-label">Select difficulty</span>
                </div>
                <div class="control-divider"></div>
                <div class="control-hint">
                    <span class="kbd">1</span>–<span class="kbd">9</span>
                    <span class="hint-label">Type numbers · numpad supported</span>
                </div>
                <div class="control-divider"></div>
                <div class="control-hint">
                    <span class="kbd">↑</span><span class="kbd">↓</span><span class="kbd">←</span><span class="kbd">→</span>
                    <span class="hint-label">Navigate cells</span>
                </div>
                <div class="control-divider"></div>
                <div class="control-hint">
                    <span class="kbd">Esc</span>
                    <span class="hint-label">Menu</span>
                </div>
            </div>
        }
        else
        {
            <!-- Score / status bar -->
            <div class="score-bar">
                <div class="bar-buttons">
                    <button class="settings-toggle" tabindex="-1" onclick=@OpenPause>Pause</button>
                    <button class="settings-toggle" tabindex="-1" onclick=@ToggleSettings>Settings</button>
                </div>
                <div class="score-item">
                    <span class="score-label">Mistakes</span>
                    <span class="score-value @(Mistakes >= 3 ? "danger" : "")" style="@FS(20)">@Mistakes/3</span>
                </div>
                <div class="score-item">
                    <span class="score-label">Time</span>
                    <span class="score-value" style="@FS(20)">@FormatTime(Timer)</span>
                </div>
                <div class="score-item">
                    <span class="score-label">Hints</span>
                    <span class="score-value" style="@FS(20)">@Hints</span>
                </div>
            </div>

            <!-- Board -->
            <div class="board-wrap @(WrongFlash ? "wrong-flash" : "")">
                @for ( int row = 0; row < 9; row++ )
                {
                    int r = row;
                    <div class="sudoku-row">
                        @for ( int col = 0; col < 9; col++ )
                        {
                            int c = col;
                            int val = Board[r * 9 + c];
                            bool isFixed  = Puzzle[r * 9 + c] != 0;
                            bool isSel    = SelectedR == r && SelectedC == c;
                            bool isPeer   = HighlightPeers && !isSel && IsPeer( r, c );
                            bool isSameNum = SelectedR >= 0 && val != 0 && val == Board[SelectedR * 9 + SelectedC];
                            bool isError  = ShowMistakes && val != 0 && !isFixed && val != Solution[r * 9 + c];
                            bool isBoxRight  = (c + 1) % 3 == 0 && c < 8;
                            bool isBoxBottom = (r + 1) % 3 == 0 && r < 8;

                            string cls = "sudoku-cell";
                            if ( isSel )     cls += " selected";
                            else if ( isSameNum ) cls += " same-num";
                            else if ( isPeer )    cls += " peer";
                            if ( isError )   cls += " error";
                            if ( isFixed )   cls += " fixed";
                            if ( isBoxRight )  cls += " box-right";
                            if ( isBoxBottom ) cls += " box-bottom";

                            <div class="@cls" onclick=@(() => SelectCell( r, c ))>
                                @if ( val != 0 )
                                {
                                    <span class="cell-num" style="@FS(22)">@val</span>
                                }
                                else
                                {
                                    <!-- Pencil notes grid -->
                                    <div class="notes-grid">
                                        @for ( int n = 1; n <= 9; n++ )
                                        {
                                            int nn = n;
                                            bool hasNote = Notes[r * 9 + c].Contains( nn );
                                            <span class="note @(hasNote ? "visible" : "")" style="@FS(9)">@nn</span>
                                        }
                                    </div>
                                }
                            </div>
                        }
                    </div>
                }

                @if ( Phase == GamePhase.Won )
                {
                    <div class="overlay">
                        <p class="overlay-title">Solved!</p>
                        <p class="overlay-hint">Time: @FormatTime(Timer) · Mistakes: @Mistakes</p>
                        <button class="accent-btn" tabindex="-1" onclick=@(() => StartGame( CurrentDifficulty ))>Play Again</button>
                        <button class="quit-btn"   tabindex="-1" onclick=@GoToMenu>Menu</button>
                    </div>
                }
            </div>

            <!-- Number pad -->
            <div class="numpad">
                @for ( int n = 1; n <= 9; n++ )
                {
                    int nn = n;
                    int placed = CountPlaced( nn );
                    bool done = placed >= 9;
                    <button class="num-btn @(done ? "done" : "") @(NoteMode && !done ? "note-active" : "")"
                            style="@FS(20)" onclick=@(() => InputNumber( nn ))>@nn</button>
                }
            </div>

            <!-- Action buttons -->
            <div class="action-bar">
                <button class="action-btn" onclick=@HandleUndo>
                    <span class="action-icon">↩</span>
                    <span class="action-label">Undo</span>
                </button>
                <button class="action-btn" onclick=@HandleErase>
                    <span class="action-icon">⌫</span>
                    <span class="action-label">Erase</span>
                </button>
                <button class="action-btn @(NoteMode ? "active" : "")" onclick=@ToggleNoteMode>
                    <span class="action-icon">✏</span>
                    <span class="action-label">Notes @(NoteMode ? "ON" : "OFF")</span>
                </button>
                <button class="action-btn @(Hints <= 0 ? "disabled" : "")" onclick=@HandleHint>
                    <span class="action-icon">💡</span>
                    <span class="action-label">Hint (@Hints)</span>
                </button>
            </div>
        }

        <!-- Pause menu (Esc). Hidden while the settings panel is up so the two never stack. -->
        @if ( PauseOpen && !ShowSettings )
        {
            <div class="pause-layer">
                <div class="pause-card">
                    @if ( Confirm == PauseConfirm.None )
                    {
                        <div class="pause-head">
                            <label class="pause-title">Paused</label>
                            <label class="pause-sub">@PauseSubtitle()</label>
                        </div>

                        <div class="pause-row" onclick=@ClosePause>
                            <div class="pause-key"><label>Esc</label></div>
                            <div class="pause-info">
                                <label class="pause-name">Resume</label>
                                <label class="pause-desc">@(InPuzzle ? "Back to the puzzle" : "Back to the menu")</label>
                            </div>
                            <label class="pause-icon">play_arrow</label>
                        </div>

                        @if ( InPuzzle )
                        {
                            <div class="pause-row" onclick=@RequestRestart>
                                <div class="pause-key"><label>R</label></div>
                                <div class="pause-info">
                                    <label class="pause-name">Restart Puzzle</label>
                                    <label class="pause-desc">Same puzzle, clean board</label>
                                </div>
                                <label class="pause-icon">replay</label>
                            </div>

                            <div class="pause-row" onclick=@RequestNewPuzzle>
                                <div class="pause-key"><label>N</label></div>
                                <div class="pause-info">
                                    <label class="pause-name">New Puzzle</label>
                                    <label class="pause-desc">Back to difficulty select</label>
                                </div>
                                <label class="pause-icon">grid_view</label>
                            </div>
                        }

                        <div class="pause-row" onclick=@OpenSettingsFromPause>
                            <div class="pause-key"><label>S</label></div>
                            <div class="pause-info">
                                <label class="pause-name">Settings</label>
                                <label class="pause-desc">Mistakes, notes and text size</label>
                            </div>
                            <label class="pause-icon">tune</label>
                        </div>

                        <div class="pause-row" onclick=@OpenEngineSettings>
                            <div class="pause-key"><label>V</label></div>
                            <div class="pause-info">
                                <label class="pause-name">@("Audio & Video")</label>
                                <label class="pause-desc">Graphics, sound and controls</label>
                            </div>
                            <label class="pause-icon">settings</label>
                        </div>

                        <div class="pause-divider"></div>

                        <div class="pause-row quit" onclick=@RequestQuit>
                            <div class="pause-key"><label>Q</label></div>
                            <div class="pause-info">
                                <label class="pause-name">@("Quit to s&box")</label>
                                <label class="pause-desc">@("Back to the s&box main menu")</label>
                            </div>
                            <label class="pause-icon">logout</label>
                        </div>
                    }
                    else
                    {
                        <div class="pause-head">
                            <label class="confirm-title">@ConfirmTitle()</label>
                            <label class="pause-sub">Your progress on this puzzle will be lost.</label>
                        </div>

                        <div class="confirm-actions">
                            <div class="confirm-btn" onclick=@CancelConfirm>
                                <div class="pause-key"><label>Esc</label></div>
                                <label class="confirm-label">Cancel</label>
                            </div>
                            <div class="confirm-btn @(Confirm == PauseConfirm.Quit ? "danger" : "go")" onclick=@AcceptConfirm>
                                <div class="pause-key"><label>Enter</label></div>
                                <label class="confirm-label">@ConfirmVerb()</label>
                            </div>
                        </div>
                    }
                </div>
            </div>
        }

    </div>
</root>

@code {

    // ── Enums ─────────────────────────────────────────────────────────────

    public enum GamePhase { Menu, Playing, Won }
    public enum Difficulty { Easy, Medium, Hard }
    public enum PauseConfirm { None, Restart, NewPuzzle, Quit }

    // ── Settings ──────────────────────────────────────────────────────────

    bool ShowSettings;
    bool ShowMistakes    = true;
    bool AutoClearNotes  = true;
    bool HighlightPeers  = true;
    int  TextScale       = 100; // percent, 70–180

    // ── Game state ────────────────────────────────────────────────────────

    GamePhase Phase = GamePhase.Menu;
    Difficulty CurrentDifficulty = Difficulty.Medium;

    int[] Puzzle   = new int[81];
    int[] Solution = new int[81];
    int[] Board    = new int[81];

    // Notes: each cell holds a set of pencil-mark numbers
    List<int>[] Notes = MakeNotes();

    int SelectedR = -1;
    int SelectedC = -1;

    int Mistakes;
    int Hints = 3;
    bool NoteMode;
    bool WrongFlash;

    float Timer;
    bool TimerRunning;

    // Pause menu: an overlay on top of any phase, so it also works from the title screen
    bool PauseOpen;
    PauseConfirm Confirm = PauseConfirm.None;

    bool InPuzzle => Phase == GamePhase.Playing || Phase == GamePhase.Won;

    // Undo history: (index, oldValue, oldNotes)
    struct HistoryEntry { public int Idx; public int OldVal; public List<int> OldNotes; }
    List<HistoryEntry> History = new();

    // ── Lifecycle ─────────────────────────────────────────────────────────

    protected override void OnAfterTreeRender( bool firstTime )
    {
        base.OnAfterTreeRender( firstTime );
        if ( firstTime )
        {
            AcceptsFocus = true;
            Focus();
        }
    }

    public override void Tick()
    {
        base.Tick();
        HandleInput();

        if ( TimerRunning && !PauseOpen )
        {
            Timer += Time.Delta;
            StateHasChanged();
        }
    }

    // ── Input ─────────────────────────────────────────────────────────────

    void HandleInput()
    {
        // SudokuInput swallows the engine's Escape flag (so the s&box pause menu stays shut)
        // and latches it here. Also accept the raw flag in case that component is missing.
        bool esc = SudokuInput.Escape || Input.EscapePressed;
        SudokuInput.Escape = false;
        if ( Input.EscapePressed ) Input.EscapePressed = false;
        if ( esc ) HandleEscape();
    }

    // Esc backs out one layer at a time: settings → confirm → pause menu, else opens the pause menu
    void HandleEscape()
    {
        if      ( ShowSettings )                              CloseSettings();
        else if ( PauseOpen && Confirm != PauseConfirm.None ) CancelConfirm();
        else if ( PauseOpen )                                 ClosePause();
        else                                                  OpenPause();
    }

    void HandlePauseKey( string btn )
    {
        if ( Confirm != PauseConfirm.None )
        {
            if ( btn == "enter" || btn == "return" || btn == "kp_enter" || btn == "space" ) AcceptConfirm();
            return;
        }

        if      ( btn == "r" && InPuzzle ) RequestRestart();
        else if ( btn == "n" && InPuzzle ) RequestNewPuzzle();
        else if ( btn == "s" )             OpenSettingsFromPause();
        else if ( btn == "v" )             OpenEngineSettings();
        else if ( btn == "q" )             RequestQuit();
    }

    // Fires for all key presses while this panel has focus.
    public override void OnButtonTyped( ButtonEvent e )
    {
        var btn = e.Button;

        // Menus on top own the keyboard: no typing into the board behind them
        if ( ShowSettings ) return;
        if ( PauseOpen ) { HandlePauseKey( btn ); return; }

        // number row 1–9 and numpad kp_1–kp_9
        int num = 0;
        for ( int n = 1; n <= 9; n++ )
        {
            if ( btn == n.ToString() || btn == $"kp_{n}" )
            { num = n; break; }
        }

        if ( Phase == GamePhase.Menu )
        {
            if      ( num == 1 ) StartEasy();
            else if ( num == 2 ) StartMedium();
            else if ( num == 3 ) StartHard();
            return;
        }

        if ( Phase != GamePhase.Playing ) return;

        bool kUp    = btn == "up"    || btn == "w";
        bool kDown  = btn == "down"  || btn == "s";
        bool kLeft  = btn == "left"  || btn == "a";
        bool kRight = btn == "right" || btn == "d";
        bool kErase = btn == "delete" || btn == "backspace";

        if ( SelectedR < 0 && ( kUp || kDown || kLeft || kRight || num != 0 ) )
        {
            SelectedR = 0; SelectedC = 0;
            StateHasChanged();
            return;
        }

        if ( kUp    && SelectedR > 0 ) { SelectedR--; StateHasChanged(); }
        if ( kDown  && SelectedR < 8 ) { SelectedR++; StateHasChanged(); }
        if ( kLeft  && SelectedC > 0 ) { SelectedC--; StateHasChanged(); }
        if ( kRight && SelectedC < 8 ) { SelectedC++; StateHasChanged(); }

        if ( num != 0 )  InputNumber( num );
        if ( kErase )    HandleErase();
    }

    // ── Game management ───────────────────────────────────────────────────

    void StartEasy()   => StartGame( Difficulty.Easy );
    void StartMedium() => StartGame( Difficulty.Medium );
    void StartHard()   => StartGame( Difficulty.Hard );

    void StartGame( Difficulty diff )
    {
        CurrentDifficulty = diff;
        GeneratePuzzle( diff );
        BeginPuzzle();
    }

    // Same puzzle again: wipe the player's entries back to the givens
    void RestartPuzzle()
    {
        for ( int i = 0; i < 81; i++ )
            Board[i] = Puzzle[i];
        BeginPuzzle();
    }

    // Resets everything except the board itself, then starts the clock
    void BeginPuzzle()
    {
        // Auto-select the first empty cell so the keyboard works immediately
        SelectedR = 0;
        SelectedC = 0;
        for ( int i = 0; i < 81; i++ )
        {
            if ( Puzzle[i] == 0 ) { SelectedR = i / 9; SelectedC = i % 9; break; }
        }
        Mistakes  = 0;
        Hints     = 3;
        NoteMode  = false;
        WrongFlash = false;
        Timer     = 0f;
        TimerRunning = true;
        History   = new List<HistoryEntry>();
        Notes     = MakeNotes();
        Phase     = GamePhase.Playing;
        ShowSettings = false;
        PauseOpen = false;
        Confirm   = PauseConfirm.None;

        StateHasChanged();
    }

    void GoToMenu()
    {
        Phase = GamePhase.Menu;
        TimerRunning = false;
        PauseOpen = false;
        Confirm   = PauseConfirm.None;
        StateHasChanged();
    }

    // ── Pause menu ────────────────────────────────────────────────────────

    void OpenPause()
    {
        PauseOpen = true;
        Confirm   = PauseConfirm.None;
        Focus(); // so the R / N / S / V / Q shortcuts reach OnButtonTyped
        StateHasChanged();
    }

    void ClosePause()
    {
        PauseOpen = false;
        Confirm   = PauseConfirm.None;
        Focus(); // keyboard straight back to the board
        StateHasChanged();
    }

    // Anything that would throw away an unfinished puzzle asks first
    bool HasProgress()
    {
        if ( Phase != GamePhase.Playing ) return false;
        if ( History.Count > 0 ) return true;
        for ( int i = 0; i < 81; i++ )
            if ( Notes[i].Count > 0 ) return true;
        return false;
    }

    void RequestRestart()   => Ask( PauseConfirm.Restart );
    void RequestNewPuzzle() => Ask( PauseConfirm.NewPuzzle );
    void RequestQuit()      => Ask( PauseConfirm.Quit );

    void Ask( PauseConfirm action )
    {
        if ( HasProgress() ) { Confirm = action; StateHasChanged(); }
        else Perform( action );
    }

    void AcceptConfirm() => Perform( Confirm );
    void CancelConfirm() { Confirm = PauseConfirm.None; StateHasChanged(); }

    void Perform( PauseConfirm action )
    {
        switch ( action )
        {
            case PauseConfirm.Restart:   RestartPuzzle(); break;
            case PauseConfirm.NewPuzzle: GoToMenu(); break;
            case PauseConfirm.Quit:      Game.Close(); break; // back to the s&box main menu
        }
    }

    void OpenSettingsFromPause()
    {
        // The pause menu stays open underneath, so closing settings lands back on it
        ShowSettings = true;
        StateHasChanged();
    }

    void OpenEngineSettings() { Game.Overlay.ShowSettingsModal(); }

    string PauseSubtitle()
    {
        if ( Phase == GamePhase.Won )     return $"Solved in {FormatTime( Timer )}";
        if ( Phase == GamePhase.Playing ) return $"{CurrentDifficulty} · {FormatTime( Timer )} · {Mistakes}/3 mistakes";
        return "Main menu";
    }

    string ConfirmTitle() => Confirm switch
    {
        PauseConfirm.Restart   => "Restart this puzzle?",
        PauseConfirm.NewPuzzle => "Start a new puzzle?",
        _                      => "Quit to s&box?"
    };

    string ConfirmVerb() => Confirm switch
    {
        PauseConfirm.Restart   => "Restart",
        PauseConfirm.NewPuzzle => "New Puzzle",
        _                      => "Quit"
    };

    // ── Cell interaction ──────────────────────────────────────────────────

    void SelectCell( int r, int c )
    {
        if ( Phase != GamePhase.Playing ) return;
        SelectedR = r;
        SelectedC = c;
        Focus(); // ensure panel has keyboard focus after a cell click
        StateHasChanged();
    }

    void InputNumber( int num )
    {
        if ( Phase != GamePhase.Playing ) return;
        if ( SelectedR < 0 || SelectedC < 0 ) return;

        int idx = SelectedR * 9 + SelectedC;
        if ( Puzzle[idx] != 0 ) return; // fixed cell

        if ( NoteMode )
        {
            // Toggle pencil mark
            if ( Notes[idx].Contains( num ) )
                Notes[idx].Remove( num );
            else
                Notes[idx].Add( num );
            StateHasChanged();
            return;
        }

        // Save undo state
        History.Add( new HistoryEntry
        {
            Idx      = idx,
            OldVal   = Board[idx],
            OldNotes = new List<int>( Notes[idx] )
        } );

        Board[idx] = num;
        Notes[idx].Clear();

        if ( AutoClearNotes )
            ClearPeerNotes( SelectedR, SelectedC, num );

        if ( num != Solution[idx] )
        {
            Mistakes++;
            WrongFlash = true;
        }

        if ( CheckWin() )
        {
            Phase = GamePhase.Won;
            TimerRunning = false;
        }

        StateHasChanged();
    }

    void HandleErase()
    {
        if ( Phase != GamePhase.Playing ) return;
        if ( SelectedR < 0 || SelectedC < 0 ) return;

        int idx = SelectedR * 9 + SelectedC;
        if ( Puzzle[idx] != 0 ) return;

        History.Add( new HistoryEntry
        {
            Idx      = idx,
            OldVal   = Board[idx],
            OldNotes = new List<int>( Notes[idx] )
        } );

        Board[idx] = 0;
        Notes[idx].Clear();
        StateHasChanged();
    }

    void HandleUndo()
    {
        if ( Phase != GamePhase.Playing || History.Count == 0 ) return;

        var last = History[History.Count - 1];
        History.RemoveAt( History.Count - 1 );

        Board[last.Idx]  = last.OldVal;
        Notes[last.Idx]  = last.OldNotes;
        StateHasChanged();
    }

    void HandleHint()
    {
        if ( Phase != GamePhase.Playing || Hints <= 0 ) return;
        if ( SelectedR < 0 || SelectedC < 0 ) return;

        int idx = SelectedR * 9 + SelectedC;
        if ( Puzzle[idx] != 0 || Board[idx] == Solution[idx] ) return;

        History.Add( new HistoryEntry
        {
            Idx      = idx,
            OldVal   = Board[idx],
            OldNotes = new List<int>( Notes[idx] )
        } );

        Board[idx] = Solution[idx];
        Notes[idx].Clear();
        Hints--;

        if ( CheckWin() )
        {
            Phase = GamePhase.Won;
            TimerRunning = false;
        }

        StateHasChanged();
    }

    // ── Helpers ───────────────────────────────────────────────────────────

    bool CheckWin()
    {
        for ( int i = 0; i < 81; i++ )
            if ( Board[i] != Solution[i] ) return false;
        return true;
    }

    bool IsPeer( int r, int c )
    {
        if ( SelectedR < 0 ) return false;
        return r == SelectedR || c == SelectedC ||
               (r / 3 == SelectedR / 3 && c / 3 == SelectedC / 3);
    }

    int CountPlaced( int num )
    {
        int count = 0;
        for ( int i = 0; i < 81; i++ )
            if ( Board[i] == num ) count++;
        return count;
    }

    void ClearPeerNotes( int row, int col, int num )
    {
        int boxR = (row / 3) * 3;
        int boxC = (col / 3) * 3;
        for ( int i = 0; i < 9; i++ )
        {
            Notes[row * 9 + i].Remove( num );
            Notes[i * 9 + col].Remove( num );
        }
        for ( int r = boxR; r < boxR + 3; r++ )
            for ( int c = boxC; c < boxC + 3; c++ )
                Notes[r * 9 + c].Remove( num );
    }

    string FormatTime( float seconds )
    {
        int s = (int)seconds;
        return $"{s / 60:D2}:{s % 60:D2}";
    }

    // ── Puzzle generation ─────────────────────────────────────────────────

    void GeneratePuzzle( Difficulty diff )
    {
        // Fill solution using backtracking
        Solution = new int[81];
        FillBoard( Solution );

        // Manual copy — Array.Clone() is not whitelisted in s&box
        Puzzle = new int[81];
        Board  = new int[81];
        for ( int i = 0; i < 81; i++ )
        {
            Puzzle[i] = Solution[i];
            Board[i]  = Solution[i];
        }

        int removals = diff switch
        {
            Difficulty.Easy   => 36,
            Difficulty.Medium => 46,
            Difficulty.Hard   => 54,
            _                 => 46
        };

        // Shuffle cell indices and remove
        var indices = ShuffledIndices();
        int removed = 0;
        foreach ( var idx in indices )
        {
            if ( removed >= removals ) break;
            Puzzle[idx] = 0;
            Board[idx]  = 0;
            removed++;
        }
    }

    bool FillBoard( int[] board )
    {
        for ( int i = 0; i < 81; i++ )
        {
            if ( board[i] != 0 ) continue;

            int r = i / 9, c = i % 9;
            var nums = ShuffledNums();

            foreach ( var n in nums )
            {
                if ( IsValidPlacement( board, r, c, n ) )
                {
                    board[i] = n;
                    if ( FillBoard( board ) ) return true;
                    board[i] = 0;
                }
            }
            return false;
        }
        return true;
    }

    bool IsValidPlacement( int[] board, int row, int col, int num )
    {
        // Row
        for ( int c = 0; c < 9; c++ )
            if ( board[row * 9 + c] == num ) return false;
        // Column
        for ( int r = 0; r < 9; r++ )
            if ( board[r * 9 + col] == num ) return false;
        // Box
        int br = (row / 3) * 3, bc = (col / 3) * 3;
        for ( int r = br; r < br + 3; r++ )
            for ( int c = bc; c < bc + 3; c++ )
                if ( board[r * 9 + c] == num ) return false;
        return true;
    }

    // ── Settings helpers ──────────────────────────────────────────────────

    void FocusPanel()          { Focus(); }
    void CloseSettings()       { ShowSettings = false; StateHasChanged(); }
    void ToggleSettings()      { ShowSettings = !ShowSettings; StateHasChanged(); }
    void ToggleShowMistakes()  { ShowMistakes = !ShowMistakes; StateHasChanged(); }
    void ToggleAutoNotes()     { AutoClearNotes = !AutoClearNotes; StateHasChanged(); }
    void ToggleHighlightPeers(){ HighlightPeers = !HighlightPeers; StateHasChanged(); }
    void IncreaseTextScale()   { if ( TextScale < 180 ) { TextScale += 10; StateHasChanged(); } }
    void DecreaseTextScale()   { if ( TextScale > 70  ) { TextScale -= 10; StateHasChanged(); } }
    void ToggleNoteMode()      { NoteMode = !NoteMode; StateHasChanged(); }

    // Returns an inline font-size style string scaled by TextScale %
    string FS( int basePx ) => $"font-size: {Math.Max( 8, (int)(basePx * TextScale * 0.01f) )}px;";

    // ── Static helpers ────────────────────────────────────────────────────

    static List<int>[] MakeNotes()
    {
        var arr = new List<int>[81];
        for ( int i = 0; i < 81; i++ )
            arr[i] = new List<int>();
        return arr;
    }

    // Fisher-Yates shuffle returning a new list of 0..80 in random order
    List<int> ShuffledIndices()
    {
        var list = new List<int>( 81 );
        for ( int i = 0; i < 81; i++ ) list.Add( i );
        for ( int i = 80; i > 0; i-- )
        {
            int j = Game.Random.Int( i );
            int tmp = list[i]; list[i] = list[j]; list[j] = tmp;
        }
        return list;
    }

    // Returns numbers 1–9 in a random order
    List<int> ShuffledNums()
    {
        var list = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
        for ( int i = 8; i > 0; i-- )
        {
            int j = Game.Random.Int( i );
            int tmp = list[i]; list[i] = list[j]; list[j] = tmp;
        }
        return list;
    }
}