rules
The round loop of *Lamplighter's Round*: Dread, oil, the hold-to-light interaction, the Ember economy, the prelude, and the win/lose resolution.
rules
The round loop of Lamplighter's Round: Dread, oil, the hold-to-light interaction, the Ember economy, the prelude, and the win/lose resolution.
require("game.rules"). It reads game.core and writes only the state
fields Core names it the owner of.
The shape of the round
You carry a lantern. It is not light enough to see by — it is light enough to keep your nerve for a little while. Dread rises whenever you are away from a burning lamp, slowly at first and faster the longer you stay out, and falls hard the moment you step into gaslight. The lantern buys you most of a minute; walking burns it, running burns it faster, and when it is dry the dark has you at the full rate.
Lighting a lamp is the one thing that helps and the one thing that costs.
You hold the taper, and while you hold it you cannot move at all — the
rules write the body's walk speed to zero. Letting go does not throw the
attempt away: the flame gutters, draining back at 1.5× the fill rate, so a
flinch costs you and a change of mind is final. An attempt that got past 15%
and then died emits lampFailed.
The Pale takes you in the dark. Under a burning lamp it can reach you and it costs 38 Dread, but it cannot have you — the gaslight is the sanctuary the whole round is walked for. That branch lives here because the round's ending is the rules' to own.
Measured behaviour
Every number below was read out of a live round, not derived:
| Standing still in the dark, full lantern | dead in 32.7 s (cause = "dark") |
| A walked 11 m crossing | +8 Dread, −8 oil |
| Holding the taper at the far end | peaks at ~18 Dread |
| The lamp taking | Dread 0 again inside 1.2 s, oil full |
| Lantern dry | the Dread rate roughly doubles; ending cause = "dry" |
| A full nine-lamp round | won at 0:18 clear, 0:48 with the Pale hunting |
Upgrades
One Ember per lamp, so a round pays out nine. Rank n costs n Embers and each track stops at rank 3, so a maxed track costs 6 of the 9 — you can max one and open a second, never all three. Buying is refused unless you are standing in lamplight.
| Track | Rank 0 → 3 |
|---|---|
reach | interact range 3.0 → 6.6 m |
stride | walk speed 2.5 → 3.7 m/s (written to the body's CharacterController) |
wick | light time 2.6 → 0.9 s |
A full Dread bar also adds 30% to the light time — shaking hands — which is the reason to rest before crossing rather than after.
What the HUD calls
local Rules = require("game.rules")
Rules.buyUpgrade(name) -- -> ok, reason spend an Ember
Rules.canBuy(name) -- -> ok, reason why a button is dimmed
Rules.upgradeCost(name) -- -> cost | nil nil once the track is maxed
Rules.upgradeRank(name) -- -> rank, maxRank
Rules.upgradeEffect(name) -- -> "4.2 m" the number it changes, now
Rules.UPGRADES -- ordered { name, label, blurb }
Rules.prompt() -- -> the line to print near the reticle
Rules.stats() -- -> everything the meters and end cards want
Rules.targetLamp() -- -> lamp, index, distance, inReach
Rules.setLightHeld(down) -- drive hold-to-light from a screen button
Rules.restart() -- begin a fresh round from a win/lose card
stats() returns phase cause time lampsLit lampTotal dread dreadFrac oil oilFrac embers lightProgress lighting inLamplight lampLight darkTime sinceLastLamp reach lightSeconds targetDistance targetInReach held.
Input
Hold-to-light is the engine's own interact control on
@builtin::inputMaps.default — KeyE on a keyboard, the west face button
on a pad, an on-screen button on a phone. The module activates that map and
subscribes to the control rather than polling a key code, so all three
devices work with no extra plumbing. setLightHeld ORs a HUD button on top
of it for a bespoke on-screen control.
Notes for the other slices
- Lamp positions are read from
Core.lampsevery frame — move the posts wherever the street wants them; nothing here assumes a layout. Core.state.paleDistfeeds a Dread multiplier that rises to 2.2× as the Pale closes inside 14 m. While that field is left unwritten the multiplier sits at 1 and the pressure never engages.- Subscriptions are made in
build(), never at module scope: the scene entrypoint clears the event bus on every rebuild, so a handler registered once at load is silently dropped the first time any slice hot-reloads.
Scoped to this part · feeds back into the world's score.