Skip to content

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

The finished files are in docs/examples/day-night-torches/:

FileRunsGoes to
worldclock.tsserverthe Scripts editor (a plain server script)
weapons/daynight.client.tsevery clientthe Weapons editor, daynight entry, Clientside tab
npcs/torch.npc.client.tsevery clientthe 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 serverr sparingly. Each serverr write is broadcast to every client immediately and persisted to data/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 onCreated can start a fresh setInterval on every reload without stacking them.
  • server.clockoffset is a server-only server flag (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)
}
  • serverr is 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 is undefined, 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.
  • setambient is batched and cheap, but apply() still skips identical values, and at full daylight it calls resetambient(), 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 findimg text image: screen = true makes x/y screen pixels, positioned from ScreenWidth so 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:

PropertyMeaningDefault
lightcolor'r,g,b''255,255,255'
lightintensitybrightness at the source; above 1 widens the fully lit core1 (max 10)
lightradiustiles until it fades out in open air8 (max 48)
lightshapecircle, diamond, square, cone, ringcircle
lightdir / lightarcaim and width of a cone, in degrees0 / 90
lightmode1 = also glow additively by day0

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 script scripts/worldclock.ts (use 'weapon' for a weapon's serverside half). The server calls its onActionServerSide(player, ...params).
  • The server never trusts the client: it checks the account against the staff list in serveroptions.json (read through serverOptions) and validates the hour.
  • triggerClient('weapon', 'daynight', ...) answers the same player's clientside weapon, which receives it in onActionClientSide.

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 ​

  1. Create daynight.client.ts in the Weapons editor, then worldclock.ts in the Scripts editor, and save both. worldclock.ts grants the weapon to players as they join, so log in again (or grant daynight from the Players window).
  2. Place two or three torch NPCs near some walls and save the level.
  3. Log in as a staff account. The clock appears in the top-right corner.
  4. Type /time 19: the world slides into dusk over a few seconds. /time 22: night, and the torches glow and cast shadows behind walls.
  5. /time 6 for dawn, /time 12 for full day (the lighting pass switches off).
  6. 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.worldclock in onUpdate and sets lightintensity to 0 by day.
  • Directional light: a lighthouse with lightshape = 'cone' whose lightdir rotates in onUpdate.
  • Indoor levels: skip the cycle in caves and houses by checking player.level in onUpdate and applying a fixed dark ambient.
  • Night-only NPCs: combine with the villager class and send villagers home once serverr.worldclock passes 21:00.
  • Weather: publish serverr.weather from the same clock script and tint the ambient grey when it rains.