# 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
```luau
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.lamps` every frame — move the posts
wherever the street wants them; nothing here assumes a layout.
* `Core.state.paleDist` feeds 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.
# game.dread — the Pale, the HUD, and how fear feels
Slice C of *Lamplighter's Round*. Three things live here.
## 1. The Pale
A three-metre hooded figure built from primitives — a hem pooling on the
cobbles, a heavy skirt, a hunched shoulder line, a narrow hood, arms too long
for the body — with a cold light where a face should be. It carries its own
point light, because the fog swallows anything that does not.
It hunts on the vendored `vendor.stalker` brain, driven by the game's central
rule and nothing else:
> **Lamplight makes you safe but visible. The dark hides you from the Pale and
> feeds your Dread.**
So `Core.lightAt(player)` is fed straight in as the stalker's `visible`,
`hidden`, `exposure` and `flashOn` senses. Standing under a burning lamp is
what lets it see you across the district; standing in the dark is what makes
it lose you — and what fills the meter that kills you.
The moment it takes an interest is deliberately unmistakable: the face flares
and its light swells, the screen stings red, and the HUD says IT HAS SEEN YOU.
This slice writes `Core.state.paleState` and `Core.state.paleDist` and nothing
else, and emits `paleCaught` when the Pale reaches the player.
## 2. The feel — the `dreadveil` post pass
One fullscreen effect, registered at priority 240 on the `scene` layer so it
runs after the district's fog, bloom and tonemap and never dims the HUD. Three
properties, pushed every frame:
| Property | Driven by | What it does |
|---|---|---|
| `dread` | `Core.state.dread` | drains the colour, cools what is left, and closes the vignette from a wide frame to a keyhole |
| `beat` | a double-thump heartbeat envelope, 52 bpm calm to 138 bpm at full dread | squeezes the vignette and warms the rim on every thump |
| `sting` | the Pale noticing you, decaying at 1.55/s | bruises the rim red and flares the frame cold |
The bruise is keyed to the screen radius rather than to the vignette, so a
closed keyhole reddens its own rim instead of washing the whole frame red.
## 3. The HUD driver
`ui.hud` owns every pixel; this module owns the snapshot. It reads
`Core.state` for the meters and the rules slice's own published surface for
everything the player is offered:
- `Rules.UPGRADES` / `upgradeRank` / `upgradeCost` / `canBuy` / `upgradeEffect`
→ the three upgrade rows, including the exact reason a row is dim and the
number that rank currently buys
- `Rules.prompt()` → the line under the reticle
- `Rules.buyUpgrade(name)` → what a click on a row does
- `Rules.restart()` → what WALK IT AGAIN does
The slow half of the HUD refreshes at 7 Hz and the focus screen at 18 Hz,
rising to every frame while a taper is held or a sting is fading.
## Inspecting it
`require("game.dread").debug()` returns the live hunt reading, the sting and
heartbeat envelopes, the veil's registration and whether the HUD is mounted.