# helm
The 'Helm' control.
# move
The 'Move' control.
# jump
The 'Jump' control.
# free
The 'Free' control.
# lavic
<!-- zero:scaffolded-readme -->
<!-- Replace the line below with what this module provides and who
consumes it, then delete both comment lines. -->
TODO: describe this module.
# hud
What the pilgrim reads while climbing: `require("game.screens.hud")`.
- `H.build(lay, st)` — the HUD. Two narrow columns pinned to the two long
edges. **Left, the body:** the helm (lit slit while worn, a bare lit-eyed
head under a thrown-back cowl while off), the heat column, the six lost
knights, the clock. **Right, the climb:** the dawn ladder — a strip of
the game's own palette standing on end, ticked by canto in roman
numerals, with a bead riding it at the pilgrim's height — and the height
itself.
- `H.visor(lay, st)` — the helmet-slit overlay while helmed, and the
ember-red vignette that closes in on a bare head as the heat climbs.
Returns `nil` when neither applies.
- `H.toast(lay, index, canto)` — a canto announcing itself at the top of
the frame. One tree with one animation: it rises in, holds, and fades.
## Why it is shaped like this
**Nothing spans the width.** The frame this game films in is 1080x1440,
taller than it is wide, so horizontal room is the scarce thing. Both
columns are 17% of the short side; on a wide screen they simply stand
further apart.
**Both columns live in the upper half.** The rules slice's on-screen touch
controls take the lower-left and lower-right zones and the staging slice's
captions take the bottom band. A HUD that reached into either would be a
HUD a thumb sits on top of.
**Every glyph carries a dark halo, and each column sits on an edge scrim
that fades inward.** The thing this HUD is read over is a lake of molten
rock at nearly full brightness; unbacked text disappears into it.
**The toast is at the TOP.** The staging slice captions the narrator across
the bottom of the frame, and the two must never write over each other.
# look
The 'Look' control.
# pause
The 'Pause' control.
# game.sound.sfx — the sound library
Everything in FIRST LIGHT that makes a noise goes through here.
```lua
local sfx = require("game.sound.sfx")
sfx.play("helm_off") -- a one-shot, heard flat
sfx.play("footstep", { at = footEntity }) -- a one-shot, heard from where it is
sfx.play("lava_bubble", { at = { x, y, z }, pitch = 0.8, volume = 0.6 })
local hold = sfx.loop("free_hold") -- a sustained sound
sfx.loop("free_hold", { pitch = 1.2 }) -- same source, moved
sfx.stop(hold)
sfx.bed("rim_wind") -- crossfade the ambience
sfx.duck(2.5) -- get out of a spoken line's way
sfx.music("music_dawn") -- crossfade the score
```
Guard the require if your slice must survive the library being absent:
```lua
if asset.exists("game.sound.sfx", "module") then
sfx = require("game.sound.sfx")
end
```
## The bank
| name | when |
|---|---|
| `ui_move` `ui_confirm` `ui_back` | menu focus, a control taken, a card closed |
| `helm_off` `helm_on` | the one verb — the world arriving, and shutting |
| `footstep` `jump` `land` | the pilgrim on the rock |
| `crust_crack` `plate_collapse` | a plate warning, and a plate going |
| `ember_hit` `fumarole` | ember weather on a bare head, gas out of the rock |
| `knight_free` `lava_bubble` | one of the lost released; the tide, close |
| `win_sting` `lose_sting` | first light, and being taken back |
| `narrator_bed` | a floor for a voice to stand on |
| `lava_rumble` `rim_wind` `free_hold` | looping beds |
| `music_title` `music_climb` `music_dawn` `music_drone` | the score |
`sfx.clip(name)` hands back the `.soundClip`; `sfx.missing()` lists any the
bank could not resolve, so a caller can tell a complete library from a
partial one.
## The mix
Every clip carries a default level, so a caller that passes no `volume` still
lands in a mix rather than at full scale. The helm, the knights and the ends
of a round are loud; footsteps, ambience and interface sit under them.
One-shots overlap freely, but the same clip will not start twice inside a
short window — three copies of a two-second vent running together is one
waveform at three times the amplitude, which is a comb filter and a peak
rather than a louder vent. Interface ticks and footsteps are effectively
unguarded; the long textural clips hold each other off for a fraction of
their length. A refused start returns `false` from `sfx.play`.
The master sits at 0.72 rather than 1, because `audio.levels().peak` is
measured after the master and the loud moments of a full mix — the score, two
beds, a sting and a spoken line at once — go over full scale without it. It is
stated once and never re-asserted, so a settings screen can set its own.
Three mixer channels are in play. `music` and `ambient` are owned here and
move with the round — they fall to about a quarter under `duck`. The `sfx`
channel is deliberately left at unity, because that is where a spoken line
plays and it is the thing the duck exists to make room for.
## What owns what
`game.sound.Sound` (mounted on `mount_sound`) ticks this library and drives
the automatic mix from `L.state` — the tide below rising with the lava's
proximity, the air at the rim rising with `L.dawn()`, the score following the
phase. It re-states that mix every frame, which is what makes a hot reload
harmless.
An explicit `sfx.bed(name)` from another slice therefore holds the ambience
for a few seconds before the automatic mix takes it back; `sfx.play`,
`sfx.loop`, `sfx.duck` and `sfx.music` are not overridden at all.
The emitters live under a temporary `SoundEmitters` entity and are found by
name, so a hot reload re-attaches to the sources already standing instead of
spawning a second set.
## Where the sound comes from
Every clip is synthesised on the CPU by `game.sound.synth` and baked into a
`.soundClip` under `game/sound/clips/`; the three scored tracks are generated,
then trimmed and folded into seamless loops by the same module. Loops are
stored as `codec = "pcm"` so the head of the clip survives decoding and the
seam does not click.
# cards
Every card **First Light: Lavic Odyssey** puts in front of the player:
`require("game.screens.cards")`.
| Builder | Card |
|---|---|
| `C.title(lay, focusId)` | the crest, the name, and two ways in |
| `C.howto(lay, focusId, deviceKey, bindings)` | the rules in three sentences, then the live control table for one device family |
| `C.pause(lay, focusId, st)` | the canto, where the climb stands, and three ways on |
| `C.won(lay, focusId, st)` | the sunrise, the knights freed, the time |
| `C.lost(lay, focusId, st)` | the tide or the heat, and how far you got |
Each returns `{ tree, controls }` — the widget tree AND the ordered list of
control ids on it. That list is both the focus order a pad walks and the
set of callback ids the component answers, from one source, so a control
can never exist on screen and be unreachable from a controller.
## Every shape
Nothing here is sized in pixels. Every measure comes off `L.layout()`,
which is a proportion of the short side of whatever screen is live.
Cards stack DOWN in a tall frame. In a wide one the how-to card puts its
prose and its control table side by side — that is where the room is — and
the title card drops its couplet, because a wide frame has far less
vertical room than a tall one and a title that overflows reads worse than
a title with one line fewer.
The title card leaves the bottom fifth of a tall frame empty: the narrator
speaks the title line over it, and the staging slice captions that across
the bottom of the frame on a screen above this one.
# menu
The scheme a **card** is driven with — the title, how-to-play, the pause
slab and the two end cards. Live only while a card is on the glass, and it
stands the gameplay groups down while it is: group `ui`, `suppresses =
{ "climb", "player" }`, so Escape closes the card instead of pausing again
and the pilgrim does not walk behind a menu.
| Control | Label | Keyboard / mouse | Gamepad | Touch |
|---|---|---|---|---|
| `confirm` | Confirm | Enter, Numpad Enter, Space | south | — |
| `back` | Back | Escape, right click | east | — |
| `navigate` | Navigate | arrow keys | left stick | — |
| `navigateDpad` | Navigate (d-pad) | WASD | d-pad | — |
| `restart` | Restart | R | north | — |
**Touch is unbound on purpose.** A card is tapped: the screens slice draws
every control at `L.layout().touchTarget` or larger, so a finger reaches
each one directly, and an on-screen pad floating over a menu would only be
in the way. A binding record states that as `touch = false` rather than
leaving the class out, so a reader sees the decision where it was made.
**The mouse does not confirm.** A pointer confirms by clicking the control
itself, which the widget's own `onClick` already delivers; binding the left
button to `confirm` as well would fire the focused control a second time on
every click. The right button is `back`, which collides with nothing.
**Two navigate controls, one job.** An `axis2` control evaluates exactly one
binding per device class, so the analogue stick and the d-pad cannot share
one entry. `navigate` carries the arrow keys and the left stick;
`navigateDpad` carries WASD and the d-pad. Both feed the same focus
movement, so a player who reaches for either finds the ring moves.
# lavic
The pilgrim's controls: climb, look, jump, dishelm, free a lost knight,
pause — plus the `sprint` the humanoid's `CharacterController` reads by
name, labelled **Scramble** here.
Group `player`, `pointerLock = true`. The rules slice stands the whole
group down through `game.rules.hold` whenever the round is not in the
player's hands.
| Control | Label | Keyboard / mouse | Gamepad | Touch |
|---|---|---|---|---|
| `move` | Climb | WASD | left stick | left virtual stick |
| `look` | Look | mouse (locked), right-drag | right stick | drag the right half |
| `jump` | Jump | Space | south | button, priority 10 |
| `sprint` | Scramble | Shift | left stick click | — (declared `false`) |
| `helm` | Helm | Q, H | west | button, largest, priority 5, group `act` |
| `free` | Free | E | north | button, priority 15, group `act` |
| `pause` | Pause | Escape | start | button, small, priority 90 |
Four touch buttons, which is the overlay's two-column budget before it
drops every circle a size step — the reason `sprint` declares no touch
class. Helm and Free share the `act` group so they render adjacently, and
Helm's low priority puts it first.
Read the live set rather than this table when drawing a prompt:
`Zin.observe.control(name)` and `Zin.scheme.live()` answer what is
actually bound this session, and `Zin.gamepad.label("west")` resolves the
glyph the connected pad prints.
# back
Leave the card — resume from pause, step back out of how-to-play. Escape
or the right mouse button on a keyboard-and-mouse; the east face button on
a pad.
The right button collides with nothing, because a widget's own click is
the left one. Touch is `false`: a card carries its own Back control, drawn
at thumb size.
# confirm
Activate the focused card control. Enter, Numpad Enter or Space on a
keyboard; the south face button on a pad.
Touch is `false` and the mouse is absent, both on purpose: a finger taps
the control and a pointer clicks it, and the widget's own `onClick`
delivers that directly. Binding the left mouse button here as well would
fire the focused control a second time on every click.
# navigate
Move the focus ring between a card's controls. Arrow keys, or the left
stick.
The d-pad and WASD are the second half of the same job and live in
`navigateDpad`, because an `axis2` control evaluates exactly one binding
per device class. Both feed the same focus movement.
Touch is `false`: a finger taps the control it wants.
# navigateDpad
The other half of `navigate`: the pad's d-pad, and WASD on the keyboard.
A player who reaches for either finds the focus ring moves.
Separate from `navigate` because an `axis2` control evaluates exactly one
binding per device class, so the analogue stick and the d-pad cannot share
one entry. Touch is `false`.
# restart
Begin a fresh climb from an end card, without walking the focus over to
Again first. R on a keyboard; the north face button on a pad.
Touch is `false`: the end cards draw an Again control at thumb size.