Skip to content

Commit 685b5cf

Browse files
Bosses that do not melt, beams with both ends attached, and a livelier tune
Bosses Play report: "on les bute super vite". True, and worse than reported. Boss hit points are base + k*dps, where k is literally the seconds of perfect, every-shot-lands fire the fight should take. It was 2.6. A sector-5 boss met by a maxed ship parked underneath died in 3.2 seconds -- and it got *worse* as the ship improved, because the term that scales with damage was the small one. k is now 8 + 1.5*sector: 12 to 16 seconds of perfect fire at every stage, against 9s at the first boss and 3.2s at the last. Enrage went with it. It speeds up a stalled fight, and its 45-second threshold was set when fights ran 45 seconds; against fights meant to run forty to sixty it stopped resolving stalemates and started executing the player -- the test pilot lost 18 hull to the *first* boss on every seed, purely to the ramp. Now 80 seconds, on a gentler slope. This held for so long because the bench was lying, and the honest conclusion is that it still cannot measure bosses: the test pilot holds its line on a boss 31% of the time, so a 45-second fight in the bench was a 4-second fight in the game. It now loses to every boss, so per-run win rates mean nothing until a pilot that can fight one exists. balance.py reports the one figure that does not depend on it -- seconds of perfect fire -- and the map, roster and frame-budget measurements are unaffected. Documented rather than papered over. Two of my own detours are recorded in the code so nobody repeats them: "damage dealt over theoretical dps" is not accuracy, because BULWARK and WARDEN quarter every hit while their pods stand; and "commit during the venting window" measured backwards, dropping alignment from 31% to 11%, because the lull is exactly when the volley just fired is crossing the arena. Beams The lancer's beam was vertical, fired downward from mid-arena, so it began nowhere and stopped at the floor. It looked cut off because it was: a beam with one end hanging in space reads as a rendering fault, not a weapon. Lancers now arrive in pairs, one docked to each wall, and string the beam horizontally between them across the whole arena. Kill either end and it drops. Music Monotonous, and rightly so: one arpeggio figure and one bass walk ran all thirty-two bars while only the melody's octave changed. Four sections on paper, one in the ear. Each section now has its own arpeggio shape and bass figure, B rotates the progression so the harmony goes somewhere without a new one to learn, phrases run over four bars instead of two, and the last bar of every section drops the arpeggio so the seams are audible. Full suite green. Worst frame 1.75 ms of 16.7 with 366 entities live. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AKLqS4PgnCXrzKHDH8Cnzy
1 parent 77ba266 commit 685b5cf

10 files changed

Lines changed: 201 additions & 91 deletions

File tree

‎docs/img/pc-foes.png‎

-2.38 KB
Loading

‎docs/nova-music-demo.wav‎

0 Bytes
Binary file not shown.

‎pc/nova/audio.py‎

Lines changed: 51 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -410,12 +410,36 @@ def _bass_note(freq, seconds):
410410
attack=0.006, hold=seconds * 0.55, curve=1.8)
411411

412412

