# ui/screens — the cards
Title, how-to-fight, the intro place card, pause, victory and defeat. Each is a
pure function of a small view table.
The frame is 1080 x 1440, so every card is ONE column no wider than 800 and the
composition runs down the tall axis: a mark, a display line stacked in two
rows, a rule, a sentence, then the choices. Nothing is a row of things spanning
the width — a headline that fits a monitor does not fit this frame, which is
why the display lockups are two lines rather than one.
| card | ground | figure |
|---|---|---|
| `title` | ember bloom, drifting motes | the saint kneeling, looming out of the bottom of the frame |
| `howto` | frosted over the live fight | key caps and mouse diagrams |
| `intro` | none — staging's shot shows through | a place card and SKIP |
| `pause` | frosted over the live fight | — |
| `victory` | quench-blue; the furnace is cold | the saint, cooled |
| `defeat` | blood; desaturated | the Emberward's blade, planted |
The end cards carry the run's real numbers — time in the basin, parries rung,
quenches driven, times you were hit — counted by `Screens.component` off the
event bus.
## Callback ids
`Screens.ACTION` names every one; `Screens.component` handles them all. A menu
choice is a whole plate with `onClick` on it, never an invisible button laid
over a box.
## Frosting
`scrim()` frosts what is behind it, and `backdropFilter` samples the scene
behind the whole UI stack rather than the card under it. So it appears only on
`pause` and `howto`, where seeing the fight behind the card is the point. The
full-bleed cards paint their own opaque ground instead — a scrim on one of
those punches the lit fight straight back through it.
# Director
The staging slice of SLAG SAINT: the moments between the blows. Attach it to
`stage_root` — `BattleBoot` does that on its own the moment the component
exists.
It owns three things no other slice touches.
**The flow.** `Rules.setMode` runs through here and nowhere else:
`title` → `intro` → `fight`, then `victory` or `defeat`, and back to `fight`
on a retry. Anything else that wants the fight to start (or start again)
raises `battle:begin` rather than setting the mode itself.
**The clock.** `engine.timeScale` is the Director's. Raise
`hitstop { duration }` and it bites for a fraction of a second and lets go;
the longest outstanding request wins, so two hits landing together do not cut
each other short. It also stops for a landed Quench (`player:attack`
`{ kind = "crit" }`), a parry and the moment the saint's poise breaks, whether
or not anybody asked for it.
**The camera, while the mode is not `fight`.** Every staged moment is played
through the engine's cutscene system, which takes the camera above the
gameplay one and gives it back when the shot is over. `CombatCamera` yields by
reading `Rules.state.mode`.
## What it plays
| Moment | Raised by | What it does |
|---|---|---|
| Title | entering play | A slow drift under the title card. Any key starts the game. |
| Opening | the title ending | The descent into the basin, the Emberward's two lines, the first bell. |
| Entrance | the opening ending | A crane from the saint's feet to its bell, and its greeting. |
| Phase beat | `phase:changed` | 1.9 seconds. You notice it; you do not lose the fight. |
| Victory | `boss:defeated` | The light goes out of it from the feet upward. |
| Defeat | `player:died` | From the floor, looking up at what is still standing. |
| Pulse | a Quench, a parry, a stagger | A letterbox squeeze over live gameplay — no camera taken, no freeze. |
The shots themselves are data in `game/staging/sequences`, so a re-timed
moment is an edit to that module and not to this component.
## Nothing here is in world coordinates
The saint walks. Every camera station is built in a frame whose forward axis
runs from the ward to the saint, wherever the two of them are standing when
the moment begins, and the distances are measured against how tall the saint
actually is. A shot written against the arena's own coordinates frames empty
floor the moment the fight moves.
## Handles
```lua
local d = entity.find("stage_root").component.get("Director")
d:status() -- stage, mode, phase, timeScale, what is playing
d:replay("entrance") -- watch one moment: title | opening | entrance |
-- phase2 | phase3 | victory | defeat
d:begin() -- skip the staging and start the fight
d:restart() -- put the fight back to its first frame
d:punch(0.2) -- ask for a hitstop without the event bus
```
`autoOpen = false` on the component stops it seizing the camera when play
starts, which is what another slice's author wants while working on the fight
itself.
## The watchdog
A staged moment that stops advancing would hold the camera and the player's
controls for the rest of the session. The Director watches the playback's own
clock: if it sits on the same frame for 2.5 real seconds, or outlives its shot
list by six, the moment is abandoned, the overlay is swept and the flow
carries on to whatever was supposed to come next.
# rules
<!-- 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.
# Emberward
The feel of the fight. Attaches to the local player's body; `BattleBoot` wires
it there the moment the component exists.
## What it does
| System | Behaviour |
|---|---|
| Locomotion | Gates the body's `CharacterController` speed by state — free, guarding, committed to a swing, rolling, hurt, dead. |
| Dodge-roll | `Space`. 0.62 s, invulnerable from 0.06 s to 0.40 s, carried by a decelerating curve so it has weight. Costs stamina. |
| Light attack | Left mouse. 0.16 s windup / 0.12 s active / 0.30 s recovery. |
| Heavy attack | Right mouse. 0.46 / 0.16 / 0.62 — a real commitment. |
| Guard + parry | Hold `Shift` to block at a stamina cost. Release it into an incoming strike inside 0.22 s and the saint rings. |
| Stamina | Everything spends it; it regenerates 0.55 s after the last spend and never while the guard is up. |
| Hit detection | Reach + arc from the Emberward's own facing to the saint's root. |
| Quench | `E`, only while the saint is staggered and within reach. 110 damage. |
| Lock-on | `Tab`. On by default. |
| Camera | Sets the live `Camera`'s `behavior` to `CombatCamera` — from this file, so the asset literal resolves. |
## Events
Raises `player:attack` `player:roll` `player:guard` `player:parry`
`player:damaged` `player:died` `player:stamina` `hit` `boss:damaged` `shake`
`hitstop` `camera:beat`. Consumes `boss:strike`.
## Ownership
The saint's health, poise and stagger belong to the boss slice. This component
reports damage and lets that slice apply it. While no `SlagSaint` component
exists on `boss_root` it applies the numbers itself, so the fight is playable
and testable alone — and it stands that fallback down the instant the boss
slice appears, so a hit is never applied twice.
## Driving it without a keyboard
Every verb is a method: `:light()` `:heavy()` `:roll()` `:guard(down)`
`:quench()` `:toggleLock()` `:revive()`. `:status()` returns the whole state
machine — state name, time in state, attack window, stamina, i-frames — which
is what a claim about the fight should be checked against.
# BattleFx
What is in the air of the Quench-Basin, and what the fight leaves on the ground.
The other half of the arena slice — Quenchyard owns the room and the grade, this
owns the particulate, the sparks and the marks.
Four standing GPU emitters run continuously and are retuned on `phase:changed`:
**steam** off the wet glass (phase 1 owns it, it thins to nothing), **embers**
rising out of the fissures (nothing in phase 1, a storm by phase 3), **ash**
falling through the whole volume — the one that makes the air read as air — and
near-camera **motes** for parallax against the far wall.
Three one-shot emitters are moved to a point and burst there, so an arbitrary
number of impacts costs three emitters rather than one per hit: sparks (coloured
and weighted per `hit.surface` — bronze, stone, flesh, shield, glass), floor
dust, and the parry ring.
A heavy `boss:strike` also stamps a `scorch_mark` decal on the glass from a pool
of seven and pushes a shock ring out across it. A mark cools over `markLife`
seconds; a second landing inside `markSpacing` deepens the first rather than
stacking a new one on it.
entity.find("fx_root").component.get("BattleFx"):status()
entity.find("fx_root").component.get("BattleFx"):test(0, 4)
# CombatCamera
The fight camera, for a 1080x1440 portrait frame.
A landscape follow rig puts a 15.5 m boss half off the top of a 3:4 screen, so
this one does not pick a distance and hope. Every frame it takes the two
extremes the shot must contain — the Emberward's feet and the crown of the
saint's bell — aims at the ANGULAR midpoint between them, and grows the
stand-off until that half-angle fits inside the frame on both axes. The eye
rises as the shot pulls back, so a wide stand-off looks down at the basin and
up at the saint rather than crawling along the floor. The portrait shape then
carries the composition: the player low, the saint towering up the tall axis.
Measured live at 15.5 m: a stand-off of 8.9 m puts the Emberward's feet at
-0.89 of the half-frame and the crown of the bell at +0.68, with the horizontal
spread well inside — both bodies held, neither on the border.
## Modes
- **Locked on** — stands on the line behind the player away from the saint and
holds both bodies. Look input swings it off that line by at most
`orbitLimit` and eases back.
- **Free** — an ordinary orbit around the player.
- **Yielding** — while `Rules.state.mode` is `title`, `intro`, `victory` or
`defeat` it writes no pose at all: the staging slice drives the camera there.
## Screen shake
It is the consumer of the `shake` event: `Rules.shake(amplitude, duration)`
from anywhere adds trauma, trauma decays, and the offset is trauma squared, so
a light hit is a nudge and the Quench is felt. Sampled from layered sines so it
is smooth at any frame rate.
## Reading it back
`:framing()` reports mode, the solved distance, the camera and aim points, the
orbit offset, live trauma and the shake displacement the last pose actually
carried — the numbers behind the shot, without a screenshot.
`:kick(amplitude, duration)` shakes it directly and `:calm()` drops everything
still ringing, which is what a cut to a new shot wants.
Attached by `Emberward`, which sets `Camera.behavior`. The follow target comes
from the standard `Camera.follow` slot.
# Quenchyard
The Quench-Basin, and the grade that travels with the fight.
Builds the place under `arena_root`: the 22m disc of black vitrified glass with
cooled slag under it, the bronze rim it was poured into, the wet stone apron, a
ring of broken bronze pillars, seven 34m foundry columns, a broken wall, the
vault overhead with its ribs and hoppers, the snapped ends of the hanging chain,
and the slag rubble.
It also owns the light. The four named scene lights (`key_light`, `rim_light`,
`fill_light`, `ambient_light`) are graded from here, the slag under the floor and
the cold lamps on the columns are its own point lights, and the medium —
`HeightFog` plus `VolumetricLighting` — and the `basin_grade` post-process pass
are driven from the same look table.
Three looks, one per phase, interpolated over `blendTime` seconds when
`phase:changed` arrives: cold blue and steam while the saint is bound, ember and
airborne particulate once it stands, and a lit floor under a cracked plate by the
time the chest is open. The floor and rubble swap material at the break rather
than fading, because the glass cracking open is the beat.
entity.find("arena_root").component.get("Quenchyard"):status()
entity.find("arena_root").component.get("Quenchyard"):preview(3, true)
# BattleBoot
<!-- zero:scaffolded-readme -->
<!-- Replace the line below with what this component provides and who
consumes it, then delete both comment lines. -->
TODO: describe this component.
# ui/hud — the fight instrumentation
The screen is 1080 x 1440, portrait, and horizontal room is the scarce thing.
So the HUD uses the corners and the tall axis, and the only element that
reaches edge to edge is a line.
```
┌──────────────────────────────┐
│ THE SLAG SAINT │ name, centred, between two bells
│ ──────────────────────────── │ the health line — with phase ticks
│ ◆◇◇ I BOUND 90% │ pips left, remainder right
│ POISE ───────── │ under it, narrower
│ │
│ E QUENCH │ centre, only while the core is open
│ │
│ ┌────────────────┐ │ the spoken line, lower third
│ │
│ EMBERWARD 120 │
│ ████████████░░░░ │ health, then stamina, stacked
│ ███████████████ MENU │ bottom-left vs bottom-right
└──────────────────────────────┘
```
`Hud.build(view)` takes a plain view table and returns a tree; it reads no
global state, which is what lets `Screens.component` drive it and a probe
render it with invented numbers.
Two things the HUD does beyond reporting numbers: the boss and player bars
carry a **chip** — a ghost fill that chases the real value a beat later, so a
big hit reads as a big hit — and the frame closes in with a **blood vignette**
as the Emberward drops under 40%, which does more for tension than a number.
`Hud.subtitle(line, lift)` is its own screen, drawn above every card, so a
spoken line lands over an end card as readily as over the fight. `lift` raises
the band out of the lower third when a card with its own menu is up.
Legibility is not left to the scene: the HUD paints its own top and bottom
gradients, because the fight is graded blue-and-steam at one end and
ember-and-particulate at the other and half the readouts would otherwise
disappear into it.
# Score
Everything you hear in SLAG SAINT, and the mix it sits in. `BattleBoot`
attaches it to `audio_root`.
## Buses
Seven sources, spawned as children of `audio_root` on `awake()`:
| entity | type | what goes on it |
|---|---|---|
| `audio_music` | Music | the phase score, one streaming track at a time |
| `audio_bed_a` / `audio_bed_b` | Ambient | the room, crossfading between two beds |
| `audio_voice` | SoundEffect | every spoken line, non-positional so it is always intelligible |
| `audio_ui` | SoundEffect | interface and the big flow stings |
| `audio_world` | SoundEffect, spatial | impacts, moved to the point they happened at |
| `audio_saint` | SoundEffect, spatial | the saint's body, tracking `boss_root` |
## Asking for a noise
No other slice decides how loud anything is:
```lua
Rules.sfx("parry_ring")
Rules.sfx("hit_bronze", { at = point, gain = 0.8 })
Rules.say("saint_greet", "THE SLAG SAINT", "WARD. YOU ARE LATE.")
```
Every key in `Rules.SFX_KEYS` resolves, plus the beds, the three score tracks
and the fourteen voiced lines. A key with no clip is a no-op, never an error.
## The score
One track per phase, crossfaded on `phase:changed` (out over 1.6s, in over
2.4s). `mode:changed` decides whether the score plays at all — the title and
the descent run on ambience alone, and the score stops for a victory or a
death so the last line has room.
## Ambience
Two Ambient sources crossfade over 3.2s, so the room changes with the grade:
`amb_threshold` at the title, `amb_basin_cold` for the first two phases,
`amb_basin_fire` once the floor opens. Each bed is a seamless loop — the tail
is crossfaded back over the head at bake time — and each is stored as
uncompressed PCM, because a codec that drops the first few milliseconds puts a
click in every lap.
## Weight
Footfalls are not events another slice sends. `Score` measures how far
`boss_root` and the player's body actually moved and fires a step every stride,
so the sound follows the body rather than an animation guess. The saint's
stride shortens as its phase speed rises.
## Ducking
A spoken line drops everything else to 34% for as long as the line lasts, then
lets it back up. Nothing else in the game touches those levels.
## Methods
- `:status()` — clips registered, missing keys, music and bed state, bus health
- `:preview(key)` — play one sound by hand
- `:previewLine(key)` — speak one line by hand
# SaintBody
The Slag Saint as an object. Attach it to `boss_root` and it casts the whole
figure — fifteen metres of scorched bronze with a cathedral bell for a head, a
shuttered furnace-door chest and an anvil on a chain for a right arm — poses it,
grades it across the three phases of the fight, and wears the damage on it.
The geometry is procedural: `art/saint/saintparts` casts every piece, `saintrig`
holds the skeleton and the pose library, and this component spawns them as a
hierarchy of temporary entities under the anchor. Legs are solved from ankle
targets rather than authored as angles, so a pose has its feet on the floor by
construction.
It listens rather than reaches: `boss:telegraph` puts up the wind-up pose for the
named move, `boss:strike` throws it through, `boss:stagger:*` drops it onto both
knees with the furnace wide open, `phase:changed` moves it between kneeling and
chained, standing and split, and unshuttered and stripped, and `boss:damaged`
takes the plate off the part that was hit. In phase three it tears off its own
bell-head and swings it from one hand on a chain.
It carries its own sound: a seamless bronze resonance under the body, and the
shear a plate makes coming away, both synthesised on the CPU.
`:status()`, `:pose(name)`, `:setPhaseVisual(n)`, `:setDamage(t)`, `:tearHead()`,
`:settle()` and `:rebuild()` are there so the figure can be inspected in any
state without playing the fight to reach it.
# ui/art — the look of SLAG SAINT
One place for everything the game's screens are made of, so they grade as a
single object: the palette translated into CSS strings, the type ramp bound to
real faces, the spacing and surface helpers, the motion, and the inline SVG
that puts art on a screen instead of words alone.
## Type
Three faces, three jobs, never mixed up.
| role | face | used for |
|---|---|---|
| display | `Anton` | titles, verdicts, the boss's name, the saint's spoken lines |
| prose | `EBGaramond` / `EBGaramondItalic` | anything read as a sentence |
| readout | `BerkeleyMono` | labels, numbers, key caps — anything instrumental |
`Anton` draws its caps low in the line box it is given — measured against
`ui.getLayoutInfo`, about 0.3em below the box the layout reserved. Anywhere a
display line is followed by something else, the display line carries the
margin. `Art.menuItem` and the `lockup` in `ui/screens` already do.
## Art
`Art.GLYPH` is the drawn material: `bell` (the saint's head, the game's mark),
`saint` (the kneeling reliquary figure the title screen is built on), `sigil`
(the order's quenching droplet), `blade` (the Emberward's weapon), `chain`,
`mouse`, `pip`, `motes`. Each takes its colours, so the same glyph carries the
cold grade on the victory card and the ember grade on the title.
## Still mode
UI animation advances on game time. While the clock is stopped — edit mode, or
a held session — a one-shot entrance freezes on its first keyframe, and a
keyframe starting at `opacity = 0` leaves the element invisible for as long as
the clock is stopped. `Art.setStill(true)` makes every motion helper return
`nil`, so a tree built in that state carries no animation and renders at its
base style. `Screens.component` flips it whenever the clock starts or stops.
## Two things measured in the engine, not assumed
- **A wrapping label ignores `textAlign`.** Anything that must read as centred
goes through `Art.lines`, which is one node per line centred by its column.
- **`backdropFilter` samples the scene behind the whole UI stack**, not the
card under it — so a frosted scrim over an opaque page punches the lit fight
straight back through it. Frost only where seeing the fight is the point
(`pause`, `howto`); the full-bleed cards paint their own ground instead.
# SlagSaint
The mind of the thing in the basin. Attach it to `boss_root`; the scene's
`BattleBoot` does that automatically the moment this component exists.
The saint takes its turn. It announces every blow at least eight tenths of a
second before it throws it, it tracks the Emberward, its poise breaks when it
is punished hard enough, and it thanks her when it dies.
## The cycle
```
idle ──▶ tell ──▶ strike ──▶ recover ──▶ idle
▲ │
└──────── stagger ◀── poise 0 ───────────┘
│
shift ◀── health crosses 62% / 24%
│
dead ◀── health 0
```
* **idle** — waits out this phase's `attackInterval`, turns toward the player,
and from phase 2 walks in when she is further away than 7.5 metres. It does
not walk in phase 1: it is still bolted to the basin.
* **tell** — the wind-up. Never shorter than 0.8s, whatever the phase. The
threatened area is drawn on the floor (see `boss/telegraph`), the saint
coils back and leans away, and the core in its chest swells.
* **strike** — the release. Raises `boss:strike`, lunges, flashes the tell.
`double_stomp` lands twice.
* **recover** — the punish window, 0.7 to 1.45 seconds depending on how big
the blow was.
* **stagger** — 5.5 seconds with the core exposed. The Quench lands here.
* **shift** — 2.5 seconds of phase transition; interrupts anything.
Interrupts run in priority order: death beats a phase change beats a stagger.
A parry interrupts a wind-up outright.
## What it raises
`boss:telegraph` `boss:strike` `boss:poise` `boss:stagger:begin`
`boss:stagger:end` `boss:defeated` `battle:victory` `battle:defeat`
`camera:beat` `hit` `shake` `hitstop` `ui:prompt` `vo` `sfx`, and
`phase:changed` through `Rules.setPhase`.
`boss:strike` carries more than the contract's `{ move, origin }` — it also
carries `damage`, `range`, `arc`, `phase` and `inside` (whether the player was
in the threatened area on the frame it landed), so the combat slice can
resolve the hit without re-deriving the geometry.
## What it consumes
`boss:damaged` is the damage channel. If the payload carries `health`, that
number is adopted; otherwise `amount` is subtracted. Either way the hit wears
down `Rules.state.boss.damage` — the named `part` takes the brunt and every
part takes a little, so by the end the saint is visibly a wreck. Send `poise`
explicitly to control the meter; without it the poise cost is inferred from
how big the hit was.
`player:parry` rings the bell: 30 poise, damage on `headBell`, and it
interrupts whatever the saint was winding up.
`player:died` and `mode:changed` do the obvious things. Returning the mode to
`title` or `intro` resets the fight to full health in phase 1, which is what a
retry needs.
## State it writes
`Rules.state.boss.health` `.poise` `.staggered` `.coreExposed` `.telegraphing`
`.move` `.damage`, plus one key this slice adds:
* **`Rules.state.boss.stance`** — `"kneeling"`, `"standing"`, `"unshuttered"`
or `"dead"`. The art slice can read it instead of deriving the pose from the
phase index.
## Fields
| field | default | what it does |
|---|---|---|
| `showTells` | `true` | draw the threatened area during a wind-up |
| `aggression` | `1.0` | multiplies the gap between attacks; above 1 is slower |
| `autoStartSeconds` | `16` | if no staging slice exists, start the fight anyway after this long |
| `verbose` | `false` | log each decision |
## Methods
```lua
local saint = entity.find("boss_root").component.get("SlagSaint")
saint:status() -- everything about the fight, including `read`
saint:moves() -- every move available now, with its tell sentence
saint:hurt(24, "legs") -- deal damage the way combat would, events and all
saint:ring() -- a perfect parry, without hands on a keyboard
saint:quench() -- the critical; only lands while the core is exposed
saint:cast("bell_toll") -- force the next wind-up
saint:restart() -- full health, phase 1, damage cleared
```
`status().read` is the sentence a player should be able to say out loud during
the current wind-up. If it does not match what you can see happening, the
telegraph is broken.
## Standing alone
The saint is playable without its neighbours. If the `Emberward` component is
not in the world it resolves its own blows against the player, and if the
`Director` is not in the world it starts the fight itself after
`autoStartSeconds`. Both checks are by existence, so neither fires once the
real slice lands.
# EmberwardBody
The Emberward — the armour she wears, the cloak she trails and the way she moves.
Attach it to the local player's body and it forges her: every plate in
`art/emberward/kit` becomes a mesh glued to the bone it belongs to, the ash
cloak is hung from the back yoke as simulated cloth, and the fighting poses are
driven from the `player:*` events the fight raises.
## What it builds
| | |
|---|---|
| Head, neck | sallet with a keel and a lit vision slit, cheek plates, gorget |
| Torso | cuirass with a sternum keel and the order's sigil, abdominal lames, waist mail, the back yoke and its cloak rings |
| Arms | three-lame pauldrons, rerebrace, elbow cop, vambrace, cloth wrap, gauntlet |
| Waist, legs | belt, buckle, quench-flask, an eleven-lame fauld, cuisse, poleyn, greave, sabaton |
| Weapon | the quenching blade: fullered blade, cross-guard, wrapped grip, ring pommel and the rune cut into the ricasso |
| Cloth | a simulated ash cloak pinned across the back yoke |
## How it moves
The cloak is cloth, not a curve: it is pinned to the spine and simulated in
world space, so turning hard leaves it behind and it swings back on its own.
The poses are authored, and every one of them winds back before it goes
forward:
| event | pose |
|---|---|
| `player:attack` `light` / `heavy` / `crit` | the swing, the overhead, the Quench |
| `player:roll` | the tuck |
| `player:guard` | held — shoulder into the blow, blade up across her |
| `player:parry` | the deflect |
| `player:damaged` | the flinch |
| `player:died` | the collapse |
| `phase:changed` | her ember burns hotter each phase |
## Fields
| field | meaning |
|---|---|
| `cloakEnabled` | simulate the cloak (off leaves it a stiff cape) |
| `updraft` | how hard the heat off the basin lifts the hem |
| `poseWeight` | strength of the whole pose layer |
| `rebuildOnReload` | re-forge the armour when the kit changes |
## Methods
- `:status()` — what got built, and what she is doing right now.
- `:preview(name)` — play one pose by name: `light` `heavy` `crit` `roll`
`parry` `hurt` `death`, or `guard` to toggle the hold.
- `:reforge()` — rebuild the armour and the cloak from the kit.