Log inGet started
World

Lamplighter's Round

Branchmain
Branchesmain
Commitea9749977c

README.md

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 lanterndead in 32.7 s (cause = "dark")
A walked 11 m crossing+8 Dread, −8 oil
Holding the taper at the far endpeaks at ~18 Dread
The lamp takingDread 0 again inside 1.2 s, oil full
Lantern drythe Dread rate roughly doubles; ending cause = "dry"
A full nine-lamp roundwon 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.

TrackRank 0 → 3
reachinteract range 3.0 → 6.6 m
stridewalk speed 2.5 → 3.7 m/s (written to the body's CharacterController)
wicklight 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.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.