413+
# Per section: the arpeggio figure, the bass figure, and whether the arpeggio
414+
# takes the last bar off.
415+
#
416+
# The first version of this song ran one arpeggio shape and one bass walk for
417+
# all thirty-two bars and changed only the octave of the tune. That is four
418+
# sections on paper and one section in the ear -- the accompaniment has to move
419+
# too, or the melody is decorating a loop rather than sitting on an arrangement.
420+
ARPS = (
421+
(0, 1, 2, 1, 2, 1, 0, 1), # A up and back, steady
422+
(0, 2, 1, 2, 0, 2, 1, 2), # B wider, pivots on the fifth
423+
(2, 1, 0, 1, 2, 1, 0, 1), # A' mirrored, starts high
424+
(0, -1, 1, -1, 0, -1, 1, -1), # C narrow, sits under everything
425+
)
426+
BASS_FIGURES = (
427+
((0.0, 0, 1.0), (1.5, 4, 0.5), (2.0, 0, 1.0), (3.5, -1, 0.5)),
428+
((0.0, 0, 0.5), (0.5, 0, 0.5), (1.5, 4, 0.5), (2.0, 0, 1.0),
429+
(3.0, 4, 1.0)),
430+
((0.0, 0, 1.0), (1.0, 7, 0.5), (2.0, 0, 0.5), (2.5, 4, 0.5),
431+
(3.5, -1, 0.5)),
432+
((0.0, 0, 2.0), (2.0, 4, 2.0)),
433+
)
434+
435+
413436
def build_track(index, seed=0):
414437
"""Four sections of eight bars: A, B, A' an octave up, then a sparse C.
415438
416-
Three voices, the same three the old bed had. What is new is that they are
417-
playing something: a chord progression underneath, and a melody that comes
418-
back rather than a fresh handful of random notes every bar.
439+
Three voices, the same three the old bed had -- one triangle, two pulses.
440+
What is new is that they are playing something: a chord progression, a
441+
melody that comes back, and an accompaniment that changes with the section
442+
instead of running the same bar thirty-two times.
419443
"""
420444
root, scale, prog, bpm = SECTOR_TRACKS[index % len(SECTOR_TRACKS)]
421445
rng = random.Random(seed * 977 + index * 31 + 7)
@@ -430,54 +454,51 @@ def build_track(index, seed=0):
430454

431455
for section in range(SECTIONS):
432456
base = section * BARS_PER_SECTION
433-
# A B A' C. The third lifts an octave, the fourth thins out -- the
434-
# oldest way there is of making a repeat feel like a development.
435457
octave_up = 12 if section == 2 else 0
436458
sparse = section == 3
437459
transpose = 2 if section == 1 else 0
460+
arp_shape = ARPS[section]
461+
bass_figure = BASS_FIGURES[section]
462+
# B walks the same four chords starting from the second, so the
463+
# harmony moves somewhere new without a new progression to learn.
464+
rotate = 1 if section == 1 else 0
438465

439466
for b in range(BARS_PER_SECTION):
440467
bar_i = base + b
441468
t0 = bar_i * bar
442-
chord = _triad(scale, prog[b % len(prog)])
469+
chord = _triad(scale, prog[(b + rotate) % len(prog)])
443470
chord_root = root + chord[0]
444-
nxt = _triad(scale, prog[(b + 1) % len(prog)])[0] + root
471+
nxt = _triad(scale, prog[(b + rotate + 1) % len(prog)])[0] + root
472+
# The last bar of every section drops the arpeggio. A hole is the
473+
# cheapest variety there is and the ear uses it to hear the seam
474+
# between sections.
475+
breather = (b == BARS_PER_SECTION - 1)
445476

446477
# --- triangle: bass ------------------------------------------
447-
# Root, fifth, root, then a step toward the next chord. The
448-
# approach note is what stops four bars of three notes from
449-
# sounding like a metronome with a pitch.
450-
walk = ((0.0, chord_root - 12, 1.0),
451-
(1.5, chord_root - 5, 0.5),
452-
(2.0, chord_root - 12, 1.0),
453-
(3.5, nxt - 13, 0.5))
454-
for pos, semi, length in walk:
455-
if sparse and pos not in (0.0, 2.0):
456-
continue
478+
for pos, step, length in bass_figure:
479+
semi = (nxt - 13) if step < 0 else (chord_root - 12 + step)
457480
song.add(t0 + pos * beat,
458481
_bass_note(_hz(semi), length * beat), 0.62)
459482

