{"id":"venignuss/gamestate","name":"gamestate","scope":"venignuss","platform":"roblox","description":"A single source of truth for your game's state, with built-in replication and safe client-to-server writes.","version":"1.0.0","latest":"1.0.0","versions":["0.1.0","1.0.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"65e837feb003b01e8e599073c54602532e7755492ce82dd0d14e1d679278df41","likes":0,"downloads":0,"install":"forest install venignuss/gamestate","url":"https://forest.dev/p/roblox/venignuss/gamestate","files":"https://api.forest.dev/ai/package/roblox/venignuss/gamestate/files","readme":"# GameState\n\n[![Wally](https://img.shields.io/badge/wally-venignuss%2Fgamestate-purple)](https://wally.run/package/venignuss/gamestate)\n[![License: MIT](https://img.shields.io/badge/license-MIT-purple.svg)](LICENSE)\n\nA single source of truth for your game's state, shared between server and client, with built-in replication and safe client-to-server writes.\n\n## Why this exists\n\nMost Roblox games end up with state scattered across a dozen different systems — some in `DataStore`, some in loose tables, some replicated by hand through `RemoteEvent`s, some only living on the client. Every new feature means wiring up another remote, another `WaitForChild`, another place bugs can hide.\n\n`GameState` collapses all of that into one nested structure you read and write like a plain Lua table. You don't design a schema up front — you just start reading and writing paths, and the structure builds itself as you go, replicating to clients and accepting safe writes back automatically:\n\n```lua\nGameState.Round.TimeRemaining(120)\nprint(GameState.Round.TimeRemaining()) --> 120\n```\n\n## GameState vs. player data / persistence libraries\n\n`GameState` is **not** a player data management library, and it's not a replacement for things like ProfileService or Scribe. Those libraries solve a different problem: getting a player's data reliably into and out of `DataStore`: session-locking, retries, versioning/migrations, save-on-leave, and so on. `GameState` doesn't touch `DataStore` at all and has no concept of a \"player profile.\"\n\nWhat `GameState` solves is **replication and controlled client writes for whatever's currently in memory** — server-authoritative state that needs to live on clients too, and sometimes accept writes back from them. That's a layer above persistence, not a substitute for it. Plenty of what you'd put in `GameState` is round-scoped or otherwise never needs to touch a `DataStore` at all — a door being open, a boss's current health, how much time is left, who's currently in a party.\n\nThe two work well together when you do have persistent data: load a player's profile with your persistence library of choice when they join, mirror the fields you want live and replicated into `GameState` (e.g. `GameState.Players[player.UserId].Coins(profile.Data.Coins)`), let `GameState` handle sync/broadcast for the rest of the session, and read back from it whenever the profile needs to save.\n\n## Install\n\n**Wally**\n\nAdd it to your `wally.toml`:\n\n```toml\n[dependencies]\nGameState = \"venignuss/gamestate@1.0.0\"\n```\n\nThen:\n\n```bash\nwally install\n```\n\nThis drops the package into your `Packages` folder. If you don't already have a Rojo setup that syncs `Packages` into your game, see [Rojo's docs](https://rojo.space/docs/) — most Wally-based projects sync it into `ReplicatedStorage.Packages`.\n\n**Manual**\n\nCopy the `src/` contents into a ModuleScript named `GameState` in `ReplicatedStorage`, matching the folder structure (`Client/` and `Server/` as sub-ModuleScripts).\n\n## Quick start\n\n**Server** (anywhere in server-side code, after requiring the module):\n\n```lua\nlocal GameState = require(path.to.GameState)\n\n-- Somewhere that sets a zone up (e.g. when a level loads)\nGameState.Zones[zoneId].DoorOpen(false)\n\n-- When a player enters this zone (via a trigger, region check, whatever you use)\nlocal function onPlayerEnteredZone(player, zoneId)\n    -- Replicate this zone's state to them\n    GameState.Zones[zoneId].addSync(player)\n\n    -- Let anyone standing in this zone pull the lever to open the door -\n    -- but only open it, never close it, from the client\n    GameState.Zones[zoneId].DoorOpen.allowClientBroadcast(player, function(isOpen)\n        return isOpen == true\n    end)\nend\n```\n\n**Client** (in a LocalScript, after requiring the same module):\n\n```lua\nlocal GameState = require(path.to.GameState)\nlocal zoneId = \"Vault\"\n\nprint(GameState.Zones[zoneId].DoorOpen())  -- reads whatever the server has synced so far\n\nGameState.Zones[zoneId].DoorOpen.Changed(function(old, new)\n    print(\"Door\", zoneId, new and \"opened\" or \"closed\")\nend)\n\n-- Pull the lever\nGameState.Zones[zoneId].DoorOpen(true)\nGameState.Zones[zoneId].DoorOpen.broadcastToServer()\n```\n\nThe module figures out on its own whether it's running on the server or the client and gives you the right version — you always just `require` the same thing.\n\n## The mental model\n\nEvery point in the tree — `GameState`, `GameState.Zones`, `GameState.Zones[someId].DoorOpen` — is a **node**. A node is called like a function to read or write it:\n\n```lua\nnode()          -- read: returns the current value (nil if it was never set)\nnode(100)       -- write: sets the value to 100\nnode(nil)       -- write: explicitly clears it\nnode(function(old) return old + 1 end)  -- write: update based on the current value\n```\n\nIndexing into a node with `.` or `[]` gets you the child node at that key, creating it on the spot if it doesn't exist yet:\n\n```lua\nGameState.Zones[zoneId].Chest.Items[1](\"Sword\")\n```\n\nYou never \"declare\" `Zones`, then `[zoneId]`, then `Chest`, then `Items` — you just write to the full path and every level along the way gets created automatically.\n\nReading a node that holds a table gives you back a **plain Lua table snapshot**, not a live reference. Changing that table doesn't change what's stored — write it back (or use `Merge`/`Insert`/`RemoveValue`/`RemoveIndex`, see below) if you want the change to stick:\n\n```lua\nlocal items = GameState.Zones[zoneId].Chest.Items()\ntable.insert(items, \"Shield\")   -- this does nothing to the actual stored state\nGameState.Zones[zoneId].Chest.Items(items)  -- this is what actually saves it\n```\n\n## Function reference\n\n### Reading and writing (available everywhere)\n\nEvery node supports these — see \"The mental model\" above for the details.\n\n- **`node()`** — read the current value.\n- **`node(value)`** — write a value.\n- **`node(nil)`** — explicitly clear the value.\n- **`node(fn)`** — write based on the current value: `fn` receives the old value and its return becomes the new value.\n- **`node.Update(fn)`** — identical to `node(fn)`, just reads more clearly at a glance when that's all you're doing.\n\n**`GameState.NIL` — writing an explicit hole into an array.** A plain Lua table can't hold a real `nil` in the middle of an array — `{1, nil, 3}` either loses its length or collapses depending on how it's built. If you need a specific array position to be explicitly empty (as opposed to just shorter), write `GameState.NIL` at that position instead of `nil`:\n\n```lua\nGameState.Round.Bracket({ \"Alice\", GameState.NIL, \"Charlie\" })\n```\n\n`GameState` converts `NIL` back into a real `nil` internally when the value is stored, so `GameState.Round.Bracket()` reads back `{ \"Alice\", nil, \"Charlie\" }` with the hole preserved. You only need this for array holes — writing `nil` directly to a *dict* key or to a node itself works exactly as you'd expect and doesn't need `NIL`.\n\n### Subscriptions (available everywhere)\n\nAll of these return a **disconnect function** — call it to stop listening:\n\n```lua\nlocal disconnect = GameState.Round.TimeRemaining.Changed(function(old, new) end)\ndisconnect()\n```\n\n- **`node.Changed(callback)`** — fires whenever this node's value changes at all, including changes to anything nested under it. `callback(old, new)`.\n- **`node.KeyChanged(callback)`** — fires when a *direct* child of this node is added, removed, or changed. `callback(key, old, new)`. Doesn't fire for grandchildren — subscribe at the level you actually care about.\n- **`node.ChildAdded(callback)`** — fires only when a direct child goes from not existing to existing. `callback(key, newValue)`.\n- **`node.ChildRemoved(callback)`** — fires only when a direct child goes from existing to being cleared. `callback(key, lastValue)`.\n\n**Timing to know about:** these callbacks fire *before* the write is actually applied internally. The `old`/`new` values you're given are correct, but if your callback ignores those and reads the node again itself (`GameState.Round.TimeRemaining()` from inside that same `TimeRemaining.Changed` callback), you'll get the *old* value, not the one being written. Always use the arguments the callback hands you.\n\n### Bulk mutation helpers (available everywhere)\n\n- **`node.Merge(table)`** — shallow-merges a table into the current value, overwriting the keys you give it and leaving everything else alone. One level deep only.\n- **`node.Insert(value)`** — appends to an array-shaped node.\n- **`node.RemoveValue(value, amount?)`** — removes matching *values* from an array-shaped node, up to `amount` times (removes every match if you don't pass `amount`).\n- **`node.RemoveIndex(index)`** — removes whatever's at a specific *position* in an array-shaped node. Throws a clear error if the index is out of range.\n\nAll four only work on nodes that are currently array- or table-shaped (or empty/unset) — calling `Insert` on a node with named keys, for example, throws instead of corrupting your data.\n\n### Server-only: replication and client writes\n\n- **`node.addSync(player)`** (or a list of players) — starts pushing this node's value, and every future change to it, to that player's client. Call this to get *anything* from server to client at all — nothing replicates unless you explicitly `addSync` it.\n- **`node.removeSync(player)`** — stops syncing to a player who's still connected. **You don't need this for players who are leaving** — see below.\n- **`node.setSync(players)`** — sets the exact list of who should be synced, adding and removing as needed in one call. Handy for things like a party or team roster that changes as a whole.\n- **`node.allowClientBroadcast(player, validateData?, clientPath?)`** — the *only* way a client can get data into server-trusted state. The client calls `broadcastToServer()` on their end; you decide here whether to accept it. `validateData(value)` should return `true` to accept the write or `false`/`nil` to reject it.\n\n  `clientPath` is optional, and only needed if the path the client will use to address this node doesn't match its real path server-side (normally they match, since both sides build the same tree). Pass the path array the client will actually broadcast under, and the server will match incoming writes against that instead of the node's real location — useful if you're deliberately structuring the server tree differently from what the client sees.\n\n- **`node.disallowClientBroadcast(player)`** — revokes that permission for a player who's still connected. Same note as `removeSync` — not needed for players leaving.\n\n**`GameState.configure({ ... })` — server-only.** The broadcast rate limit and payload size/depth/string-length limits are tunable without editing the package source (editing it directly would get overwritten on the next `wally update`):\n\n```lua\nGameState.configure({\n    MAX_BROADCASTS_PER_PLAYER_PER_FRAME = 40, -- default 20\n    MAX_BROADCAST_PATH_LENGTH = 16,           -- default 16\n    MAX_BROADCAST_DATA_DEPTH = 16,            -- default 16\n    MAX_BROADCAST_DATA_NODES = 500,           -- default 500\n    MAX_BROADCAST_STRING_LENGTH = 2000,       -- default 2000\n    BROADCAST_BUCKET_CAPACITY = 10,           -- default 10, max burst of FireServer calls\n    BROADCAST_BUCKET_REFILL_PER_SECOND = 5,   -- default 5, sustained FireServer calls/sec\n})\n```\n\nCall it once, early on the server (e.g. top of the script that first requires `GameState`). Passing an unknown key logs a warning instead of silently doing nothing, so a typo doesn't quietly no-op. This only exists on the server — there's nothing to configure on the client.\n\n### In case you want your server code to be server-only\n\nBy default, everything under `GameState` — including `ServerNode.lua` and every `validateData` function you write — lives in `ReplicatedStorage`, which means it's fully visible to any client, decompilable by exploit tools. For most games that's fine. But your `validateData` functions are exactly what an exploiter would want to read: the precise bounds, whitelists, and conditions your server actually enforces. If you'd rather that logic stay invisible, you can move `Server` into `ServerScriptService`, which clients can't see into at all.\n\nIf you want to do this:\n\n1. **In Studio, move the `Server` ModuleScript out of `ReplicatedStorage.GameState` and into `ServerScriptService`**, as its own top-level ModuleScript (e.g. `ServerScriptService.GameStateServer`). `Client` and the root `GameState` module stay in `ReplicatedStorage` exactly as they are.\n\n2. **Update the root `GameState` module's `require(script.Server)`.** It currently assumes `Server` is a child of itself — once `Server` moves out, that line no longer resolves. Point it at the new location instead:\n\n   ```lua\n   if RunService:IsServer() then\n       return require(game:GetService(\"ServerScriptService\").GameStateServer)\n   else\n       return require(script.Client)\n   end\n   ```\n\n3. **Update the two relative lookups inside `ServerNode.lua`.** It currently reaches `Client` and `SharedNode` via `script.Parent.Parent.Client`, which assumes `Server` sits right next to `Client` under the same `GameState` folder. That assumption breaks once `Server` moves, so both need to become absolute references instead:\n\n   ```lua\n   local ClientFolder = game:GetService(\"ReplicatedStorage\").GameState.Client\n   ```\n\n   and the same for the `SharedNode` require at the top of the file.\n\nEverything else in the module works unchanged — this only affects the handful of lines that navigate the tree by relative position.\n\n### Client-only: sending writes to the server\n\n- **`node.broadcastToServer()`** — sends this node's *current* value to the server for consideration. Write locally first so your own client updates immediately, then call this to ask the server to accept the same change:\n\n  ```lua\n  GameState.Zones[zoneId].DoorOpen(true)\n  GameState.Zones[zoneId].DoorOpen.broadcastToServer()\n  ```\n\n  This only does anything if the server called `allowClientBroadcast` for this exact path and player, and only takes effect if `validateData` accepts it — otherwise the server just quietly discards it. It also doesn't apply instantly: it's queued and sent roughly once per frame, not the moment you call it.\n\n## Reserved keys\n\nEvery node exposes its API — `Insert`, `Merge`, `Changed`, `addSync`, and so on — as bare fields alongside whatever data you store on it. If you write a key that happens to match one of those method names (e.g. `GameState.Players[userId].Insert(someValue)` when you meant to store data *under* a child called `Insert`), the method wins: your value is passed to the existing method as an argument rather than being saved as a new child. This can't be detected automatically, since a bare `node.Insert(x)` call is exactly as valid as this collision looks from the outside — so avoid naming your own keys after node methods (see the \"Function reference\" section above for the full list).\n\nSeparately: **you always write to a node by calling it — `node(value)` or `node.Key(value)` — never by assigning to it directly.** `GameState.Players[userId].Coins = 100` is not supported and throws a clear error rather than silently corrupting the node (in earlier versions this failed silently, permanently breaking that field with no warning). If you see this error, you almost always meant to call the node instead:\n\n```lua\nGameState.Players[userId].Coins(100)        -- correct: writes 100\nGameState.Players[userId].Coins = 100       -- wrong: throws an error now\n```\n\n## Things to be careful of\n\n- **`validateData` isn't optional in spirit, even though it's optional in the API.** Skipping it means that player can write *anything* correctly-shaped to that path, no restrictions at all. You'll get a warning in the output every time you register a broadcast target without one — treat that warning as a checklist item, not noise to ignore.\n\n- **When a player leaves, you don't need to clean up their sync or broadcast registrations yourself — that's automatic.** Anything","readmeTruncated":true,"readmeFull":"https://api.forest.dev/v1/package/venignuss/roblox/gamestate/1.0.0/readme"}