Software Architecture Question For Match3

Godot Version

4.2

Question

I’m trying to build a Match 3 game with a Board.gd and Tile.gd.

I have some questions regarding the software architecture in general also for future projects.
I want to separate the logic from the UI.

The logic would be a board with numbers, where you can check if a move can be made, matches can be checked and tiles can be removed.

The UI needs to render the board, show a tween when a tiles move and show when a match happens.
I would like the logic to have no knowledge of the UI, that way I can in theory change the UI in any way without touching the logic.

I created the following example classes and I don’t know how to separate the logic and UI in a correct way.

Board class
This could represent the board that contains a gridDictionary (could also be a multi array)

extends Resource
class_name Board

var gridDictionary = {} # Vector2 : Tile

func move(firstPosition: Vector2, secondPosition: Vector2):
	pass

func removeTile(position: Vector2):
	pass

Tile class
Tile class that contains some data

class_name Tile

var colour: int
var score: int
var isMatched: bool

GameService class
The logic class that checks if a move can be made, if a tile should be removed and if there are matches

extends Node2D

class_name GameService

@export var board: Board

func move(firstPosition: Vector2, secondPosition: Vector2):
	# Does validation logic if the move can be done
	board.move(firstPosition, secondPosition)
	pass

func checkMatches() -> Array:
	return []

func removeTile(position: Vector2):
	pass

BoardUI class
This should be the UI class that render everything

extends Node2D

# This should have childs that are instantiated
# under it as Node2D to visualise the tiles
class_name BoardUI


func initialize():
	pass
	# How to initialize this with the logic?
	
func move():
	pass
	# How can I react on moves from the BoardService
	# without tight coupling

Questions

  • How can I render the UI that listens to logic changes. When a move is made in the logic, the UI can visualise it with a tween?
  • How would you model a clear separation between logic and UI so the logic doesn’t know about the UI?
  • Any example projects or tutorials where I can learn about this kind of architecture?

You could use signals? I’d store the Node2D ui tiles in a gridDictionary, too, so you can access them using the same position variables. Then pass the positions of the tiles that should be moved with a signal.

extends Node2D
class_name GameService

signal tile_moved(firstPosition: Vector2, secondPosition: Vector2)

func move(firstPosition: Vector2, secondPosition: Vector2):
	# Does validation logic if the move can be done
	board.move(firstPosition, secondPosition)
    tile_moved.emit(firstPosition, secondPosition)
class_name BoardUI

var gridDictionary = {} # Vector2 : Node2D

func _ready():
	%your_game_service_node.tile_moved.connect(move)
	
func move(firstPosition: Vector2, secondPosition: Vector2):
	var node_to_move_1 = gridDictionary.firstPosition
    var node_to_move_2 = gridDictionary.secondPosition

Looks like a nice separation and your UI knows about the Service and not the other way around.

Is there also a solution with some kind of dependency injection without signals or would something like that not work?