Back to Blog

How I Built a Pluggable Game Logic System for Sudoku Using Blazor and Design Patterns

June 07, 2025

Featured image for How I Built a Pluggable Game Logic System for Sudoku Using Blazor and Design Patterns

Why Architecture Matters Even in Simple Games

When people think of software architecture, they often picture enterprise-scale systems: microservices, cloud infrastructure, distributed logging, and so on. But I believe the same principles apply when you're building something small, like a Sudoku game.

What You'll Learn

In this article, I'll walk you through how I built a Sudoku game that's both fun to play and maintainable to develop. You'll learn:

  • How to apply the Strategy pattern to create pluggable game logic, allowing you to swap different solving algorithms and puzzle generation techniques
  • Using the State pattern to manage game lifecycle transitions (new game, in progress, completed) in a clean, maintainable way
  • Implementing undo/redo functionality with the Memento pattern, making it easy for players to experiment with different solutions
  • Storing game state in Azure Blob Storage, with considerations for offline play and data persistence
  • Testing strategies for each component, ensuring reliability and maintainability
  • Performance optimizations for both in-memory and cloud storage scenarios
  • Real-world examples of how these patterns solve common game development challenges

Whether you're building a game, a business application, or any other software project, these patterns and practices will help you create more maintainable and flexible code.

Why? Because simplicity doesn't mean throwaway. The smaller the project, the more it teaches you about the core principles: modularity, separation of concerns, testability, and scalability. Even something as straightforward as Sudoku has complexity hiding beneath the surface: game logic, validation rules, player state, undo/redo mechanics, puzzle generation, and persistence. Without structure, that complexity turns to chaos quickly.

I wanted this project to be more than just a game. It became a canvas for:

  • Applying the Strategy pattern to swap out different solving or puzzle generation algorithms
  • Using the State pattern to manage game lifecycle transitions
  • Designing a Memento-based system to handle undo/redo in a way that feels natural and lightweight
  • Persisting game state to Azure Blob Storage in a cloud-native and cost-effective way
  • Supporting anonymous players with fun pseudo-identities like FastGiraffe12

These aren't over-engineered solutions. They're practical, purposeful decisions that made the game more maintainable, more flexible, and frankly, more fun to build.

This post is a deep dive into how I applied these ideas to structure the game's logic in a way that feels like building a serious system, even if the end goal is just solving puzzles on a coffee break.

Solver Strategies: Encapsulating Complexity with the Strategy Pattern

Sudoku solvers range from brute-force backtracking algorithms to more human-like logical deduction strategies. Rather than hard-coding a single approach, I wanted the flexibility to plug in different solving strategies depending on the context.

To achieve this, I defined an interface:

public interface ISudokuSolverStrategy
{
    bool TrySolve(Cell[] board);
}

Each implementation encapsulates a different solving style. For example:

Backtracking Solver

This is the classic recursive brute-force approach. It tries a number in each empty cell and backtracks if it hits a contradiction.

public class BacktrackingSolver : ISudokuSolverStrategy
{
    public bool TrySolve(Cell[] board)
    {
        // Recursive logic to try placing values and backtrack on failure
    }
}

Constraint Propagation Solver

This version tries to mimic how humans solve puzzles, using techniques like eliminating candidates and identifying naked singles.

public class ConstraintPropagationSolver : ISudokuSolverStrategy
{
    public bool TrySolve(Cell[] board)
    {
        // Apply constraint rules iteratively until the board is solved or stuck
    }
}

This separation allows me to test each solver independently and swap them during gameplay, testing, or generation.

Even better, the strategy can evolve over time. For instance, I could introduce an AI-powered solver that learns from player input or uses reinforcement learning. With this structure in place, it's just a matter of implementing another class.

In the next section, I'll go over how the game transitions through states like NewGame, InProgress, and Completed using the State pattern.

Game Lifecycle: Managing Transitions with the State Pattern

At first glance, a Sudoku game might seem like it only has two modes: solved or unsolved. But once you start building features like saving progress, resuming games, validating solutions, and tracking timing, the number of game states increases quickly.

To model this properly, I used the State pattern to encapsulate behavior for each phase of the game lifecycle. Here's a basic interface:

public interface IGameSessionState
{
    void End();
    void Pause();
    void RecordMove(bool isValid);
    void ReloadBoard(GameStateMemory gameState);
    void Resume();
    void Start();
}

Each concrete state handles only the logic relevant to that phase. For example:

NewGameSessionState

public class NewGameSessionState : IGameSessionState
{
    public void End()
    {
        session.Timer.Pause();
        session.SessionState = new CompletedGameSessionState(session);
    }

