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 stateSaveAsync
: Persists changes to the game stateUndoAsync
: Reverts to the previous stateResetAsync
: Returns to the initial stateDeleteAsync
: 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:
-
Memory Management
- The
CircularStack<T>
limits history size - Azure Blob Storage implementation uses efficient serialization
- Lazy loading of game states
- The
-
Network Optimization
- Batch updates when possible
- Compress game state data
- Implement caching for frequently accessed states
-
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!