Help with game mechanics like match-3, but not quite

I used emthree to build a prototype, but realized that I was going in the wrong direction, then I tried to build the grid of the playing field using tilemap, but I ran into the fact that I didn’t understand how to spawn objects in tiles, and also so that they were of different lengths.

Here is my code for building a board:

local emthree = require "emthree.emthree"

    local blocksize = 64
    local boardwidth = 8
    local boardheight = 10

    local function create_block(board, position)
    	local id = factory.create("/spawner#block_spawner", position)
    	msg.post(id, "set_parent", { parent_id = go.get_id(), keep_world_transform = 0 })
    	return id
    end

    local function init_board(self)
    	self.board = emthree.create_board(boardwidth, boardheight, blocksize, { direction = emthree.COLLAPSE_DOWN })
    	emthree.on_create_block(self.board, create_block)
    	for x = 0, self.board.width - 1 do
    		for y = 0, self.board.height - 1 do
    			if not self.board.slots[x][y] then
    				emthree.create_block(self.board, x, y)
    			end
    		end
    	end
    end

    function init(self)
    	init_board(self)
    end

Then I tried to spawn objects on top of the created board, but that didn’t work either
I want to make a similar game and practice in this genre, which way should I look? I’m more interested in the approach of constructing such logic: a grid, randomly spawning objects, and not complete filling as in match-3.

In the game, an object spawns from below and lifts all the blocks up, I also wonder if there should be blocks. Then I tried to spawn objects on top of the created board, but that didn’t help either.
I want to make a similar game and practice in this genre, which direction should I look in? I am more interested in the approach of constructing such logic: grid spawning, randomly spawning objects that move blocks from above, and not completely filling the playing field as in match-3. Do objects have to have a physical body to lift each other up and fall down if there is nothing underneath them?

I am attaching a link to the reference

I really hope for your help, this is the 5th time I’ve tried to do something, but apparently my hands are growing from the wrong place.

I suggest to try and build this from scratch. Use a Lua table to represent the game state (the board, the spawned things etc) and use game objects with sprites for the visual representation of the board and objects. You spawn game objects using factories. Use Emthree as inspiration.

Start small! Don’t try to solve everything at once!

1 Like

thank you for quick response!
how can I control the coordinates of the board? like in chess, like e3e5, etc., in order to clearly understand in what coordinates the object spawned. In general, there is still confusion for me. Moreover, blocks can have different lengths and some block can occupy 3 cells horizontally, and not one.

There was a similar game (mechanics-wise) made with Defold on the MWDJ2023:

I would also suggest to make such game step by step:

Try to separate visual representation from input handling from logic handling. Start by defining smallest part of the game and writing down its design:

Grid is a 8x8 grid (or 8x10, etc, but usually 8 is the width).
Blocks can be: 1x1, 1x2, 1x3, 1x4.

  1. Implement Block Sliding Logic:
    a) implement picking correct block
    b) then allow to move it only horizontally
    c) make sure you can slide only within the grid’s row (between “walls”)
    c) make sure you can’t slide past other blocks in a row, if there are some
  2. Implement what happens on action.released:
    a) there should be a check if some blocks can fall down, so for each row, for each block in that for check if there is space for this block below this block (notice how when forming such statement you “write” for loops)
    b) if there is space, move that block to the row below (not visually, move that “block” in your grid (a Lua table)
    c) notice how this check should be done for all blocks from top rows to bottom rows
  3. Add row “fullness” check - if blocks in a row fill the whole row - remove all blocks from that row - and repeat step above
  4. Add game over conditions (if there is a block in the highest-1 row after all blocks fall, because if you spawn next line, which you will do below, you will touch the ceiling - but you can perhaps also check it after 5.)
  5. Add new line spawning (in the very bottom of the grid, but first move all blocks to the row above, starting from top to bottom)
    a) you increase difficulty by spawning more of bigger blocks in a row, because e.g. 1x1 blocks are always a life saver - you can slide them almost anywhere and many times you make a full row with them (I know, because I play a lot of games like this xD)
2 Likes

Thank you very much for such a detailed answer! I will try further, I hope I will be able to make the core mechanics of this kind of games. A game made at a game jam is something I want to repeat to learn how to work with tables in Lua.

1 Like