    public void Pause()
    {
        // New game state doesn't pause
    }

    public void RecordMove(bool isValid)
    {
        // New game state doesn't record moves
    }

    public void Resume()
    {
        // New game state doesn't resume
    }

    public void Start()
    {
        session.Timer.Start();
        session.SessionState = new ActiveGameSessionState(session);
    }
}

ActiveProgressSessionState

public class ActiveProgressState : IGameState
{
    public void End()
    {
        session.Timer.Pause();
        session.SessionState = new CompletedGameSessionState(session);
    }

    public void Pause()
    {
        session.Timer.Pause();
    }

    public void RecordMove(bool isValid)
    {
        session.GameState.TotalMoves++;

        if (!isValid) session.GameState.InvalidMoves++;
    }

    public void Resume()
    {
        session.Timer.Resume();
    }

    public void Start()
    {
        // Active game state doesn't start
    }
}

CompletedState

public class CompletedState : IGameState
{
    public void End()
    {
        // Completed game state doesn't end
    }

    public void Pause()
    {
        // Completed game state doesn't pause
    }

    public void RecordMove(bool isValid)
    {
        // Completed game state doesn't record moves
    }

    public void ReloadBoard(GameStateMemory gameState)
    {
        // Completed game state doesn't reload board
    }

    public void Resume()
    {
        // Completed game state doesn't resume
    }

    public void Start()
    {
        // Completed game state doesn't start
    }
}

Each state is isolated, easy to test, and responsible only for what it needs to do.

Undo and Redo: Capturing History with the Memento Pattern

Undo and redo functionality is essential in any puzzle game that encourages experimentation. I wanted players the ability to try different possibilities without worrying about permanently breaking their progress.

public class GameStateMemento
{
    public string Alias { get; set; }
    public int InvalidMoves { get; set; }
    public DateTime LastUpdated { get; init; }
    public TimeSpan PlayDuration { get; set; }
    public int TotalMoves { get; set; }
}

The IGameStateStorage interface defines the contract for persisting and managing game state. It's a crucial part of the architecture that enables:

  • Saving game progress across sessions
  • Implementing undo/redo functionality
  • Supporting different storage backends (in-memory, Azure Blob Storage, etc.)
  • Managing game state history

Each method serves a specific purpose:

  • LoadAsync: Retrieves the current game state
  • SaveAsync: Persists changes to the game state
  • UndoAsync: Reverts to the previous state
  • ResetAsync: Returns to the initial state
  • DeleteAsync: Cleans up game state data

The MemoryType property helps the application determine which storage implementation is being used, allowing for different behaviors based on the storage type.

public interface IGameStateStorage
{
    GameStateMemoryType MemoryType { get; }
    Task DeleteAsync(string alias, string puzzleId);
    Task<GameStateMemento?> LoadAsync(string alias, string puzzleId);
    Task<GameStateMemento?> ResetAsync(string alias, string puzzleId);
    Task SaveAsync(GameStateMemento gameState);
    Task<GameStateMemento?> UndoAsync(string alias, string puzzleId);
}

InMemoryGameStateStorage

The InMemoryGameStateStorage implementation provides a lightweight, temporary storage solution perfect for testing and development. It uses a CircularStack<T> to maintain a fixed-size history of game states, preventing memory leaks while still supporting undo/redo operations.

Key features:

  • Limited to 50 states in history (configurable)
  • Thread-safe operations
  • Perfect for unit testing
  • No external dependencies

The implementation is particularly useful when:

  • Testing game logic without cloud dependencies
  • Running automated tests
  • Developing new features that don't require persistence
  • Providing a fallback storage mechanism
public class InMemoryGameStateStorage : IGameStateStorage
{
	private readonly CircularStack<GameStateMemory> _gameState = new(50);

    public GameStateMemoryType MemoryType => GameStateMemoryType.InMemory;

    public Task DeleteAsync(string alias, string puzzleId)
    {
        _gameState.Clear();
        return Task.CompletedTask;
    }

    public Task<PuzzleState> LoadAsync(string alias, string puzzleId)
    {
        return Task.FromResult(_gameState.Count > 0 ? _gameState.Peek() : null);
    }

    public async Task<PuzzleState> ResetAsync(string alias, string puzzleId)
    {
        while (_gameState.Count > 1)
        {
            await UndoAsync(alias, puzzleId);
        }
        return _gameState.Peek();
    }

