MediumBuilding A Roblox Cash Grab… For Science and Uhh Learn Lua… | by Krakendono | Medium
Sitemap

Building A Roblox Cash Grab… For Science and Uhh Learn Lua…

Building a Matchmaking Table System in Roblox: Chairs, Matches, Camera Locks, and Prompts

15 min readFeb 6, 2026

--

Press enter or click to view image in full size

TL;DR:

Built a complete table-based matchmaking system where:

  • Players sit in chairs → system detects occupancy and glows the empty seat
  • Two seated players → 5-second countdown with test “Buy Luck Multiplier” prompt
  • Match starts → both players locked (can’t move), camera locked to table side-view
  • Match ends → players unlocked and camera restored
  • Only The foundation to something more

Chair glow now preserves custom materials, and all logic is seat-occupancy driven instead of auto-assignment.

Today we are starting the construction of a multiplayer table style matchmaking game system inspired by the popular Roblox game Steal or Split. Roblox’s native multiplayer architecture made this ideal for our needs as we focused on creating a seamless experience where two players can sit at a table and instantly begin a match.

The core gameplay loop is straightforward: players sit → match starts → they compete for in-game currency (credits, not Robux) with real stakes on the outcome → winner takes the pot, loser walks away empty-handed, or they can split it 50/50. We layered in visual feedback, responsive game state management, and future-proofed the system for monetization hooks like the luck multiplier purchase option.

Press enter or click to view image in full size

We started with a clear issue: chairs weren’t glowing correctly to indicate an open seat. The logic was fixed so the system now detects which chair is occupied and makes the empty one glow. This makes it obvious where the second player should sit.

--[[
TableManager.luau
Manages game tables and player seating
Each table seats up to 2 players who then start a match
]]

local TableManager = {}
TableManager.__index = TableManager

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local Events = require(ReplicatedStorage.Shared.Events)

function TableManager.new()
local self = setmetatable({}, TableManager)

-- Configuration - adjust based on your game
self.MAX_TABLES = 25 -- Supports up to 25 matches (~50 players)
self.PLAYERS_PER_TABLE = 2

self.tables = {} -- {tableNumber -> {player1, player2}}
self.tableGroups = self:_FindTableGroups()

-- Initialize empty tables with visual chair models
for i = 1, self.MAX_TABLES do
self.tables[i] = {
seats = {},
seatOccupants = {},
chairSeats = {},
chairDefaults = {},
createdAt = tick(),
chairs = {},
}
self:_CreateTableChairs(i)
end

return self
end

function TableManager:_CreateTableChairs(tableNum)
local tableData = self.tables[tableNum]

-- Try to bind to existing table/chair models in the map first
local group = self.tableGroups[tableNum]
local tableFolder = group and group.folder or workspace:FindFirstChild(string.format("Table_%d", tableNum))
if not tableFolder then
local tablesRoot = workspace:FindFirstChild("Tables")
if tablesRoot then
tableFolder = tablesRoot:FindFirstChild(string.format("Table_%d", tableNum))
end
end

-- If no existing folder, create one
if not tableFolder then
tableFolder = Instance.new("Folder")
tableFolder.Name = string.format("Table_%d", tableNum)
tableFolder.Parent = workspace
end

-- Position tables in a grid pattern (5x5 grid of tables)
local rowNum = math.floor((tableNum - 1) / 5) + 1
local colNum = ((tableNum - 1) % 5) + 1
local tableX = (colNum - 1) * 15 - 30
local tableZ = (rowNum - 1) * 15 - 30
local tableY = 0

-- Create or bind two chairs at each table
local chairsRoot = tableFolder:FindFirstChild("TableChairs") or tableFolder
local existingChairs = group and group.chairs or {}
if #existingChairs == 0 then
for _, child in ipairs(chairsRoot:GetChildren()) do
if child.Name == "Chair" or string.find(child.Name, "Chair") then
table.insert(existingChairs, child)
end
end
-- Sort by position for consistent seat assignment when names are identical
table.sort(existingChairs, function(a, b)
local posA = a:IsA("Model") and a:GetPivot().Position or (a:IsA("BasePart") and a.Position)
local posB = b:IsA("Model") and b:GetPivot().Position or (b:IsA("BasePart") and b.Position)
if not posA or not posB then
return a.Name < b.Name
end
if posA.X == posB.X then
return posA.Z < posB.Z
end
return posA.X < posB.X
end)
end

