# Focus
Menu focus navigation for the cards of GREASEPAINT. A controller has no
pointer, so every menu carries an ordered ring of focusable ids and exactly one
of them is lit; this module walks that ring.
- `Focus.open(list, prefer)` — open a menu: the ids in stick order, and which starts lit.
- `Focus.axis(v, dt)` — feed the `navigate` axis; one flick is one step, a held stick repeats.
- `Focus.point(id)` — a mouse crossing a row or a finger tapping it moves the focus there.
- `Focus.id()` / `Focus.isFocused(id)` — read it.
- `Focus.close()` — nothing is focused until the next `open`.
- `Focus.release()` — let go of a held direction, so a stick held through a transition does not keep walking.
The ring is whatever the card offers, so an interval with three faces and a
fourth row is a ring of four and needs nothing said about it here: `open` takes
the list `screens.itemIds` returns for the phase.
A held stick repeats on a delay measured in seconds AND in frames. On a machine
running long frames a single flick can be one frame carrying half a second of
`dt`, and a delay measured only in time turns that flick into two steps.
Fed by `game/input/console.inputMap`; consumed by `game.ui.Screens`, which asks
it which row to draw lit.
# City
The block skyline of BLOCKBUSTER 767 — the thing the plane is aimed at.
Put it on an entity and it builds a whole tabletop city from a seed, joins every
structure together, and then scores what the player knocks off it.
```luau
local city = entity.find("mount_city").component.get("City")
city:census() -- how much of it is still standing
city:structures() -- every tower, its kind, height, tier and damage
city:rebuild() -- lay it out again from the seed
```
## What it builds
`game.city.blocks` decides the shape: six archetypes — a tapering **skyscraper**,
a banded **office slab**, a **water tower** on four legs, a thin **antenna mast**,
a three-span **bridge**, and a **gate** with a lintel — dealt across four streets
so every archetype appears at least once. Blocks are painted wood
(`game/city/materials`) over a shared grain texture, each structure turned off the
grid and each stack given a little per-block twist, so it reads hand-stacked.
## What holds it up
Every structure is a tree of fixed joints in which each block carries at most
**one** joint, because an entity carries one `Joint` component and a second would
silently replace the first. The joints are created with no threshold at all;
then, once the city has stood on its own for `settleFrames`, the component reads
`Physics.jointReaction` on every joint over `sampleFrames` and sets
breakForce = measured force × Rules.PHYS.jointBreakMargin
breakTorque = measured torque × Rules.PHYS.jointBreakMargin
with floors so a joint resting under nothing is not released by its own settling.
Nothing here is a guessed number, and the grading falls out of the measurement:
the joint under ten storeys ends up an order of magnitude stronger than the one
under a spire, so a clipped top comes off while the tower below it stands.
## What it does every frame
Every live block is watched. One that moved is clamped to `Rules.PHYS.debrisMaxSpeed`
and `debrisMaxSpin` — the governor that stands in for CCD, which is forbidden on
every body in this game — and scored:
| what happened | event | score |
|---|---|---|
| a block left home by `Rules.SCORE.displaceDistance` | `blockDisplaced` | `perDisplacedKg` × its mass |
| a block fell past the table edge | `blockOff` | `Rules.SCORE.offTable` |
| a structure lost `toppleFraction` of itself | `towerToppled` (via `Game.recordTopple`) | `topplePerTier` × tier, and the chain multiplier goes up |
A block that reaches the playroom floor is despawned, a few per frame, so a wave
of debris landing together does not rebuild the solver's islands all at once.
## Fields
| field | what it is |
|---|---|
| `seed` | the layout; the same seed builds the same city on every peer |
| `settleFrames` | frames the city stands before its joints are measured |
| `sampleFrames` | frames the resting load is sampled over; the largest reading wins |
| `minBreakForce` | newtons no threshold falls below |
| `torqueLeverScale` | scales the lever arm that floors the angular threshold |
| `cullsPerFrame` | how many floor-bound blocks are despawned in one frame |
It rebuilds itself on `sessionStart`, so a fresh session gets a fresh city.
Everything it spawns lives under one temporary `city_root`: nothing is ever baked
into the scene.
# rules
The cross-slice constants of BLOCKBUSTER 767 — read-only, and the contract
between any two parts of the game that have to agree on a number.
- `TABLE` — where the play surface is and where its edges are.
- `LAUNCH` — the launcher's position, how far the heading and elevation trim,
and the wind band: how long a notch takes, how many there are, and what the
red zone past the last one costs.
- `CITY` — the band of table the skyline stands in, its seed, and the landmarks.
- `PHYS` — masses, speed caps, the debris governor, the settle threshold and
the margin a measured joint break force is multiplied by.
- `SCORE` — what a shoved block, a toppled tower and a block off the table pay,
the chain window, the landmark and called-shot multipliers, and the star
thresholds.
- `SESSION` — three planes, the round clock, the rest and wreck beats.
- `STATES` / `EVENTS` — the only state names and event names the game uses; a
typo in either raises at the call rather than going quietly nowhere.
- `LAYOUT` — the bands every screen anchors into, as shares of the viewport.
Units are metres, kilograms and seconds. The table top is `y = 0`; `+Z` is
downrange, so the plane launches at `LAUNCH.z` and flies toward the city.
A number only one slice reads lives in that slice's own files, not here.
Consumed by every slice.
# theme
The look of BLOCKBUSTER 767's screens, in one place: the palette, the
typefaces, the metrics every screen sizes itself from, and the style fragments
the cards share.
```lua
local Theme = require("game.ui.theme")
local m = Theme.metrics()
{ type = "label", style = { fontSize = m.fs.title, fontFamily = Theme.font.display, color = Theme.color.cream } }
```
## The metrics rule
Nothing is a pixel number chosen for one screen. `Theme.metrics()` reads the
live `ui.screenSize()` and derives everything from it:
- **Type and rhythm** are a share of the SHORTER side, so a phone held either
way and a television read the same.
- **Both are additionally capped against the HEIGHT.** A card is a column, and a
wide screen is also a short one; without that cap the card that fits a 3:4
frame runs off the bottom of a 16:9 one.
- **The two big pictures** — `hero` (the toy on a card) and `strip` (the skyline
along its foot) — are budgeted straight out of the height they have to fit in.
- `cardW` caps a card to a readable single column instead of letting it stretch
across a wide monitor.
`Theme.metricsFor(w, h)` computes the same table for a shape the engine is not
currently running at — which is how a card is laid out and looked at for a
screen other than the one in front of you.
## The palette
Warm shadow, painted wood, poster paint on the toy, crayon accents, and the gold
of a sticker star. `Theme.color` names them; `Theme.font` names the four voices
(a poster face for the logo, a heavy face for headings, a plain face for reading,
a mono for numbers, and the round childlike face the Kid speaks in).
## The fragments
`cardBackdrop` (the playroom wash a card is printed on; its `see` argument is
the share of the ROOM that shows through, and the scrim's opacity is `1 - see`),
`panel` (a raised surface with a lit top rim), `row` (a menu row at rest or with
the focus on it), `chip` (the small rounded plastic label a keycap, a pad glyph
and a touch hint are all made of), `stat` (a number with its name under it), and
the three motions: `enterDrop`, `enterRise`, `pulse`.
**Nothing here animates for ever, and `pulse` breathes on opacity alone.** A
keyframe that animates `transform: scale` re-measures the widget's box every
frame, which relays out the whole tree it sits in; two HUD widgets each held one
of those permanently and one frame in ten was over budget because of it.
`pulse(iterations)` counts — eight breaths by default — so even the opacity
animation ends.
**No entrance starts from nothing.** An entrance holds its first keyframe until
its delay elapses and the UI clock is the game clock, so a card opened while
something is holding `engine.timeScale` down would sit on that first frame.
They start dimmed and slightly displaced instead, so the worst a stalled clock
can do is leave a card looking a shade soft.
# hud
The heads-up displays of BLOCKBUSTER 767. Pure builders, like `game.ui.cards`:
metrics in, a view table in, a widget tree out.
| Builder | What is on screen |
|---|---|
| `wind(m, view)` | Score and planes left, the heading-trim dial, and the LAUNCH prompt in the language of the device the player is actually holding. |
| `flight(m, view)` | Score, the last award drifting up and fading, the chain chip while a chain is alive, the speed gauge with the boost and dive marks, and the airframe part by part — a part that has snapped off is struck through, which is the only place the damage model is visible while flying. |
| `resting(m, view)` | What stays up through the wreck beat: the score and the planes, and nothing that reads a live airframe, because there is not one. |
## Where the HUD is allowed to be
The on-screen touch controls are drawn by the engine from the flight map's live
bindings, and they claim two regions: the stick takes the left 40% of the width
below 40% of the height, and the buttons take the right edge below 45%. So every
element here lives inside a top band of `0.38` of the live height, and nothing
reaches into the lower screen. That rule is a share of the height, so it holds
on a phone, a laptop and a television — measured at 1080x1440 the HUD's lowest
pixel is at 510 against a stick zone starting at 576 and a button band starting
at 648.
## Reading it over a sunlit playroom
The band carries a wash that fades out down its height, and every readout also
carries its own text shadow. Without both, cream type disappears the moment the
camera swings onto the window wall.
## The score pop
The most recent award drifts up and fades on a tween computed in Luau rather
than on a keyframe animation: the pop has to restart on every award, and an
immediate-mode tree gives a keyframe no restart edge to key off.
# Plane767
The toy 767 the player IS. It sits on the `Pilot` prototype's `airframe` body,
so every joining player gets one, and it owns the whole arc of a round: the
launcher, the flight, the damage, and the moment the airframe stops.
## The three phases
| State | The body | What the controls do |
|---|---|---|
| `wind` | kinematic, posed on the launcher | Steer **x** trims the heading (±`Rules.LAUNCH.headingRange`), steer **y** trims the launch elevation. `launch` is **held** to wind the key and released to throw. `boost` / `brake` step the called shot. |
| `flight` | dynamic | The nose follows the stick, the velocity follows the nose, the wings carry most of gravity, `boost` adds speed and `brake` spoils the lift. |
| `wreck` | dynamic, model stood down | Nothing is driven; the loose parts are still governed and culled. |
Stick **forward** pushes the nose **down** and pull-back climbs, on the
launcher and in the air, on every device at once.
## Winding the key
Launch is held. Each `Rules.LAUNCH.windSeconds` of hold is one wind of the key,
up to `LAUNCH.winds`; the last wind gets a window of its own, so a full throw is
a release the player can aim for rather than an instant. Past that window the
red zone runs for `LAUNCH.overwindSeconds`, and past THAT the spring lets go and
the throw is a dud. With the numbers the rules carry today:
| Hold | Winds | Throw | Boost fuel | Damage |
|---|---|---|---|---|
| 0 – 0.55 s | 0 | 6 m/s | 0 s | ×1 |
| 0.55 – 1.10 s | 1 | 9 m/s | 1 s | ×1 |
| 1.10 – 1.65 s | 2 | 12 m/s | 2 s | ×1 |
| 1.65 – 2.20 s | 3 | 15 m/s | 3 s | ×1 |
| 2.20 – 2.90 s | 3, **overwound** | 16 m/s | 5 s | ×`LAUNCH.overwindDamage` |
| past 2.90 s | 0, the spring slipped | 6 m/s | 0 s | ×1 |
The modelled key on the toy's back turns a full turn per wind and trembles in
the red zone; the spring's slip spins it back to rest. `wind { winds,
overwound }` lands on the bus at each notch and once as the red zone opens, and
`launch { heading, winds, overwound, speed }` when the key is let go.
Boost is what the wind BOUGHT: `Game.telemetry.boostFuel` is seconds of it, it
drains only while Boost is actually thrusting, and an empty tank is a dead
throttle for the rest of the round.
## The called shot
On the launcher, `boost` steps forward and `brake` steps back through the
landmarks `City:landmarks()` reports still standing, with "no call" as one of
the stops, via `Game.callShot`. The city pays a called landmark's topple
`Rules.SCORE.calledShot` times its landmark value. Missing costs nothing. The
two buttons keep their flight meanings in the air, so the call needs no control
of its own on any device.
## The flight model
The airframe is driven with **force**, never with a velocity write, and the
solver's own reading (`Physics.bodyState().linearVelocity`) is what the model
reads back — so a block that hits the toy actually moves it, and the model sees
that it did.
- `targetSpeed` is script state, clamped to `Rules.PHYS.planeMaxSpeed`
(`planeBoostSpeed` while Boost is held) *before* any force is spent, so the
controller can never aim past the envelope. Anything above the cap — a
collision, a fall — is taken straight back off the same frame.
- The controller pulls the velocity onto the nose over `followTime` and hands
back the share of gravity the wings carry, inside a budget of
`maxControlAccel`. A harder hit than that budget still throws the toy.
- Once the airframe has been scraping the table for `groundedSeconds`, the
model stands down entirely and lets friction finish the round.
- The round ends when the airframe has been below `settleSpeed` for
`Rules.SESSION.restSeconds` **and** `City:atRest()` agrees, or when it has
waited a further `SESSION.restCityGrace` for a city that will not settle. A
toy that stops under a tower still coming down does not cut the tally off.
- CCD is never enabled, here or on anything this component spawns.
## Damage
Every frame in flight the airframe's contacts are read. A contact past
`impactThreshold` raises `impact { force, position, other }` on the bus and adds
to a damage pool; each whole point of damage sheds the part the hit landed
nearest — the child the art slice built is hidden and a bounded loose copy is
dropped in its place, with `partLost { part, position }` on the bus.
Losing a wing costs lift, control authority and rolls the toy toward the hole
it left; losing an engine costs half the thrust and the boost cap; losing the
tail costs pitch authority and most of the turn coupling.
`Game.telemetry.parts` and `.health` carry the state for the HUD.
Loose parts are clamped every frame to `Rules.PHYS.debrisMaxSpeed` /
`debrisMaxSpin`, culled below `Rules.TABLE.floorY`, and cleared when the next
plane is armed.
## Pause
`menu` toggles `Game.setPaused`. The airframe is frozen where it hangs — the
body goes kinematic, its pose is held, and the model spends nothing — and
resuming hands the same speed back along the nose. Scoped to this airframe on
purpose: stopping the whole clock also stalls every `task.wait` in the world.
## Reading it
- `Game.telemetry` — written here every frame: `speed, altitude, heading,
pitch, bank, stick, boost, brake, health, parts, launched, elevation, winds,
windCharge, winding, overwound, boostFuel, launchSpeed`.
- `ref:status()` — the same telemetry table.
- `ref:airframe()` — `{ lost, partsLeft, damage, hardestHit, loosePieces,
heading, elevation, winding, windHold, winds, windCharge, overwound, sprung,
keyAngle, boostFuel, launchSpeed, damageScale, called, landmarks }`, for a
screen or a test that wants more than telemetry.
## Controls
`game/input/flight.inputMap` — group `flight`, suppresses `player`, so the
engine's walking scheme stands down while the toy is live. Every control
carries keyboard, gamepad and touch; the on-screen controls are a stick in the
left zone and one right-edge column, **Launch** (large, thumb-nearest), then
**Boost**, **Dive**, **Menu**. The map's own README records what the overlay
does with the margin it is given.
The builtin walking map is also held live here, inside the suppression this map
already declares. It never reaches anything — `flight` outranks it — and it is
what stops the engine arming the editor's map as the keyboard floor, which
would put a "Frame" button and a look-drag zone on a touch player's screen.
# caption
The written form of everything the Kid says, so a spoken line survives a muted
player, a noisy room and a failed generation.
`SoundDirector` writes; anything that draws reads.
```lua
local Caption = require("game.audio.caption")
local now = Caption.current() -- { id, text, remaining, duration } or nil
local off = Caption.on(function(shown) ... end) -- every new line, and nil on clear
```
`SoundDirector` draws the words itself, in a band above the bottom control
band, laid out from the live `ui.screenSize()`. A screen that would rather
carry the line inside its own composed card takes the job:
```lua
Caption.claim("bb_screens") -- SoundDirector's band stands down
Caption.release() -- hand it back
```
Time is handed in rather than read: `Caption.tick(dt)` is called once a frame
by `SoundDirector`, so a caption follows the game's clock and holds still while
the game is paused.
# cards
Every full-screen card BLOCKBUSTER 767 puts between the player and the table.
```lua
local Cards = require("game.ui.cards")
ui.updateScreen("bb_card", Cards.title(Theme.metrics(), { focus = "bb_row_play", caption = nil }))
```
| Builder | The card |
|---|---|
| `title(m, view)` | The toy, the logo, the tagline, WIND IT UP and HOW TO PLAY. Printed on a SEE-THROUGH backdrop over the real launcher and the untouched city, so the drawn skyline emblem comes off. |
| `howto(m, view)` | The brief and the control table — one row per action, carrying the keyboard caps, the connected pad's own legends (`Zin.gamepad.label`, so `A` on an Xbox pad and `Cross` on a PlayStation one) and the touch control's name, plus a legend saying which colour is which device. `view.showPlay` is false when the card was opened over a pause card, where the only sensible move is back. |
| `pause(m, view)` | Score, plane and towers so far, and the four things a paused player might want. Printed on a SEE-THROUGH backdrop, so the toy is still visible where it was left. |
| `tally(m, view)` | The wreck beat's panel: which plane went down and why, the round's towers / blocks / off-table, what it scored and the running total. Not a menu — it plays and passes on its own, and it sits low so the resting airframe stays in frame above it. |
| `endCard(m, view)` | The verdict, the stars, the final score, what is still owed for the next one, the session's counts, and the way back to the table or to the title. Also see-through, over the camera's slow swing across the wreckage. A star not earned is drawn in a dark socket with a lit rim rather than at half opacity on a ground it could not be told from. |
Every builder is PURE: metrics in, a plain view table in, a widget tree out.
Nothing here registers a screen, reads the session, activates a map or moves the
state machine — `Screens.component` does all of that and hands the answer in.
That split is what lets a card be rebuilt every frame the focus moves without
any of the game noticing.
## The shape rules
- **One column, centred, capped to `m.cardW`** — so a 3:4 phone and a 21:9
monitor both get a readable measure rather than a stretched band.
- **Every size comes from `Theme.metrics()`**, which reads the live screen.
- **Prose wraps against a container, never against the label.** A label carrying
both `wrap` and a width breaks to one glyph per line, and a column that
CENTRES its children sizes them to their content, which leaves a label no
definite width to wrap against and does the same. `paragraph` handles both.
## The rows
`Cards.row(m, id, label, hint, lit, delay)` builds one focusable row, and its
`id` is at once the widget id, the focus id and the callback id — so a pad's
focus, a mouse's hover and a finger's tap can never disagree about which row
they mean. The ids the component walks are `bb_row_play`, `bb_row_howto`,
`bb_row_back`, `bb_row_resume`, `bb_row_restart`, `bb_row_title` and
`bb_row_again`.
# game
The shared memory of one BLOCKBUSTER 767 session, and the bus every slice
talks over.
`Game.session` holds what the run is: the state it is in, the score, which
plane is loose, how many are left, the chain and its multiplier, the counts of
blocks shoved and blocks off the table, and the star rating once the session
ends. `Game.telemetry` holds what the airframe is doing this frame — speed,
altitude, pitch, bank, boost and its remaining fuel, the winds the key is
holding, which parts are still attached — written only by the flight slice and
read by the HUD, the camera and the sound mix.
`Game.on` / `Game.emit` are the bus. Handlers run in subscription order, which
matters: more than one slice answers `roundStart`, and which of them the player
ends up looking at cannot be left to the order a table happens to iterate in.
`Game.set` moves the state machine through the names in `game.rules`, and
`Game.reset` / `startRound` / `endRound` / `advance` are the session flow — a
fresh city, a plane on the launcher, a wreck, and then either the next plane or
the end card.
Everything it remembers lives in `modules.state()`, so editing this file while
the game is running does not drop the subscriptions the other slices hold.
Consumed by every slice: `flight`, `city`, `camera`, `ui`, `set` and `audio`.