    public Task SaveAsync(PuzzleState gameState)
    {
        if (_gameState.Count > 0)
        {
            var previousGameState = _gameState.Peek();
            if (AreGameStatesEqual(gameState, previousGameState)) return Task.CompletedTask;
        }
        _gameState.Push(gameState);
        return Task.CompletedTask;
    }

    public Task<PuzzleState> UndoAsync(string alias, string puzzleId)
    {
        return _gameState.Count == 0 ? Task.FromResult<PuzzleState>(null) : Task.FromResult(_gameState.Pop());
    }
}

CircularStack<> is the internal storage for InMemoryGameStateStorage

public class CircularStack<T>
{
    private readonly T[] buffer;
    private int top;

    public void Push(T item) { /* ... */ }
    public T Pop() { /* ... */ }
    public bool CanUndo => /* ... */;
    public bool CanRedo => /* ... */;
}

Persisting Game State with Azure Blob Storage

Each user's game state is stored as a blob, keyed by a pseudo-identity like FastGiraffe12.

public class AzureBlobGameStateStorage(IStorageService storageService) : IGameStateStorage, IDisposable
{
    private const int MaxUndoHistory = 50;
    private readonly SemaphoreSlim _semaphore = new(1, 1);

    public GameStateMemoryType MemoryType => GameStateMemoryType.AzureBlobPersistence;

    public async Task DeleteAsync(string alias, string puzzleId)
    {
        ValidateGameStateArgs(alias, puzzleId);

        await _semaphore.WaitAsync();
        try
        {
            await foreach (var blobItem in storageService.GetBlobNamesAsync(ContainerName, $"{alias}/{puzzleId}"))
            {
                await storageService.DeleteAsync(ContainerName, blobItem);
            }
        }
        finally
        {
            _semaphore.Release();
        }
    }

    public void Dispose()
    {
        _semaphore?.Dispose();
    }

    public async Task<GameStateMemory> LoadAsync(string alias, string puzzleId)
    {
        ValidateGameStateArgs(alias, puzzleId);

        await _semaphore.WaitAsync();
        try
        {
            var latestBlobName = await GetLatestBlobNameAsync(alias, puzzleId);
            if (latestBlobName == null)
            {
                return null;
            }
            return await storageService.LoadAsync<GameStateMemory>(ContainerName, latestBlobName);
        }
        finally
        {
            _semaphore.Release();
        }
    }

    public async Task<GameStateMemory> ResetAsync(string alias, string puzzleId)
    {
        ValidateGameStateArgs(alias, puzzleId);

        await _semaphore.WaitAsync();
        try
        {
            var blobList = await GetSortedBlobNamesAsync(alias, puzzleId);
            if (blobList.Count <= 1)
            {
                throw new CannotResetInitialStateException();
            }
            var initialBlobName = blobList.First();
            var blobsToDelete = blobList.Where(x => x != initialBlobName);
            foreach (var blobName in blobsToDelete)
            {
                await storageService.DeleteAsync(ContainerName, blobName);
            }
            return await storageService.LoadAsync<GameStateMemory>(ContainerName, initialBlobName);
        }
        finally
        {
            _semaphore.Release();
        }
    }

    public async Task SaveAsync(GameStateMemory gameState)
    {
        ValidateGameState(gameState);

        var currentGameState = await LoadAsync(gameState.Alias, gameState.PuzzleId);
        if (currentGameState != null && currentGameState.IsSameGameStateAs(gameState))
        {
            return;
        }

        await _semaphore.WaitAsync();
        try
        {
            var nextBlobName = await GetNextBlobNameAsync(gameState.Alias, gameState.PuzzleId);
            await storageService.SaveAsync(ContainerName, nextBlobName, gameState);
            await TrimHistoryIfNeededAsync(gameState.Alias, gameState.PuzzleId);
        }
        finally
        {
            _semaphore.Release();
        }
    }

    public async Task<GameStateMemory> UndoAsync(string alias, string puzzleId)
    {
        ValidateGameStateArgs(alias, puzzleId);

        await _semaphore.WaitAsync();
        try
        {
            var blobList = await GetSortedBlobNamesAsync(alias, puzzleId);
            if (blobList.Count <= 1)
            {
                throw new CannotUndoInitialStateException();
            }
            var latestBlobName = blobList.Last();
            await storageService.DeleteAsync(ContainerName, latestBlobName);
            blobList.RemoveAt(blobList.Count - 1);
            if (blobList.Count == 0)
            {
                return null;
            }
            var previousBlobName = blobList.Last();
            return await storageService.LoadAsync<GameStateMemory>(ContainerName, previousBlobName);
        }
        finally
        {
            _semaphore.Release();
        }
    }
}

Testing the Architecture

