Skip to content

sergioadevita/matchblast

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

49 Commits
 
 
 
 
 
 

Repository files navigation

MatchBlast

A Unity-based 2D match-3 puzzle game.

  • Minimum cluster is 3.
  • To get a row bomb you need to make a cluster of 5.
  • To get a columb bomb you need a cluster of 7.
  • To get an area bomb you need a cluster of 9.

Summary

I implemented the project trying to maintain as much separation of responsabilities as possible, and a separation in layers Core, Logic, View, UI. Below I will explain what things are and what do they do, but the code is also self explanatory. There are some comments here and there, on specific complex places, caveats or justifications, but everything should be pretty much readeable.

Things worth mentioning:

  • This summary focuses only on the implementation.
  • This uses DOTween for view animations.
  • I used patterns, separation of responsabilities and SOLID principles, but there only a few specific places where I moved away from this, like using Vector2Int in the Logic layer.
  • There is an exception that I got only once after finishing everything, that couldn't see the stack because Unity recompiled. I couldn't reproduce it anymore after doing a fix, even after having been playing for an hour. What happen was that I made a match, the exception appeared and everything froze on the board, my assumption is that was caused by timing with DOTween, probably using a different solution to know when all animations finished would avoid this.

Configs

  • BoardViewConfig — Has all the configs for the view of the board and the values needed for the DOTween animations that happen in the view.
  • ElementConfig — Is a PoolObjectConfig (see below). Has all the element configs, the type, health, and specific sounds that it triggers.
  • LevelConfig — Is a PoolObjectConfig (see below). Has all the gameplay configs for the level, board size and the references to the assets needed for elements, the prefab board and the board view config.

AssetService

Takes care of loading and unloading Addressables. It has method to load in bulk or specific assets, methods to get and store assets manually for it to handle and the methods to release (unload). There is also the config AssetServiceConfig, which is used to reference the addressables group labels and the ui and audio configs addresses.


AudioService

Takes care of managing and triggering audios (SFXs/Music). Controls which music needs to be playing, makes sure no audiosources are left behind and mutes/unmutes audio. This one comes with the AudioConfig, that has the asset address to the music and sounds that are project wide. Also comes with AudioObjectConfig, which is a PoolObjectConfig (see below), and it is used to pass the config of the audio, which right now is only the volume.


CameraService

Takes care of adjusting the camera to keep the board centered and entirely on screen. For this, it calculates based on the values provided in the BoardViewConfig.


DatabaseService

Takes care of the persistency. If anything needs to be saved, this service takes care of it and is the entry point to the specific StorageServices. Right now, only a PlayerPrefsStorageService is implemented, but I did it considering the possibility of file or server storage in the future, which is why I made it async.


DataProvider

Basically, what the name says. Provides type safe methods to obtain data/configs from the AssetService. This avoids having the assetservice accesses spreading across the project and centralises things for address and type management. For cleaner, more readable access.


EventService

This is the typical event bus that provides decoupled communication between systems. Anything can subscribe or dispatch events that are typed with structs. It keeps a dictionary of <type, listener> to know what to trigger. This allows the logic, view, and UI layers to communicate without direct references to each other.


Injector

