World & props
Synchronised time and weather
Synchronised session time and weather: the network model, shared configuration, ACL-gated commands, random weather events, and the server and client APIs.
On this page7 sections
open77_weather synchronizes the session's clock and weather. The server owns the canonical state; clients apply it locally.
At server boot the canonical state is 12:00:00, the weather is sunny, and the clock advances at
timeScale. A player joining later receives the current time, never the boot time, and joins a
weather transition already in progress at its current point rather than restarting it.
Use this guide if you are writing a resource that needs to read the time, react to weather, or β from
a trusted server resource β change either. The rate the world runs at β slow motion for a scripted
beat β is a different lever and has its own page, Time scale; nothing here slows a
clock. From the server the entry point is
Open77.environment.*, which the host installs everywhere and gates
behind world.environment; a routing bucket can hold an environment of its own.
Source: open77_weather.
Network model
server monotonic clock + canonical state
|
| versioned snapshot / reliable broadcast
v
client request ---- RTT/2 ----> local REDengine projection
^
| periodic resynchronisation
The snapshot carries scope, authorityEpoch, revision, secondsOfDay, rate, frozen, the
weather name and preset, the transition, the priority, and the deadline of the next random event.
The client measures the round trip of its own request, adds at most two seconds of half-RTT, then
re-anchors its monotonic reference. A mutation broadcast applies immediately; a reply carrying an
older revision of the same epoch is rejected. A new epoch lets a server hot-reload restart at
revision 1 without leaving clients stuck on the previous incarnation.
scope names the environment the snapshot describes β default, or bucket:<n> for a routing
bucket holding an override. A snapshot whose scope differs from the one a
client is projecting is adopted unconditionally: it is another world, not a newer reading of
the one the client was in, so the revision rule is suspended for that one packet and resumes inside
the new scope.
Open77 projects the server clock twice a second. It deliberately does not hold REDengine's
SetPausedState: two-client runtime testing proved that this flag can also slow gameplay and
vehicle physics. Every pass reads the live game clock and writes it only when it has really
drifted from the projection (more than timeDriftToleranceSeconds, 120 game seconds by default),
because every write is a world time-jump. At the engine's natural rate of 8 game seconds per real
second nothing is ever written once a player is in the world. A player who joins late receives the
first snapshot in the main menu; the save that loads next carries its own time of day, and the next
pass corrects it β within half a second, whatever the save said. At midnight,
circular arithmetic turns the roll over to 00:00 into normal forward motion. A small step back
caused by a late packet is ignored, so REDengine's "next occurrence" semantics are not triggered β
which would jump a whole day.
weather.time.freeze has a cost. Nothing native holds the clock, so the engine keeps advancing
at 8x underneath a projection that does not move; every client re-asserts the frozen time each
time the drift passes the tolerance β every 15 s at the defaults, a two-minute step back. Each step
is a world time-jump, the kind that makes the streamer re-resolve world nodes and can bring back
destroyed props. Freeze the clock for what needs a fixed sky (a pinned match, a recording); leave
it running at 8 otherwise, where synchronisation is free.
Configuration
Edit resources/system/open77_weather/shared/config.lua:
startupTimeβ12:00:00by default;timeScaleβ game seconds per real second (4.0);syncIntervalMsβ full resynchronisation (15000);applyIntervalMsβ projection frequency (500);heartbeatIntervalMsβ authoritative server broadcast (5000);environmentEnforceIntervalMsβ local lock and preset check (5000);randomWeatherandinitialWeatherDurationSeconds;presetsβ weight, real min/max duration, and transition for each weather.
The file is shared and therefore public. It must hold no secret, key, or ACL.
Presets supplied: sunny, lightclouds, cloudy, rain, heavyclouds, fog, pollution, and
sandstorm. The REDengine 24h_weather_* values are still accepted server-side.
Server commands and ACL
From the in-game Open77 terminal or the dedicated console:
weather.status
weather.time.set 06:45
weather.time.freeze
weather.time.resume
weather.rate 12
weather.set fog 25
weather.random off
weather.next
weather, weather.status, and weather.time are read-only. Every other command uses
RegisterCommand(..., true) and is authorised against the player's authenticated public identity.
For example:
{
"permissions": ["command.weather.*"]
}
The client DLL exposes no local time.* or weather.* command, so every interactive change goes
through the server and its ACL. The network events the resource accepts serve only to request a
snapshot: no client mutation event exists.
API for a server resource
Open77.environment.* is the API to write new code against. It is installed by the host, in
every server VM, and is gated by the world.environment manifest capability:
-- permissions { "world.environment" }
local state, reason = Open77.environment.setTime(23, 0, 0)
if not state then return print("no environment authority here: " .. reason) end
Open77.environment.setTimeFrozen(true)
Open77.environment.setWeather("rain", 20)
Open77.environment.setWeatherFrozen(true) -- pins the preset: no more random draws
print(("%02d:%02d %s"):format(state.hour, state.minute, state.weather))
| Call | Effect |
|---|---|
setTime(hour, minute, second, bucket?) |
Sets the authoritative clock. |
setTimeFrozen(frozen, bucket?) |
Stops or resumes it at its current reading. |
setTimeRate(rate, bucket?) |
Game seconds per real second, 0β120. |
setWeather(preset, transitionSeconds?, bucket?) |
Applies a preset over a 0β300 s transition. |
setWeatherFrozen(frozen, bucket?) |
Pins or releases the weighted random scheduler. |
getState(bucket?) |
The canonical state; see the field table. |
clearBucket(bucket) |
Retires a per-bucket override. |
Every call answers a state table, or nil plus a stable reason. The one worth branching on is
environment_unavailable: it means open77_weather is not running on this server, which
resources.load can cause silently β a resource that is not in that allowlist is never started
and never logged.
setWeatherFrozen deserves a word, because the name means something different on each side. On
the client it is an unconditional lock that stops a vanilla controller or a quest from
submitting its own preset, and it is not an operator choice. On the server it means pin the
preset: the weighted random scheduler stops drawing, so the sky stays where it was put. That is
the half a gamemode actually wants, and it is the same switch weather.random off throws.
Why the authority is a resource and not a host service
Every other authoritative registry the platform owns β loot, vehicles, NPCs, props, effects,
elevators β is a C# service, because the host is the only thing that can replicate it. Time and
weather are not in that shape, and that was measured before this API was designed: nothing in
the host can move a sky. The only code that touches the engine is Open77.environment.* in the
client VM, and that projection ships as client/main.lua of this same resource. Lifting the
clock into C# would therefore buy no platform guarantee it does not already have β a server whose
resources.load omits open77_weather loses the projection along with the authority, and a
host-held clock nobody applies is a clock nobody sees. It would only duplicate an implementation
that already survives a reload, answers late joiners and is covered by tests.
The host provides the capability-gated API and returns nil, reason when authority is unavailable. The resource implements the operations through synchronous environment.* exports in server/main.lua.
Per-bucket overrides
A routing bucket is a separate world, so a race gamemode at night and a freeroam bucket at noon is
one server. Pass bucket to any setter:
Open77.environment.setTime(23, 0, 0, 5) -- bucket 5 only
Open77.environment.setWeather("rain", 0, 5)
local night = Open77.environment.getState(5) -- scope = "bucket:5"
local day = Open77.environment.getState() -- scope = "default", buckets = { 5 }
Open77.environment.clearBucket(5)
An override is created on first use, seeded from the default environment as it reads at that
moment β so a gamemode that only pins the time keeps whatever weather the session had, and an
override created at dusk does not restart at noon. At most 64 exist at once
(too_many_environment_overrides).
Two things this costs, both measured rather than assumed:
- The revision counter is now global. A client accepts a snapshot only when its revision is at least the last one it applied. Had each scope counted for itself, a player moving from a bucket sitting at revision 40 into one whose last change was revision 12 would have rejected that bucket's snapshots β heartbeats included β forever, and stayed on the sky of a bucket they had left. One monotonic counter shared by every scope makes every snapshot a client can receive newer than every snapshot it has already applied. A player who changes bucket is additionally re-synced individually, with a fresh revision drawn on the scope being entered.
- A broadcast becomes a fan-out. While no override exists the authority still sends one
reliable broadcast to
-1per mutation and per heartbeat, exactly as before. The moment one exists a broadcast is wrong β it would reach the overridden bucket too, with a higher revision, and clobber it β so the default scope switches to one send per connected player. That is the price of an override, not of the feature existing, and a server that never creates one pays nothing.
onEnvironmentChanged
The authority publishes a host-wide onEnvironmentChanged after every real change β never on the
heartbeat, and never on a client's sync request. It is a platform name: every resource receives it
with no capability at all, and no resource can publish it (TriggerEvent answers false, "reserved_event").
AddEventHandler("onEnvironmentChanged", function(state)
-- state is exactly what getState() returns, plus `reason`.
print(("%s in %s at %02d:%02d (%s)")
:format(state.weather, state.scope, state.hour, state.minute, state.reason))
end)
reason names the door the change came through: command_* for a console command, api_* for
Open77.environment.*, server_resource_* for the legacy events below, random_weather for a
scheduler draw, and bucket_cleared when an override is retired.
The older doors, still open
These events are local to the server runtime and are unchanged:
TriggerEvent("open77:weather:setTime", 20, 15, 0)
TriggerEvent("open77:weather:setRate", 4)
TriggerEvent("open77:weather:setFrozen", false)
TriggerEvent("open77:weather:setWeather", "rain", 30)
TriggerEvent("open77:weather:setRandomEnabled", true)
To observe the state:
AddEventHandler("open77:weather:state", function(state)
print(("weather=%s revision=%d"):format(state.weather, state.revision))
end)
TriggerEvent("open77:weather:requestState")
open77:weather:timeChanged and open77:weather:weatherChanged report the precise cause. These
APIs are meant for trusted server resources; server scripts are not distributed to players.
What world.environment does and does not gate, stated plainly. It gates the platform facade,
which is where new code is told to go. It does not close the two older doors: open77:weather:*
is deliberately not a reserved event name β reserving it would break every resource already using
the block above β so any server resource can still publish those, exactly as it always could. The
environment.* exports the facade calls are likewise the resource's public surface and reachable
with Open77.exports.callSync. Nothing was widened here; the capability is a door marked for new
callers, not a lock on the room. Closing the older doors means reserving the open77:weather:
prefix on the bus and is a breaking change with its own decision to make.
API for a client resource
Listening, with no dependency:
AddEventHandler("open77:weather:updated", function(state)
print(state.weather, state.rate, state.frozen)
end)
A one-off read through an export:
CreateThread(function()
local promise, reason = Open77.exports.call("open77_weather", "getState")
assert(promise, reason)
local state = promise:await()
print(string.format("%02d:%02d:%02d", state.hour, state.minute, state.second))
end)
Available exports:
isReady()β has the first snapshot arrived?getState()β time predicted at the moment of the call, and the weather state, plus thescopeandbucketthis client is currently projecting;requestSync()β forces a reliable resynchronisation request.
The native client Open77.environment table (getTime, setTime, setTimeFrozen,
setWeather, setWeatherFrozen, isWeatherFrozen) is guarded by the world.environment
permission and is unchanged. It exists to implement the projection, not for ordinary gameplay
scripts, and it is a different API from the server table of the same name: the client one drives
the local engine, the server one drives the authority every client projects.
Random weather events
The server makes a weighted draw that excludes the current weather, applies the new preset's
transition, then schedules its real min/max duration. The seed comes from the process's monotonic
clock at the first tick, so two boots do not systematically replay the same sequence.
weather.random off suspends the draws without changing the current weather; weather.random on
cleanly reschedules the deadline.
Deployment
The resource sits under the configured resources.root. The server watcher prepares its VM,
rebuilds the signed set, and distributes only the manifest, the client and shared scripts, and this
README. The new client DLL is required for the Open77.environment primitive; if it is not loaded
yet, the resource stays inert and explicitly asks for Cyberpunk to be restarted.
// For agents & LLMsthis page as Markdownllms.txtllms-full.txt