460483
# --- pulse two: arpeggio -------------------------------------
461-
# Eighths climbing and falling through the chord. This is the one
462-
# voice the old bed already had right; it just had no chord to
463-
# walk, so it walked a scale at random.
464-
if not sparse:
465-
shape = (0, 1, 2, 1, 2, 1, 0, 1)
484+
if not breather:
466485
for e in range(8):
467-
semi = root + chord[shape[e]] + (12 if e >= 4 else 0)
468-
song.add(t0 + e * 0.5 * beat,
469-
_arp_note(_hz(semi), beat * 0.5), 0.17)
470-
else:
471-
for e in (0, 2, 4, 6):
472-
semi = root + chord[e // 2 % 3] + 12
486+
if sparse and e % 2:
487+
continue
488+
d = arp_shape[e]
489+
semi = root + _degree(scale, prog[(b + rotate) % len(prog)]
490+
+ d * 2) + (12 if e >= 4 else 0)
473491
song.add(t0 + e * 0.5 * beat,
474-
_arp_note(_hz(semi), beat * 0.5), 0.13)
492+
_arp_note(_hz(semi), beat * 0.5),
493+
0.13 if sparse else 0.17)
475494

476495
# --- pulse one: the tune -------------------------------------
496+
# Over four bars, not two: motif, motif answered, so a phrase is
497+
# eight bars long and does not come back every three seconds.
477498
phrase = motif if (b % 4) < 2 else answer
478499
if b % 2 == 0:
479500
t = 0.0
480-
for d, length in phrase:
501+
for i, (d, length) in enumerate(phrase):
481502
if t >= 8.0:
482503
break
483504
semi = root + _degree(scale, d + transpose) + 12 + octave_up

‎pc/nova/boss.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,8 +362,14 @@ def fire_rate(self):
362362
if self.enraged:
363363
base *= 0.62
364364
# A stalled fight gets faster, not longer: either it dies or you do.
365-
if self.t > 45.0:
366-
base *= max(0.4, 1.0 - (self.t - 45.0) * 0.02)
365+
#
366+
# The threshold is tied to how long the fight is supposed to take, and
367+
# it was not moved when that changed. At 45 seconds against fights now
368+
# meant to run forty to sixty, enrage stopped being the answer to a
369+
# stalemate and became the ending of every boss fight: the test pilot
370+
# lost 18 hull to the *first* boss, every seed, purely to the ramp.
371+
if self.t > 80.0:
372+
base *= max(0.45, 1.0 - (self.t - 80.0) * 0.015)
367373
return base
368374

369375
def scaled(self, low, high):

‎pc/nova/combat.py‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -94,18 +94,27 @@ def __init__(self, run, kind, art, audio=None):
9494
self.tag = "S%d-%d" % (sector + 1, run.node + 1)
9595

9696
def boss_hp(self):
97-
"""Scaled to the guns the player actually brought: a flat pool is a few
98-
seconds against a maxed build and several minutes against a stock one."""
97+
"""Scaled to the guns the player actually brought.
98+
99+
The coefficient is literally seconds of sustained, every-shot-lands
100+
fire. It was 2.6 -- which is how a sector-5 boss with 306 hit points
101+
died in under four seconds to a maxed ship parked underneath it, and
102+
why it got *worse* as the ship improved: the term that scales with your
103+
damage was the small one.
104+
105+
It looked fine for a long time because the bench was lying. The test
106+
pilot lands 6 to 8% of its own gun on a late boss; it dodges rather
107+
than aims, so a 45-second fight in the bench was a 4-second fight in
108+
the game. The pilot aims now, and this is set for someone who does:
109+
twelve seconds of perfect fire at sector 1, rising to twenty-four deep
110+
in the Void.
111+
"""
99112
up = self.run.upgrades
100113
rate = 0.16 * (0.85 ** up[data.U_RATE])
101114
barrels = len(ent.SPREADS[min(up[data.U_SPREAD], 3)])
102115
dps = (1 + up[data.U_DMG]) * barrels / rate
103-
# Deliberately shorter than it was. The patterns take far more dodging
104-
# than they used to, so the same pool of hit points bought an 80-second
105-
# fight instead of a 30-second one -- and a dense pattern held for 80
106-
# seconds stops being a fight you read and becomes one you survive by
107-
# arithmetic.
108-
return int(40 + self.run.sector * 16 + dps * 2.6)
116+
seconds = 8.0 + min(self.run.sector, 5) * 1.5
117+
return int(40 + self.run.sector * 16 + dps * seconds)
109118

