Feel probe v2: phase-aware dead air, opening pacing, human anchors
Feel probe v2: phase-aware dead air, opening pacing, human anchors
Section titled “Feel probe v2: phase-aware dead air, opening pacing, human anchors”Added 2026-10-22 to tools/feel_probe/ (run it with scripts/feel-probe.sh; skill bh-feel-probe).
The v1 metrics, their bands and their calibration log are in
docs/research/2026-10-17-objective-feel-metrics.md; this page covers only what v2 adds. As with v1,
the probe pre-screens for Chris’s eye and never replaces it: a number here is a measurement of the
autopilot, not a verdict on how the game feels.
The deliberate choice up front: every v2 number is advisory. No PASS / WARN / FAIL, no new band, no gate step, no band loosened. The reasons are in “Why advisory only” below.
Why v2 exists
Section titled “Why v2 exists”Three things v1 could not tell you:
- Whole-run dead air mixed by-design emptiness with real lulls. A default-cap Normal run spends (median of 8 seeds) 12% of its ticks in WIND_DOWN (the field is being drained), 3% in BOSS_PREP (an empty arena by design) and 23% in the BOSS fight (a few big enemies), none of which can have a healthy kill stream. v1 counted every zero-kill second in all of them, so “dead air 0.45 FAIL” could not say how much of the 0.45 was the game working as designed. The WAVES-only rows are the phase-based answer to the research doc’s next-step “exclude boss-alive seconds from dead air”.
- Normal’s opening defect was invisible. Normal ramps the wave fill target from 15% of
Sim.BIOMASS_TARGET(60 of 400) at t=0 to 100% over 600 s (Difficulty.NORMAL_BIOMASS_START_FRAC,NORMAL_BIOMASS_RAMP_S), while a wave is armed whenever field biomass is underSim.BIOMASS_TRIGGER(100). Until the ramped target passes 100 (about 70.6 s in), a fill leaves the field below the trigger, so the next 2 s “WAVE INCOMING” countdown is armed at once; and when the field is already at the target the fill loop adds nothing (only the sim’s “at least two enemy types” guard adds one forced enemy, of a random unlocked type) yetwave_numberstill advances, which pulls the enemy unlock gate forward. v1 had no column that could see any of that. - No band had a human beside it. The first level-up and level-up gap rows are PASS / WARN / FAIL against practitioner convention, with no real-player value in sight.
What the probe prints now
Section titled “What the probe prints now”The v1 rows are unchanged (same keys, same bands, same order). New rows are marked info, and one
header line (the human-anchor caveat, below) follows the run-length line. A default-cap Normal run
(--seeds=8, 2026-10-22), trimmed:
== feel probe | difficulty=normal profile=median seeds=8 cap=300s | medians across seeds run length median 299s, died 4/8, kills/s median 1.04, threat-near median 19.8 human anchors (as of 2026-10-22): Hard-era humans, many sub-25 s deaths included; 37 of 40 runs with a level-up are Dev-channel builds; see tools/feel_probe/human_anchors.json FAIL dead air (share of s) 0.45 zero-kill seconds after 10 s info dead air, WAVES only 0.33 p25 0.32 p75 0.39, 8/8 seeds; BOSS_PREP, WIND_DOWN, BOSS, REST left out ... WARN first level-up 24.7s Hard-era human p50 17.65s (p25 16.58, p75 19.93, n=40) ... info early level-up gap 14.2s p25 12.4s p75 14.8s, 8/8 seeds; first 66 s, needs 3 level-ups; Hard-era human p50 11.3s (p25 9.7, p75 13.5, n=28) -- opening pacing and phases (advisory: no band, no verdict) -- info WAVE INCOMING banner share 0.56 p25 0.53 p75 0.58, 8/8 seeds; first 120 s info first charger 8.1s p25 8.1s p75 8.1s, seen 8/8 seedsThe anchor numbers in that sample are the 2026-10-22 file’s; they change when the file is refreshed.
Every info row shows the median across seeds, then p25 / p75 and how many seeds measured it
(8/8 seeds). Quantiles use linear interpolation (numpy type 7), the same rule as
percentile() in tools/economy_sim/human_targets.py, so a probe spread and a human anchor are the
same kind of number.
| Printed row | Per-seed row key | What it measures |
|---|---|---|
| dead air, WAVES only | dead_air_waves_share |
Share of WAVES-phase seconds (after the 10 s warmup) with zero kills. BOSS_PREP, WIND_DOWN, BOSS and REST seconds are left out of the denominator, and so is a second that straddles a phase change. |
| dead air longest, WAVES only | dead_air_waves_longest_s |
Longest kill-free streak inside WAVES. A streak ends at any non-WAVES second instead of bridging it, so a boss countdown never lengthens it. |
| early level-up gap | early_gap_s |
Per-run median gap between consecutive level-ups that happened in the first 66 s. Needs at least 3 level-ups in that window (early_gap_levels says how many there were), otherwise the run is unmeasured. |
| WAVE INCOMING banner share | banner_share_120, over banner_window_s |
Mean share of each second that Sim.spawn_banner() returned “WAVE INCOMING”, over the first min(120 s, run length). banner_window_s is that many seconds for the seed. When any seed’s run ended before 120 s the printed row says so and gives the windows (first 120 s, or until the run ended: windows 26-69 s), so a pilot that died at 26 s is not read as a full two minutes. |
| wave counter at 120 s | wave_no_120 |
Sim.wave_number at the end of second 120, or when the run ended if that came first. |
| waves that added nothing | empty_fill_waves, of waves_fired |
Waves whose fill put at most one new enemy on the field (see “Definitions”). |
first <type> |
first_seen_s |
Run second the first enemy of that type was spawned, listed in unlock order. |
| phase share of run (ticks) | phase_share |
Share of the run’s ticks in each of waves, wind_down, boss_prep, boss, rest (sums to 1). |
Compatibility keys, so saved JSON and the A/B tooling keep working: dead_air_share and
dead_air_longest keep their names and keep meaning the whole run. dead_air_all (and
dead_air_all_longest_s per seed) is the same whole-run figure under its explicit name, and
dead_air_waves_counted_s says how many WAVES seconds the WAVES-only share is over. In the summary JSON
the v2 spreads live under advisory, and the anchors that were printed under human_anchors ({}
when the file was missing or malformed), so an A/B JSON carries the reference values it was read
against.
Every per-seed row carries its seed (an integer). The A/B probe (tools/ab/ab_probe.mjs, workstream
dfr-ab-probe) pairs the two arms’ rows by it and refuses a row without one, which kept the feel probe out
of that tool until the key was added. The tool’s verdict is bot survival (restricted-mean survival time,
from each row’s t and dead), so it reads these rows but not the v2 columns: comparing a v2 column
across two arms (the banner share before and after a pacing change) still means running this probe twice
on the same seeds and reading the two outputs side by side, or extending the A/B tool.
Definitions
Section titled “Definitions”- Phase.
Sim.spawn_phase(WAVES, WIND_DOWN, BOSS_PREP, BOSS, REST). The observer keeps the phase of each whole second and a second whose ticks straddle a change isPHASE_MIXED, which belongs to no phase for dead air.phase_shareis tick-exact and does not use the seconds.FeelMetrics.PHASE_NAMESis pinned against the sim enum by a test, so a phase added to the sim fails a test instead of silently skewing a share. - Banner. The text
Sim.spawn_banner()returns each tick. The probe reads the sim’s own answer, not the HUD, so it says what the sim is asking the HUD to show. - Waves fired / empty fill. The tick
wave_numberadvances is the tickSim._spawn_waveran. New enemies that tick are found byentity_idabove the highest id seen so far (ids are monotonic and stable under swap-remove), never by diffing the pool size, because the end-of-tick death sweep can remove as many as the wave added. A wave that added at most one new enemy (EMPTY_FILL_MAX_NEW) “added nothing”. When the fill puts nothing on the field the sim’s at-least-two-types guard (Sim._spawn_wave, then_pick_extra_variety) still forces in one enemy of a random unlocked type, Swarmer included, so a wave that added nothing shows up as one new enemy, not zero; that one enemy is the guard’s, not the fill’s. This is an approximation in both directions: an enemy born the same tick by something else (a due elite, a splitter child) hides an empty fill, and a fill that legitimately added exactly one enemy reads as empty (it takes the guard being skipped, on Home’s first wave, or finding no candidate type: after a one-enemy fill the guard normally adds a second enemy). - First seen. The first tick an enemy of that type is in the pool. Enemies spawn on the
off-screen ring, so this is “first spawned”, not “first on screen”. The spider is not watched: Home’s
roster excludes it (
area_defs.gd), so it would read “never” in every Survival run. - Early gap. The first 66 s and at least 3 level-ups are the same definition the human anchor
uses (
EARLY_GAP_WINDOW_S,EARLY_GAP_MIN_LEVELS). A run with fewer isnull, not 0. - Window constants.
OPENING_S(120), the early-gap window and the minimum level-up count are dev constants, not game tunables: they define what a column means, the key names carry the 120, and a different window would be a different column.
Reading Normal against Hard
Section titled “Reading Normal against Hard”Median-profile autopilot, real content, seeds 5000 and 5001, 7200 ticks (a 120 s cap), measured 2026-10-22. Two seeds show direction, not a claim; the default-cap run below is the representative one. Every figure on this page was measured with the probe’s start as it was that day (bundled content defaults, no shop save, every weapon open from level 1). A change to the probe’s default start moves them, so re-run before quoting one.
| Column | Normal | Hard, plain pilot | Hard, sturdy pilot |
|---|---|---|---|
| Run length | 120 s, alive 2/2 | 26 s and 69 s, died 2/2 | 120 s, alive 2/2 |
| WAVE INCOMING banner share, first 120 s | 0.58 | 0.07 | 0.07 |
| Wave counter at 120 s (or at death) | 36 | 2 | 5 |
| Waves that added nothing, of waves fired | 11 of 34 | 0 of 2 | 0 of 4 |
| First shooter | 5.1 s (seen 2/2) | 34.0 s (seen 1/2) | 31.9 s (seen 2/2) |
| First charger | 8.1 s (seen 2/2) | never (seen 0/2) | 77.5 s (seen 2/2) |
| Dead air, WAVES only | 0.27 | 0.05 | 0.20 |
How to read it:
- Normal opens with churn. The banner is up for more than half of the first two minutes and the wave counter reads 36 at 120 s against 2 to 5 on Hard. About a third of those waves added nothing. The early unlocks follow the counter: Shooter at about 5 s and Charger at about 8 s on Normal, against about 32 s and about 77 s on Hard. This is the mechanism in “Why v2 exists”, measured. Whether it is a problem for a player, and what to do about it, is a design call this page does not make.
- Hard’s plain pilot is censored. It dies at 26 s and 69 s, before the Charger unlocks, so
“never” there means “died first”, not “does not exist”. The sturdy-pilot column re-runs the same
seeds with
SoakRuncfgpilot_hp = 100000(a pilot that survives the window) and shows the real unlock times.feel_probe.gdhas no flag forpilot_hpyet, so that column came from a throwaway driver that calls the productionFeelObserverandFeelMetrics; the numbers are the probe’s. - A watched type nobody saw is printed as
neverwithseen 0/N, and a type seen by only some seeds prints the median of those seeds withseen k/N. A type that was not seen is absent from the row’sfirst_seen_s, never0.0, which would read as “on the field at once”.
The default-cap Normal run
Section titled “The default-cap Normal run”scripts/feel-probe.sh --seeds=8 --seed0=5000 --ticks=18000 --difficulty=normal (300 s cap, 4 of 8
seeds died, median run 299 s):
| Column | Whole run (v1 key) | WAVES only (v2) |
|---|---|---|
| Dead air share | 0.45 (FAIL: v1 fails above 0.25) | 0.33 (p25 0.32, p75 0.39) |
| Dead air longest streak | 22 s (FAIL: v1 fails above 12 s) | 7 s (p25 6 s, p75 10 s) |
The phase share of ticks is waves 0.60, wind_down 0.12, boss_prep 0.03, boss 0.23, rest 0.00, so about 40% of the run is outside WAVES. Excluding it explains the long streak (22 s becomes 7 s) and part of the share (0.45 becomes 0.33). It does not clear the share: 0.33 is still above the 0.25 line v1 fails whole-run dead air at. Whether that line should apply to WAVES-only time is Chris’s call (below).
The same run’s opening columns: banner share 0.56, wave counter 34 at 120 s, 14 of 38 waves added nothing, first shooter 4.1 s, first charger 8.1 s, first centurion 53.7 s, first boss 226.7 s.
Human anchors
Section titled “Human anchors”tools/feel_probe/human_anchors.json holds the real-player values the probe prints beside the rows it
can compare to. The numbers live in that file only: this page does not copy them, so a refresh cannot
leave it stale. Each anchor there has p25 / p50 / p75 / min, the run count n and its own definition:
| Anchor | Printed beside | The per-run value it is the spread of |
|---|---|---|
first_level_s |
first level-up | Run second of the first level-up. Runs that ended before one are not in the sample. |
early_gap_s |
early level-up gap | Median gap between consecutive level-ups at or before 66 s; runs with at least 3 level-ups in that window. |
median_gap_s |
median level-up gap | Median gap between consecutive level-ups over the whole run; runs with at least 2 level-ups. |
An anchor never prints bare. A “human p50” beside a Normal row reads as a Normal target, and it is not one, so the probe labels the numbers in two places:
- One header line, printed once after the run-length line whenever anchors are loaded. It carries the
file’s
caveatverbatim, theas_ofdate and the Dev-channel share from the file’spopulationblock (see the sample above). - Every anchor row starts with the file’s
label(Hard-era human p50 ...), so a single grepped row still says whose number it is.
FeelMetrics.anchor_problems requires the label, the caveat, the source and the
quantile_method, so a refresh cannot drop the era without the probe saying so.
- Source. Read-only SELECTs on the telemetry
runstable, 2026-10-22: human Survival runs (notis_auto, no persona or console-autopilot marker) from builds before the Normal selector shipped, that recorded level-up times. The file’ssourcefield holds the exact filter andquantile_methodthe rule; the file contains aggregate statistics only. - Caveat, verbatim from the file: “Hard-era humans, many sub-25 s deaths included”. Builds before the
Normal selector were played on Hard, and a large share of the runs ended under 25 s (the file’s
populationblock counts them). These are not Normal players and not a target to hit: an anchor shows how far the autopilot’s number sits from what real players produced, nothing more. - Survivorship.
first_level_sonly contains runs that reached a level-up. Thepopulationblock counts the runs that did not, so the figure is conditional on getting that far and says nothing about how long those players would have taken. - Same timestamp, to a frame or two. A human level-up time is the second the level-up panel opened; the probe’s is the second the level was earned. The sim clock stops while a panel is open, so they agree to within a frame or two.
- Channel mix. Most of the runs come from Dev-channel builds (the two developers’ own devices), so
they are practised players rather than new ones; the header line prints the exact share. The
runs.difficultycolumn is NULL on every row today, so the difficulty cannot be read off the data and “Hard-era” is by build number. Once runs carry a difficulty, regenerate the anchors per difficulty. - Not like for like on one point. v1’s per-seed
median_gapis 0.0 for a seed with exactly one level-up (FeelMetrics.reward_cadencehas no gap to take), while themedian_gap_sanchor only includes human runs with at least 2 level-ups. A short run can therefore pull the median-gap row down in a way the anchor never sees; the early-gap row, which needs 3 level-ups on both sides, is the cleaner comparison. - How to use the first-level anchor. On Hard the median-profile autopilot’s first level-up (18.7 s on two seeds, 2026-10-22) sits close to the anchor’s p50. On Normal it reads 24.0 s to 24.7 s, later than the Hard-era humans; the research doc (section 6, item 1) traces Normal’s slower first level-up, against Hard’s, to Normal’s biomass ramp. Treat it as “Normal is slower than what these players saw”, not as an error.
The probe validates the file on load (FeelMetrics.anchor_problems). A malformed file prints
[feel] human anchors skipped (...) on stderr and the probe runs without them (no header, no anchor
rows). To refresh the anchors, re-run the filter in source, rewrite the numbers, as_of, label,
caveat and population together, and keep the era named in the label and the caveat.
Why advisory only
Section titled “Why advisory only”This is a deliberate choice, not an unfinished one.
- There is no human number behind the WAVES-only columns. Telemetry holds level-up times, not kills per second, banner state or enemy first-sighting times. A PASS / FAIL line with nothing real behind it is a guess presented as a verdict, which is the exact failure the research doc warns about.
- A band is a different claim on a different quantity. v1’s dead-air band (0.10 / 0.25) was chosen against the whole-run figure. Re-using it on WAVES-only time, or loosening it because WAVES-only reads lower, is a decision about what dead air should be. That decision is Chris’s, and the research doc’s open question is still unanswered: “Ask Chris for a verdict on Normal’s opening minute. That one answer calibrates the dead-air and first-level bands.”
- A gate step would make an unreviewed definition blocking. None of this is wired into
scripts/gate.sh. - v1 rules still hold.
bh-feel-probeasks a new metric for math with a GUT test (done:tests/test_feel_metrics_v2.gd), a band with its source (deliberately none), and the past Chris verdict it would have predicted (none yet, stated plainly). No v1 band was moved.
To promote a column to a band: get the verdict, add the band const and _summary row in
feel_probe.gd, add its source and a row to the research doc’s calibration log.
What only a device or a person can verify
Section titled “What only a device or a person can verify”- That the HUD really shows the banner for the share of the opening the sim reports. The probe
reads
Sim.spawn_banner(), not pixels. - How the Normal opening feels. No human verdict on Normal’s first minute exists yet, and the anchors are Hard-era.
- Whether the autopilot’s early pacing predicts a human’s on Normal. There are no Normal-era human
runs in the anchors (the
difficultycolumn is empty). - Everything render, input and frame-pacing related, as with v1.
Where it lives
Section titled “Where it lives”tools/feel_probe/feel_observer.gdrecords the per-tick state (read-only: no RNG, no sim writes;tests/test_feel_probe.gdpins a run bit-identical with it attached).tools/feel_probe/feel_metrics.gdholds the math and the report text, pure and unit-testable:spread,v2_columns,v2_block_lines,anchor_text,anchor_header_line,anchor_problems.tools/feel_probe/feel_probe.gdruns the seeds, prints the report and writes the JSON. Its rows are data, not code:BAND_SPECSlists every band row (key, label, direction, band, format, note) and the anchors and advisory rows hang off those keys. The text is built by the static, purehead_linesandband_report(and the per-seed_row), so a test drives them without starting a probe._summaryonly loads and validates the anchors and prints what those two return.tools/feel_probe/human_anchors.jsonholds the anchors.tests/test_feel_metrics_v2.gdcovers the math with synthetic tick streams, the observer against the real sim, the report text, the anchors file’s shape and that wiring: every anchor and advisory row is attached to a real band row, every band key is a key of the probe’s per-seed row (a rename fails a test instead of dropping a row silently), and the row carries theseedthe A/B tool pairs on.