Skip to content

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.

Three things v1 could not tell you:

  1. 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”.
  2. 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 under Sim.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) yet wave_number still advances, which pulls the enemy unlock gate forward. v1 had no column that could see any of that.
  3. 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.

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 seeds

The 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.

  • 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 is PHASE_MIXED, which belongs to no phase for dead air. phase_share is tick-exact and does not use the seconds. FeelMetrics.PHASE_NAMES is 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_number advances is the tick Sim._spawn_wave ran. New enemies that tick are found by entity_id above 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 is null, 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.

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 SoakRun cfg pilot_hp = 100000 (a pilot that survives the window) and shows the real unlock times. feel_probe.gd has no flag for pilot_hp yet, so that column came from a throwaway driver that calls the production FeelObserver and FeelMetrics; the numbers are the probe’s.
  • A watched type nobody saw is printed as never with seen 0/N, and a type seen by only some seeds prints the median of those seeds with seen k/N. A type that was not seen is absent from the row’s first_seen_s, never 0.0, which would read as “on the field at once”.

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.

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 caveat verbatim, the as_of date and the Dev-channel share from the file’s population block (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 runs table, 2026-10-22: human Survival runs (not is_auto, no persona or console-autopilot marker) from builds before the Normal selector shipped, that recorded level-up times. The file’s source field holds the exact filter and quantile_method the 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 population block 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_s only contains runs that reached a level-up. The population block 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.difficulty column 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_gap is 0.0 for a seed with exactly one level-up (FeelMetrics.reward_cadence has no gap to take), while the median_gap_s anchor 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.

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-probe asks 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.

  • 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 difficulty column is empty).
  • Everything render, input and frame-pacing related, as with v1.
  • tools/feel_probe/feel_observer.gd records the per-tick state (read-only: no RNG, no sim writes; tests/test_feel_probe.gd pins a run bit-identical with it attached).
  • tools/feel_probe/feel_metrics.gd holds 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.gd runs the seeds, prints the report and writes the JSON. Its rows are data, not code: BAND_SPECS lists 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, pure head_lines and band_report (and the per-seed _row), so a test drives them without starting a probe. _summary only loads and validates the anchors and prints what those two return.
  • tools/feel_probe/human_anchors.json holds the anchors.
  • tests/test_feel_metrics_v2.gd covers 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 the seed the A/B tool pairs on.