# DuelHud
The shell around the fight, and the shape of a session. Sits beside `DuelMatch`
on the `DuelDirector` entity.
## The shell is furniture at the edges
The boundary itself is the real health bar — it stands in the world where both
fencers can see it — so the shell reads the ceremony rather than covering it.
Four screens:
| screen | layer | what it is |
|---|---|---|
| `hedgerow_assizes` | 10 | the ribbon along the **top edge**: the two scores, the boundary strip with the leading post standing in it, and under it the umpire's call on a caption band |
| `assize_read` | 10 | the read strip along the **bottom edge**: a chip per fencer naming the commitment as its wind-up starts, the measure gauge between them, and a footer carrying the docket line and the live key hints |
| `assize_riposte` | 11 | the riposte banner, which exists only while a riposte is open, tucked under the caption where the sky is |
| `assize_card` | 12 | the writ that opens a docket and the verdict that closes an assize — anchored over the **sky**, never over the lawn |
| `assize_scrim` | 8 | a top-down ramp that settles the sky behind the writ. The verdict gets none: the board at the end of a bout is the picture |
Measured at a 1280×720 canvas: the ribbon group is 38–66 pt tall (the caption
band is only as tall as the words in it and collapses to nothing when the court
is quiet) and the read strip 68 pt, so the shell occupies **5.3–9.2 % of frame
height at the top**, **9.4 % at the bottom**, and **7.9–8.2 % of frame area**,
with 79–83 % of the frame height carrying nothing at all.
The canvas is the same 1280×720 whatever the framebuffer is — `ui.screenSize()`
is pinned there while the window is 1920×1080 — so the shell is laid out on a
720p canvas and upscaled uniformly, and every fraction above holds at both
sizes. `pinCanvas(960, 600)` moves the ribbon to x 210 and the read strip to
y 522, which is the proof that the layout is canvas-relative rather than
measured against numbers somebody typed.
## The docket
An assize is one bout. A **docket** is the court's whole sitting: every
neighbour from the one you name down to the last on the roster, heard in order,
with the ground won and lost carried forward.
- `docketFrom` — the neighbour the sitting opened at (1–`rosterSize`)
- `docketHeard` / `docketWon` — assizes concluded, and how many you took
- `docketGround` — metres of boundary held on the day, summed across assizes
- `docketRecord` — the same thing as a readable string,
`pettifer:lost:-10:0-9:29s | wickham:won:+10:10-6:74s`
- `public:docket()` returns all of it as structured data, one row per assize
The docket advances on the transition **out of** `state == "over"`, whichever
path takes it there — this card's button, the restart key, or an agent calling
`DuelMatch:restart()` — so no path has to know the docket exists. The next
neighbour is armed the moment an assize ends, by writing `DuelMatch.skill`,
`.neighbour` and `.neighbourKey` before any restart can read them.
## Identity, and what this component owns
`DuelMatch` owns **identity** (`neighbourKey`) and **time** (`matchClock`); this
component owns the **prose** — names, seats, writs, notes and the umpire's whole
vocabulary. Both optional fields are detected by enumerating the component
rather than by touching a name, because reading an absent field logs an engine
error on every frame. When they are absent the HUD falls back to its own roster
index and its own bout clock, so neither component has to land first.
Commitments and key hints are generated from `Duel.ACTIONS` and the live input
profile (`Zin.actions.get`), so a stroke added to `duel.module` or a rebound key
appears on the writ, in the measure gauge and in the hint line with no edit here.
## The front door
A player arriving gets the writ: what the fence means, what a touch does to the
boundary, the four commitments with their live keys and the triangle between
them, and the whole roster as tiles. Naming a tile opens the sitting at that
neighbour and hears every one below them in turn, so all of the roster is
reachable from a cold arrival.
**The opening is gated on this component's own boot flag** — a module local,
fresh exactly when the component is — and on nothing that persists. Every
counter that persists has already proved it can reach a value the door never
opens at again: `assizes` was a replicated field gating the writ on `== 0`, it
reached 1 the first time anybody opened a docket in this world, and after that a
new player arrived inside a stranger's losing bout with nothing on screen. The
sitting is now held in a module-local table and the fields are written **from**
it every tick, so the layer's overlay restoring a docket counter five seconds
after a load is undone inside a frame and the fields stay what they are for — a
readable mirror for everyone else.
The board itself is only put back when it is not being fenced. Arriving in the
middle of somebody's live bout raises the writ and leaves their fight alone.
## The way out
`N` **rises the court** from anywhere — mid-bout, on a verdict, on the
court-rises card. It clears the docket, puts the boundary back to nought, names
the first neighbour again and shows the writ. It is what makes the opening
impossible to lock out, so it does not depend on any other component: the scheme
is asked for an action named `rise` first, and the raw `KeyN` code is used when
the scheme has none. **A `rise` action added to `fencing_controls` is picked up
with no edit here** — including its touch button, which the raw code cannot
give.
The card's own `RISE — A FRESH SITTING` button does the same thing with a
pointer.
## Clicks are recorded, not obeyed on the spot
Every action the cards offer ends in `DuelMatch:restart()`, which rebuilds the
garden, the fence and the rival — over a second of work on a loaded machine.
Run straight from the UI callback it was cut off by the script watchdog partway
through, leaving the board at nought-all with `state` still `over` while
`gui.clickElement` reported `ok`. `onCallback` records what was asked and the
component's own update serves it. A board that reads `over` with **nobody
named** is not a verdict — it is a restart that did not finish — so it carries
no card, goes on nobody's docket, and is asked for again.
## Methods
- `public:beginAssize(index)` — open a fresh sitting at that neighbour
- `public:callNext()` — call the next assize on the open docket
- `public:riseCourt()` — rise the court and return to the writ
- `public:chosenNeighbour()` — `{ index, key, name, skill }` of who stands opposite
- `public:docket()` — the sitting as data, one row per assize concluded
- `public:pinCanvas(w, h)` — lay the shell out against a pretend canvas
## Fields
`screen` / `readScreen` / `cardScreen` / `scrimScreen` (registered screen names),
`neighbour`, `card` (`""` | `"writ"` | `"verdict"` | `"docket"`), `cardAge`,
`assizes`, `refresh`, `boutClock`, and the six docket fields above. None of them
replicate: the sitting belongs to whoever is sitting in it, and every one of
these is a mirror of the module-local copy that actually holds it.
Runs in both edit and play so the shell is visible while authoring.
# Fencer
One combatant in the fence duel. Attaches to a bare entity — it asks
`fencer_body` for its rig in `awake`, so it needs no Model or mesh of its own.
Owns footwork along the piste (world X, depth and height held) and the
windup → active → recover state machine behind the four commitments: `thrust`,
`sweep`, `lunge` and `parry`. Every commitment carries the body over its own
`advance`, so distance and commitment can be made in one action; the step stops
at the fencer's own `minReach` rather than carrying it inside its own measure.
Footwork is bounded by the other fencer, not by the property line: a man who has
drawn back into his own garden is still reachable, which is the whole reason to
hold the measure. Breaking ground is slower than taking it.
A commitment that reached nobody — short of its reach, or crowded inside it, or
a guard that turned nothing aside — enters recovery from a debt. So does one
that was parried. The debt is the commitment's own (`duel.whiffOf` /
`duel.parriedOf`), so it scales with what the commitment bought: a thrust turned
aside costs half a second and a lunge turned aside costs the better part of two.
That debt is the opening the other fencer is meant to punish, and
`phaseTime < 0` in `recover` is how a reader tells a real opening from an
ordinary recovery.
**The lunge is recovered to the line it was thrown from.** It is the one
commitment marked `recoil`: whatever of its 1.30 m of `advance` it managed to
cover, it gives back over its own recovery, travelling backwards at the pace it
went out and unable to answer anything while it does. `recoilLeft` is the ground
still owed. So the lunge buys half a metre of reach nothing else has, and buys
no position at all — which is what stops it being a free way to close the
measure.
A commitment made while `riposte` is open comes out at `RIPOSTE_QUICK` of its
wind-up, reaches `RIPOSTE_REACH` further, cannot be turned aside, and plants
`RIPOSTE_POSTS` posts instead of one.
The component is input-agnostic except for one case: a `role = "player"` fencer
on a local entity reads the `move` axis and the `thrust` / `sweep` / `lunge` /
`parry` actions itself. A `role = "rival"` fencer is driven by `FencerAI`
through the same public methods a human's input reaches.
Fields worth reading from outside: `role`, `action`, `phase`, `phaseTime`,
`riposte`, `ripostePending`, `whiffed`, `frozen`, `touches`, `facing`.
`action` is `""` while idle, not `"guard"`; the idle `phase` is `"guard"`.
Methods: `commit(name)`, `walk(dir, dt, line)`, `resetTo(line)`,
`receiveTouch()`, `beParried()`, `clash()`, `openRiposte()`, `markContact()`,
`freeze(on)`, `isStriking()`, `currentReach()`, `currentMinReach()`.
Requires the `duel` and `fencer_body` modules and `@builtin::modules.zinput`.
`DuelMatch` umpires between two of these; it is what calls `receiveTouch` /
`beParried` / `clash` / `openRiposte` / `markContact`.
# FencerAI
The neighbour across the line. Sits beside a `Fencer` (`role = "rival"`) and
drives it through the same public methods a human's input reaches, so it plays
by exactly the rules the player does.
## What it does
- **Keeps the measure.** Between decisions it sits inside its own thrust
measure rather than at the far edge of it, and breaks ground when crowded.
- **Reads the tell.** Seeing a wind-up costs `0.34 - 0.20 * skill` seconds, so
a thrust's 0.22 s tell is under an ordinary neighbour's eye and a sweep's or
a lunge's is not. A read thrust or lunge is answered with a guard; a read
sweep is answered by not being there, because a broadside crashes through a
guard.
- **Keeps a book.** Every commitment the opponent makes is counted into a
decaying tally. A man who has thrust four times running meets a guard that
went up *before* the wind-up it answers — which is the only reply to a tell
too short to see. A man who keeps holding a guard meets a broadside. A man
who keeps swinging broadsides takes the parry's weight back out of the book.
- **Punishes an opening.** A blade that whiffed or was turned aside recovers
from a debt; that is an opening, and an ordinary recovery is not.
- **Takes its riposte** without thinking, at the extended measure a riposte
reaches.
- **Observes the forms.** A neighbour does not run a man through who has not
raised his panel. When the opponent has offered nothing for a while, the
invitation is waited for — for `patience` seconds' worth of good form — and
the measure is held rather than closed. The riposte, the punish and the read
all run above this, so the courtesy costs nothing against a man who is
actually fencing; it is felt only by one who is not.
## Style
`style` names which neighbour's fencing this is, and it is what makes the roster
three opponents rather than one opponent at three settings. It is set from
`DuelMatch.neighbourKey`, and the table it selects lives in `duel.STYLES`.
- **Mrs Pettifer** keeps bees and keeps her distance. She stands at the far edge
of the measure, takes a long time over a decision, will not be hurried into a
lunge, and will wait all afternoon for a man who has not yet offered anything.
- **Major Wickham** fenced for the regiment and remembers everything. He fences
the man rather than the measure: a stroke thrown twice is a stroke he has
already answered, and the third one meets the answer waiting for it — a guard
already rising against a repeated thrust, a thrust inside a repeated
broadside's wind-up, a broadside through a panel that keeps being hidden
behind.
- **The Dowager Lady Ashcombe** has not surrendered a boundary in forty years.
She fights close, gives no ground, favours the broadside and the lunge, and
fences hardest of all with her own hedge behind her: `ardour` reports the
quality she is actually fencing at, which rises with the line as it comes back
toward her.
## Skill
`skill` (0..1) moves six things at once: how quickly the eye settles on a
wind-up, how reliably that read turns into the right answer, how often an
opening is punished, how briskly it decides, how strongly the book steers it,
and — the one that shows most — how truly it judges the measure. A low
neighbour believes his panel is longer than it is and throws from too far out,
whiffing and handing over the openings a good one never gives.
## Fields
`skill`, `style`, `opponent` (left unset, it finds the `role = "player"`
fencer), and the live read-outs `decision`, `reading` (`attacker` | `turtle` |
`sweeper` | `mixed`), `thinkIn`, `sawExposed`, `idleFor`, `indulge`, `ardour`,
and the `habit*` counters.
Requires the `duel` module.
# DuelFx
Everything between the rules firing and the player feeling it. The umpire
decides what happened; this decides what it feels like, and where in the garden
it sounded like it happened.
Attach it to `DuelDirector`. It reads `DuelMatch` and both `Fencer`s and writes
nothing back to either.
```luau
entity.find("DuelDirector").component.add("DuelFx", {})
```
## Four commitments, four weights
A thrust arrives on a point and is over. A sweep is a whole fence panel and it
turns the man it lands on. A lunge brings a body 1.30 m with it and tears grit
off the piste the whole way. A riposte comes off a guard rather than out of a
wind-up, and it costs two posts.
Which one landed is read out of `DuelMatch.lastExchange`, and every channel of
the reaction is driven from one row of the `PROFILE` table:
| | recoil | spin | shove | hitstop | dust | landing sound |
|---|---|---|---|---|---|---|
| thrust | 0.88 | 0.08 | ×1.0 | ×0.9 | a narrow plume at chest height | `touch_thud` +1.12, `panel_clack` |
| sweep | 1.00 | **1.00** | ×2.1 | ×1.6 | a low wide sheet across the piste | `touch_thud` −0.80, **`panel_crunch`** |
| lunge | 0.96 | 0.20 | **×3.2** | ×2.1 | a ground trail from the back foot to the contact | `touch_thud` −0.94, `panel_clack` |
| riposte | 0.92 | 0.30 | ×1.4 | ×1.3 | bright chips off the guard | `riposte_open`, `touch_thud` |
`spin` is the sweep's signature — only a broadside turns a fencer off the line,
so a still frame of a sweep is nameable without sound. `shove` is the lunge's:
it gives up three times the ground a thrust does.
## The chalk, and the hold
At the measure a touch resolves at, two men holding whitewashed fence panels
are one beige mass from side on. Two things put the read back into the frame,
and both of them last as long as the umpire holds the bout rather than as long
as an impact.
**The chalk.** Every panel in this garden is whitewashed timber, so a stroke
that lands leaves its coat behind. The mark runs FROM the man who struck TO the
man who took it, so which way the blow went is in the picture; its shape is
which stroke it was:
| | what it writes |
|---|---|
| thrust | one straight level line along the picket that led |
| lunge | a long low line off the back foot rising into the contact, with the point on the end of it |
| sweep | five strokes of a falling quarter circle, from above the shoulder it came off down onto the man |
| riposte | the guard it came off, crossed, and the answer out of it |
It is drawn 0.62 m nearer the camera than the piste, so it is never swallowed
by the two bodies it is describing, and it holds its length while it thins.
`chalk` turns it off; `chalkLife` is how long it hangs.
**The hold.** A reaction used to be gone in three tenths of a second, which at
this build's frame time is one frame — so the pose that names the commitment
was never in the picture. `contactHold` pins each fencer's recoil, spin, lean,
overreach and jam at the values the blow gave them, as a floor rather than an
assignment, so a shove still running out or a second event inside the beat
still reads on top of it.
`PisteCamera` swings off the piste axis for the same beat, which is the other
half of the same fix — see its README.
## The two faults, which are not touches
`SHORT` and `CROWDED` are recorded by the umpire and never reach the event bus,
so `fx` derives them from the exchange record. Their feedback is deliberately
built out of the opposite parts of the vocabulary from a touch, so the two can
never be confused:
- the reaction is on the **attacker**, not the defender
- there is **no hitstop**, **no contact flash** and **no rim**
- the shape left behind is a flat scuff disc lying on the piste — the only
thing this component ever draws on the ground
`SHORT` pitches the attacker forward through a stroke that found nothing
(`whiff_air` + `gravel_scuff`). `CROWDED` jams the panel and shoves both
fencers apart (`panel_scrape` + a tight `panel_clack`).
## The post is driven, not moved
The umpire awards a touch and the boundary is already somewhere else: the line
is a number, and `Garden.fence` rebuilds the run at its new value inside the
same frame. What the eye is given instead is the thing the rite is actually
about.
Over `driveTime`, the surveyor's cord walks from where the line stood to where
it now runs — `driveWalk` seconds of it, on a curve that leaves fast and
settles — carrying a post held clear of the turf. Then `driveBlows` mallet
blows put the post in, each one seating it further, throwing its own turf and
its own `post_drive` at a lower pitch than the last, until it is flush with the
boundary's own post.
`driveFrom`, `driveTo`, `drivePct` and `driveState` report it live, so a
photograph taken across the pause can be matched to exactly how far in the post
was. `driveFrom` is derived from the umpire's published `lastDrive` and
`lastTouch` rather than remembered, so it is exact.
## The rite
The umpire is on a beat at all times and `DuelMatch.ceremonyPhase` names it.
Each beat has its own calls, drawn from a bag — a call is not heard twice until
every other call on that beat has been heard once — and which calls are
eligible depends on the state of the bout:
| beat | calls | narrowed by |
|---|---|---|
| `salute` | `umpire_salute`, `umpire_engarde`, `umpire_ready` | |
| `enGarde` | `umpire_engarde`, `umpire_ready`, `umpire_score`, `umpire_last` | the score only when it is level; the last post only when one post ends it |
| `allez` | `umpire_allez`, `umpire_play`, `umpire_come` | "come on" only on a ground that does not stand on form |
| `halt` | `umpire_halt`, `umpire_hold`, `umpire_arret` | "arrêt" only on a ground that does |
| `post` | `umpire_point`, `umpire_touche`, `umpire_award_for`, `umpire_award_against` | the award reads for or against by who scored |
| `verdict` | `umpire_verdict` | |
Whether a ground stands on form is `Duel.ceremonyScale(neighbourKey)`, so the
umpire keeps a different rite in each of the six gardens without anybody
writing six rites.
`ceremonyLog` is the record: one line per call, numbered, with the beat it
belongs to and — where a frame was long enough to step over a 0.28 s beat
entirely — the beat the umpire said he was on, as `post@halt`.
**The bell counts.** One strike opens an assize; three close it, rising, with
the wall giving the last one back. **Both men breathe** in the pause, the one
who took it harder than the other, and both of them harder once the bout is six
touches deep.
**Every neighbour has their own throat.** `NEIGHBOUR_VOICE` pitches and weights
the rival's tells, efforts and breath per `neighbourKey` — Mrs Pettifer light
and high, Lady Ashcombe low and quiet, Young Tibb high and loud — so who you
are fencing is audible before you look.
## The soundstage
The lawn is thirty metres of piste between two hedges, and three of its places
can be heard:
| source | where | what |
|---|---|---|
| `amb_orchard` | west end, x −14 | the player's own orchard: leaf wash, bees, a blackbird |
| `amb_wall` | east end, x +14 | the neighbour's walled end: drier wind, a wood pigeon, a wren |
| `sfx_bell` | the garden wall, x 0 z −13 | the assize bell, and its return off the wall 0.40 s later |
| `sfx_umpire` | walks the piste beside the fight | `allez`, `halt`, `point` |
| `sfx_bed` | everywhere | the garden's own bed, non-spatial |
The two hedge beds crossfade on where the fight has got to, so a bout driven
into the player's garden sounds like the player's garden. `ambience` is the
readback: `focus=-10.00 near=orchard orchard=0.296 wall=0.171 bed=0.340`.
Footfall knows what it is standing on. The piste is raked gravel end to end,
but the ground within 1.05 m of a driven post is the spoil thrown out of its
hole, so a fencer standing on the boundary steps in broken turf. `stepSurface`
reports it.
## Clip → event
Every clip in `/source/audio/` and what fires it.
| clip | fired by |
|---|---|
| `bell_assize` | `salute`; `over:*` — three strikes, rising, at +0.55 / +1.05 / +1.55 s |
| `bell_return` | `salute` (+0.40 s); `over:*` (+1.95 s) — the wall giving it back |
| `umpire_allez` | the `allez` beat |
| `umpire_play` | the `allez` beat |
| `umpire_come` | the `allez` beat, on a ground with no ceremony to observe |
| `umpire_halt` | the `halt` beat, and the one that ends the assize |
| `umpire_hold` | the `halt` beat |
| `umpire_arret` | the `halt` beat, on a ground that stands on form |
| `umpire_point` | the `post` beat, 0.60 s after the stop |
| `umpire_touche` | the `post` beat, on a ground that stands on form |
| `umpire_award_for` | the `post` beat, when the touch was the player's |
| `umpire_award_against` | the `post` beat, when it was against |
| `umpire_salute` | the `salute` beat that opens an assize |
| `umpire_engarde` | the `salute` and `enGarde` beats |
| `umpire_ready` | the `salute` and `enGarde` beats |
| `umpire_score` | the `enGarde` beat, when the touches are level |
| `umpire_last` | the `enGarde` beat, when one post ends the assize |
| `umpire_verdict` | the `verdict` beat, read over the last of the bells |
| `breath_hard` | the fencer who took the touch, in the pause after it |
| `breath_soft` | the other one, and neither of them once the bout is deep |
| `post_drive` | `post`; `allez` (a light tap, pitched up) |
| `touch_thud` | `touch:*`, pitched by the commitment that landed |
| `panel_clack` | `touch:*` other than a sweep; `parry:*`; `clash`; `crowded:*` |
| `panel_crunch` | `touch:*` where the landing commitment was a **sweep** |
| `panel_scrape` | `crowded:*`; `clash` |
| `riposte_open` | `parry:*`; `riposte:*` |
| `whiff_air` | `short:*` |
| `gravel_scuff` | `short:*` — the foot saving the over-reach |
| `gravel_step` | `step:*` on the raked piste |
| `turf_step` | `step:*` within 1.05 m of the boundary post |
| `tell_thrust` | `<role>:thrust`, and any commitment with no voice of its own |
| `tell_sweep` | `<role>:sweep` |
| `tell_lunge` | `<role>:lunge` |
| `tell_parry` | `<role>:parry` |
| `effort_sweep` | `<role>:sweep` — the breath under the arc |
| `effort_lunge` | `<role>:lunge` — the breath of going the whole way |
| `garden_bed` | looping, non-spatial |
| `hedge_orchard` | looping at the west end, gain by focus |
| `hedge_wall` | looping at the east end, gain by focus |
## The flash
The rim is the `outline` channel on a struck fencer's models, and nothing else
in the world writes it. It marks the **body** that took the touch, so it skips
the two classes that are not one: what a fencer stands on, and what they are
holding. Both are matched by name (`panel`, `shoe`, `stocking`, `leg`, `foot`,
`sole`), because a name is the only property of a rig part that a pose does not
change — position and scale are rewritten every frame by `fencer_body.pose`, so
a geometric gate answers differently depending on which frame it is asked.
`flashState` reports the live rim level and how many models it is allowed on:
`player=0.64/11 rival=0.00/19`. A rim that stops appearing because a body module
renamed its parts is invisible in a screenshot and obvious there.
## Timing, and the frame rate it was tuned at
Every window here was tuned against a measured **104 ms frame — about 9.6 fps**
(`zero profiler retro --seconds 20`), and every one is expressed in real
seconds rather than frames, so it means the same thing when the frame rate
comes up. The hitstops run 0.09 s to 0.26 s: one to three frames at 9.6 fps,
five to sixteen at 60.
## Instruments
| field | what it says |
|---|---|
| `busProduced` / `busTaken` / `busMissed` | events the umpire emitted, events drained off `fxQueue`, and the difference. `busMissed` is zero when nothing is lost. |
| `recordsMissed` | exchange records overwritten inside one frame. The events still arrive; only the detail behind them is gone, and the reaction falls back to its default weight (`lastReaction` says `unread`). |
| `eventCount` | events dispatched, including the two derived faults. |
| `fxPath` | `queue` once the lossless read is live, `derived` before it. |
| `soundCount` / `lastSound` / `soundLog` | every clip that has played, stamped on the clock `capture` reports as `sceneTime` per cell. |
| `lastReaction` / `reactionLog` | every reaction, named — `touch:player lunge`, `short:rival overreach`, `crowded:player jam`. |
| `ambience` / `stepSurface` / `flashState` / `motesAlive` | the soundstage, the ground underfoot, the rim, the live mote count. |
| `ceremonyLog` / `ceremonyCount` / `lastLine` | every umpire line that has fired, in order, with the beat it fired on. What a rite that repeats looks like, as data. |
| `driveFrom` / `driveTo` / `drivePct` / `driveState` | where the line stood, where it now runs, and how far through putting the post in this frame is. |
## What it owns
A temporary `duel_fx` root holding the ears, seven emitters, the flash, the
turf puff, the scuff disc, a sixteen-mote pool, a nine-segment chalk pool and
the post, collar, mallet and cord of the drive; the `rig` child transform of
each fencer; the outline channel on their body models; `punch` / `kick` /
`shake` for `PisteCamera`; and `engine.timeScale`, for the length of one
hitstop and no longer.
The drive's post goes in at `z = -2.40`, which is where `garden.module` stands
the boundary's own post — the two are the same place on purpose, so what the
drive puts in is the post that is already there.
# PisteCamera
The fencing broadcast: a locked side-on view straight down the property line.
It lives on the PlayerPrototype's `CameraRig` beside the `Camera` component, so
every joining user gets their own.
It frames by **extent** rather than by separation. Each frame it works out the
exact span it has to cover — both fencers, the boundary post, a margin — and
sits at the nearest distance whose frustum covers it, floored by `minDistance`
and ceilinged by the widest span the duel's own leash can produce. Framing in
proportion to the gap pulls the camera out whether the frame needs it or not,
and a game whose whole content is two figures and a fifth-of-a-second wind-up
cannot afford figures that small.
It also reacts: `DuelFx` publishes `punch`, `kick` and `shake`, and this turns
them into a dolly and a wobble. The reaction is applied after the extent solve
and only ever pulls the camera IN, so it can never uncover something the solve
decided had to be covered.
## The shot is solved, not guessed
The frame is drawn at a **60° vertical** field of view whatever `Camera.fov`
says — that field is a silent engine no-op, filed as
[#8071](https://github.com/OrigoZero/zero/issues/8071) — so `fov` here means
"the angle actually on screen" and every number below is measured against it.
At 60° and 16:9, `height` / `lookHeight` / `minDistance` place, in tangent
space:
| | |
|---|---|
| fencer's feet | 84.2 % down the frame |
| crown of the mask | 44.9 % down the frame |
| **figure** | **39.3 % of frame height** |
| **empty lawn below the feet** | **15.8 % of frame height** |
The dial that decides the dead lawn is `lookHeight`, not `height`: the margin
under the feet is exactly the part of the frame NOT spent on the world between
the ground and the point the camera aims at. Aiming at the mask rather than at
the chest is what buys it back. `height` sits at a standing fencer's eye so the
piste reads flat and neither figure is looked down on.
`fencerFramePct` and `lawnBelowPct` report those two numbers live, computed the
way the frame is actually projected rather than by the small-angle shortcut —
they agree with `worldToScreen` to seven decimal places, so a test can assert
the framing without re-deriving the projection.
## The contact framing
Side-on is the framing that makes distance legible, and distance is the whole
skill, so the broadcast holds it for every frame the two are fencing. It is
also the framing in which two men holding fence panels at two metres are one
beige mass: the panels lie in the plane of the view, so they stack instead of
separating, and the one frame that decides the bout is the one nobody can read.
The umpire already stops the bout on a touch. This takes that stop:
- the station **swings `contactYaw` off the piste axis**, orbiting the focus
rather than sliding along it, so the two fencers land at different depths and
their panels stop being coplanar;
- it **cranes `contactLift` and aims `contactAim` lower**, which drops the near
panel's top edge below the contact instead of across it;
- it **closes** to a distance solved from the pair's own span with
`contactPad` of the fight's margin, floored at `contactClose` of
`minDistance` and ceilinged at `minDistance` — so contact is never framed
wider than the fight, and the figure can only grow;
- it **slides `contactBias` of the way toward the fencer who took the touch**,
who is the subject of the moment;
- and it **holds `contactHold` seconds past the umpire's own stop**, so the
beat has a tail rather than a cut.
Nothing above touches the frame the distance is judged in: `contactPct` is 0
for every frame of `state == "fence"`, and the dials it blends toward are all
stated as offsets or fractions of the shot's own, so re-tuning the shot
re-tunes the contact framing with it.
`contactForce` holds the framing for a number of seconds on demand. The beat is
about six tenths of a second long and a screenshot cannot be asked for from
outside the engine inside it, so this is how the moment gets photographed.
## Public fields
- `height: number` — the camera's world Y.
- `lookHeight: number` — the world Y it aims at; the difference from `height`
is the whole of the pitch, and this is the dial that sets the dead lawn.
- `minDistance: number` — the nearest the broadcast ever sits. This is the
number that decides how large a fencer reads when the two are at measure. At
4.60 the extent solve covers a 7.1 m gap before it pulls out at all, which is
wider than the fight gets outside a full retreat — so the shot holds still
through an exchange instead of breathing on every step.
- `padding: number` — world metres held either side of the outermost thing in
the shot.
- `fov: number` — the vertical field of view the frame is actually drawn at.
- `smoothing: number` — how quickly the rig chases its solved pose.
- `reactionRoom: number` — the furthest a punch or a kick may pull the camera
in, in metres. A metre buys much more of the frame from 4.60 than from 6.60.
- `focusX: number` — the smoothed X the rig sits at. Live output.
- `distance: number` — the smoothed distance the rig sits at. Live output.
- `fencerFramePct: number` — the share of frame height a 2.13 m fencer covers.
Live output.
- `lawnBelowPct: number` — the share of frame height below their feet. Live
output. The frame's dead space, as a number.
- `contactYaw: number` — degrees the station swings off the piste axis at
contact.
- `contactLift` / `contactAim: number` — metres the camera rises and metres
the aim point drops at contact.
- `contactClose` / `contactPad: number` — the contact station's near bound and
its margin, each as a fraction of `minDistance` and `padding`.
- `contactBias: number` — how far the frame slides off the pair's midpoint
toward the fencer who took the touch.
- `contactRise` / `contactFall: number` — how fast the swing goes on and comes
off, per second.
- `contactHold: number` — seconds the framing runs on past the umpire's stop.
- `contactForce: number` — seconds of contact framing to hold on demand,
counted down in `update`.
- `contactPct: number` — 0 side-on, 1 fully in the contact framing. Live
output, and the field a test asserts the beat on.
- `contactSide: number` — which way the swing went: −1 the struck fencer is
west, +1 east. Live output.
## Lifecycle
- `awake()` — pushes `fov` onto the sibling `Camera`.
- `update(dt)` / `editorUpdate(dt)` — solves and applies the pose. It runs in
edit mode too, so the shot can be judged without entering play.
## Usage
```luau
entity(id).component.add("PisteCamera", {})
entity(id).component.get("PisteCamera").minDistance = 5.2
```
## Notes
- **The scene's authored props outrank every default in this file.**
`scenes/main.scene/scene.json` carries a `PisteCamera` prop block, and a cold
`layers.load` re-attaches the component with those numbers — so the shot the
world runs after a reload is the one written there, not the one tuned here.
Read the live component, not this file, before believing any framing number.
- Leave the sibling `Camera`'s `behavior` field unset. A camera-controller
component named there is re-instantiated on every scene load and fights this
one for the rig's transform.
- The fencers and the director are held by id and re-scanned only when a
cached id stops resolving. Asking `entity.findAll()` once a frame is a walk
of the whole arena, which is a few hundred parts: it cost 4.2 ms a frame and
made this the second most expensive script in the world. It now costs 0.21.
# duel
The rules of the Hedgerow Assizes, in one place: the ground plan of the walled
garden, the frame data of the four commitments, the measure each one works at,
the triangle that decides what a contact means, how far a post drives the
property line, and the roster of neighbours with the fencing each one keeps.
Every duel component reads its numbers from here, so the whole match is tuned in
one file.
Consumed by `Fencer`, `FencerAI`, `DuelMatch`, `DuelHud`, `DuelFx`, `garden` and
the main scene's entrypoint.
## The measure
Each action carries a band, not a range: `reach` is the furthest the blade
crosses and `minReach` the nearest — inside that, the panel is smothered and the
attack is *crowded out*. `advance` is the ground the commitment itself takes, so
every attack steps in and a defender cannot simply walk backwards out of the
sport. A commitment that reached nobody runs its recovery from a debt, and that
debt is the whole punishment for committing at the wrong distance.
| Action | windup | active | recover | reach | minReach | advance | whiff | parried |
|---|---|---|---|---|---|---|---|---|
| `thrust` | 0.22 | 0.10 | 0.42 | 2.55 | 1.55 | 0.35 | 0.35 | 0.45 |
| `sweep` | 0.34 | 0.20 | 0.58 | 2.15 | 0.00 | 0.25 | 0.55 | 0.65 |
| `lunge` | 0.30 | 0.16 | 0.95 | 3.05 | 1.75 | 1.30 † | 1.00 | 1.20 |
| `parry` | 0.05 | 0.34 | 0.40 | — | — | — | 0.30 | — |
† The lunge is marked `recoil`: whatever of its `advance` it managed to cover it
gives back over its own recovery, travelling backwards and unable to answer
anything while it does. It buys reach; it never buys position.
## The triangle
`parry` turns a `thrust` and a `lunge` aside and earns a riposte; a `sweep`
crashes through a held guard; a `thrust` arrives inside a `sweep`'s long
wind-up. A riposte cannot be turned at all.
**The guard answers from its first frame.** `resolveTouch` treats a `parry` in
`windup` exactly as it treats one in `active`, because a guard that only worked
once it had finished rising could be beaten by an attack thrown from further out
than the wind-up is long — which made a correct read lose to a committed attack,
the one outcome the triangle exists to prevent.
## What a post is worth
A post is not a fixed step. Three properties of the ground decide how far the
line moves, and between them they are what gives a bout its length:
| | | |
|---|---|---|
| `POST_STEP` | 1.15 | fresh mown lawn in the middle of a garden |
| `POST_ROOTED` | 0.60 | the last feet before a hedge are old rooted ground, and give up to this much of the step |
| `POST_RECLAIM` | 0.35 | lawn you already conceded holds somebody else's posts; they come out before the line passes back |
| `POST_CHURN` | 0.05 | each post already driven breaks the ground a little further, to `POST_CHURN_MAX` |
`postDrive(line, dir, posts, driven)` is the whole rule. Rooted ground stops a
garden being annexed by a short run of luck; conceded ground stops two even
fencers walking the line back and forth all afternoon. Chosen against a
simulation of the rule over 600 bouts a configuration, with the riposte rate and
touch cadence taken from played logs: a median bout of about 20 touches, 80 % of
bouts between 45 s and 120 s.
## The roster, and how each one fences
`NEIGHBOURS` is the roster — index, `key`, `skill` and `style`. `STYLES` is the
fencing attached to each key, and it is what makes three opponents rather than
one opponent at three settings:
| | `hold` | `think` | `patience` | `adapt` | `boundary` |
|---|---|---|---|---|---|
| `pettifer` | +0.55 | 1.45 | 2.4 | 0.6 | 0.0 |
| `wickham` | 0.00 | 0.90 | 1.0 | 2.2 | 0.15 |
| `ashcombe` | −0.35 | 0.75 | 0.6 | 1.1 | 1.0 |
`hold` moves the measure held at rest; `think` the pause between commitments;
`patience` how long an opponent who has offered nothing is indulged before the
touch is simply fetched; `adapt` how heavily a repeated stroke steers the answer
and how much faster the eye settles on one it has seen; `boundary` how far the
defence of one's own ground stiffens the fencing as the line comes back toward
one's own hedge. `w*` are the standing appetites for each commitment.
`style(key)` falls back to `wickham`'s for an unknown name rather than erroring.
## Surface
- `ACTIONS` — the table above.
- `isAttack(name)`, `reachOf(name, isRiposte)`, `windupOf(name, isRiposte)`,
`advanceSpeed(name)`, `whiffOf(name)`, `parriedOf(name)`.
- `resolveTouch(attack, defAction, defPhase, isRiposte)` — `"touch"` |
`"parried"` | `"nothing"`.
- `measureFault(name, gap, isRiposte)` — `"short"` | `"crowded"` | `nil`.
- `postDrive(line, dir, posts, driven)` — metres one touch moves the boundary.
- `resetX(line, side)` / `clampX(x, line, side, foeX)` — where a fencer stands,
and how far it may travel: bounded by the opponent, not by the line.
- `winner(line)` — `"player"` | `"rival"` | `nil` for the boundary's position.
- `NEIGHBOURS`, `neighbour(index)`, `neighbourIndexOf(key)`,
`neighbourNearestSkill(skill)`, `STYLES`, `style(key)`.
- `LAWN_HALF`, `LINE_LIMIT`, `POST_STEP`, `POST_ROOTED`, `POST_RECLAIM`,
`POST_CHURN`, `POST_CHURN_MAX`, `RIPOSTE_POSTS`, `STANCE`, `WALK_SPEED`,
`RETREAT_FACTOR`, `MIN_GAP`, `LEASH`, `RIPOSTE_WINDOW`, `RIPOSTE_QUICK`,
`RIPOSTE_REACH`, `CLASH_PUSHBACK`, `HIT_STUN`, `SALUTE_TIME`, `TOUCH_PAUSE`,
`SIDE`, `COLOR`.
# GardenDressing
The morning. Everything about how the Hedgerow Assizes LOOK lives in this
component, so the look is part of the scene's behaviour and survives a reload
with no `scene.json` edit.
```luau
entity.find("DuelDirector").component.add("GardenDressing")
```
## What it owns
- **The sun.** `sunAzimuth` / `sunElevation` place it; the component turns those
into the direction the light travels and writes it through the `lighting`
toolbox. Setting the sun entity's rotation does nothing — the directional
light's direction lives on the light, not on the transform.
- **The sky.** The `ProceduralSky` on the scene's `sky` entity: a saturated blue
overhead falling to gold at the hedge line.
- **The ambient fill**, warm-sky tinted and low enough that a hedge shadow is a
hedge shadow.
- **The tone curve**: bloom, an ACES tonemap, a touch of contrast, and a
vignette. It explicitly REMOVES the `fog` preset, which tints the sky along
with everything else and makes a bright garden read overcast whatever the sky
is set to. Distance is done in the materials instead.
- **Keeping the garden built.** `garden.module` and `fencer_body.module` are the
builders; this component notices when an arena, a fence or a body standing in
the world was not built by them and rebuilds it, and poses both fencers every
frame from their own live state. Every check is a marker lookup, so once every
call site has moved to the modules this costs one `entity.find` and returns.
Set `keepBuilt = false` to switch it off.
## Fields
| field | meaning |
|---|---|
| `sunAzimuth` | degrees, measured from +X toward +Z |
| `sunElevation` | degrees above the horizon — low, for long shadows |
| `sunIntensity` | directional light strength |
| `ambientIntensity` | sky fill; raise to lift the shadow side |
| `haze` | how far the frame closes down at its edges |
| `keepBuilt` | rebuild an arena/body that another builder put up |
| `rebuilds` | how many arenas it has had to repair (diagnostic) |
| `lastLine` | the property line the fence was last built for |
`refreshLook()` re-applies the whole look in one call.
# DuelMatch
The umpire nobody sees. One instance, on the `DuelDirector` entity.
Asks `garden` for the walled garden and the boundary fence, spawns the rival,
resolves every exchange between the two `Fencer`s through `duel.resolveTouch`,
plants a post and drives the property line into the loser's garden on a touch,
resets both fencers and runs the salute, and calls the match when the line
reaches a hedge.
The boundary is the health bar: `line` is the fence's leading post, and the
fence is rebuilt from the player's hedge out to it whenever `line` changes, so
the score is a physical object standing in the world.
## What a touch is worth
How far a post drives the line is `duel.postDrive`, and it is a property of the
ground rather than a constant: fresh lawn in the middle of a garden gives, the
last feet before a hedge are rooted and stubborn, lawn already conceded holds
somebody else's posts and has to be cleared before the line passes back over it,
and every post driven breaks the ground a little further for the next one.
`postsDriven` counts the posts this bout and `lastDrive` reports the metres the
last one bought. Between them these are what give a bout a length rather than
only a variance.
## Resolution
An attack whose blade is live inside its measure is an exchange. Two live
blades in the same tempo is a **clash**: neither scores and both pay for it,
which is what stops the umpire's own resolution order from deciding the bout.
An attack outside its measure is recorded as `SHORT` or `CROWDED` and never
touches its man, so the fencer who threw it pays the whiff debt.
A touch taken inside a riposte window plants `RIPOSTE_POSTS` posts rather than
one, and `lastPosts` reports which it was.
## The event bus
`fxEvent` names the moment and `fxSeq` moves on **every** write, including a
repeat of the same event — so a reader polls `fxSeq` against the last one it saw
rather than diffing fields for edges. Vocabulary: `salute`, `allez`,
`short:player`, `short:rival`, `clash`, `parry:player`, `parry:rival`,
`riposte:player`, `riposte:rival`, `touch:player`, `touch:rival`, `post`,
`over:player`, `over:rival`. `":player"` means *the player did it*.
`fxQueue` is the lossless read of the same vocabulary: every event since the
last frame, in the order it happened. `DuelMatch` empties it at the top of its
own update, and it runs before everything that reads it, so a reader gets the
whole of this frame's list — including a `touch:player` and the `post` that
follows it in the same frame, which a single slot cannot carry.
## The roster
`neighbour` (1..3), `neighbourKey` (`"pettifer"` | `"wickham"` | `"ashcombe"`)
and `skill` are one identity written three ways. Writing any of them settles the
other two off `duel.NEIGHBOURS` and pushes the matching `style` onto the live
rival's `FencerAI`, so a garden, a turnout and a verdict can all name the same
person without any of them guessing. `DuelHud` keeps ownership of the prose.
## Fields
`line`, `state` (`salute` | `fence` | `touch` | `over`), `lastTouch`, `winner`,
`playerTouches`, `rivalTouches`, `skill` (the rival's quality — writing it
retunes the live rival), `announce`, `lastPosts`, `postsDriven`, `lastDrive`,
`matchClock` (seconds since this bout's first `allez`), `neighbour`,
`neighbourKey`, `fxEvent` / `fxSeq` / `fxQueue`, and `lastExchange` /
`lastExchangeSeq` / `exchanges` — the tuning record, naming who struck into what
and what the triangle made of it, including attacks that fell short of their
reach or were smothered inside it.
## Method
`restart()` — a fresh assize, the line back to nought. Callable at any time,
including from `state == "over"`, where the `restart` action also reaches it.
Pairs with `DuelHud` on the same entity. Requires the `duel` and `garden`
modules and `@builtin::modules.zinput`.
# fencing_controls
The piste scheme for a 2.5D fence duel: footwork along one axis plus four
mutually-exclusive commitments and a call for the next bout. Extends `default`,
so everything the built-in scheme binds stays bound.
Footwork rides the canonical `move` axis rather than a new one, which is what
makes advance and retreat work on keyboard, gamepad and touch without a second
binding: only the axis's X component is read.
## Actions this map adds
| Action | `kbm` | `touch` |
|---|---|---|
| `lunge` | `KeyI`, middle mouse | button, `right-lower`, group `duel`, priority 13 |
| `thrust` | `KeyJ`, left mouse | button, `right-lower`, group `duel`, priority 10 |
| `sweep` | `KeyK`, right mouse | button, `right-lower`, group `duel`, priority 11 |
| `parry` | `KeyL`, `Space` | button, `right-lower`, group `duel`, priority 12 |
| `restart` | `KeyR`, `Enter` | button, `right-upper`, group `duel`, priority 20 |
The four commitments sit under the right hand in the order they cost you: `I`
lunges the furthest and recovers the slowest, `J` thrusts, `K` sweeps, `L` or
`Space` holds the guard. They share the `duel` group so the touch overlay lays
them out adjacently as one cluster. `restart` sits apart, in `right-upper`,
because it is only live once the assize is decided.
## Axes
None of its own — `move` comes from `default`.
## Activation
Activated from the scene entrypoint on load:
```lua
local map = asset.resolve("fencing_controls", "inputMap")
if map ~= nil then map:activate() end
```
The four commitments and the `move` axis are read by the `Fencer` component on
a `role = "player"` fencer; `restart` is read by `DuelMatch` while the match is
over.