diff --git a/.codex/AGENTS.md b/.codex/AGENTS.md
index 09f5df7..2a6d294 100644
--- a/.codex/AGENTS.md
+++ b/.codex/AGENTS.md
@@ -1,8 +1,13 @@
-This project is a Minecraft-like voxel system for Roblox using Rojo. The server
-is authoritative over chunks and blocks. Clients only handle input, UI, and
-requests. Clients must never create or destroy blocks directly. The server must
-validate distance, block type, and target. Shared modules must not reference
-Roblox services.
+# Agents
-Keep in mind that to replicate anything across the client-server model or to
-write to any instance you must use serial luau
+- This project is a Minecraft-like voxel system for Roblox using Rojo.
+- The server is authoritative over chunks and blocks.
+- Clients only handle input, UI, and requests.
+- Clients must never create or destroy blocks directly.
+- The server must validate distance, block type, and target.
+- Shared modules must not reference Roblox services.
+
+Note: To replicate anything across the client-server model or to write to any
+instance you must use serial Luau.
+
+The main AGENTS.md file is in the `.codex` folder!
diff --git a/src/ReplicatedStorage/Remotes/InventoryRequest.rbxmx b/src/ReplicatedStorage/Remotes/InventoryRequest.rbxmx
new file mode 100644
index 0000000..623396b
--- /dev/null
+++ b/src/ReplicatedStorage/Remotes/InventoryRequest.rbxmx
@@ -0,0 +1,7 @@
+
+ -
+
+ InventoryRequest
+
+
+
diff --git a/src/ReplicatedStorage/Remotes/InventorySync.rbxmx b/src/ReplicatedStorage/Remotes/InventorySync.rbxmx
new file mode 100644
index 0000000..5ca08e8
--- /dev/null
+++ b/src/ReplicatedStorage/Remotes/InventorySync.rbxmx
@@ -0,0 +1,7 @@
+
+ -
+
+ InventorySync
+
+
+
diff --git a/src/ReplicatedStorage/Shared/Catppuccin.lua b/src/ReplicatedStorage/Shared/Catppuccin.lua
new file mode 100644
index 0000000..89f3a50
--- /dev/null
+++ b/src/ReplicatedStorage/Shared/Catppuccin.lua
@@ -0,0 +1,32 @@
+local Catppuccin = {}
+
+Catppuccin.mocha = {
+ rosewater = Color3.fromRGB(245, 224, 220),
+ flamingo = Color3.fromRGB(242, 205, 205),
+ pink = Color3.fromRGB(245, 194, 231),
+ base = Color3.fromRGB(30, 30, 46),
+ mantle = Color3.fromRGB(24, 24, 37),
+ crust = Color3.fromRGB(17, 17, 27),
+ surface0 = Color3.fromRGB(49, 50, 68),
+ surface1 = Color3.fromRGB(69, 71, 90),
+ surface2 = Color3.fromRGB(88, 91, 112),
+ overlay0 = Color3.fromRGB(108, 112, 134),
+ overlay1 = Color3.fromRGB(127, 132, 156),
+ overlay2 = Color3.fromRGB(147, 153, 178),
+ text = Color3.fromRGB(205, 214, 244),
+ subtext0 = Color3.fromRGB(166, 173, 200),
+ subtext1 = Color3.fromRGB(186, 194, 222),
+ mauve = Color3.fromRGB(203, 166, 247),
+ red = Color3.fromRGB(243, 139, 168),
+ maroon = Color3.fromRGB(235, 160, 172),
+ peach = Color3.fromRGB(250, 179, 135),
+ yellow = Color3.fromRGB(249, 226, 175),
+ green = Color3.fromRGB(166, 227, 161),
+ teal = Color3.fromRGB(148, 226, 213),
+ sky = Color3.fromRGB(137, 220, 235),
+ sapphire = Color3.fromRGB(116, 199, 236),
+ blue = Color3.fromRGB(137, 180, 250),
+ lavender = Color3.fromRGB(180, 190, 254),
+}
+
+return Catppuccin
diff --git a/src/ReplicatedStorage/Shared/Inventory.lua b/src/ReplicatedStorage/Shared/Inventory.lua
new file mode 100644
index 0000000..3e62dc6
--- /dev/null
+++ b/src/ReplicatedStorage/Shared/Inventory.lua
@@ -0,0 +1,106 @@
+local Inventory = {}
+
+local HOTBAR_SIZE = 9
+local slots = table.create(HOTBAR_SIZE)
+local selectedIndex = 1
+
+local listeners = {
+ changed = {},
+ selected = {},
+}
+
+local function addListener(list, fn)
+ table.insert(list, fn)
+ local active = true
+ return {
+ Disconnect = function()
+ if not active then
+ return
+ end
+ active = false
+ for i, cb in ipairs(list) do
+ if cb == fn then
+ table.remove(list, i)
+ break
+ end
+ end
+ end,
+ }
+end
+
+local function notify(list, ...)
+ for _, cb in ipairs(list) do
+ cb(...)
+ end
+end
+
+function Inventory.GetHotbarSize(): number
+ return HOTBAR_SIZE
+end
+
+function Inventory.GetSlots(): {string?}
+ return slots
+end
+
+function Inventory.GetSlot(index: number): string?
+ return slots[index]
+end
+
+function Inventory.SetSlots(ids: {string?})
+ for i = 1, HOTBAR_SIZE do
+ local value = ids[i]
+ if value == "" then
+ value = nil
+ end
+ slots[i] = value
+ end
+ selectedIndex = math.clamp(selectedIndex, 1, HOTBAR_SIZE)
+ if slots[selectedIndex] == nil then
+ for i = 1, HOTBAR_SIZE do
+ if slots[i] ~= nil then
+ selectedIndex = i
+ break
+ end
+ end
+ end
+ notify(listeners.changed, slots)
+ notify(listeners.selected, selectedIndex, Inventory.GetSelectedId())
+end
+
+function Inventory.SetSlot(index: number, id: string?)
+ if index < 1 or index > HOTBAR_SIZE then
+ return
+ end
+ slots[index] = id
+ notify(listeners.changed, slots)
+end
+
+function Inventory.GetSelectedIndex(): number
+ return selectedIndex
+end
+
+function Inventory.SetSelectedIndex(index: number)
+ if index < 1 or index > HOTBAR_SIZE then
+ return
+ end
+ if selectedIndex == index then
+ return
+ end
+ selectedIndex = index
+ notify(listeners.selected, selectedIndex, Inventory.GetSelectedId())
+end
+
+function Inventory.GetSelectedId(): string?
+ local id = slots[selectedIndex]
+ return id
+end
+
+function Inventory.OnChanged(callback)
+ return addListener(listeners.changed, callback)
+end
+
+function Inventory.OnSelected(callback)
+ return addListener(listeners.selected, callback)
+end
+
+return Inventory
diff --git a/src/ServerScriptService/Actor/ServerChunkManager/init.server.lua b/src/ServerScriptService/Actor/ServerChunkManager/init.server.lua
index 18f291e..7d404ea 100644
--- a/src/ServerScriptService/Actor/ServerChunkManager/init.server.lua
+++ b/src/ServerScriptService/Actor/ServerChunkManager/init.server.lua
@@ -67,6 +67,8 @@ local tickRemote = ReplicatedStorage.Tick
local remotes = ReplicatedStorage:WaitForChild("Remotes")
local placeRemote = remotes:WaitForChild("PlaceBlock")
local breakRemote = remotes:WaitForChild("BreakBlock")
+local inventorySync = remotes:WaitForChild("InventorySync")
+local inventoryRequest = remotes:WaitForChild("InventoryRequest")
local blocksFolder = ReplicatedStorage:WaitForChild("Blocks")
local function propogate(a, cx, cy, cz, x, y, z, bd)
task.synchronize()
@@ -75,7 +77,9 @@ local function propogate(a, cx, cy, cz, x, y, z, bd)
end
local MAX_REACH = 24
+local HOTBAR_SIZE = 9
local blockIdMap = {}
+local playerInventories = {}
local function rebuildBlockIdMap()
table.clear(blockIdMap)
@@ -92,6 +96,94 @@ rebuildBlockIdMap()
blocksFolder.ChildAdded:Connect(rebuildBlockIdMap)
blocksFolder.ChildRemoved:Connect(rebuildBlockIdMap)
+local function buildDefaultSlots(): {string}
+ local ids = {}
+ for _, block in ipairs(blocksFolder:GetChildren()) do
+ local id = block:GetAttribute("n")
+ if id ~= nil then
+ table.insert(ids, tostring(id))
+ end
+ end
+ table.sort(ids, function(a, b)
+ return a < b
+ end)
+ local slots = table.create(HOTBAR_SIZE, "")
+ for i = 1, HOTBAR_SIZE do
+ slots[i] = ids[i]
+ end
+ return slots
+end
+
+local function syncInventory(player: Player)
+ local data = playerInventories[player.UserId]
+ if not data then
+ return
+ end
+ task.synchronize()
+ inventorySync:FireClient(player, data.slots)
+end
+
+local function rebuildAllInventories()
+ for _, player in ipairs(game:GetService("Players"):GetPlayers()) do
+ local slots = buildDefaultSlots()
+ local allowed = {}
+ for i = 1, HOTBAR_SIZE do
+ local id = slots[i]
+ if id ~= "" then
+ allowed[id] = true
+ end
+ end
+ playerInventories[player.UserId] = {
+ slots = slots,
+ allowed = allowed,
+ }
+ syncInventory(player)
+ end
+end
+
+blocksFolder.ChildAdded:Connect(rebuildAllInventories)
+blocksFolder.ChildRemoved:Connect(rebuildAllInventories)
+
+game:GetService("Players").PlayerAdded:Connect(function(player: Player)
+ local slots = buildDefaultSlots()
+ local allowed = {}
+ for i = 1, HOTBAR_SIZE do
+ local id = slots[i]
+ if id ~= "" then
+ allowed[id] = true
+ end
+ end
+ playerInventories[player.UserId] = {
+ slots = slots,
+ allowed = allowed,
+ }
+ syncInventory(player)
+end)
+
+game:GetService("Players").PlayerRemoving:Connect(function(player: Player)
+ playerInventories[player.UserId] = nil
+end)
+
+for _, player in ipairs(game:GetService("Players"):GetPlayers()) do
+ local slots = buildDefaultSlots()
+ local allowed = {}
+ for i = 1, HOTBAR_SIZE do
+ local id = slots[i]
+ if id ~= "" then
+ allowed[id] = true
+ end
+ end
+ playerInventories[player.UserId] = {
+ slots = slots,
+ allowed = allowed,
+ }
+ syncInventory(player)
+end
+
+inventoryRequest.OnServerEvent:Connect(function(player: Player)
+ syncInventory(player)
+end)
+
local function getPlayerPosition(player: Player): Vector3?
local character = player.Character
if not character then
@@ -117,6 +209,14 @@ local function resolveBlockId(blockId: any): string | number | nil
return blockIdMap[blockId]
end
+local function playerHasBlockId(player: Player, blockId: string | number): boolean
+ local data = playerInventories[player.UserId]
+ if not data then
+ return false
+ end
+ return data.allowed[tostring(blockId)] == true
+end
+
local function getServerChunk(cx: number, cy: number, cz: number)
task.desynchronize()
local chunk = TG:GetChunk(cx, cy, cz)
@@ -146,6 +246,9 @@ placeRemote.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z, blockId)
if not resolvedId then
return
end
+ if not playerHasBlockId(player, resolvedId) then
+ return
+ end
local chunk = getServerChunk(cx, cy, cz)
if chunk:GetBlockAt(x, y, z) then
diff --git a/src/StarterGui/Game_UI/LocalScript.client.lua b/src/StarterGui/Game_UI/LocalScript.client.lua
index 0b92109..48532ef 100644
--- a/src/StarterGui/Game_UI/LocalScript.client.lua
+++ b/src/StarterGui/Game_UI/LocalScript.client.lua
@@ -5,6 +5,11 @@ end
local ui = script.Parent
local ReplicatedStorage = game:GetService("ReplicatedStorage")
+local Inventory = require(ReplicatedStorage.Shared.Inventory)
+local Catppuccin = require(ReplicatedStorage.Shared.Catppuccin)
+local blocksFolder = ReplicatedStorage:WaitForChild("Blocks")
+
+local mocha = Catppuccin.mocha
ReplicatedStorage:WaitForChild("Objects"):WaitForChild("MLLoaded")
@@ -17,6 +22,165 @@ cd.Parent = game:GetService("Workspace"):FindFirstChildOfClass("Terrain")
sky.Parent = game:GetService("Workspace"):FindFirstChildOfClass("Terrain")
base.Parent = game:GetService("Workspace"):FindFirstChildOfClass("Terrain")
+local hotbarRoot = ui:WaitForChild("Hotbar")
+local hotbarSlotsRoot = hotbarRoot:WaitForChild("Frame")
+local hotbarSelectLabel = ui:WaitForChild("HotbarItemSelectLabel")
+local hotbarSelectText = hotbarSelectLabel:WaitForChild("TextLabel")
+local hotbarDebug = ui:WaitForChild("HotbarDebug")
+local hotbarDebugText = hotbarDebug:WaitForChild("TextLabel")
+local debugUpperText = ui:WaitForChild("DebugUpperText")
+local hotbarStroke = hotbarRoot:FindFirstChild("UIStroke")
+local hotbarSelectStroke = hotbarSelectLabel:FindFirstChild("UIStroke")
+local hotbarDebugStroke = hotbarDebug:FindFirstChild("UIStroke")
+
+hotbarRoot.BackgroundColor3 = mocha.base
+if hotbarStroke then
+ hotbarStroke.Color = mocha.blue
+end
+hotbarSelectLabel.BackgroundColor3 = mocha.base
+if hotbarSelectStroke then
+ hotbarSelectStroke.Color = mocha.blue
+end
+hotbarDebug.BackgroundColor3 = mocha.base
+if hotbarDebugStroke then
+ hotbarDebugStroke.Color = mocha.surface2
+end
+hotbarSelectText.TextColor3 = mocha.text
+hotbarDebugText.TextColor3 = mocha.text
+debugUpperText.TextColor3 = mocha.text
+
+local slotFrames = {}
+local slotStrokes = {}
+local slotLabels = {}
+local renderHotbar = nil
+
+local blockDisplayNames = {}
+local blockIcons = {}
+
+local function rebuildBlockMappings()
+ table.clear(blockDisplayNames)
+ table.clear(blockIcons)
+ for _, block in ipairs(blocksFolder:GetChildren()) do
+ local id = block:GetAttribute("n")
+ if id ~= nil then
+ blockDisplayNames[tostring(id)] = block.Name
+ local icon = block:GetAttribute("icon")
+ if typeof(icon) == "string" and icon ~= "" then
+ blockIcons[tostring(id)] = icon
+ end
+ end
+ end
+ if renderHotbar then
+ renderHotbar()
+ end
+end
+
+rebuildBlockMappings()
+blocksFolder.ChildAdded:Connect(rebuildBlockMappings)
+blocksFolder.ChildRemoved:Connect(rebuildBlockMappings)
+
+for i = 1, Inventory.GetHotbarSize() do
+ local slotName = `HotbarSlot{i - 1}`
+ local slot = hotbarSlotsRoot:WaitForChild(slotName)
+ slotFrames[i] = slot
+ local stroke = slot:FindFirstChild("UIStroke")
+ if not stroke then
+ stroke = Instance.new("UIStroke")
+ stroke.Parent = slot
+ end
+ slotStrokes[i] = stroke
+
+ local imageLabel = slot:FindFirstChild("ImageLabel")
+ if not imageLabel then
+ imageLabel = Instance.new("ImageLabel")
+ imageLabel.Name = "ImageLabel"
+ imageLabel.BackgroundTransparency = 1
+ imageLabel.Size = UDim2.fromScale(1, 1)
+ imageLabel.ScaleType = Enum.ScaleType.Fit
+ imageLabel.Parent = slot
+ end
+
+ local label = slot:FindFirstChild("TextLabel")
+ if not label then
+ label = Instance.new("TextLabel")
+ label.Name = "TextLabel"
+ label.BackgroundTransparency = 1
+ label.Size = UDim2.fromScale(1, 1)
+ label.TextScaled = true
+ label.Font = Enum.Font.Gotham
+ label.TextColor3 = mocha.text
+ label.TextWrapped = true
+ label.ZIndex = 3
+ label.Parent = slot
+ end
+ slotLabels[i] = label
+end
+
+renderHotbar = function()
+ for i = 1, Inventory.GetHotbarSize() do
+ local slot = slotFrames[i]
+ if slot then
+ slot.BackgroundColor3 = mocha.surface0
+ local stroke = slotStrokes[i]
+ if stroke then
+ if i == Inventory.GetSelectedIndex() then
+ stroke.Color = mocha.blue
+ stroke.Thickness = 2
+ else
+ stroke.Color = mocha.surface2
+ stroke.Thickness = 1
+ end
+ end
+
+ local imageLabel = slot:FindFirstChild("ImageLabel")
+ local id = Inventory.GetSlot(i)
+ local displayName = id and (blockDisplayNames[tostring(id)] or tostring(id)) or ""
+ if imageLabel then
+ local icon = id and blockIcons[tostring(id)] or nil
+ imageLabel.Visible = icon ~= nil
+ imageLabel.Image = icon or ""
+ imageLabel.BackgroundTransparency = 1
+ imageLabel.ZIndex = 2
+ end
+
+ local textLabel = slotLabels[i]
+ if textLabel then
+ textLabel.Text = displayName
+ textLabel.TextColor3 = mocha.text
+ textLabel.Visible = displayName ~= ""
+ end
+ end
+ end
+end
+
+local function updateSelectedLabel()
+ local id = Inventory.GetSelectedId()
+ local displayName = id and (blockDisplayNames[tostring(id)] or tostring(id)) or nil
+ if hotbarSelectText then
+ hotbarSelectText.Text = displayName and `Selected: {displayName}` or "Selected: (empty)"
+ end
+ if hotbarDebugText then
+ hotbarDebugText.Text = `Slot {Inventory.GetSelectedIndex()} Id {displayName or "empty"}`
+ end
+end
+
+local extraSlot = hotbarSlotsRoot and hotbarSlotsRoot:FindFirstChild("HotbarSlot9")
+if extraSlot then
+ extraSlot.Visible = false
+end
+
+renderHotbar()
+updateSelectedLabel()
+
+Inventory.OnChanged(function()
+ renderHotbar()
+ updateSelectedLabel()
+end)
+
+Inventory.OnSelected(function()
+ renderHotbar()
+ updateSelectedLabel()
+end)
game:GetService("RunService").RenderStepped:Connect(function(dt)
local fps = math.round(1/dt)
diff --git a/src/StarterPlayer/StarterPlayerScripts/Actor/BlockInteraction.client.lua b/src/StarterPlayer/StarterPlayerScripts/Actor/BlockInteraction.client.lua
index 2af7785..90ba90e 100644
--- a/src/StarterPlayer/StarterPlayerScripts/Actor/BlockInteraction.client.lua
+++ b/src/StarterPlayer/StarterPlayerScripts/Actor/BlockInteraction.client.lua
@@ -7,12 +7,11 @@ local UIS = game:GetService("UserInputService")
ReplicatedStorage:WaitForChild("Objects"):WaitForChild("MLLoaded")
-local blocksFolder = ReplicatedStorage:WaitForChild("Blocks")
local PM = require(ReplicatedStorage.Shared.PlacementManager)
-
-local HOTBAR_SIZE = 9
-local hotbar = table.create(HOTBAR_SIZE)
-local selectedSlot = 1
+local Inventory = require(ReplicatedStorage.Shared.Inventory)
+local remotes = ReplicatedStorage:WaitForChild("Remotes")
+local inventorySync = remotes:WaitForChild("InventorySync")
+local inventoryRequest = remotes:WaitForChild("InventoryRequest")
local keyToSlot = {
[Enum.KeyCode.One] = 1,
@@ -26,40 +25,25 @@ local keyToSlot = {
[Enum.KeyCode.Nine] = 9,
}
-local function rebuildHotbar()
- local ids = {}
- for _, block in ipairs(blocksFolder:GetChildren()) do
- local id = block:GetAttribute("n")
- if id ~= nil then
- table.insert(ids, tostring(id))
+local function findNextFilledIndex(startIndex: number, direction: number): number
+ local size = Inventory.GetHotbarSize()
+ for step = 1, size do
+ local idx = ((startIndex - 1 + (direction * step)) % size) + 1
+ if Inventory.GetSlot(idx) ~= nil then
+ return idx
end
end
-
- table.sort(ids)
- for i = 1, HOTBAR_SIZE do
- hotbar[i] = ids[i] or ""
- end
- selectedSlot = math.clamp(selectedSlot, 1, HOTBAR_SIZE)
+ return startIndex
end
-local function getSelectedBlockId(): string?
- local id = hotbar[selectedSlot]
- if id == "" then
- return nil
- end
- return id
-end
-
-local function setSelectedSlot(slot: number)
- if slot < 1 or slot > HOTBAR_SIZE then
+inventorySync.OnClientEvent:Connect(function(slots)
+ if typeof(slots) ~= "table" then
return
end
- selectedSlot = slot
-end
+ Inventory.SetSlots(slots)
+end)
-rebuildHotbar()
-blocksFolder.ChildAdded:Connect(rebuildHotbar)
-blocksFolder.ChildRemoved:Connect(rebuildHotbar)
+inventoryRequest:FireServer()
UIS.InputBegan:Connect(function(input: InputObject, gameProcessedEvent: boolean)
if gameProcessedEvent then
@@ -68,7 +52,9 @@ UIS.InputBegan:Connect(function(input: InputObject, gameProcessedEvent: boolean)
local slot = keyToSlot[input.KeyCode]
if slot then
- setSelectedSlot(slot)
+ if Inventory.GetSlot(slot) ~= nil then
+ Inventory.SetSelectedIndex(slot)
+ end
return
end
@@ -83,10 +69,25 @@ UIS.InputBegan:Connect(function(input: InputObject, gameProcessedEvent: boolean)
if not mouseBlock then
return
end
- local blockId = getSelectedBlockId()
+ local blockId = Inventory.GetSelectedId()
if not blockId then
return
end
PM:PlaceBlock(mouseBlock.chunk.X, mouseBlock.chunk.Y, mouseBlock.chunk.Z, mouseBlock.block.X, mouseBlock.block.Y, mouseBlock.block.Z, blockId)
end
end)
+
+UIS.InputChanged:Connect(function(input: InputObject, gameProcessedEvent: boolean)
+ if gameProcessedEvent then
+ return
+ end
+ if input.UserInputType == Enum.UserInputType.MouseWheel then
+ local delta = input.Position.Z
+ if delta == 0 then
+ return
+ end
+ local direction = delta > 0 and -1 or 1
+ local nextIndex = findNextFilledIndex(Inventory.GetSelectedIndex(), direction)
+ Inventory.SetSelectedIndex(nextIndex)
+ end
+end)