Skip to content

State bags and server validated writes (tm.state) ​

Replicated player and entity state is written on the server. This import lets a client ask for a replicated value and lets the server decide. Local UI flags and non-networked preview entities keep using ordinary non-replicated state.

Strict mode (setr sv_stateBagStrictMode true, server artifact 12739 or newer) stops clients from publishing network state changes on their own. tm.state is the sanctioned path: the client sends a request, the server checks it against a rule your resource registered, and the server writes the value. Strict mode does not turn client reports into trusted gameplay evidence, so a rule should check what it can (distance, ownership, a sane range) and not assume the client is honest.

lua
-- Server: register a narrowly scoped rule for a key this resource owns.
tm.state.registerEntity('blinker', function(source, entity, value)
    local driver = GetPedInVehicleSeat(entity, -1) == GetPlayerPed(source)
    return driver and tm.state.number(value, 0, 3) and value % 1 == 0, value
end, { entityType = 2, distance = 10 })

tm.state.registerPlayer('handsup', tm.state.boolean)

-- Client: request replication. The server checks the sender, bucket, distance and rule.
tm.state.entity(vehicle, 'blinker', 1)
tm.state.player('handsup', true)

-- Client: wait for permission before a gameplay action.
if tm.state.requestPlayer('inTrunk', vehicleNetId) then
    -- attach the local ped only after the server reserved a trunk space
end

-- Client: acknowledge the exact snapshot that was applied.
tm.state.ack(vehicle, 'vehicleProperties', appliedProperties)

-- Local state only (an ordinary state:set(key, value, false) is also fine).
tm.state.player('editorActive', true, false)

Where each call runs ​

CallSideDescription
tm.state.registerPlayer(key, validate, options?)ServerLets clients ask for key on their own player state.
tm.state.registerEntity(key, validate, options?)ServerLets clients ask for key on an entity near them.
tm.state.setStaffGuard(fn)ServerReplaces the staff check (see below).
tm.state.isStaff(source)ServerWhether the player passes the staff check.
tm.state.player(key, value, replicated?)ClientAsks the server to set a player key. Predicts the value locally.
tm.state.requestPlayer(key, value)ClientAsks and waits for the answer. Returns true when accepted.
tm.state.entity(entity, key, value, replicated?, expected?)ClientAsks the server to set an entity key.
tm.state.ack(entity, key, expected)ClientClears an entity key if it still holds exactly expected.
tm.state.number(value, lo, hi), tm.state.boolean, tm.state.vector(value, limit?)BothValidator helpers.

Validators ​

validate(source, entity, value, expected) returns true, canonicalValue to accept. For player rules entity is nil and the target is always the sender. Returning false or nil rejects the update. Returning true, nil clears the key.

  • The canonical value is what is stored, not the client's table. Build a fresh payload for compound data.
  • Validators must not yield. An error inside a validator rejects the request and is contained.
  • tm.state.boolean is a ready validator for boolean keys. tm.state.number(value, lo, hi) is a finite-number range check. tm.state.vector(value, limit?) returns a fresh { x, y, z } table (or nil), with each coordinate within limit (default 20000).

Options ​

OptionApplies toDescription
entityTypeEntity1 ped, 2 vehicle, 3 object.
ownerEntityThe sender must be the network owner of the entity.
distanceEntityLargest distance between the sender's ped and the entity, default 10 meters.
maxBytesBothLargest packed payload, default 8 KiB.
staffOnlyBothOnly senders that pass the staff check are accepted.

Entity rules always check that the entity exists, shares the sender's routing bucket and is within distance.

What the dispatcher enforces ​

  • Deny by default. A key without a registered rule is rejected.
  • Requests per player are limited to 80 per second with a burst of 120.
  • Values and the expected snapshot are bounded before any validator sees them: nesting depth 6, 512 entries, strings up to 4096 characters, only finite numbers, no functions, and a packed size within maxBytes.
  • Entity network ids must be whole numbers from 1 to 65535.
  • Rules belong to the resource that registered them. Each resource that imports tm.state has its own rule table and its own set of server events (named after the resource), so one resource can never accept or replace another resource's keys. Rules disappear when their resource stops.

Staff guard ​

A rule registered with staffOnly = true only accepts players that pass the staff check. By default that is tm.fw.isAdmin(source). Replace it with tm.state.setStaffGuard(function(source) return ... end) (for example to use your own permission system). A guard that errors counts as "not staff". tm.state.isStaff(source) runs the same check inside a validator.

Behavior of the client calls ​

  • player and entity are asynchronous requests. They do not return approval. player predicts the value locally for immediate controls and UI, and skips the request when a plain value did not change.
  • requestPlayer waits up to 5 seconds, returns true when the server accepted, and applies the server's canonical value locally. It yields, so call it from a thread.
  • entity writes ordinary local state for non-networked entities, or when its fourth argument is false. It returns false for an entity that does not exist.
  • ack is entity(entity, key, nil, true, expected). The server compares expected with what the key holds now, so a late owner cannot clear newer work.

Notes ​

  • The staff check is the injectable guard above (default tm.fw.isAdmin).
  • The dispatcher is part of this import, not a separate resource, so there is no cross-resource policy table. The validator must be a plain function.
  • requestPlayer uses its own request and response events.
  • initPed, used by tm.ped, is registered by the server ped import in the resource that uses it.