One of the biggest benefits of this architecture is its testability. Each component can be tested in isolation, making it easier to catch bugs early and maintain high code quality.

Unit Testing Strategies

[Fact]
public async Task InMemoryGameStateStorage_Undo_RemovesLatestState()
{
    // Arrange
    var storage = new InMemoryGameStateStorage();
    var initialState = CreateTestGameState();
    await storage.SaveAsync(initialState);
    var modifiedState = CreateTestGameState();
    await storage.SaveAsync(modifiedState);

    // Act
    var result = await storage.UndoAsync("test", "puzzle1");

    // Assert
    Assert.Equal(initialState, result);
}

Integration Testing

For integration tests, we can swap implementations:

public class GameSessionTests
{
    [Fact]
    public async Task GameSession_WithAzureStorage_PersistsCorrectly()
    {
        // Arrange
        var storage = new AzureBlobGameStateStorage(_storageService);
        var session = new GameSession(storage);

        // Act
        await session.Start();
        await session.MakeMove(1, 1, 5);
        await session.Save();

        // Assert
        var loadedSession = await storage.LoadAsync("test", "puzzle1");
        Assert.Equal(5, loadedSession.Board[1, 1]);
    }
}

Real-World Problem Solving

Let me share a few examples of how these patterns solved real problems:

1. Handling Network Issues

When implementing the Azure Blob Storage backend, I wanted a way to handle network failures gracefully. The State pattern made this easy:

public class OfflineGameSessionState : IGameSessionState
{
    private readonly Queue<GameStateMemento> _pendingSaves = new();

    public async Task SaveAsync(GameStateMemento state)
    {
        _pendingSaves.Enqueue(state);
        // Attempt to sync when connection is restored
    }
}

2. Interactive Tutorial System

One of the features I plan to implement in the future is an interactive tutorial system. The State pattern will be perfect for this because it will allow me to guide new players through the learning process without complicating the core game logic. Here's how I envision it:

public class TutorialGameSessionState : IGameSessionState
{
    private readonly Queue<TutorialStep> _tutorialSteps;
    private readonly IGameSessionState _baseState;

    public TutorialGameSessionState(IGameSessionState baseState)
    {
        _baseState = baseState;
        _tutorialSteps = new Queue<TutorialStep>(GetTutorialSteps());
    }

    public async Task MakeMove(int row, int col, int value)
    {
        var currentStep = _tutorialSteps.Peek();
        if (currentStep.IsValidMove(row, col, value))
        {
            await _baseState.MakeMove(row, col, value);
            _tutorialSteps.Dequeue();

            if (_tutorialSteps.Count == 0)
            {
                // Transition to normal gameplay
                session.TransitionTo(new ActiveGameSessionState(session));
            }
        }
        else
        {
            // Show helpful hint based on the current tutorial step
            await ShowTutorialHint(currentStep);
        }
    }

    private IEnumerable<TutorialStep> GetTutorialSteps()
    {
        yield return new TutorialStep(
            "Welcome to Sudoku! Let's start by placing a 5 in the top-left cell.",
            (0, 0),
            5
        );
        yield return new TutorialStep(
            "Great! Now try placing a 3 in the cell to its right.",
            (0, 1),
            3
        );
        // More tutorial steps...
    }
}

This design will offer several advantages:

  • Tutorial steps will be isolated and easily modifiable
  • Players won't be able to make invalid moves during the tutorial
  • The tutorial system can be enabled/disabled without affecting core game logic
  • Different tutorial paths can be created for different skill levels
  • The base game state will remain clean and focused on core gameplay

Performance Considerations

While the architecture provides flexibility, we needed to consider performance:

  1. Memory Management

    • The CircularStack<T> limits history size
    • Azure Blob Storage implementation uses efficient serialization
    • Lazy loading of game states
  2. Network Optimization

    • Batch updates when possible
    • Compress game state data
    • Implement caching for frequently accessed states
  3. UI Responsiveness

    • State changes are non-blocking
    • Background saving of game state
    • Optimistic UI updates

Lessons Learned and What's Next

  • Architecture matters, even small apps benefit from patterns like Strategy, State, and Memento.
  • Azure services are a great fit for lightweight persistence.
  • Mobile-first design is more than layout—it's about shaping the experience.

Planned features:

  • Statistics dashboard
  • AI-powered hint system
  • Custom puzzle import

What features would you add to a Sudoku game? I'd love to hear your thoughts!


Profile picture

Written by Shannon Stewart I am a software architect and engineer focused on Azure, C#, and AI. I write about real-world projects, cloud-native patterns, and building smarter systems with modern tools.