Appearance
Day/night cycle & torches
Give your world a clock. The sky dims through a golden dusk into a blue night, torches flicker to life along the paths, and dawn washes it all away again. Every player sees the same time of day, because one server script owns the clock and each client paints its own screen from it.
By the end you will have:
- a server clock that publishes the in-game minute to every client,
- a day/night weapon that turns that minute into an ambient light level and shows the time on screen,
- torch NPCs that cast warm, flickering, wall-aware light at night,
- a staff
/time <hour>command for testing.
What you'll learn
- Broadcasting world state with
serverrflags, and why to write them sparingly - Server timers (
setInterval) and hot-reload-safe setup - Darkening the world with
setambient/resetambient - Turning NPCs into light sources with
drawaslightand thelight*properties - Calling a plain server script from the client with
triggerServer('script', ...)
The finished files are in docs/examples/day-night-torches/:
| File | Runs | Goes to |
|---|---|---|
worldclock.ts | server | the Scripts editor (a plain server script) |
weapons/daynight.client.ts | every client | the Weapons editor, daynight entry, Clientside tab |
npcs/torch.npc.client.ts | every client | the Clientside tab of each torch NPC in a level |
How lighting works
Lighting is entirely clientside (Lighting). The client multiplies the world by an ambient color: 255,255,255 means "lighting off", and anything lower darkens it. Lights are NPCs that called drawaslight(). They brighten the darkness around them and blocking tiles cast shadows. At full ambient, normal lights are invisible and the lighting pass costs nothing.
Step 1: The server clock
The time of day has to be identical for everyone, so the server owns it. A plain server script derives the in-game minute from the server's wall clock and publishes it as the serverr.worldclock flag:
ts
const DAY_SECONDS = 24 * 60 // one in-game day per 24 real minutes (1 game minute per second)
const PUBLISH_MS = 5000 // how often the flag is refreshed
/** In-game minute of the day, derived from the server's wall clock. */
function minuteOfDay(): number {
const days = Date.now() / 1000 / DAY_SECONDS
const offset: number = server.clockoffset ?? 0 // staff /time adjustments
const minute = Math.floor((days % 1) * 1440 + offset)
return ((minute % 1440) + 1440) % 1440
}
function publish() {
// Round to 5 in-game minutes: every write is broadcast to every client,
// so only write when the value actually changes.
const minute = Math.floor(minuteOfDay() / 5) * 5
if (serverr.worldclock !== minute)
serverr.worldclock = minute
}
function onCreated() {
publish()
// Timers belong to the script and are cancelled when it hot-reloads,
// so onCreated can safely start a fresh one.
setInterval(publish, PUBLISH_MS)
}Design choices worth copying:
- Derive, don't count. Computing the time from
Date.now()means the clock survives restarts and hot reloads without drifting. A counter incremented in a timer would reset every time the script reloads. - Write
serverrsparingly. Eachserverrwrite is broadcast to every client immediately and persisted todata/flags.json. Publishing a 5-minute-rounded value, and only when it changes, costs one tiny broadcast every five seconds. The client does the smoothing (step 3). - Timers are cleaned up for you. A script's timers are cancelled when it unloads, so
onCreatedcan start a freshsetIntervalon every reload without stacking them. server.clockoffsetis a server-onlyserverflag (never sent to clients) that the staff command in step 5 adjusts.
Step 2: Granting the weapon
Server scripts all receive onPlayerJoined, so the clock script can hand every player the clientside weapon as they log in:
ts
function onPlayerJoined(player: Player) {
player.addWeapon('daynight')
}player.addWeapon is a no-op when the player already has the weapon. (Alternatively, list "daynight" in startWeapons in serveroptions.json. See Weapons.)
Step 3: Painting the sky
The weapon maps the minute of the day to a color with a small keyframe table and linear interpolation:
ts
// Ambient color at key minutes of the day, [minute, r, g, b]. Everything in
// between is interpolated. 255,255,255 is full daylight (lighting off).
const KEYFRAMES: [number, number, number, number][] = [
[0, 25, 30, 70], // midnight: deep blue
[300, 25, 30, 70], // 05:00 still night
[390, 210, 140, 120], // 06:30 dawn glow
[480, 255, 255, 255], // 08:00 full day
[1080, 255, 255, 255], // 18:00
[1170, 220, 120, 90], // 19:30 dusk
[1260, 25, 30, 70], // 21:00 night again
[1440, 25, 30, 70], // wraps to midnight
]
function ambientAt(minute: number): [number, number, number] {
for (let i = 1; i < KEYFRAMES.length; i++) {
const [m1, r1, g1, b1] = KEYFRAMES[i]
if (minute > m1) continue
const [m0, r0, g0, b0] = KEYFRAMES[i - 1]
const t = (minute - m0) / (m1 - m0)
return [r0 + (r1 - r0) * t, g0 + (g1 - g0) * t, b0 + (b1 - b0) * t]
}
return [255, 255, 255]
}Night isn't black: a dark blue ambient (25,30,70) keeps the world readable and makes warm torchlight pop. Dawn and dusk pass through orange tints.
Then, every frame, it eases the current color toward the target and applies it:
ts
// The ambient we are currently showing; eased toward the target every frame
// so the 5-minute steps of the server clock never pop.
let current: [number, number, number] = [255, 255, 255]
let shown = ''
function onCreated() {
current = ambientAt(serverr.worldclock ?? 720)
apply()
const clock = findimg(1)
clock.screen = true // x/y in screen pixels, drawn above the world
clock.style = 'b'
clock.fontsize = 14
clock.textshadow = true
}
function onUpdate(dt: number) {
const minute: number = serverr.worldclock ?? 720 // noon until the flag arrives
const target = ambientAt(minute)
const k = Math.min(1, dt * 2) // ~0.5s to catch up
for (let i = 0; i < 3; i++)
current[i] += (target[i] - current[i]) * k
apply()
const clock = findimg(1)
clock.x = ScreenWidth - 70
clock.y = 10
clock.text = `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`
}
/** Pushes the eased color to the engine, but only when it visibly changed. */
function apply() {
const [r, g, b] = current.map(Math.round)
const key = `${r},${g},${b}`
if (key === shown) return
shown = key
if (r >= 255 && g >= 255 && b >= 255)
resetambient() // full daylight: skip the lighting pass entirely
else
setambient(r, g, b)
}serverris read-only on the client and live-updating, so there is nothing to subscribe to. Just read it. Until the flag arrives (or if the clock script isn't installed) it isundefined, hence the?? 720(noon).- Easing hides the 5-minute steps of the server clock. Without it, each step would be a visible jump at dusk and dawn.
setambientis batched and cheap, butapply()still skips identical values, and at full daylight it callsresetambient(), which turns the lighting pass off entirely.- The ambient level persists across level changes for the session and is reset automatically if the weapon is removed.
- The clock readout is a screen-space
findimgtext image:screen = truemakes x/y screen pixels, positioned fromScreenWidthso it stays in the corner when the window is resized.
Step 4: Torches
A torch is a level NPC with only a clientside script. Place one with GRC's level editor (NPC tool, right-click to place, double-click to edit), leave the image empty, and paste this into the Clientside tab:
ts
const BASE_INTENSITY = 1.1
const BASE_RADIUS = 7
function onCreated(this: NpcThis) {
this.lightcolor = '255,160,70' // warm orange
this.lightintensity = BASE_INTENSITY
this.lightradius = BASE_RADIUS
this.lightshape = 'circle'
this.drawaslight() // one-way: from now on this NPC is a light source
this.flicker = 0
this.buildFlame()
}drawaslight() is one-way, like showCharacter(). To switch a light off, set lightintensity = 0. The properties:
| Property | Meaning | Default |
|---|---|---|
lightcolor | 'r,g,b' | '255,255,255' |
lightintensity | brightness at the source; above 1 widens the fully lit core | 1 (max 10) |
lightradius | tiles until it fades out in open air | 8 (max 48) |
lightshape | circle, diamond, square, cone, ring | circle |
lightdir / lightarc | aim and width of a cone, in degrees | 0 / 90 |
lightmode | 1 = also glow additively by day | 0 |
Because these are clientside NPC properties, the writes stay on this client. Every client runs the same script, so everyone sees a torch, but the server never hears about lights.
Flicker
ts
function onUpdate(this: NpcThis, dt: number) {
// Two out-of-phase sine waves plus a little noise read as a living flame.
this.flicker += dt
const wobble = Math.sin(this.flicker * 9) * 0.05
+ Math.sin(this.flicker * 23) * 0.03
+ (Math.random() - 0.5) * 0.04
this.lightintensity = BASE_INTENSITY + wobble
this.lightradius = BASE_RADIUS + wobble * 6
}A clientside NPC's onUpdate(dt) runs every frame. Mixing two sine waves with a little noise reads as a living flame, where a single sine looks like a pulsing beacon. Writing light properties every frame is fine: NPC property writes are coalesced into one update per frame.
A visible flame
Lights only show where the ambient is darkened, so by day the torch would be invisible. The script therefore also runs a tiny particle emitter (covered in depth in the particle tutorial):
ts
// A small particle flame so the torch is visible by day too. With no image
// and no shape, the light sits at the center of the NPC's top-left tile.
function buildFlame(this: NpcThis) {
const img = findimg(1)
img.x = this.x + 0.5
img.y = this.y + 0.5
img.visible = false // only an anchor: an empty image draws a white square
const e = img.emitter
e.delaymin = 0.05
e.delaymax = 0.1
e.nrofparticles = 1
e.particle.lifetime = 0.5
e.particle.angle = Math.PI / 2 // straight up
e.particle.speed = 1.2
e.particle.zoom = 0.3 // 16px square * 0.3
e.particle.red = 1
e.particle.green = 0.6
e.particle.blue = 0.15
e.particle.mode = 0 // additive: overlapping sparks glow
e.addlocalmodifier('once', 0, 0, 'x', 'add', -0.15, 0.15)
e.addlocalmodifier('range', 0, 0.5, 'alpha', 'replace', 1, 0)
}An NPC with no image and no shape is centered on its top-left tile for lighting, so the flame goes at this.x + 0.5, this.y + 0.5 to sit right on the light.
Daytime glow instead
If you'd rather have lamps glow by day too, set this.lightmode = 1 and lower lightintensity to about 0.3–0.6. Mode 1 adds the light on top of the scene at any ambient level, and still lights the tiles at night.
Step 5: A staff command for testing
Waiting 24 minutes to test a sunset gets old. The weapon forwards /time <hour> to the clock script:
ts
function onPlayerChats(who: ChatPlayer, chat: string) {
if (who.id !== player.id || !chat.startsWith('/time '))
return
const hour = Number(chat.slice('/time '.length))
triggerServer('script', 'worldclock', 'settime', hour)
}
function onActionClientSide(action: string) {
if (action === 'denied')
echo('Usage: /time <0-23> (staff only)')
}ts
// triggerServer('script', 'worldclock', 'settime', hour) from the daynight
// weapon. Staff only: it shifts the clock for everyone.
function onActionServerSide(player: Player, action: string, hour: unknown) {
if (action !== 'settime') return
const staff: unknown = serverOptions.staff
const isStaff = Array.isArray(staff)
&& staff.some(a => String(a).toLowerCase() === player.account.toLowerCase())
if (!isStaff || typeof hour !== 'number' || !(hour >= 0 && hour < 24)) {
triggerClient('weapon', 'daynight', 'denied')
return
}
// Shift the offset so that minuteOfDay() lands on the requested hour.
const current = minuteOfDay()
server.clockoffset = ((server.clockoffset ?? 0) + hour * 60 - current) % 1440
publish()
echo(`[worldclock] ${player.account} set the time to ${hour}:00`)
}triggerServer('script', 'worldclock', ...)targets the plain server scriptscripts/worldclock.ts(use'weapon'for a weapon's serverside half). The server calls itsonActionServerSide(player, ...params).- The server never trusts the client: it checks the account against the
stafflist inserveroptions.json(read throughserverOptions) and validates the hour. triggerClient('weapon', 'daynight', ...)answers the same player's clientside weapon, which receives it inonActionClientSide.
Complete files
Every file from this tutorial, in full, for copying into GRC.
npcs/torch.npc.client.ts
ts
// Clientside script of a torch NPC (paste into the NPC's "Clientside" tab in
// GRC's level editor; the Serverside tab can stay empty). Lights are purely
// clientside: every client runs this script and lights its own screen.
const BASE_INTENSITY = 1.1
const BASE_RADIUS = 7
function onCreated(this: NpcThis) {
this.lightcolor = '255,160,70' // warm orange
this.lightintensity = BASE_INTENSITY
this.lightradius = BASE_RADIUS
this.lightshape = 'circle'
this.drawaslight() // one-way: from now on this NPC is a light source
this.flicker = 0
this.buildFlame()
}
function onUpdate(this: NpcThis, dt: number) {
// Two out-of-phase sine waves plus a little noise read as a living flame.
this.flicker += dt
const wobble = Math.sin(this.flicker * 9) * 0.05
+ Math.sin(this.flicker * 23) * 0.03
+ (Math.random() - 0.5) * 0.04
this.lightintensity = BASE_INTENSITY + wobble
this.lightradius = BASE_RADIUS + wobble * 6
}
// A small particle flame so the torch is visible by day too. With no image
// and no shape, the light sits at the center of the NPC's top-left tile.
function buildFlame(this: NpcThis) {
const img = findimg(1)
img.x = this.x + 0.5
img.y = this.y + 0.5
img.visible = false // only an anchor: an empty image draws a white square
const e = img.emitter
e.delaymin = 0.05
e.delaymax = 0.1
e.nrofparticles = 1
e.particle.lifetime = 0.5
e.particle.angle = Math.PI / 2 // straight up
e.particle.speed = 1.2
e.particle.zoom = 0.3 // 16px square * 0.3
e.particle.red = 1
e.particle.green = 0.6
e.particle.blue = 0.15
e.particle.mode = 0 // additive: overlapping sparks glow
e.addlocalmodifier('once', 0, 0, 'x', 'add', -0.15, 0.15)
e.addlocalmodifier('range', 0, 0.5, 'alpha', 'replace', 1, 0)
}weapons/daynight.client.ts
ts
// Clientside day/night weapon: turns serverr.worldclock (in-game minute of
// the day, written by scripts/worldclock.ts) into an ambient light level, and
// shows the time in the corner of the screen.
//
// /time 21 (staff) jump to 21:00 for everyone
// Ambient color at key minutes of the day, [minute, r, g, b]. Everything in
// between is interpolated. 255,255,255 is full daylight (lighting off).
const KEYFRAMES: [number, number, number, number][] = [
[0, 25, 30, 70], // midnight: deep blue
[300, 25, 30, 70], // 05:00 still night
[390, 210, 140, 120], // 06:30 dawn glow
[480, 255, 255, 255], // 08:00 full day
[1080, 255, 255, 255], // 18:00
[1170, 220, 120, 90], // 19:30 dusk
[1260, 25, 30, 70], // 21:00 night again
[1440, 25, 30, 70], // wraps to midnight
]
function ambientAt(minute: number): [number, number, number] {
for (let i = 1; i < KEYFRAMES.length; i++) {
const [m1, r1, g1, b1] = KEYFRAMES[i]
if (minute > m1) continue
const [m0, r0, g0, b0] = KEYFRAMES[i - 1]
const t = (minute - m0) / (m1 - m0)
return [r0 + (r1 - r0) * t, g0 + (g1 - g0) * t, b0 + (b1 - b0) * t]
}
return [255, 255, 255]
}
// The ambient we are currently showing; eased toward the target every frame
// so the 5-minute steps of the server clock never pop.
let current: [number, number, number] = [255, 255, 255]
let shown = ''
function onCreated() {
current = ambientAt(serverr.worldclock ?? 720)
apply()
const clock = findimg(1)
clock.screen = true // x/y in screen pixels, drawn above the world
clock.style = 'b'
clock.fontsize = 14
clock.textshadow = true
}
function onUpdate(dt: number) {
const minute: number = serverr.worldclock ?? 720 // noon until the flag arrives
const target = ambientAt(minute)
const k = Math.min(1, dt * 2) // ~0.5s to catch up
for (let i = 0; i < 3; i++)
current[i] += (target[i] - current[i]) * k
apply()
const clock = findimg(1)
clock.x = ScreenWidth - 70
clock.y = 10
clock.text = `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`
}
/** Pushes the eased color to the engine, but only when it visibly changed. */
function apply() {
const [r, g, b] = current.map(Math.round)
const key = `${r},${g},${b}`
if (key === shown) return
shown = key
if (r >= 255 && g >= 255 && b >= 255)
resetambient() // full daylight: skip the lighting pass entirely
else
setambient(r, g, b)
}
function onPlayerChats(who: ChatPlayer, chat: string) {
if (who.id !== player.id || !chat.startsWith('/time '))
return
const hour = Number(chat.slice('/time '.length))
triggerServer('script', 'worldclock', 'settime', hour)
}
function onActionClientSide(action: string) {
if (action === 'denied')
echo('Usage: /time <0-23> (staff only)')
}worldclock.ts
ts
// Plain server script (scripts/worldclock.ts): the single source of truth for
// the time of day. Every client reads serverr.worldclock — the in-game minute
// of the day, 0..1439 — and lights its own screen from it.
const DAY_SECONDS = 24 * 60 // one in-game day per 24 real minutes (1 game minute per second)
const PUBLISH_MS = 5000 // how often the flag is refreshed
/** In-game minute of the day, derived from the server's wall clock. */
function minuteOfDay(): number {
const days = Date.now() / 1000 / DAY_SECONDS
const offset: number = server.clockoffset ?? 0 // staff /time adjustments
const minute = Math.floor((days % 1) * 1440 + offset)
return ((minute % 1440) + 1440) % 1440
}
function publish() {
// Round to 5 in-game minutes: every write is broadcast to every client,
// so only write when the value actually changes.
const minute = Math.floor(minuteOfDay() / 5) * 5
if (serverr.worldclock !== minute)
serverr.worldclock = minute
}
function onCreated() {
publish()
// Timers belong to the script and are cancelled when it hot-reloads,
// so onCreated can safely start a fresh one.
setInterval(publish, PUBLISH_MS)
}
function onPlayerJoined(player: Player) {
player.addWeapon('daynight')
}
// triggerServer('script', 'worldclock', 'settime', hour) from the daynight
// weapon. Staff only: it shifts the clock for everyone.
function onActionServerSide(player: Player, action: string, hour: unknown) {
if (action !== 'settime') return
const staff: unknown = serverOptions.staff
const isStaff = Array.isArray(staff)
&& staff.some(a => String(a).toLowerCase() === player.account.toLowerCase())
if (!isStaff || typeof hour !== 'number' || !(hour >= 0 && hour < 24)) {
triggerClient('weapon', 'daynight', 'denied')
return
}
// Shift the offset so that minuteOfDay() lands on the requested hour.
const current = minuteOfDay()
server.clockoffset = ((server.clockoffset ?? 0) + hour * 60 - current) % 1440
publish()
echo(`[worldclock] ${player.account} set the time to ${hour}:00`)
}Try it
- Create
daynight.client.tsin the Weapons editor, thenworldclock.tsin the Scripts editor, and save both.worldclock.tsgrants the weapon to players as they join, so log in again (or grantdaynightfrom the Players window). - Place two or three torch NPCs near some walls and save the level.
- Log in as a staff account. The clock appears in the top-right corner.
- Type
/time 19: the world slides into dusk over a few seconds./time 22: night, and the torches glow and cast shadows behind walls. /time 6for dawn,/time 12for full day (the lighting pass switches off).- Log in with a second client: both show the same time and lighting.
Next steps
- Streetlamps that switch on: give lamp NPCs a script that reads
serverr.worldclockinonUpdateand setslightintensityto 0 by day. - Directional light: a lighthouse with
lightshape = 'cone'whoselightdirrotates inonUpdate. - Indoor levels: skip the cycle in caves and houses by checking
player.levelinonUpdateand applying a fixed dark ambient. - Night-only NPCs: combine with the villager class and send villagers home once
serverr.worldclockpasses 21:00. - Weather: publish
serverr.weatherfrom the same clock script and tint the ambient grey when it rains.