for seatNum = 1, self.PLAYERS_PER_TABLE do
local chair = existingChairs[seatNum]
if not chair then
chair = tableFolder:FindFirstChild(string.format("Chair_%d", seatNum))
end
if not chair then
-- Fallback: create a simple chair part if no model exists
chair = Instance.new("Part")
chair.Name = string.format("Chair_%d", seatNum)
chair.Shape = Enum.PartType.Cylinder
chair.Size = Vector3.new(1.5, 0.5, 1.5)
chair.Color = Color3.fromRGB(100, 100, 100)
chair.Material = Enum.Material.Plastic
chair.CanCollide = true
chair.TopSurface = Enum.SurfaceType.Smooth
chair.BottomSurface = Enum.SurfaceType.Smooth

-- Position chairs on opposite sides of the table
local offsetX = (seatNum == 1) and -3 or 3
chair.Position = Vector3.new(tableX + offsetX, tableY + 0.5, tableZ)

chair.Parent = tableFolder
end

tableData.chairs[seatNum] = chair
self:_CacheChairDefaults(tableNum, seatNum)
local seat = chair:IsA("Seat") and chair or chair:FindFirstChildWhichIsA("Seat", true)
if not seat then
seat = chair:FindFirstChildWhichIsA("VehicleSeat", true)
end
tableData.chairSeats[seatNum] = seat
if seat then
seat:GetPropertyChangedSignal("Occupant"):Connect(function()
self:_OnSeatOccupantChanged(tableNum, seatNum)
end)
end
end
end

--[[ Internal: Cache default chair appearance ]]
function TableManager:_CacheChairDefaults(tableNum, seatNum)
local tableData = self.tables[tableNum]
if not tableData then return end
local chair = tableData.chairs[seatNum]
if not chair then return end
if not tableData.chairDefaults[seatNum] then
tableData.chairDefaults[seatNum] = {}
end

local function cachePart(part)
if tableData.chairDefaults[seatNum][part] then return end
tableData.chairDefaults[seatNum][part] = {
Color = part.Color,
Material = part.Material,
}
end

if chair:IsA("BasePart") then
cachePart(chair)
elseif chair:IsA("Model") then
for _, descendant in ipairs(chair:GetDescendants()) do
if descendant:IsA("BasePart") then
cachePart(descendant)
end
end
end
end

--[[ Internal: Find table chair groups in the map ]]
function TableManager:_FindTableGroups()
local groups = {}
for _, descendant in ipairs(workspace:GetDescendants()) do
if descendant.Name == "TableChairs" and (descendant:IsA("Folder") or descendant:IsA("Model")) then
local chairs = {}
for _, child in ipairs(descendant:GetChildren()) do
if child.Name == "Chair" or string.find(child.Name, "Chair") then
table.insert(chairs, child)
end
end
if #chairs >= 2 then
table.insert(groups, {
folder = descendant,
chairs = chairs,
})
end
end
end

-- Sort groups by full name for deterministic table numbering
table.sort(groups, function(a, b)
return a.folder:GetFullName() < b.folder:GetFullName()
end)

return groups
end

function TableManager:Initialize()
print(string.format("TableManager: Initialized with %d tables", self.MAX_TABLES))
end

--[[ Find and assign player to an available table ]]
function TableManager:AssignPlayerToTable(player)
if not player or not player.Parent then return false end

-- Find an empty or partially filled table
for tableNum = 1, self.MAX_TABLES do
local table = self.tables[tableNum]
local seatCount = self:_GetSeatCount(table)

