State Stores¶
store({...}) creates an explicit shared runtime-state owner. Tua derives the
state shape from the initial table, so reads, replacements, updates,
subscriptions, and keyed watchers receive completion and type checking without
a generic annotation or package install.
Explicit instances, not a global singleton
store is a constructor. Put an instance in a normal module and share it
with require, or construct isolated stores for tests, previews, save
slots, replay simulations, and separate game worlds.
Store API¶
| Method | Behavior |
|---|---|
get() |
Return the current state table. |
set(next) |
Replace the state, notify listeners, and return next. |
update(mutator) |
Run mutator(state), notify listeners, and return the state. |
subscribe(listener) |
Run listener(state, previous) after every change and return an unsubscribe function. |
watch(key, listener) |
Run listener(value, previous, state) when one top-level field changes and return an unsubscribe function. |
The initial table supplies the state type:
local game = store({ score = 0, paused = false })
game:set({ score = 20, paused = true }) -- accepted
game:set({ score = "high", paused = true }) -- TL1003
game:watch("score", function(value: number, previous: number)
print(previous, value)
end)
Completion after game:get(). offers score and paused. Signature help for
watch derives one overload per field, so the key and callback value type stay
connected. Hovering "score" shows its state-field type, and definition on the
key returns to score in the initial table. The returned unsubscribe function
is idempotent. The inferred state contract is preserved when the Store is
returned directly or nested in a required module table, so update(function(state)
does not need a repeated annotation at the use site.
Updates are shallow
When whole-store subscribers exist, update takes a shallow snapshot
before running the mutator so every subscriber receives the complete
previous state. With only keyed watchers, it retains only the watched
top-level values; with no observers, it does not create a snapshot.
Whole-store subscribers always run, including after a no-op update. A keyed
watcher runs only when its value changes according to Lua's ~=
comparison. Mutating inside the same nested table does not change that
nested table's identity; replace the top-level field or use a whole-store
subscription when that distinction matters.
Subscriptions may be added or removed from inside a listener. The active notification keeps its original listener list, and the change takes effect on the next notification. The runtime implements this with copy-on-write listener collections, so ordinary notification does not copy every callback list.
The same applies to a mutator: update decides which values to snapshot
before the mutator runs, so a subscription added from inside a mutator is
first notified by the next set or update, not by the one that added it.
Share A Store Through A Module¶
Lua caches successful require results, so a normal module is enough to share
one explicit store instance:
local game = require("game_state")
local function addScore(points: number)
local current = game:get()
game:set({
score = current.score + points,
paused = current.paused,
})
end
return { addScore = addScore }
The exported specialized store shape participates in normal module inference, so consumers retain state fields and method signatures.
Persist With LOVE¶
Runtime state and save data are separate concerns. The store does not choose a
serialization format, filename, save slot, schema version, or migration policy.
Use a subscription as the adapter to love.filesystem:
local game = require("game_state")
local stopSaving = game:subscribe(function(state)
local contents = encodeSave(state) -- Your JSON/Lua/binary serializer.
local ok, message = love.filesystem.write("save.json", contents)
if not ok then
print("save failed", message)
end
end)
Load and migrate persisted data before constructing or replacing the store:
local defaults = { score = 0, paused = false }
local saved = loadAndMigrateSave("save.json")
local game = store(saved or defaults)
Control save frequency in the adapter
Gameplay may update state every frame. Debounce saves, save at checkpoints, or mark state dirty and flush it from a suitable LOVE callback rather than writing the filesystem after every update.
Runtime And Overrides¶
store({...}) lowers to require("tuastore"). tua build writes the bundled
tuastore.lua beside generated Lua only when the helper is used. A
project-provided tuastore.lua is never overwritten, so compatible custom
implementations can replace the bundled runtime.