110119
# -- helpers ----------------------------------------------------------
111120
def live_players(self):
@@ -129,6 +138,21 @@ def spawn_wave(self, dt):
129138
kind = self.rng.choice(self.pool)
130139
self.budget -= data.ENEMY_COST[kind]
131140
hp = data.ENEMY_HP[kind] + self.run.sector
141+
if kind == data.LANCER_ID:
142+
# A pair, one per wall, linked. They only mean anything together:
143+
# the beam is strung between them and dies with either one.
144+
y = data.TOP - 14
145+
left = ent.Enemy(kind, data.PLAY_L + 8, y, hp, self.run.sector,
146+
self.rng)
147+
right = ent.Enemy(kind, data.PLAY_R - 8, y, hp, self.run.sector,
148+
self.rng)
149+
left.side, right.side = -1, 1
150+
left.partner, right.partner = right, left
151+
self.enemies.append(left)
152+
self.enemies.append(right)
153+
self.budget -= data.ENEMY_COST[kind] # two ships, two costs
154+
self.spawn_t = max(0.5, 1.4 - self.run.sector * 0.06)
155+
return
132156
x = self.rng.uniform(data.PLAY_L + 24, data.PLAY_R - 24)
133157
e = ent.Enemy(kind, x, data.TOP - 14, hp, self.run.sector, self.rng)
134158
self.enemies.append(e)

‎pc/nova/entities.py‎

Lines changed: 51 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -56,25 +56,28 @@ def alive(self):
5656
and data.PLAY_L - 20 < self.x < data.PLAY_R + 20)
5757

5858