if seatCount == 0 then
-- Empty table - seat first player
-- Try to seat at chair 1 first
self:_SeatPlayerAtTable(player, tableNum, 1)
self:_SetChairGlow(tableNum, 1, false) -- Turn off glow on occupied chair
self:_SetChairGlow(tableNum, 2, true) -- Glow the available chair
print(string.format("[TABLE WAIT] %s sat at table %d seat 1 - WAITING for another player...", player.Name, tableNum))
return true
elseif seatCount == 1 then
-- One player already seated - determine which seat is occupied
local occupiedSeat = table.seats[1] and 1 or 2
local availableSeat = occupiedSeat == 1 and 2 or 1
local existingPlayer = table.seats[occupiedSeat]

if existingPlayer and existingPlayer.Parent then
-- Seat second player and trigger ready event
self:_SeatPlayerAtTable(player, tableNum, availableSeat)
self:_SetChairGlow(tableNum, 1, false) -- Turn off both glows
self:_SetChairGlow(tableNum, 2, false)

print(string.format("[TABLE READY] %s sat at table %d seat %d with %s - MATCH STARTING!", player.Name, tableNum, availableSeat, existingPlayer.Name))
-- Fire ready event (ensure we pass them in the right order)
if occupiedSeat == 1 then
Events.PlayersReadyAtTable:Fire(existingPlayer, player, tableNum)
else
Events.PlayersReadyAtTable:Fire(player, existingPlayer, tableNum)
end
return true
else
-- Existing player left, remove them and seat new player
table.seats[occupiedSeat] = nil
self:_SeatPlayerAtTable(player, tableNum, 1)
self:_SetChairGlow(tableNum, 1, false) -- Turn off glow on occupied chair
self:_SetChairGlow(tableNum, 2, true) -- Glow the second chair
print(string.format("[TABLE WAIT] %s sat at table %d - WAITING for another player...", player.Name, tableNum))
return true
end
end
end

-- No tables available
print(string.format("[TABLE FULL] No available tables for %s", player.Name))
return false
end

--[[ Remove player from their table ]]
function TableManager:RemovePlayerFromTable(player)
for tableNum = 1, self.MAX_TABLES do
local table = self.tables[tableNum]
for seatNum = 1, self.PLAYERS_PER_TABLE do
if table.seats[seatNum] == player then
table.seats[seatNum] = nil
self:_SetChairGlow(tableNum, seatNum, false) -- Stop glow when removed
self:_UpdateChairGlows(tableNum)
print(string.format("TableManager: Removed %s from table %d", player.Name, tableNum))
return tableNum
end
end
end
end

--[[ Clear all players from a table after match starts ]]
function TableManager:ClearTable(tableNumber)
if not self.tables[tableNumber] then return end

self.tables[tableNumber].seats = {}
self.tables[tableNumber].seatOccupants = {}
self:_SetChairGlow(tableNumber, 1, false)
self:_SetChairGlow(tableNumber, 2, false)
Events.TableCleared:Fire(tableNumber)
end

--[[ Get count of available tables ]]
function TableManager:GetAvailableTableCount()
local count = 0
for tableNum = 1, self.MAX_TABLES do
local table = self.tables[tableNum]
if self:_GetSeatCount(table) == 0 then
count += 1
end
end
return count
end

--[[ Get total number of tables ]]
function TableManager:GetTotalTables()
return self.MAX_TABLES
end

--[[ Get occupied table count ]]
function TableManager:GetOccupiedTableCount()
local count = 0
for tableNum = 1, self.MAX_TABLES do
local table = self.tables[tableNum]
if self:_GetSeatCount(table) > 0 then
count += 1
end
end
return count
end

--[[ Internal: Seat player at table ]]
function TableManager:_SeatPlayerAtTable(player, tableNum, seatNum)
self.tables[tableNum].seats[seatNum] = player
self.tables[tableNum].seatOccupants[seatNum] = player
print(string.format("TableManager: %s seated at table %d, seat %d", player.Name, tableNum, seatNum))
Events.PlayerSeated:Fire(player, tableNum)
end