In case of any troubles, don’t hesitate to ask :wink: I can’t do it myself now, because of lack of time, but I will try to help as much as I can, I’m very interested in such a game :smiley:

1 Like

Is it better to build a grid like in emthree or use tilemap as an option? or neither at all and just use an abstract table like

self.grid = {
		{ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
		{ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
		{ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
		{ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
		{ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
		{ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
		{ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
		{ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
		{ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},

	}
	for x = 1,10 do
		for y = 1,20 do
			tilemap.set_tile("/grid#grid", "layer", x, y, 1)
		end
	end
1 Like

Yes, I think your approach will be the best, you nicely separated logic from representation, so you can operate only on your table easily to do logic and at the end you can just set visuals (in this loop) :wink:

Will the “cats”/“blocks” be separated into tiles? If so, think of how will you "move them, when you will be “sliding”

2 Likes

Hello!

I started my journey, I built a grid, learned to move a block along a line, though only a 1x1 block, it is also magnetized to the cell when moved, and for the 3rd day now I’ve been trying to learn how to move blocks 1x2, 1x3, etc., in which direction should I move? Where to begin? How to position 1x2, 1x3 blocks? Here is my code, I would be grateful if you could help me.

board.lua

local M = {}

local cell_width = 64
local cell_height = 64
local rows = 10
local cols = 8

local screen_width = tonumber(sys.get_config("display.width"))
local screen_height = tonumber(sys.get_config("display.height"))

local start_x = (screen_width - cols * cell_width) / 2
local start_y = (screen_height - rows * cell_height) / 2

function M.create_board()
	M.grid = {}
	for i = 1, rows do
		M.grid[i] = {}
		for j = 1, cols do
			local x = start_x + (j - 1) * cell_width
			local y = start_y + (i - 1) * cell_height
			M.grid[i][j] = {x = x, y = y, block = nil, occupied = false}
			factory.create("/spawner#block_spawner", vmath.vector3(x, y, 0))
		end
	end
end

function M.spawn_block(i, j, length)
	local can_place = true
	for k = 0, length - 1 do
		if j + k > cols or M.grid[i][j + k].occupied then
			can_place = false
			break
		end
	end

	if can_place then
		for k = 0, length - 1 do
			local cell = M.grid[i][j + k]
			cell.block = true
			cell.occupied = true
		end

		local x = M.grid[i][j].x
		local y = M.grid[i][j].y
		local props = { length = length }
		local block = factory.create("/cat_spawner#factory", vmath.vector3(x + (length - 1) * cell_width / 2, y, 1), nil, props)
	end
end

function M.remove_block(i, j, length)
	for k = 0, length - 1 do
		local cell = M.grid[i][j + k]
		cell.block = nil
		cell.occupied = false
	end
end

function M.find_nearest_cell(pos, row, length)
	local nearest_j
	local min_dist = math.huge

	if not M.grid[row] then
		return nil, nil
	end

	for j = 1, #M.grid[row] do
		local can_place = true
		for k = 0, length - 1 do
			if j + k > cols or M.grid[row][j + k].occupied then
				can_place = false
				break
			end
		end

		if can_place then
			local dist = vmath.length(vmath.vector3(M.grid[row][j].x, M.grid[row][j].y, 0) - pos)
			if dist < min_dist then
				min_dist = dist
				nearest_j = j
			end
		end
	end
	return row, nearest_j
end

function M.find_current_cell(pos, length)
	for i = 1, #M.grid do
		for j = 1, #M.grid[i] do
			local can_place = true
			for k = 0, length - 1 do
				if j + k > cols or not M.grid[i][j + k].occupied then
					can_place = false
					break
				end
			end

			if can_place and math.abs(M.grid[i][j].x - pos.x) < cell_width / 2 and math.abs(M.grid[i][j].y - pos.y) < cell_height / 2 then
				return i, j
			end
		end
	end
	return nil, nil
end

return M

Here is the script that is attached to the block.

go.property("length", 1)

local cursor = require "in.cursor"
local board = require "code.grid.board"
local pressed_position = vmath.vector3()
local released_position = vmath.vector3()

local function shake(self)
	go.cancel_animations(".", "scale.x")
	go.cancel_animations(".", "scale.y")
	go.set_scale(self.initial_scale)
	local scale = go.get_scale()
	go.set_scale(scale * 1.2)
	go.animate(".", "scale.x", go.PLAYBACK_ONCE_FORWARD, scale.x, go.EASING_OUTELASTIC, 0.8)
	go.animate(".", "scale.y", go.PLAYBACK_ONCE_FORWARD, scale.y, go.EASING_OUTELASTIC, 0.8, 0, function()
		go.set_scale(self.initial_scale)
	end)
end

local function get_current_block_pos(self, length)
	local nearest_i, nearest_j = board.find_current_cell(go.get_position(), length)
	if nearest_i == nil or nearest_j == nil then
		print("Error: nearest_i or nearest_j is nil in get_current_block_pos")
		return nil, nil
	end
	return nearest_i, nearest_j
end

function init(self)
	self.over_pos = vmath.vector3()
	self.initial_scale = go.get_scale()
	self.dragging = false
	self.original_cell = nil
	self.length = self.length or 1
end

function on_message(self, message_id, message, sender)
	if message_id == cursor.PRESSED then
		shake(self)
		pressed_position = go.get_position()
		self.dragging = true
		local current_i, current_j = get_current_block_pos(self, self.length)

		if current_i == nil or current_j == nil then
			print("Error: current_i or current_j is nil in cursor.PRESSED")
			return
		end

		self.original_cell = {i = current_i, j = current_j}

		for k = 0, self.length - 1 do
			if board.grid[current_i] and board.grid[current_i][current_j + k] and board.grid[current_i][current_j + k].occupied then
				board.grid[current_i][current_j + k].occupied = false
				board.grid[current_i][current_j + k].block = nil
			end
		end

		print("Block's cell: row: " .. tostring(current_i) .. " col: " .. tostring(current_j))
	elseif message_id == cursor.RELEASED then
		released_position = go.get_position()
		if pressed_position ~= released_position then
			shake(self)
			self.dragging = false

			if self.original_cell then
				local current_row = self.original_cell.i
				local nearest_i, nearest_j = board.find_nearest_cell(go.get_position(), current_row, self.length)

				if nearest_i == nil or nearest_j == nil then
					nearest_i, nearest_j = self.original_cell.i, self.original_cell.j
				end

				local cell = board.grid[nearest_i] and board.grid[nearest_i][nearest_j]

				if cell then

					go.set_position(vmath.vector3(cell.x + (self.length - 1) * 64 / 2, cell.y, 1))
					if self.original_cell.i ~= nearest_i or self.original_cell.j ~= nearest_j then
						for k = 0, self.length - 1 do
							board.grid[self.original_cell.i][self.original_cell.j + k].occupied = false
							board.grid[self.original_cell.i][self.original_cell.j + k].block = nil
							board.grid[nearest_i][nearest_j + k].occupied = true
							board.grid[nearest_i][nearest_j + k].block = true
						end
						self.original_cell = {i = nearest_i, j = nearest_j}
					end
				else
					print("Error: cell is nil in cursor.RELEASED")
				end
			else
				print("Error: original_cell is nil in cursor.RELEASED")
			end
		else
			shake(self)
			return
		end
	elseif message_id == cursor.DRAG then
		if self.dragging then
			if self.original_cell then
				local new_pos = go.get_position()
				local nearest_i, nearest_j = board.find_nearest_cell(new_pos, self.original_cell.i, self.length)
				if nearest_i == nil or nearest_j == nil then
					new_pos = vmath.vector3(board.grid[self.original_cell.i][self.original_cell.j].x + (self.length - 1) * cell_width / 2, board.grid[self.original_cell.i][self.original_cell.j].y, 1)
				else
					local min_x = board.grid[self.original_cell.i][1].x
					local max_x = board.grid[self.original_cell.i][#board.grid[self.original_cell.i]].x
					new_pos.x = math.max(min_x, math.min(new_pos.x, max_x))
				end
				go.set_position(new_pos)
			else
				print("Error: original_cell is nil in cursor.DRAG")
			end
		end
	end
end

Moving a 1x1 block was very easy to implement, as it turned out, I can’t imagine what will happen when I get to 1x4…

Now the 1x2 block moves incorrectly, stands as the center of the sprite in a cell, cannot move one cell to the right and jumps 2 cells when you move it by one.