Skip to content

feat: catherine wheel accessory (the hedgehog is now a firework) - #44

Open
ShannonHolgate wants to merge 12 commits into
PostHog:mainfrom
ShannonHolgate:feat/catherine-wheel
Open

feat: catherine wheel accessory (the hedgehog is now a firework)#44
ShannonHolgate wants to merge 12 commits into
PostHog:mainfrom
ShannonHolgate:feat/catherine-wheel

Conversation

@ShannonHolgate

Copy link
Copy Markdown

the hedgehog can now be a firework.

what this adds

a catherine-wheel accessory. wear it, press f, and he spins through two full
rotations over three seconds while throwing sparks radially, then stops upright,
slightly dizzy, and ready to go again. reusable, not consumed.

it's the first accessory that does anything. the other sixteen remain purely
decorative, which is fine, they have equity.

the actual change: accessories can grant abilities

skins have had createAbility since spiderhog learned to sling. accessories
never did, so all behaviour lived on the skin and a hat was a hat.

HedgehogActorAccessoryInfo now takes an optional createAbility, and a new
HedgehogAccessoryAbilities collaborator on the actor owns whatever the worn
accessories grant. it sits alongside ai, controls and interface and follows
the same shape.

the interesting part is that sync() diffs rather than rebuilds.
updateOptions() runs on every option change, including colour, ai toggle and
drag, so a naive rebuild would snuff a lit firework every time someone recoloured
the hog. it also runs on the death path, which bypasses updateOptions()
entirely, so a hog that dies mid-burn tears the ability down with the accessory
instead of spinning through his own death animation. that one has a comment on it
because it looks redundant next to the updateOptions() call nine lines above,
and it isn't.

fire() is wired into the existing maybeSpawnFireball(), so f already meant
"use whatever you've got". it just used to only ask the skin.

how the spin works

Actor.forceAngle is what keeps hedgehogs upright. the ability tweens it, and
Actor.update() copies it onto rigidBody.angle, so the physics body genuinely
rotates too. collision response gets a bit loose for three seconds. it reads as
part of the effect, but flagging it plainly: if you'd rather he stayed physically
upright, the alternative is rotating only the accessory sprite and i'm happy to
switch it.

sparks are existing FlameActor fireballs thrown along whatever angle the wheel
has reached, so no new projectile type. they keep FlameActor's mask, so they
collide with platforms and ground and cannot touch actors.

one clock, not two

worth calling out because the first version got this wrong.

the obvious implementation is a gsap tween for the spin and a setInterval for
the sparks. that's two clocks, and this engine detaches gsap from its own ticker
and drives updateRoot from matter's afterUpdate. so a setInterval version:

  • outlives HedgeHogMode.destroy(), which doesn't call beforeUnload() on
    elements. runner stops, tween freezes, onComplete never fires, interval never
    clears, and you get 20hz of leaked matter bodies and uncaught TypeErrors into
    the host page until reload. same family as fix: stop flames crashing on the way out (teardown removeChild guard) #33.
  • ignores setSpeed, so the slow cheat stretches the spin to about twelve
    seconds while the sparks keep firing on wall time, giving roughly 240 instead
    of 60.

so there is no timer. spark count is derived from spin progress inside the
tween's onUpdate, which means killTweensOf is a complete stop and the whole
firework scales with setSpeed exactly as the spin does. there are no
setInterval, setTimeout or requestAnimationFrame calls in the ability.

on the spritesheet

texturepacker 7.4 is a paid gui app, so the frame was packed by hand: the atlas
grew 1440 to 1520 and sprites.json gained one entry, alphabetically between
cap and chef where a repack would put it. the source frame is committed at
texturepacker/assets/accessories/catherine-wheel.png, and the .tps globs the
assets directory rather than listing files, so your next real repack picks it up
and absorbs this cleanly.

things worth knowing:

worry answer
did the re-encode disturb existing sprites? no. the top 1440 rows are byte-identical to before
is the new region actually the committed source frame? byte-identical, checked rather than assumed
does it fit the packer's own settings? yes. shapePadding 0, borderPadding 0, extrude 0, max 2048x2048, all matched
meta.smartupdate is stale deliberate. it's texturepacker's own incremental hash and pixi never reads it. a stale hash makes the packer regenerate, not skip
what if you take only the hedgehog-mode/ half? then the atlas entry has no source and the next repack loses the art. that's the one real failure mode, hence the committed source frame

registering it in the "other" group also means getRandomAccessoryCombo can hand
one out, so the ai got a firework entry in its weighted action table at the same
frequency as jump and wave. without it a randomly-spawned hog would wear an
inert prop. it's one table row and trivially droppable if you'd rather it wasn't
autonomous.

how it was tested

29 unit tests, up from 27. pnpm test, pnpm --dir hedgehog-mode build,
oxlint . and oxfmt --check . all clean.

the ability's tests mock gsap and FlameActor, which is the setup where a suite
can look thorough while only proving mocks got called, so they were written to
fail against specific wrong implementations. duration: 30 instead of 3, an end
angle 100x too large, and a deleted killTweensOf were each applied and confirmed
to break the suite.

