# 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.
# Screens
Every authored screen of BLOCKBUSTER 767, on the glass and driven. One
instance, on `mount_screens`; it needs nothing else on that entity.
## What it puts up
| Screen | Layer | What it carries |
|---|---|---|
| `bb_hud` | 20 | The wind-up HUD, the flight HUD, and the reduced readout that stays up through the wreck beat. |
| `bb_card` | 800 | The title card, the how-to-play card, the pause card, the wreck tally and the end card. |
The trees are built by `game.ui.cards` and `game.ui.hud`, styled from
`game.ui.theme`, and drawn with `game.ui.art`. Only this component holds state:
which card is up, which row is lit, which line is printed, and the moves of the
state machine that belong to presentation.
## The three moves it owns
- **`title` → `howto` → `opening`.** The first `roundStart` of a session puts
the title card up; WIND IT UP moves to `opening` and hands the game to the
director.
- **`end` → `title`.** BACK TO THE BOX starts a fresh session and lands on the
title card; WIND IT UP AGAIN starts one and goes straight back to the table.
- **A watchdog on `opening`.** The opening flythrough belongs to the
camera-director slice. If nothing hands the launcher over inside
`openingWatchdog` seconds, this component moves `opening` → `wind` itself,
rather than leaving the player looking at a camera move that never lands.
Everything else is somebody else's: the flight slice moves `wind` → `flight`
and calls `Game.endRound`, and the scene entrypoint advances out of `wreck`.
## Driving a card
`game.input.console` is activated the instant a card goes up and released the
instant the last one comes down, and it stands down the whole `flight` group
while it is live — so a stick held on a menu never also steers the toy.
| | Move the focus | Confirm | Back | Restart | Resume |
|---|---|---|---|---|---|
| Keyboard | arrows, W / S | Enter, Space | Backspace | R | Escape |
| Pad | left stick, d-pad | south | east | north | start |
| Pointer / finger | hover, tap | click or tap the row | tap BACK | the pause card's row | tap the row |
All four move the SAME focus (`game.ui.Focus`), so the lit row is always the one
that acts. A pointer only moves the focus when it CROSSES a row, so a mouse left
resting somewhere cannot stop a pad walking the menu.
No control on the console map carries a touch binding, deliberately: a card
draws its rows as real targets, and an on-screen Confirm button would be a
second way to do what the card already offers.
## Captions
Every line the Kid speaks is printed as well. `game.audio.caption` holds the
current line; this component subscribes to it with `Caption.on` and takes
`Caption.claim("bb_screens")` for as long as ANY card is up. That stands the
sound slice's own bottom band down and carries the line inside the card
instead, so a line spoken over the title, the pause card, the tally or the end
card is read there rather than behind it. The claim is released — and the band
comes back — the moment the last card comes down.
## What it costs a frame
The card layer is decided every frame, because a card has to go up on the frame
the state moves. The HUD is not: it is pushed at most twenty times a second,
and only when a deliberately coarse reading of the telemetry has changed. A
pushed tree is relaid out by the engine and the flight HUD is thirty-odd
widgets, so pushing it every frame of a flight was one frame in ten over budget
and a flicker the player could see. `Screens:report().pushes` counts what this
component has actually handed the engine.
## Fields
| Field | Default | What it decides |
|---|---|---|
| `consoleMap` | `game.input.console` | The controls every card answers to. |
| `captionSeconds` | 12 | How long the most recent spoken line stays printed on a card. |
| `openingWatchdog` | 9 | Seconds the opening flythrough may hold the game before the launcher is handed over anyway. |
## Reading it back
`Screens:report()` returns which card is up, which row is lit, the rows that
card walks, whether the console map has the input, which screens are shown, the
live caption, the game state and whether it is paused — the reading a test
asserts against.
# 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.
# Set
The playroom BLOCKBUSTER 767 is played in. It sits on `mount_set` and builds
everything the scene's `build.luau` does not: the room around the table, the
floorboards and rug twelve metres below the table edge, the window the
afternoon comes through, the skirting, dado and pictures, the toybox and the
corner lamp — and the game's whole photographic finish.
Everything hangs under the temporary root `set_root`, so rebuilding is
`awake()` again and nothing accumulates.
## What it owns beyond its own entities
Three things in the scene belong to the plan and are retuned here by name,
because the room's look is not separable from them:
- **`sun`** — aimed low and from the window side, warm, so the towers lay long
shadows down the table towards the player.
- **`ambient`** — cool and dim, the fill under that warm key. Warm light and
warm fill together render the whole room one flat olive.
- **`sky`** — swapped from the plan's `ProceduralSky` to a `Skybox` over
`set_sky`. The procedural sky drove the sun's own direction from its time of
day (`syncSunToLight`), and its outdoor blue was the image-based light
washing every painted surface in the room grey.
The plan's `table` slab keeps its position, scale and collider; only the
material it wears is set here, to `set_tabletop`.
## The one collider
`set_floor` is the only collider this component creates, and its top sits
2.6 m BELOW `Rules.TABLE.floorY`. The city culls a loose block when its centre
passes that plane, so a floor placed just under it would let a tall block come
to rest with its centre still above the plane and stay on the rug forever.
Nothing else in the room has a collider at all.
## The look
`playroom_paint` is one procedural painted-wood shader; every surface in the
room is a material on it, differing only in paint colour, grain, board width
and wear. `playroom_grade` is one post-process pass carrying the tilt-shift
defocus, the bloom, the warm split-toned grade and the vignette — measured at
0.16 ms of GPU per frame at 1080x1440.
Both hold their shape at any aspect: the tilt-shift band is a fraction of
screen HEIGHT and the vignette is measured on a square-corrected coordinate,
so a tall frame and a wide one get the same look rather than the same pixels.
## Fields
| Field | Default | What it moves |
|---|---|---|
| `sunIntensity` | 3.7 | How hard the afternoon comes through the window. |
| `ambientIntensity` | 0.20 | The cool fill under that key. |
| `lampIntensity` | 60 | The corner lamp's own pool of warm light. |
| `bandCenter` | 0.55 | Where down the frame the sharp band sits. |
| `bandHalf` | 0.30 | Half-height of the sharp band, as a share of screen height. |
| `blurMax` | 4.0 | Pixels of blur at full defocus. |
| `bloomIntensity` | 0.45 | How much the window light blooms. |
| `exposure` | 1.05 | Overall exposure of the graded frame. |
# Director
The staging of BLOCKBUSTER 767. It puts itself on the scene's bare
`mount_director` entity at runtime (`cam.ensureDirector`, called from
ChaseCamera's awake) rather than editing the scene, listens on the game bus, and
writes an override pose into `game.camera.cam` for `ChaseCamera` to blend the
frame toward. It never touches the camera transform itself.
## The beats
| beat | cue | what the frame does |
|---|---|---|
| `opening` | `roundStart` for plane 1, or anyone setting the state to `opening` | a flythrough: high off the far corner over the block city, down through the towers, a low skim across the bare table with the toy growing in frame, then a sweep past the launcher into the chase. Lands exactly on the live chase pose and sets the state to `wind`. |
| launch | `launch` | the lens opens 9°, the camera is kicked back 2 m, a light shake — all decaying |
| impact | `impact` | shake scaled by the contact impulse behind it (`impactShake` is the impulse that shakes at full amplitude) |
| part lost | `partLost` | a hard shake and a small punch in |
| `topple` | `towerToppled` | `engine.timeScale` dips to 0.35 for 0.42 s and ramps back over 0.45 s, the frame shakes, the lens punches in 8°, and the aim leans 55 % of the way onto the falling tower before easing back. One per 1.8 s — a chain shakes but does not stutter. |
| `wreck` | `wreck` | the camera leaves the chase and orbits the resting airframe for 1.7 s, closing from 1.55 to 1.05 spans, then CUTS to a low close station at 0.78 spans for the tally |
| `ending` | `sessionEnd` | a cut to a held wide from the launcher end, swinging slowly rather than circling — a camera that kept orbiting would end up behind the skyline with the table between it and the wreckage |
Every beat is timed in REAL seconds (`time.realtime`), so the slow-motion dip
cannot stretch the beat that asked for it, and every station's fov goes through
`cam.fovForAspect` and every radius through `cam.distanceScale`, so a tall phone
and a wide television both get a framed shot.
## What it will not do
- **It does not run the clock down past somebody else's pause.** It records the
value it set; if the clock has moved since (a screen holding it at zero) it
leaves it where it is. A paused game holds its beat where it stands, and a
slow-motion dip gives the clock straight back.
- **It does not fight the state machine.** Anything moving the state out of
`opening` ends the flythrough cleanly.
- **A screen that stops the simulation clock stops this component too** — with
`engine.timeScale` at zero no component update runs at all. That is why the
end-card camera is a CUT rather than a move: a drift-in that needed a second
of frames would never leave the chase.
## The surface other slices may call
- `Director:skipOpening()` — end the flythrough now and hand the plane over.
Any slice may call it; the presentation slice's `confirm` and the flight
slice's `launch` are the two that reach a player who wants to get on with it.
- `Director:replayOpening()` — play it again from the top.
- `Director:openingStation(u)` — the station the flythrough stands at, `u` of
the way through (0..1), solved against the live chase pose. It is how the
opening is checked as a contact sheet without racing the six seconds it takes
to play.
- `Director:beat()` — which beat is running, how far into it, the override
weight and the current time scale.
## Tuning
Every number above is a `public` field with a default: the flythrough's length
and lens, the topple dip / hold / ramp / cooldown / lean / punch, the wreck
orbit's length, sweep and two lenses, and the end wide's lens and swing.
# 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.
# ChaseCamera
The camera behavior on the PlayerPrototype's `CameraRig`, and the only thing in
BLOCKBUSTER 767 that moves the camera. The Camera component instantiates it
beside itself and hands it the avatar in `Camera.follow`.
It trails the toy from behind and above, so the plane flies AWAY from the viewer
into the skyline and the tall axis of a 3:4 frame carries the composition: the
aim point sits ahead of and below the nose, which puts the plane in the lower
third with the towers rising above it.
Each frame, in order:
1. solve the chase pose — the station the camera would take if nothing were
staging a moment — and publish it as `cam.chase`;
2. blend that toward `cam.override`, the pose the `Director` asked for;
3. add the decaying impulses (shake, lens punch, distance kick) and a slow
handheld sway, so the frame is never mechanically still;
4. write the rig's transform and the Camera's fov.
## The feel
Speed pushes the camera back and opens the lens; boost adds to both; a plane
that has stopped is closed in on, because the wreck is the subject now. The
offset is smoothed AS AN OFFSET FROM THE PLANE, never as a world position, so
the lag never grows with speed.
The chase smooths on SIMULATED seconds, so a slow-motion beat slows the camera's
own catch-up with the world it is filming. When the simulation clock is stopped
altogether it smooths on real ones instead, so a staged move over a frozen scene
still plays.
## Spans, not metres
Every distance is a multiple of the toy's own longest dimension, measured once
off whatever the plane-art slice built (`framing` 2.3 spans behind, `height`
0.85 above, `lookAhead` 1.8 ahead, `lookDown` 0.28 below). A camera holding
absolute metres would frame a bigger or smaller 767 wrongly the moment that
slice changed it. `ChaseCamera:rig()` reads back the measured span and the
station it produces.
## Every screen shape
Nothing here is a number chosen for a 1080x1440 frame. The lens opens on a
screen taller than the filmed 3:4 and the camera stands further off
(`cam.fovForAspect`, `cam.distanceScale`), and how far the plane rides below the
aim point scales with how tall the screen is — all read from the live viewport
every frame.
Measured at the parked launcher: 16:9 → 58° and the authored distance, with the
aim 0.48 m higher so the plane centres a little; 3:4 → 58° and the authored
distance; 9:16 → 66.5° and 1.5 m further back. The plane and the skyline are
both in frame at every one.
## Where the staging lives
Nothing in this file knows what a topple or a wreck is. It wakes, puts the
`Director` on the scene's `mount_director` (retrying until the component type
has registered), and from then on only reads `game.camera.cam`.
# SoundDirector
The sound of BLOCKBUSTER 767 — the whole of it, in one component on
`mount_sound`.
It plays a music bed and a playroom ambience that loop end to end, rides the
wind-up motor and the air over the airframe off `Game.telemetry`, fires the
one-shot library from the event bus, sets the mixer levels, and gives the Kid
a voice with the words on screen.
## What it needs
- `game/audio/clips/*.soundClip` — baked by `game/audio/bake.luau` from
`game.audio.recipes`. Pure synthesis; costs no credits, cannot fail.
- `game/audio/voice/*.soundClip` — bought ONCE from `audio_gen.speech` by
`game/audio/voicebake.luau`, one clip per line in `game.audio.lines`.
- An `AudioListener` somewhere. The scene puts it on the `CameraRig`, which is
what makes a spatial hit at a block come from where the camera is watching.
A clip that is missing is not an error: the sound is skipped and, for a voice
line, the caption is still shown.
## Playing a sound from another slice
```lua
local sound = entity.find("mount_sound").component.get("SoundDirector")
sound:play("hit_heavy", { position = pos, volume = 0.8, pitch = 1.1 })
sound:say("topple") -- the Kid, from the pool for that cue
```
`play(name, opts)` takes any name in `game.audio.recipes`: `launch`, `whoosh`,
`windup`, `hit_light`, `hit_heavy`, `collapse`, `clatter`, `snap`,
`wreck_sting`, `score_pop`, `ui_tick`, `ui_confirm`, `ui_back`, `star_chime`.
With a `position` the sound is spatial; without one it is flat. Each family
carries a cooldown, so forty blocks landing at once is a collapse rather than
forty overlapping ticks.
Nothing HAS to call either of these: the bus already drives them. `launch`,
`impact`, `partLost`, `blockDisplaced`, `blockOff`, `towerToppled`, `score`,
`wreck`, `sessionEnd`, `pause` and `state` each have a sound, so a slice that
emits its events correctly is heard without doing anything else.
## The captions
Every spoken line is also written down, through `game.audio.caption`:
```lua
local Caption = require("game.audio.caption")
local now = Caption.current() -- { id, text, remaining, duration } or nil
local off = Caption.on(function(c) ... end) -- fires on every new line
```
SoundDirector draws the words itself, in a band above the bottom control band,
proportional to `ui.screenSize()`. **The presentation slice can take that job**
— carry the line inside a composed card instead:
```lua
Caption.claim("bb_screens") -- SoundDirector's band stands down
Caption.release() -- hand it back
```
The band is registered at UI layer 10, below every authored screen, so it never
takes a tap that belongs to a card or a HUD button.
## The mix
Channels: `music`, `ambient`, `sfx`, `engine`, `voice`, `ui`. The public fields
set each one; change one and call `:refreshMix()`. Every clip was baked with
headroom, so nothing in the library reaches the mix above about 0.77 peak on
its own.
`:report()` is the read: the master levels, how many sources are audible, the
engine gain and pitch, the caption, and the mixer state.