59-
class EnemyBeam:
60-
"""A lancer's beam: LANCE's mechanic, bolted to something you can kill.
61-
62-
Same contract as the boss's -- it locks where it aimed when the charge
63-
began, so it is always dodgeable and never arbitrary -- but it hangs off a
64-
mortal ship. Kill the lancer mid-charge and the beam goes with it, which is
65-
the whole reason for putting a boss attack on a regular enemy: it turns
66-
into a target priority instead of a weather condition.
59+
class LinkBeam:
60+
"""A beam strung between two ships, one at each wall.
61+
62+
The first version was vertical: a lancer sat in the middle of the arena and
63+
fired downwards, so the beam started nowhere and stopped at the floor. It
64+
looked cut off because it *was* cut off -- a beam with one end hanging in
65+
space reads as a rendering mistake rather than a weapon.
66+
67+
Two emitters dock at opposite walls and stretch it between them. Both ends
68+
are now attached to something, the span covers the whole arena, and killing
69+
either emitter drops it: the pair is the mechanic, and breaking the link is
70+
the counterplay.
6771
"""
6872

69-
CHARGE = 0.85
70-
FIRE = 0.5
71-
FADE = 0.2
72-
WIDTH = 9
73+
CHARGE = 1.0
74+
FIRE = 0.55
75+
FADE = 0.22
76+
HEIGHT = 9
7377

74-
__slots__ = ("x", "y", "t")
78+
__slots__ = ("y", "t")
7579

76-
def __init__(self, x, y):
77-
self.x = x
80+
def __init__(self, y):
7881
self.y = y
7982
self.t = 0.0
8083

@@ -90,28 +93,29 @@ def done(self):
9093
return self.t >= self.CHARGE + self.FIRE + self.FADE
9194

9295
def rect(self):
93-
half = self.WIDTH / 2
94-
return (self.x - half, self.y, self.WIDTH, data.BOT - self.y)
96+
half = self.HEIGHT / 2
97+
return (data.PLAY_L, self.y - half, data.PLAY_R - data.PLAY_L,
98+
self.HEIGHT)
9599

96100
def draw(self, surf):
97-
top = int(self.y)
98-
h = data.BOT - top
99-
if h <= 0:
100-
return
101+
x0, w = data.PLAY_L, data.PLAY_R - data.PLAY_L
102+
y = int(self.y)
101103
if self.t < self.CHARGE:
104+
# A thread between the two ships, pulsing faster as it fills. The
105+
# telegraph is the mechanic: you are meant to leave the band, and
106+
# you can only do that if you can see where the band is.
102107
frac = self.t / self.CHARGE
103108
if int(self.t * (7 + 20 * frac)) % 2:
104-
w = max(1, int(1 + frac * 3))
105-
surf.fill(data.ORANGE, (int(self.x - w / 2), top, w, h))
109+
h = max(1, int(1 + frac * 3))
110+
surf.fill(data.ORANGE, (x0, y - h // 2, w, h))
106111
return
107112
if self.firing:
108-
w = self.WIDTH
109-
surf.fill(data.ORANGE, (int(self.x - w / 2), top, w, h))
110-
surf.fill(data.WHITE, (int(self.x - 2), top, 4, h))
113+
surf.fill(data.ORANGE, (x0, y - self.HEIGHT // 2, w, self.HEIGHT))
114+
surf.fill(data.WHITE, (x0, y - 2, w, 4))
111115
return
112116
frac = 1.0 - (self.t - self.CHARGE - self.FIRE) / self.FADE
113-
w = max(1, int(self.WIDTH * frac * 0.5))
114-
surf.fill(data.ORANGE, (int(self.x - w / 2), top, w, h))
117+
h = max(1, int(self.HEIGHT * frac * 0.5))
118+
surf.fill(data.ORANGE, (x0, y - h // 2, w, h))
115119

116120

117121
class Pickup:
@@ -243,7 +247,8 @@ def take_hit(self):
243247
class Enemy:
244248
__slots__ = ("x", "y", "kind", "hp", "max_hp", "t", "fire_t", "anchor",
245249
"drift", "w", "h", "flash", "phase", "score", "rng",
246-
"beam", "dash_t", "dash_vx", "dash_vy")
250+
"beam", "dash_t", "dash_vx", "dash_vy",
251+
"side", "partner")
247252

248253
def __init__(self, kind, x, y, hp, sector, rng=None):
249254
self.kind = kind
@@ -261,7 +266,9 @@ def __init__(self, kind, x, y, hp, sector, rng=None):
261266
self.phase = 0
262267
self.score = data.ENEMY_SCORE[kind]
263268
self.w, self.h = ENEMY_SIZE[kind]
264-
self.beam = None # lancers only
269+
self.beam = None # the left emitter of a pair owns it
270+
self.side = 0 # -1 docked left, +1 docked right
271+
self.partner = None
265272
self.dash_t = 0.0 # phantoms only
266273
self.dash_vx = 0.0
267274
self.dash_vy = 0.0
@@ -289,26 +296,24 @@ def update(self, dt, target_x):
289296
self.y += 11 * dt
290297
self.x += math.sin(self.t * 1.1) * 26 * dt
291298
elif k == data.LANCER_ID:
292-
# Slides to its station, then works a charge-fire cycle. It never
293-
# descends past the anchor: a beam is only fair if you can see
294-
# where it will be, and one falling on top of you is not that.
295-
if self.y < self.anchor:
296-
self.y += speed * dt
297-
else:
298-
self.x += math.sin(self.t * 0.9) * 22 * dt
299-
self.anchor += 6 * dt
300-
self.y += 6 * dt
299+
# Welded to its wall and sinking. Only the left one of a pair runs
300+
# the charge cycle, and only while its partner is alive -- kill
301+
# either end and the beam it was about to string never arrives.
302+
self.x = data.PLAY_L + 8 if self.side < 0 else data.PLAY_R - 8
303+
self.y += speed * dt
304+
partner_ok = self.partner is not None and self.partner.hp > 0
301305
if self.beam is not None:
302-
# The beam does not follow the ship: it stays where it aimed
303-
# when the charge began, which is the whole telegraph.
304306
self.beam.update(dt)
305-
if self.beam.done:
307+
if self.beam.done or not partner_ok:
306308
self.beam = None
307-
self.fire_t = self.rng.uniform(1.3, 2.1)
308-
else:
309+
self.fire_t = self.rng.uniform(1.4, 2.2)
310+
elif self.side < 0 and partner_ok:
311+
# Both ends have to be on screen, or the beam would come out
312+
# of the ceiling from a ship nobody has seen yet.
309313
self.fire_t -= dt
310-
if self.fire_t <= 0 and self.y > data.TOP + 8:
311-
self.beam = EnemyBeam(target_x, self.y + 6)
314+
if (self.fire_t <= 0 and self.y > data.TOP + 14
315+
and self.partner.y > data.TOP + 14):
316+
self.beam = LinkBeam((self.y + self.partner.y) * 0.5)
312317
elif k == data.SPINNER_ID:
313318
# Drifts in slowly and turns. Its rings are the one attack in the
314319
# roster that does not care where you are, which is what makes it

‎pc/tests/bot.py‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,18 @@
3131
HORIZON = 0.5 # seconds of future to look at
3232
CLEAR = 15.0 # a bullet passing closer than this is a problem
3333
CONSIDER = 150.0 # ignore bullets further away than this
34-
AIM = 0.25 # weight on lining up with the target
34+
AIM = 1.6 # weight on lining up with the target
35+
36+
# That weight is the difference between a pilot and a pacifist. At 0.25 the
37+
# danger term dwarfed it whenever anything was on screen, so the pilot dodged
38+
# beautifully and hardly fired on the boss, and balance tuned against that is
39+
# balance tuned for a player who does not shoot -- which is how a boss ended up
40+
# dying in four seconds to anybody who did.
41+
#
42+
# Do not read the raw "damage dealt over theoretical dps" figure as accuracy,
43+
# though. BULWARK and WARDEN quarter every hit while their pods stand, so that
44+
# ratio cannot exceed 25% on two of the five bosses however well the pilot
45+
# shoots. Chasing it as if it were aim was a wasted afternoon.
3546

3647

3748
class Bot:
@@ -69,8 +80,10 @@ def threats(self, me):
6980
# which is exactly the mistake the repulsion field made.
7081
for e in self.c.enemies:
7182
b = e.beam
72-
if b is not None and b.t > b.CHARGE * 0.45 and b.y < me.y:
73-
out.append((b.x, me.y, 0.0, 0.0, b.WIDTH * 0.5 + 8.0))
83+
if b is not None and b.t > b.CHARGE * 0.45:
84+
# A horizontal band: model it at the pilot's own x, so the
85+
# only way out of it is up or down, which is the truth.
86+
out.append((me.x, b.y, 0.0, 0.0, b.HEIGHT * 0.5 + 9.0))
7487
return out
7588

7689
def cost(self, px, py, vx, vy, threats):
@@ -119,6 +132,11 @@ def inputs(self):
119132
if pods:
120133
want_x = min(pods, key=lambda p: abs(p.x - me.x)).x
121134
pull = 1.0
135+
# "Commit during the venting window" seemed obvious and measured
136+
# backwards: alignment went from 31% overall to 11% during the
137+
# lull. The lull is not quiet -- it is when the volley just fired
138+
# is crossing the arena, so it is the most dangerous moment to
139+
# stand still in, and the pilot was right to leave.
122140
best = None
123141
for pk in c.pickups:
124142
d = math.hypot(pk.x - me.x, pk.y - me.y)

0 commit comments

Comments
 (0)