in the playground:

check result
wheel renders on the hog yes, and in the customization picker
press f spins, throws sparks, ends upright
held f does not stutter or restart the tween
fire again after a burn works
"stop game" mid-burn zero console errors, which is the whole point of the one-clock change

not verified by hand: an ai hog setting one off unprompted, and the slow/fast
paths. both are covered by unit tests but i didn't sit and wait for the ai to roll
it.

no unit test on the death-path teardown. exercising HedgehogActor.destroy()
needs pixi and matter scaffolding this suite doesn't have, and the behaviour it
depends on is already covered against the collaborator directly. saying so rather
than implying coverage that isn't there.

two things that are yours, not mine

the pixi deprecation warning. addChild: Only Containers will be allowed to add children in v8.0.0, thrown from syncAccessories on every accessory. i checked
because it appears while testing this: it dates to 07d7e3f, fires for a tophat
just as readily, and this branch never touches that function. not introduced here,
just noting it so it doesn't get blamed on the wheel.

a residual teardown path. because HedgeHogMode.destroy() doesn't call
beforeUnload() on elements, a tween that's mid-flight at teardown stays parked in
gsap's global timeline and a subsequent engine instance replays it. that's a
property of every gsap tween under the per-instance updateRoot design rather than
anything the wheel introduces, and #33 looks like it already addresses it. flagging
it rather than reaching into destroy() from a contributor branch.

diff shape

612 insertions, 2 deletions. one of the deletions is the maybeSpawnFireball
comment that said "(hogzilla only)", which this makes untrue. the other is the
atlas height. Hedgehog.ts takes 9 added lines, items/Accessory.ts and
GlobalKeyboardListeners.ts are untouched, and nothing is renamed.

…ressions

Review round 2 found the suite was under-verifying the gsap tween (duration,
rotation angle, and killTweensOf on destroy were all unchecked) and sampling
the radial spark test at the one angle (progress 0.5) where the direction
degenerates, leaving the spec's monotonic heading-sweep requirement untested.
Test-only fix per coordinator authorisation: abilities.ts is unchanged.
destroy() clears options.accessories and calls the private
syncAccessories() directly, bypassing updateOptions() (the only prior
caller of accessoryAbilities.sync()). A hedgehog dying mid-burn kept
its catherine wheel ability alive until beforeUnload() eventually ran.

Also updates the maybeSpawnFireball() comment, now stale since it
fans out to accessory abilities too, not just the skin ability.
getAccessoryAbilityFactory() narrowed with `"createAbility" in info`,
which throws a TypeError when `info` is undefined. Accessories are
restored from unvalidated storage — state.ts JSON.parses localStorage
with no schema, and the browser extension reads chrome.storage from
untyped JS — so a stale or hand-edited key reaches it. That key used to
cost a log line and a missing sprite; via the new accessory abilities it
threw straight out of the HedgehogActor constructor and took hedgehog
mode down on startup.

Reads the entry through a partial view instead, which gets at the
optional member without widening the registry or annotating it (both of
which would change the public .d.ts).
The sparks ran on their own setInterval, a second clock for one effect,
which broke in two ways.

HedgeHogMode.destroy() stops the Matter runner and destroys the Pixi app
but never calls beforeUnload() on live elements, so a burning wheel is
never torn down. The engine drives gsap from Matter's afterUpdate, so a
stopped runner means the tween never completes and never clears the
interval. emitSpark() then kept firing at 20Hz forever, leaking a body
and throwing into a destroyed stage on every tick until the tab was
reloaded.

setSpeed (the `slow` and `fast` cheats) scales engine.timing.timeScale
and gsap.globalTimeline.timeScale. A wall-clock interval is in neither,
so `slow` stretched a 3 second burn to about 12 seconds of real time and
roughly 240 sparks instead of 60.

Emission now comes from the tween's onUpdate: `ease: "none"` makes the
angle linear in tween time, so how far the spin has got is how many of
the burn's sparks are owed. One clock for the whole firework — sparks
stop dead when the engine stops, and stretch with setSpeed like the spin
does. killTweensOf() in destroy() is now a complete stop, as the ability
owns no timer of its own.

Counting sparks owed by progress rather than accumulating elapsed time
and subtracting a 1/20 interval is deliberate: the subtraction drifts,
emitting 59 rather than 60 over the ~180 updates a 3 second burn gets at
60fps. The burn test now drives the tween frame by frame to hold that
line, and asserts the sparks come out steadily rather than all at once.
The sync() inside updateOptions() nine lines above looks like it already
covers this one, so the call reads as duplicated and invites deletion —
which would quietly restore the bug it fixes, with no test to catch it.
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

@ShannonHolgate is attempting to deploy a commit to the PostHog Team on Vercel.

A member of the Team first needs to authorize it.

@ShannonHolgate
ShannonHolgate marked this pull request as ready for review August 28, 2026 00:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant