rules
Lanternwake turns on one dial. Raise the lantern and Dr. Vane can see, read and work — and every eye in the fen can see her. Hood it and she is invisible and useless. This module owns that dial, the movement it costs her, the things she can reach out and touch, and what happens w…
game.rules — the loop and the lantern
Lanternwake turns on one dial. Raise the lantern and Dr. Vane can see, read and work — and every eye in the fen can see her. Hood it and she is invisible and useless. This module owns that dial, the movement it costs her, the things she can reach out and touch, and what happens when the Hollow Vicar catches up.
It spawns everything it needs under the scene's Rules container, marks it
temporary, and re-makes it on every load. Nothing here is baked into the
scene.
Controls
| Control | Keyboard | Pad | What it does |
|---|---|---|---|
lantern | F / Q | north | raise / hood the lantern |
creep | Ctrl / C | east | creep: quiet, slow |
sprint | Shift | left shoulder | run: fast, and loud |
interact | E | west | use whatever the prompt names |
lantern and creep come from rules/lanternwake.inputMap, which this
module activates alongside the engine's default map. Both maps compose;
neither suppresses the other.
State it writes
phase · lanternUp · lanternLit — the fields Core.state names rules
as the writer of. Plus two additive fields this module owns:
| field | meaning |
|---|---|
Core.state.prompt | { id, label, blocked } for the thing under the reticle, or nil |
Core.state.deaths | how many times the Vicar has reached her |
Core.lightAt() follows lanternUp / lanternLit straight out of the box,
so the fog, the Vicar's eyes and anything else that reads light move the
moment the hood does.
The interaction registry
Anything a player can look at and press. Register over the bus — no slice has to require this one:
Core.emit("registerInteractable", {
id = "mill_sluice", -- required, unique; re-registering replaces
label = "Free the sluice", -- what the prompt says
pos = { -38, 1, 22 }, -- a point, or a function returning one
entity = someEntityOrId, -- ...or an entity to track instead
reach = 2.6, -- metres, default 3.0
needsLight = true, -- refuses while the hood is down
blockedLabel = "Too dark to read", -- optional override of the refusal text
once = false, -- retire it after one use
sfx = "clue_read", -- optional `sfx` key on use
onUse = function(rec) end, -- optional callback
})
Core.emit("unregisterInteractable", "mill_sluice")
Core.emit("setInteractableEnabled", "mill_sluice", false)
Rules picks the nearest enabled one inside its reach that the player is
actually facing, publishes it as Core.state.prompt, and emits
promptChanged(prompt) when it changes — that is the HUD's cue. On a
press it calls onUse and emits interacted(id, record).
An interactable marked needsLight shows a refusal instead of a prompt
while the hood is down, and pressing it makes Vane say so. That is the
whole tension of the game: the work needs the light, and the light is
what brings the Vicar.
Events
Emitted by this module:
| event | when |
|---|---|
phaseChanged(new, old) | any phase transition (via Core.setPhase) |
gameStart | the title has been dismissed and the opening begins |
controlReturned | the opening is over and the player has the body |
lanternToggled(isUp) | the hood moved |
promptChanged(prompt) | the thing under the reticle changed |
interacted(id, record) | something was used |
sfx(key, pos) | lantern_open, lantern_hood, footstep_stone, sigil_claim, caught, crypt_open, ui_back |
speak(key, text) | Vane on being caught, on waking, at the gate |
Listened for:
| event | effect |
|---|---|
beginGame | leave the title (the title screen's button) |
introDone | the opening is finished; hand control over |
sigilSolved(key) | move the checkpoint to that site |
cryptOpen | light the gate ward and arm the way out |
playerCaught | douse the lantern, hold the beat, respawn — or end it |
lanternDouse / lanternRelight | put the flame out / light it again |
registerInteractable / unregisterInteractable / setInteractableEnabled | the registry above |
The phases
boot ──▶ title ──▶ intro ──▶ explore ──┬──▶ caught ──▶ explore (respawn)
├──▶ lost (caught after the crypt opened)
└──▶ won (through the gate)
title waits for beginGame, or for interact / lantern to be pressed.
If no UI slice has put a screen up (Core.state.uiScreen == "none") it moves
on by itself after two seconds, so the game is playable with the screens
half-built; there is a 12 s backstop either way, so a title screen that never
emits beginGame costs a pause, not the game. intro waits for introDone,
or seven seconds.
Death is not the end until the crypt is open. A catch before that
returns her to the last site she solved with every sigil still counted —
sigilSolved moves the checkpoint, and nothing in this module touches
Core.state.sigils. A catch after cryptOpen is the end of the night:
that is the endgame chase, and it ends in lost.
won needs both: the crypt open, and the churchyard gate at
(0, 0, 42) used. Until the crypt is open the gate is registered but
disabled, and its ward-lamp is dark.
Sound
Every noise goes out as Core.emit("sfx", key, worldPos) so the sound
slice owns the mix. This module also synthesises its own hood and footfall
clips with audio.encodePcm and plays them only when no sound slice is
present (game/sound.module absent), so the lantern is never silent on a
half-built world and never doubled on a finished one.
Footfalls are metered by distance, not time, so the gait sets the rhythm: 2.30 m between steps creeping, 1.75 m walking, 1.28 m running — and the running step is nearly twice as loud as the walking one.
Reading the live state
A hot reload hands a fresh copy of a module to the next caller while the
running scene keeps the one it built with, so
require("game.rules").debug() can answer for a module that never ran. The
live loop therefore mirrors itself onto the container as an entity
attribute, which is engine state and survives the split:
entity.find("rules_state").attribute.get("rules")
--> phase=explore;lanternUp=true;lanternLit=true;light=1.00;raise=1.00;...
rules_state is a temporary entity of this slice's own, under the Rules
container. It is deliberately not the container itself: an attribute written
onto a scene-owned entity counts as a live scene edit and blocks
world.commit for everyone sharing the world.
Rules.debug() returns the same picture as a table when you are holding
the live instance.
Scoped to this part · feeds back into the world's score.