--[[ Internal: When a seat's occupant changes ]]
function TableManager:_OnSeatOccupantChanged(tableNum, seatNum)
local tableData = self.tables[tableNum]
if not tableData then return end
local seat = tableData.chairSeats[seatNum]
if not seat then return end

local humanoid = seat.Occupant
if humanoid then
local character = humanoid.Parent
local player = character and Players:GetPlayerFromCharacter(character)
if player then
self:_AssignPlayerToSpecificSeat(player, tableNum, seatNum)
end
else
local previousPlayer = tableData.seatOccupants[seatNum]
if previousPlayer then
tableData.seatOccupants[seatNum] = nil
self:RemovePlayerFromTable(previousPlayer)
end
end
end

--[[ Internal: Assign player to a specific chair seat ]]
function TableManager:_AssignPlayerToSpecificSeat(player, tableNum, seatNum)
if not player or not player.Parent then return false end
local tableData = self.tables[tableNum]
if not tableData then return false end

if tableData.seats[seatNum] == player then
return true
end

if tableData.seats[seatNum] and tableData.seats[seatNum] ~= player then
return false
end

-- Ensure player isn't seated elsewhere
self:RemovePlayerFromTable(player)

self:_SeatPlayerAtTable(player, tableNum, seatNum)

local otherSeat = seatNum == 1 and 2 or 1
local otherPlayer = tableData.seats[otherSeat]

if otherPlayer and otherPlayer.Parent then
self:_SetChairGlow(tableNum, 1, false)
self:_SetChairGlow(tableNum, 2, false)
print(string.format("[TABLE READY] %s sat at table %d seat %d with %s - MATCH STARTING!", player.Name, tableNum, seatNum, otherPlayer.Name))
if seatNum == 1 then
Events.PlayersReadyAtTable:Fire(player, otherPlayer, tableNum)
else
Events.PlayersReadyAtTable:Fire(otherPlayer, player, tableNum)
end
else
self:_SetChairGlow(tableNum, seatNum, false)
self:_SetChairGlow(tableNum, otherSeat, true)
print(string.format("[TABLE WAIT] %s sat at table %d seat %d - WAITING for another player...", player.Name, tableNum, seatNum))
end

return true
end

--[[ Internal: Get seat count for a table ]]
function TableManager:_GetSeatCount(table)
local count = 0
for _ in pairs(table.seats) do count += 1 end
return count
end

--[[ Internal: Update chair glow based on current occupancy ]]
function TableManager:_UpdateChairGlows(tableNum)
local table = self.tables[tableNum]
if not table then return end

local seatCount = self:_GetSeatCount(table)
if seatCount == 0 then
self:_SetChairGlow(tableNum, 1, false)
self:_SetChairGlow(tableNum, 2, false)
return
end

if seatCount == 1 then
local occupiedSeat = table.seats[1] and 1 or 2
local availableSeat = occupiedSeat == 1 and 2 or 1
self:_SetChairGlow(tableNum, occupiedSeat, false)
self:_SetChairGlow(tableNum, availableSeat, true)
return
end

-- Both seats occupied
self:_SetChairGlow(tableNum, 1, false)
self:_SetChairGlow(tableNum, 2, false)
end

--[[ Internal: Set chair glow effect (waiting indicator) ]]
function TableManager:_SetChairGlow(tableNum, chairNum, shouldGlow)
local table = self.tables[tableNum]
if not table then
print(string.format("[GLOW DEBUG] Table %d not found", tableNum))
return
end

if not table.chairs then
print(string.format("[GLOW DEBUG] Table %d has no chairs array", tableNum))
return
end

if not table.chairs[chairNum] then
print(string.format("[GLOW DEBUG] Chair %d not found at table %d", chairNum, tableNum))
return
end

local chair = table.chairs[chairNum]
print(string.format("[GLOW] Table %d Chair %d: %s", tableNum, chairNum, shouldGlow and "GLOWING" or "OFF"))

local function applyGlowToPart(part)
if shouldGlow then
part.Color = Color3.fromRGB(255, 255, 0)
part.Material = Enum.Material.Neon
else
local defaults = table.chairDefaults and table.chairDefaults[chairNum]
local cached = defaults and defaults[part]
if cached then
part.Color = cached.Color
part.Material = cached.Material
else
part.Color = Color3.fromRGB(100, 100, 100)
part.Material = Enum.Material.Plastic
end
end
end

if chair:IsA("BasePart") then
applyGlowToPart(chair)
elseif chair:IsA("Model") then
for _, descendant in ipairs(chair:GetDescendants()) do
if descendant:IsA("BasePart") then
applyGlowToPart(descendant)
end
end
else
print(string.format("[GLOW DEBUG] Chair %d at table %d is not a Part or Model", chairNum, tableNum))
end
end

--[[ Public: Get camera CFrame for a table ]]
function TableManager:GetTableCameraCFrame(tableNum)
local tableData = self.tables[tableNum]
if not tableData then return nil end

local function getPos(obj)
if not obj then return nil end
if obj:IsA("BasePart") then
return obj.Position
end
if obj:IsA("Model") then
return obj:GetPivot().Position
end
return nil
end

local pos1 = getPos(tableData.chairs[1])
local pos2 = getPos(tableData.chairs[2])
local center
local sideDir

if pos1 and pos2 then
center = (pos1 + pos2) * 0.5
local dir = (pos2 - pos1)
if dir.Magnitude > 0.001 then
sideDir = Vector3.new(-dir.Z, 0, dir.X).Unit
end
end

if not center then
center = pos1 or pos2 or Vector3.new(0, 0, 0)
end
if not sideDir then
sideDir = Vector3.new(1, 0, 0)
end

local cameraPos = center + sideDir * 8 + Vector3.new(0, 4, 0)
local focusPos = center + Vector3.new(0, 2, 0)
local cameraCFrame = CFrame.new(cameraPos, focusPos)
return cameraCFrame, focusPos
end

return TableManager

Next, we made sure the glow was targeting the actual chairs in the map. The original script was creating invisible placeholders and glowing those instead of the real seats. We updated chair binding to use the existing Chair models in the scene and apply glow to all of their parts. That instantly made the glow visible in the live environment.

Get Krakendono’s stories in your inbox

Join Medium for free to get updates from this writer.

Once that was resolved, we removed the auto‑seating behavior. Players were being assigned to tables automatically on join, which felt wrong for a chair‑based experience. Now seating is driven entirely by the player actually sitting in a chair. This is done by listening to each chair seat’s Occupant and reacting when someone sits or stands.

We also preserved visual upgrades. Since chairs can be upgraded, forcing them to gray plastic on glow-off was a mistake. We now cache the default materials and colors per chair at startup, then restore those defaults when glow turns off. This keeps each chair’s custom look intact.

From there, we locked gameplay down. When a match begins, both players are movement‑locked: they can’t walk or jump until the match ends. This ensures the match feels like a committed interaction rather than something players can walk out of.

To complete the experience, we added a camera lock. At match start, both players’ cameras snap to a side‑view of the table and stay there for the duration of the match. When the match ends or is cancelled, the camera is restored to normal.

--[[
MatchManager.luau
Manages all active matches and match lifecycle
Creates matches, tracks them, and cleans them up when complete
]]

local MatchManager = {}
MatchManager.__index = MatchManager

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Events = require(ReplicatedStorage.Shared.Events)
local MatchLogic = require(script.Parent.MatchLogic)

function MatchManager.new()
local self = setmetatable({}, MatchManager)

self.matches = {} -- {matchId -> {player1, player2, tableNumber, startTime, state}}
self.matchCounter = 0
self.MAX_MATCH_DURATION = 300 -- 5 minutes per match
self.playerLocks = {}

return self
end

function MatchManager:Initialize()
print("MatchManager: Initialized")

-- Start match update loop
task.spawn(function()
self:_StartUpdateLoop()
end)
end

--[[ Create a new match ]]
function MatchManager:CreateMatch(player1, player2, tableNumber)
if not player1 or not player1.Parent or not player2 or not player2.Parent then
return nil
end

self.matchCounter += 1
local matchId = "match_" .. self.matchCounter

self.matches[matchId] = {
id = matchId,
player1 = player1,
player2 = player2,
tableNumber = tableNumber,
startTime = tick(),
state = "Active",
score1 = 0,
score2 = 0,
}

print(string.format("MatchManager: Created match %s - %s vs %s", matchId, player1.Name, player2.Name))
Events.MatchCreated:Fire(matchId, player1, player2)
self:_LockPlayer(player1)
self:_LockPlayer(player2)

-- Start match logic
MatchLogic.StartMatch(matchId, player1, player2, tableNumber)

return matchId
end

--[[ Complete a match (winner declared) ]]
function MatchManager:CompleteMatch(matchId, winner)
local match = self.matches[matchId]
if not match then return end

match.state = "Completed"
match.winner = winner
match.endTime = tick()
self:_UnlockPlayer(match.player1)
self:_UnlockPlayer(match.player2)

local duration = match.endTime - match.startTime
print(string.format("MatchManager: Match %s ended - Winner: %s (Duration: %.1fs)",
matchId, winner.Name, duration))

-- Clean up match after a delay
task.delay(2, function()
if self.matches[matchId] then
self.matches[matchId] = nil
end
end)
end

--[[ Handle player disconnection during match ]]
function MatchManager:PlayerDisconnected(player)
for matchId, match in pairs(self.matches) do
if match.player1 == player or match.player2 == player then
print(string.format("MatchManager: %s disconnected from match %s", player.Name, matchId))

-- Determine winner (other player)
local winner = (match.player1 == player) and match.player2 or match.player1
self:CompleteMatch(matchId, winner)
break
end
end
end

--[[ Cancel all active matches (for shutdown) ]]
function MatchManager:CancelAllMatches()
for matchId in pairs(self.matches) do
local match = self.matches[matchId]
if match then
self:_UnlockPlayer(match.player1)
self:_UnlockPlayer(match.player2)
end
Events.MatchCancelled:Fire(matchId)
end
self.matches = {}
print("MatchManager: All matches cancelled")
end

--[[ Get current match count ]]
function MatchManager:GetMatchCount()
local count = 0
for match in pairs(self.matches) do count += 1 end
return count
end

--[[ Get match info ]]
function MatchManager:GetMatch(matchId)
return self.matches[matchId]
end

--[[ Get players in a match ]]
function MatchManager:GetMatchPlayers(matchId)
local match = self.matches[matchId]
if not match then return nil, nil end
return match.player1, match.player2
end

--[[ Update match score ]]
function MatchManager:UpdateScore(matchId, player, points)
local match = self.matches[matchId]
if not match then return end

if match.player1 == player then
match.score1 += points
elseif match.player2 == player then
match.score2 += points
end
end

--[[ Get match score ]]
function MatchManager:GetScore(matchId)
local match = self.matches[matchId]
if not match then return 0, 0 end
return match.score1, match.score2
end

--[[ Internal: Update loop to check for timed-out matches ]]
function MatchManager:_StartUpdateLoop()
while self do
task.wait(5) -- Check every 5 seconds

local currentTime = tick()
for matchId, match in pairs(self.matches) do
if match.state == "Active" then
local elapsed = currentTime - match.startTime

if elapsed > self.MAX_MATCH_DURATION then
print(string.format("MatchManager: Match %s timed out after %.1fs", matchId, elapsed))
-- In a real game, you'd declare winner by score or other logic
-- For now, just cancel it
self:_UnlockPlayer(match.player1)
self:_UnlockPlayer(match.player2)
Events.MatchCancelled:Fire(matchId)
self.matches[matchId] = nil
end
end
end
end
end

--[[ Internal: Get a player's humanoid ]]
function MatchManager:_GetHumanoid(player)
if not player then return nil end
local character = player.Character
if not character then return nil end
return character:FindFirstChildWhichIsA("Humanoid")
end

--[[ Internal: Lock a player's movement during a match ]]
function MatchManager:_LockPlayer(player)
local humanoid = self:_GetHumanoid(player)
if not humanoid then return end

if not self.playerLocks[player] then
self.playerLocks[player] = {
WalkSpeed = humanoid.WalkSpeed,
JumpPower = humanoid.JumpPower,
JumpHeight = humanoid.JumpHeight,
UseJumpPower = humanoid.UseJumpPower,
AutoRotate = humanoid.AutoRotate,
}
end

humanoid.WalkSpeed = 0
if humanoid.UseJumpPower then
humanoid.JumpPower = 0
else
humanoid.JumpHeight = 0
end
humanoid.AutoRotate = false
if humanoid.SeatPart then
humanoid.Sit = true
end
end

--[[ Internal: Unlock a player's movement after a match ]]
function MatchManager:_UnlockPlayer(player)
local humanoid = self:_GetHumanoid(player)
local lock = self.playerLocks[player]
if not humanoid or not lock then return end

humanoid.WalkSpeed = lock.WalkSpeed
humanoid.UseJumpPower = lock.UseJumpPower
if lock.UseJumpPower then
humanoid.JumpPower = lock.JumpPower
else
humanoid.JumpHeight = lock.JumpHeight
end
humanoid.AutoRotate = lock.AutoRotate

self.playerLocks[player] = nil
end

return MatchManager

Finally, we layered in a 5‑second countdown and a test purchase prompt. As soon as both players are seated, a UI appears asking if they want to buy a “Luck Multiplier.” It counts down from 5 seconds, then hides and the match begins. Responses are sent back to the server so we can wire up real monetization next.

--[[
GameManager.luau
Main orchestrator for the game system
Coordinates all game features and manages the overall game state
]]

local GameManager = {}
GameManager.__index = GameManager

local ReplicatedStorage = game:GetService("ReplicatedStorage")

-- Managers folder is a sibling to GameManager, so use script.Parent
local TableManager = require(script.Parent.Managers.TableManager)
local MatchManager = require(script.Parent.Managers.MatchManager)
local PlayerManager = require(script.Parent.Managers.PlayerManager)
local Events = require(ReplicatedStorage.Shared.Events)

--[[ Initialize GameManager ]]
function GameManager.new()
local self = setmetatable({}, GameManager)

self.tableManager = TableManager.new()
self.matchManager = MatchManager.new()
self.playerManager = PlayerManager.new()

self.isRunning = false

return self
end

--[[ Start the game system ]]
function GameManager:Start()
if self.isRunning then return end
self.isRunning = true

print("GameManager: Starting...")

-- Initialize all managers
self.tableManager:Initialize()
self.matchManager:Initialize()
self.playerManager:Initialize()

-- Connect event listeners
self:_ConnectEvents()

print("GameManager: Initialization complete")
end

function GameManager:_GetMatchCameraEvent()
local existing = ReplicatedStorage:FindFirstChild("MatchCamera")
if existing and existing:IsA("RemoteEvent") then
return existing
end
local remote = Instance.new("RemoteEvent")
remote.Name = "MatchCamera"
remote.Parent = ReplicatedStorage
return remote
end

function GameManager:_GetMatchPromptEvents()
local promptEvent = ReplicatedStorage:FindFirstChild("MatchPrompt")
if not promptEvent or not promptEvent:IsA("RemoteEvent") then
promptEvent = Instance.new("RemoteEvent")
promptEvent.Name = "MatchPrompt"
promptEvent.Parent = ReplicatedStorage
end

local responseEvent = ReplicatedStorage:FindFirstChild("MatchPromptResponse")
if not responseEvent or not responseEvent:IsA("RemoteEvent") then
responseEvent = Instance.new("RemoteEvent")
responseEvent.Name = "MatchPromptResponse"
responseEvent.Parent = ReplicatedStorage
end

return promptEvent, responseEvent
end

function GameManager:_StartMatchCountdown(player1, player2, tableNumber)
local promptEvent = self._promptEvent
local countdownSeconds = 5

if player1 then
promptEvent:FireClient(player1, "Show", countdownSeconds)
end
if player2 then
promptEvent:FireClient(player2, "Show", countdownSeconds)
end

task.wait(countdownSeconds)

if player1 then
promptEvent:FireClient(player1, "Hide")
end
if player2 then
promptEvent:FireClient(player2, "Hide")
end

if not player1 or not player1.Parent or not player2 or not player2.Parent then
return
end

self.matchManager:CreateMatch(player1, player2, tableNumber)
local cameraCFrame, focusPos = self.tableManager:GetTableCameraCFrame(tableNumber)
if cameraCFrame then
self._cameraEvent:FireClient(player1, "Lock", cameraCFrame, focusPos)
self._cameraEvent:FireClient(player2, "Lock", cameraCFrame, focusPos)
end
self.tableManager:ClearTable(tableNumber)
end

--[[ Stop the game system ]]
function GameManager:Stop()
self.isRunning = false
self.matchManager:CancelAllMatches()
self.playerManager:ClearAllPlayers()
print("GameManager: Stopped")
end

--[[ Connect all event listeners between managers ]]
function GameManager:_ConnectEvents()
local cameraEvent = self:_GetMatchCameraEvent()
local promptEvent, responseEvent = self:_GetMatchPromptEvents()
self._cameraEvent = cameraEvent
self._promptEvent = promptEvent

-- When a player joins the game
Events.PlayerJoined:Connect(function(player)
self.playerManager:AddPlayer(player)
end)

-- When a player leaves the game
Events.PlayerLeft:Connect(function(player)
self.playerManager:RemovePlayer(player)
self.matchManager:PlayerDisconnected(player)
self.tableManager:RemovePlayerFromTable(player)
end)

-- When two players are ready at a table
Events.PlayersReadyAtTable:Connect(function(player1, player2, tableNumber)
task.spawn(function()
self:_StartMatchCountdown(player1, player2, tableNumber)
end)
end)

-- When a match ends
Events.MatchEnded:Connect(function(matchId, winner)
local player1, player2 = self.matchManager:GetMatchPlayers(matchId)
self.matchManager:CompleteMatch(matchId, winner)
if player1 then cameraEvent:FireClient(player1, "Unlock") end
if player2 then cameraEvent:FireClient(player2, "Unlock") end
end)

-- When a match is cancelled
Events.MatchCancelled:Connect(function(matchId)
local player1, player2 = self.matchManager:GetMatchPlayers(matchId)
if player1 then cameraEvent:FireClient(player1, "Unlock") end
if player2 then cameraEvent:FireClient(player2, "Unlock") end
end)

responseEvent.OnServerEvent:Connect(function(player, response)
print(string.format("MatchPrompt: %s responded '%s'", player.Name, tostring(response)))
end)
end

--[[ Get current game stats ]]
function GameManager:GetStats()
return {
activePlayers = self.playerManager:GetPlayerCount(),
activeMatches = self.matchManager:GetMatchCount(),
availableTables = self.tableManager:GetAvailableTableCount(),
totalTables = self.tableManager:GetTotalTables(),
}
end

--[[ Debug info ]]
function GameManager:PrintStats()
local stats = self:GetStats()
print(string.format(
"=== GAME STATS === Players: %d | Matches: %d | Available Tables: %d/%d",
stats.activePlayers,
stats.activeMatches,
stats.availableTables,
stats.totalTables
))
end

return GameManager
Press enter or click to view image in full size

Current system summary:

  • Seats are detected when players actually sit.
  • The open chair glows properly.
  • Chair glow restores each chair’s default look.
  • Matches lock player movement and camera.
  • A countdown and test “Yes/No” purchase prompt runs before match start.

That’s a complete loop: sit → prompt → countdown → match → locked camera and movement → unlock at match end. Next steps can include wiring real purchase logic, canceling the countdown if a player stands, and adding match UI feedback or win conditions.

Press enter or click to view image in full size

--

--