A small dependency injection. I could have used Zenject or VContainer, but I thought that doing this myself would show that I understand what these do under the hood, but I would use proper solutions in a production context. I created this that fulfills injection on field dependencies using the [Inject]. It automatically calls Initialise() on services implementing IService, detects circular dependencies, and supports optional disposal of IDisposable and MonoBehaviour instances on unbind. It is also used to resolve the dependencies of instantiated GameObjects. And the multi-context enables clean separation between core services and level gameplay services (there aren't any right now).


PoolFactoryService

Responsable for the object pooling of all GameObject in the project to avoid the cost of instantiation. It keeps the pools and references to all the GameObjects and provides inactive ones when are needed or creates new ones if there aren't any inactives. When it creates new ones, it adds them to the pool and uses the Injector to resolve the dependencies. The PoolObjectConfig, is the base config of all GameObjects that will be pooled. It holds an AssetReference, to be able to obtain the address to get the assets from the AssetService. With these, I keep references to all assets and avoid having ids/strings being the references to assets.


StateMachine

A general purpose state machine that handles state transitions. Keeps a reference to the current state and triggers Enter and Exit of them. The project has 2 state machines, one to control the GameManager states and orchestrate the game initialisation and flow between screens, and one for the LevelManager states, which orchestrates the gameplay.


UIService

Takes care of the UI panels, what to show and when. It activates the panels with id, gets them from the pool factory, provides access to the UIPresenter, and triggers Setup and CleanUp on activation or deactivation. This keeps UI logic encapsulated in presenters while the service handles creation and toggling.


GameManager and Game States

GameManager is the application entry point. It initialises the game StateMachine, holds references to the main camera, UI canvas, and configuration assets, and handles application events (pause, quit) to flush the database and clean up assets.

The game flows through three states:

  • GameLoadingState — Shows a loading screen, initialises all core services (AssetService, DatabaseService, EventService, Injector, etc.), loads all Addressable assets by label with progress feedback, and then initialises remaining services (DataProvider, CameraService, PoolFactoryService, UIService, AudioService). And transitions to main menu when complete.
  • GameMainMenuState — Activates the main menu UI and listens for the play button event. Transitions to gameplay on play.
  • GameGameplayState — Activates the gameplay UI, selects a random level config, instantiates a LevelManager with pooling, and listens for the back to menu event. On exit, it destroys the level and returns to the main menu.

LevelManager and Level States

LevelManager orchestrates a single level by owning a LogicController, a ViewController, and holds the level StateMachine. It receives a LevelConfig, creates logic and view with their factories, and kicks off the level state machine.

The level flows through five states:

  • LevelInitialisingState — Fits the camera to the board, populates the logic board, initialises the view with the resulting element states, and checks if valid matches exist. If they do, transitions to waiting for input. If not, removes all elements and transitions to processing.
  • LevelWaitingInputState — Listens for element selection events from the view. On tap, it runs the matching algorithm at the selected position. If the cluster is too small, it triggers a bounce animation. If not, it removes the matched elements and transitions to processing.
  • LevelProcessingMatchState — Waits for the view removal animation to finish, then transitions to resolve.
  • LevelResolveMatchState — If the match was large enough, creates a special element (row bomb, column bomb, or area bomb) at the match origin. Then cascades elements downward and spawns new ones from the top. Transitions to cascading.
  • LevelCascadingState — Waits for the cascade animation to complete, then checks for valid matches. If matches exist, returns to waiting for input. If not, removes all remaining elements and loops back to processing.

Logic Layer

The logic layer owns the board state and all matching/cascading rules, with no knowledge of the view or Unity beyond basic data types (I am using Vector2Int).

  • LogicController — Maintains the 2D array of ILogicElement. Provides PopulateBoard() to create the initial grid, GetMatchesAt(position) which runs a BFS algorithm respecting element matching patterns, RemoveElements() to decrement health and null out dead elements, CascadeElements() to drop existing elements and spawn new ones from the top, HasValidMatches() to scan for any remaining clusters meeting the minimum size, and ShouldCreateSpecial()/CreateSpecialElement() for bomb creation logic. By the moment I finished, I realised that this one ended up responsable of quite a few things, and that I could separate it into other objects, but I had to move on to deliver on time, but I am well aware of this improvement.
  • LogicElement — Represents a normal board element with an ID, type, and health. Matches adjacent elements of the same type and ID. Returns the four cardinal neighbours as its matching positions.
  • LogicSpecialElement — Abstract base for bomb elements. Never matches normally, always triggers via blast. The subclasses define the blast patterns: LogicRowBombElement returns the entire row, LogicColumnBombElement returns the entire column, and LogicAreaBombElement returns the surrounding 3×3 area.
  • LogicElementFactory — Creates logic elements from configs using a dictionary that maps ElementType to creation functions. Supports creating by config ID, by explicit parameters, or randomly from the level available elements.

View Layer

The view layer handles all visuals, animations, and player input, and translates logic states into visual representations.

  • ViewController — Manages a dictionary that maps IViewElement to grid position. Initialises the visual board from element states, animates invalid match bounces, orchestrates element removal animations, creates new elements at world positions, and runs cascade animations with staggered delays. Fires events (OnViewBoardReady, OnViewBoardRemovalFinish and OnViewBoardCascadeFinish) when animation sequences complete, allowing the level states to synchronise transitions with visuals.
  • ViewElement — A MonoBehaviour representing a single board element on screen. Sets up its sprite and collider from config, handles OnPointerClick to dispatch the selection event, and has DOTween animations: MoveTo for cascade movement, Bounce for invalid match while triggering a sound, and Destroy to scale down the element for removal, also triggering a sound.
  • ViewElementFactory — Creates view elements via pooling, configures the collider size based on the camera cell dimensions, and initialises them with their config and world position. Uses a dictionary to support custom view types per element type, though currently all elements share a single ViewElement prefab, but it is future ready.

Shared Structs

These are value types used to transfer data between the logic and view layers to avoid coupling.

  • ElementType — Enum defining element categories: Normal, RowBomb, ColumnBomb, AreaBomb.
  • ElementState — Carries an element id, grid position, and health from logic to view during board initialisation.
  • ElementMatchResult — Passes an element position and remaining health after a match. Is used by the view to update the view elements.
  • ElementMovement — Describes a cascade movement with element id, origin position, destination position, and health. A negative origin Y position means a newly spawned element entering from above.
  • MatchData — Stores a match root position, total match count, and whether the root was a special element. Is used to determine if and what type of special element to create.

UI Layer: Presenters and Components

The UI follows a presenter pattern where each screen is a UIPresenter subclass attached to a panel prefab.

  • LoadingScreenUIPresenter — Shows a progress bar during the loading of the game.
  • MainMenuUIPresenter — Listens to the play button and dispatches OnPlayButtonClicked.
  • GameplayUIPresenter — Listens to the back button and dispatches OnBackToMenuClicked.
  • AudioTogglesComponent — A reusable UI component that holds the sound and music mute toggle buttons, and communicates with the AudioService to mute/unmute.

Tests

These uses the Unity Test Framework with NUnit and the assembly created to provide access to the production code to the tests is called GameAssembly. I set up the base test infrastructure, and overall testing approach, the assembly definitions, the separation between what is TestMode and PlayMode and the testing strategy, then used AI assistance to generate the test cases to avoid bias towards happy path. The tests validate matching algorithms, cascade behaviour, special element blast patterns, state transitions, dependency injection resolution, service management, etc. Mostly everything in the project is covered by a test, I can't think right now of something that is not.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors